mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-24 20:22:44 +02:00
feat(cli): overhaul commands and add live scan status (#424)
* refactor(cli): list workspaces natively instead of via the worker image * feat(cli): preflight that Docker is installed and running * feat(cli): stop scans by workspace or --all, terminating their Temporal workflows * fix(worker): abort the running agent on cancellation so Temporal cancel takes effect * refactor(cli): split destructive teardown out of stop into a reset command * refactor(cli): centralise flag parsing and confirmation across commands * fix(cli): pass provider credentials to docker by name to keep secrets out of argv * feat(cli): add per-command help via <command> --help/-h and help <command> * feat(cli): replace raw docker output with clack spinners for infra and scan teardown * fix(cli): verify scan stop by re-querying container and workflow state instead of assuming success * fix(cli): resolve running state before prompting on stop and report no-op stops honestly * refactor(cli): show splash first and drive start with one spinner resolving to a clean line * fix(cli): validate --url up front so a bad value fails cleanly instead of a late crash * refactor(cli): centralize error reporting with fail() for expected errors and a crash handler that logs the stack and links the issue tracker * feat(cli): add --json/--plain machine-readable output to workspaces and status * refactor(cli): remove the workspaces command * refactor(cli): remove the status command * feat(cli): add 'progress <workspace>' — live scan progress from Temporal * fix(cli): mark metric-less agents as skipped in progress, not done * feat(cli): animate running agents in progress with a clack-style spinner * feat(cli): rename progress->status, reveal agents as they run, show live per-agent elapsed * fix(cli): mark passed-over phases as skipped live, not pending * style(cli): rename status footer 'Wall-clock' to 'Time Taken', drop the parenthetical * style(cli): drop '(sum of agents)' from status total cost line * style(cli): green filled circle for completed, Shannon gold for running * style(cli): use Shannon gold in place of green in status * feat(cli): suggest closest command or flag on typo * refactor(cli): single-source start help and drop ./repos bare-name shortcut * feat(cli): name providers and fix in multi-provider credential error * feat(cli): support --flag=value syntax and expand leading ~ in paths * refactor(cli): centralize ANSI color codes in colors.ts * feat(cli): add scans command listing completed scans with cost and duration * fix(cli): keep stdout clean off-TTY for logs and start * feat(cli): add repo link to top-level help * feat(worker): record auth-validation metrics and register resume attempts early * refactor(cli): share resume-aware workflow-id resolution and surface root-cause failures * feat(cli): add status --json, auth phase, dashboard link, and stable live redraw * refactor(cli): drop cost from status and scans output * feat(worker): surface both PDF and markdown report at run root * refactor(cli): normalize error/warning prefixing through fail and warn * feat(cli): add version --json for machine-readable output * refactor(cli): rename start --debug to --keep-container * refactor(cli): point start's progress hint at status instead of the Temporal dashboard * refactor(cli): centralize the mode-aware command prefix * refactor(cli): trim start and logs output to durable facts off-TTY * feat(cli): require typed confirmation for reset instead of --yes reset permanently wipes all Temporal data and volumes — a severe, irreversible action. Replace its default y/N confirm (bypassable with --yes) with a typed-word confirmation that has no bypass, so the wipe can only be triggered by a deliberate interactive answer. * feat(cli): surface logs and status hints after start on a TTY * feat(cli): exit 2 on usage errors, distinct from operational failures * feat(cli): add start --follow to stream logs and exit on scan outcome * refactor(cli): redesign splash with sunset-gradient wordmark and truecolor * refactor(cli): remove the uninstall command * docs: sync CLI docs with removed uninstall/workspaces, new scans and --follow * docs: fix reset confirmation — typed confirm, not --yes/-y * style(cli): restructure status footer with divider, aligned Logs/Temporal rows * feat(cli): show splash in the status command * fix(worker): validate auth-state shape, not entry count * docs: correct reset confirmation and add markdown report to run-root docs
This commit is contained in:
@@ -290,6 +290,14 @@ export async function runPiPrompt(
|
||||
// Declared out here so the catch can bill spend accrued before a failure.
|
||||
let session: AgentSession | undefined;
|
||||
|
||||
// Abort the in-flight agent when the Temporal activity is cancelled (UI/CLI cancel).
|
||||
// Without this the top-level session runs to startToCloseTimeout despite the cancel.
|
||||
const onCancellation = (): void => {
|
||||
void session?.abort().catch(() => {
|
||||
// Best-effort — the session is torn down regardless once the prompt unwinds.
|
||||
});
|
||||
};
|
||||
|
||||
progress.start();
|
||||
|
||||
try {
|
||||
@@ -307,6 +315,13 @@ export async function runPiPrompt(
|
||||
resourceLoader,
|
||||
}));
|
||||
|
||||
// Wire activity cancellation to the session now that it exists.
|
||||
if (cancellationSignal?.aborted) {
|
||||
onCancellation();
|
||||
} else {
|
||||
cancellationSignal?.addEventListener('abort', onCancellation, { once: true });
|
||||
}
|
||||
|
||||
// 5. Map pi events to audit logging + progress + error capture.
|
||||
session.subscribe((event: AgentSessionEvent) => {
|
||||
switch (event.type) {
|
||||
@@ -414,5 +429,7 @@ export async function runPiPrompt(
|
||||
cacheWriteTokens: usage.cacheWriteTokens,
|
||||
retryable: isRetryableFailure(err),
|
||||
};
|
||||
} finally {
|
||||
cancellationSignal?.removeEventListener('abort', onCancellation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ export const ASSEMBLED_REPORT_PDF_FILENAME = 'comprehensive_security_assessment_
|
||||
/** Filename of the human-facing PDF report surfaced at the run directory root */
|
||||
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
|
||||
|
||||
/** Filename of the human-facing markdown report surfaced at the run directory root, alongside the PDF */
|
||||
export const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md';
|
||||
|
||||
/** Structured findings the report agent emits; the markdown report is rendered from it. */
|
||||
export const REPORT_JSON_FILENAME = 'report.json';
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ASSEMBLED_REPORT_FILENAME,
|
||||
ASSEMBLED_REPORT_PDF_FILENAME,
|
||||
deliverablesDir,
|
||||
FINAL_REPORT_MD_FILENAME,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
resolveSessionJsonPath,
|
||||
SARIF_FILENAME,
|
||||
@@ -175,8 +176,8 @@ export async function injectModelIntoReport(
|
||||
/**
|
||||
* Surface the run's deliverables at the run directory's top level, so a customer opening the run
|
||||
* folder sees the report without digging through internals. Sources stay in the deliverables dir
|
||||
* (git-checkpointed, used by resume). The PDF is the customer-facing report surfaced here; the
|
||||
* markdown remains in the deliverables dir but is not surfaced.
|
||||
* (git-checkpointed, used by resume). Both the PDF and the markdown report are surfaced here as the
|
||||
* customer-facing copies.
|
||||
*
|
||||
* The SARIF log is surfaced beside it when present, since a CI step consuming it needs a stable
|
||||
* path and cannot be expected to reach into the internals directory. It is absent whenever the
|
||||
@@ -199,6 +200,15 @@ export async function copyReportToRunRoot(
|
||||
logger.warn(`PDF report not found, skipping ${FINAL_REPORT_PDF_FILENAME}`);
|
||||
}
|
||||
|
||||
const markdownSource = path.join(dir, ASSEMBLED_REPORT_FILENAME);
|
||||
if (await fs.pathExists(markdownSource)) {
|
||||
const destination = path.join(runDir, FINAL_REPORT_MD_FILENAME);
|
||||
await fs.copy(markdownSource, destination, { overwrite: true });
|
||||
logger.info(`Surfaced markdown report at ${destination}`);
|
||||
} else {
|
||||
logger.warn(`Markdown report not found, skipping ${FINAL_REPORT_MD_FILENAME}`);
|
||||
}
|
||||
|
||||
const sarifSource = path.join(dir, SARIF_FILENAME);
|
||||
if (await fs.pathExists(sarifSource)) {
|
||||
const sarifDestination = path.join(runDir, SARIF_FILENAME);
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { AgentEndResult } from '../types/audit.js';
|
||||
import type { DistributedConfig } from '../types/config.js';
|
||||
import { ErrorCode } from '../types/errors.js';
|
||||
import type { AgentMetrics } from '../types/metrics.js';
|
||||
import { err, ok, type Result } from '../types/result.js';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import { loadPrompt } from './prompt-manager.js';
|
||||
@@ -97,7 +98,9 @@ export interface ValidateAuthInput {
|
||||
readonly cancellationSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function validateAuthentication(input: ValidateAuthInput): Promise<Result<void, PentestError>> {
|
||||
export async function validateAuthentication(
|
||||
input: ValidateAuthInput,
|
||||
): Promise<Result<AgentMetrics | null, PentestError>> {
|
||||
const {
|
||||
distributedConfig,
|
||||
repoPath,
|
||||
@@ -113,7 +116,7 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
|
||||
|
||||
const authentication = distributedConfig.authentication;
|
||||
if (!authentication) {
|
||||
return ok(undefined);
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
logger.info('Validating authentication credentials with live browser...', {
|
||||
@@ -160,9 +163,10 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
const endResult: AgentEndResult = {
|
||||
attemptNumber,
|
||||
duration_ms: Date.now() - startTime,
|
||||
duration_ms: durationMs,
|
||||
cost_usd: result.cost || 0,
|
||||
success: classification.ok,
|
||||
...(result.model !== undefined && { model: result.model }),
|
||||
@@ -170,7 +174,21 @@ export async function validateAuthentication(input: ValidateAuthInput): Promise<
|
||||
};
|
||||
await auditSession.endAgent(AGENT_NAME, endResult);
|
||||
|
||||
return classification;
|
||||
if (!classification.ok) {
|
||||
return err(classification.error);
|
||||
}
|
||||
|
||||
const metrics: AgentMetrics = {
|
||||
durationMs,
|
||||
inputTokens: result.inputTokens ?? null,
|
||||
outputTokens: result.outputTokens ?? null,
|
||||
cacheReadTokens: result.cacheReadTokens ?? null,
|
||||
cacheWriteTokens: result.cacheWriteTokens ?? null,
|
||||
costUsd: result.cost ?? null,
|
||||
numTurns: result.turns ?? null,
|
||||
...(result.model !== undefined && { model: result.model }),
|
||||
};
|
||||
return ok(metrics);
|
||||
}
|
||||
|
||||
async function verifySavedAuthState(stateFile: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
|
||||
@@ -205,28 +223,32 @@ async function verifySavedAuthState(stateFile: string, logger: ActivityLogger):
|
||||
);
|
||||
}
|
||||
|
||||
const cookieCount = countStorageEntries(parsed, 'cookies');
|
||||
const originCount = countStorageEntries(parsed, 'origins');
|
||||
if (cookieCount === 0 && originCount === 0) {
|
||||
const cookies = storageEntries(parsed, 'cookies');
|
||||
const origins = storageEntries(parsed, 'origins');
|
||||
if (!cookies || !origins) {
|
||||
return err(
|
||||
new PentestError(
|
||||
`Preflight saved an authenticated session to ${stateFile}, but it contains no cookies or origins — the browser was not actually logged in.`,
|
||||
`Preflight saved an authenticated session to ${stateFile}, but it is not a storage state — cookies and origins arrays are missing.`,
|
||||
'validation',
|
||||
true,
|
||||
{ stateFile, cookieCount, originCount },
|
||||
{ stateFile, hasCookies: !!cookies, hasOrigins: !!origins },
|
||||
ErrorCode.AGENT_EXECUTION_FAILED,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Preflight authenticated session saved', { stateFile, cookieCount, originCount });
|
||||
logger.info('Preflight authenticated session saved', {
|
||||
stateFile,
|
||||
cookieCount: cookies.length,
|
||||
originCount: origins.length,
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function countStorageEntries(parsed: unknown, key: 'cookies' | 'origins'): number {
|
||||
if (typeof parsed !== 'object' || parsed === null) return 0;
|
||||
function storageEntries(parsed: unknown, key: 'cookies' | 'origins'): unknown[] | null {
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const value = (parsed as Record<string, unknown>)[key];
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
return Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function classifyResult(
|
||||
|
||||
@@ -637,7 +637,7 @@ export async function runPreflightValidation(input: ActivityInput): Promise<void
|
||||
* block; otherwise surfaces a classified failure (failurePoint +
|
||||
* failureDetail in ApplicationFailure.details) on credential rejection.
|
||||
*/
|
||||
export async function runAuthenticationValidation(input: ActivityInput): Promise<void> {
|
||||
export async function runAuthenticationValidation(input: ActivityInput): Promise<AgentMetrics | null> {
|
||||
const startTime = Date.now();
|
||||
const attemptNumber = Context.current().info.attempt;
|
||||
|
||||
@@ -655,13 +655,13 @@ export async function runAuthenticationValidation(input: ActivityInput): Promise
|
||||
if (isErr(configResult)) {
|
||||
// runPreflightValidation already validated parsing, so this is unexpected.
|
||||
logger.warn(`runAuthenticationValidation: config load failed unexpectedly: ${configResult.error.message}`);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const distributedConfig = configResult.value;
|
||||
if (!distributedConfig?.authentication) {
|
||||
logger.info('No authentication configured — skipping credential validation');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const auditSession = new AuditSession(sessionMetadata);
|
||||
@@ -700,6 +700,8 @@ export async function runAuthenticationValidation(input: ActivityInput): Promise
|
||||
truncateStackTrace(failure);
|
||||
throw failure;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationFailure) {
|
||||
throw error;
|
||||
@@ -1138,9 +1140,19 @@ export async function restoreGitCheckpoint(
|
||||
/**
|
||||
* Record a resume attempt in session.json and write resume header to workflow.log.
|
||||
*/
|
||||
/**
|
||||
* Register this resume's workflow id in session.json before loadResumeState (which can throw),
|
||||
* so the CLI can resolve and follow the resume even when validation fails instead of timing out.
|
||||
*/
|
||||
export async function registerResumeAttempt(input: ActivityInput, terminatedWorkflows: string[]): Promise<void> {
|
||||
const sessionMetadata = buildSessionMetadata(input);
|
||||
const auditSession = new AuditSession(sessionMetadata);
|
||||
await auditSession.initialize();
|
||||
await auditSession.addResumeAttempt(input.workflowId, terminatedWorkflows);
|
||||
}
|
||||
|
||||
export async function recordResumeAttempt(
|
||||
input: ActivityInput,
|
||||
terminatedWorkflows: string[],
|
||||
checkpointHash: string,
|
||||
previousWorkflowId: string,
|
||||
completedAgents: string[],
|
||||
@@ -1149,10 +1161,7 @@ export async function recordResumeAttempt(
|
||||
const auditSession = new AuditSession(sessionMetadata);
|
||||
await auditSession.initialize();
|
||||
|
||||
// Update session.json with resume attempt
|
||||
await auditSession.addResumeAttempt(input.workflowId, terminatedWorkflows, checkpointHash);
|
||||
|
||||
// Write resume header to workflow.log
|
||||
// session.json entry already added by registerResumeAttempt; here we only write the workflow.log header.
|
||||
await auditSession.logResumeHeader({
|
||||
previousWorkflowId,
|
||||
newWorkflowId: input.workflowId,
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ActivityCancellationType,
|
||||
ApplicationFailure,
|
||||
CancellationScope,
|
||||
isCancellation,
|
||||
@@ -96,6 +97,8 @@ const acts = proxyActivities<typeof activities>({
|
||||
startToCloseTimeout: '2 hours',
|
||||
heartbeatTimeout: '60 minutes', // Extended for nested pi task execution
|
||||
retry: PRODUCTION_RETRY,
|
||||
// Cancel promptly instead of waiting out startToCloseTimeout; the agent aborts on the signal.
|
||||
cancellationType: ActivityCancellationType.TRY_CANCEL,
|
||||
});
|
||||
|
||||
// Activity proxy with testing retry configuration (fast)
|
||||
@@ -103,6 +106,7 @@ const testActs = proxyActivities<typeof activities>({
|
||||
startToCloseTimeout: '30 minutes',
|
||||
heartbeatTimeout: '30 minutes', // Extended for sub-agent execution in testing
|
||||
retry: TESTING_RETRY,
|
||||
cancellationType: ActivityCancellationType.TRY_CANCEL,
|
||||
});
|
||||
|
||||
// Retry configuration for preflight validation (short timeout, few retries)
|
||||
@@ -119,6 +123,7 @@ const preflightActs = proxyActivities<typeof activities>({
|
||||
startToCloseTimeout: '2 minutes',
|
||||
heartbeatTimeout: '2 minutes',
|
||||
retry: PREFLIGHT_RETRY,
|
||||
cancellationType: ActivityCancellationType.TRY_CANCEL,
|
||||
});
|
||||
|
||||
// Credential rejection is not retryable; transient provider errors get 3 attempts.
|
||||
@@ -135,6 +140,7 @@ const authValidationActs = proxyActivities<typeof activities>({
|
||||
startToCloseTimeout: '10 minutes',
|
||||
heartbeatTimeout: '10 minutes',
|
||||
retry: AUTH_VALIDATION_RETRY,
|
||||
cancellationType: ActivityCancellationType.TRY_CANCEL,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -246,6 +252,10 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
let resumeState: ResumeState | null = null;
|
||||
|
||||
if (input.resumeFromWorkspace) {
|
||||
// 0. Register the resume's workflow id in session.json before validation can fail, so the CLI
|
||||
// can resolve and follow it instead of polling for an entry that never lands.
|
||||
await a.registerResumeAttempt(activityInput, input.terminatedWorkflows || []);
|
||||
|
||||
// 1. Load resume state (validates workspace, cross-checks deliverables)
|
||||
resumeState = await a.loadResumeState(
|
||||
input.resumeFromWorkspace,
|
||||
@@ -277,10 +287,9 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
return state;
|
||||
}
|
||||
|
||||
// 4. Record this resume attempt in session.json and workflow.log
|
||||
// 4. Write the resume header to workflow.log (the session.json entry was recorded in step 0)
|
||||
await a.recordResumeAttempt(
|
||||
activityInput,
|
||||
input.terminatedWorkflows || [],
|
||||
resumeState.checkpointHash,
|
||||
resumeState.originalWorkflowId,
|
||||
resumeState.completedAgents,
|
||||
@@ -480,7 +489,11 @@ export async function pentestPipeline(input: PipelineInput): Promise<PipelineSta
|
||||
// === Authentication Validation ===
|
||||
state.currentPhase = 'auth-validation';
|
||||
state.currentAgent = 'validate-authentication';
|
||||
await authValidationActs.runAuthenticationValidation(activityInput);
|
||||
const authMetrics = await authValidationActs.runAuthenticationValidation(activityInput);
|
||||
// Null when no login ran (no-auth scan); left absent so status renders it skipped, not completed.
|
||||
if (authMetrics) {
|
||||
state.agentMetrics['validate-authentication'] = authMetrics;
|
||||
}
|
||||
state.currentAgent = null;
|
||||
log.info('Authentication validation passed');
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (C) 2025 Keygraph, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
/**
|
||||
* Workspace listing tool for Shannon.
|
||||
*
|
||||
* Reads workspaces/ directories, parses session.json files, and displays
|
||||
* a formatted table of all workspaces with status, duration, and cost.
|
||||
*
|
||||
* Usage:
|
||||
* node dist/temporal/workspaces.js
|
||||
*
|
||||
* Environment:
|
||||
* WORKSPACES_DIR - Override workspaces directory (default: ./workspaces)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { WORKSPACES_DIR as DEFAULT_WORKSPACES_DIR, resolveSessionJsonPath } from '../paths.js';
|
||||
|
||||
interface SessionJson {
|
||||
session: {
|
||||
id: string;
|
||||
webUrl: string;
|
||||
status: 'in-progress' | 'completed' | 'failed';
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
metrics: {
|
||||
total_cost_usd: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'in-progress' | 'completed' | 'failed';
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getStatusDisplay(status: string): string {
|
||||
return status;
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return `${str.slice(0, maxLen - 1)}\u2026`;
|
||||
}
|
||||
|
||||
async function listWorkspaces(): Promise<void> {
|
||||
const workspacesDir = process.env.WORKSPACES_DIR || DEFAULT_WORKSPACES_DIR;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(workspacesDir);
|
||||
} catch {
|
||||
console.log('No workspaces directory found.');
|
||||
console.log(`Expected: ${workspacesDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaces: WorkspaceInfo[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const sessionPath = resolveSessionJsonPath(path.join(workspacesDir, entry));
|
||||
try {
|
||||
const content = await fs.readFile(sessionPath, 'utf8');
|
||||
const data = JSON.parse(content) as SessionJson;
|
||||
|
||||
workspaces.push({
|
||||
name: entry,
|
||||
url: data.session.webUrl,
|
||||
status: data.session.status,
|
||||
createdAt: new Date(data.session.createdAt),
|
||||
completedAt: data.session.completedAt ? new Date(data.session.completedAt) : null,
|
||||
costUsd: data.metrics.total_cost_usd,
|
||||
});
|
||||
} catch {
|
||||
// Skip directories without valid session.json
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
console.log('\nNo workspaces found.');
|
||||
console.log('Run a pipeline first: ./shannon start -u <url> -r <repo>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by creation date (most recent first)
|
||||
workspaces.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
console.log('\n=== Shannon Workspaces ===\n');
|
||||
|
||||
// Column widths
|
||||
const nameWidth = 30;
|
||||
const urlWidth = 30;
|
||||
const statusWidth = 14;
|
||||
const durationWidth = 10;
|
||||
const costWidth = 10;
|
||||
|
||||
// Header
|
||||
console.log(
|
||||
' ' +
|
||||
'WORKSPACE'.padEnd(nameWidth) +
|
||||
'URL'.padEnd(urlWidth) +
|
||||
'STATUS'.padEnd(statusWidth) +
|
||||
'DURATION'.padEnd(durationWidth) +
|
||||
'COST'.padEnd(costWidth),
|
||||
);
|
||||
console.log(` ${'\u2500'.repeat(nameWidth + urlWidth + statusWidth + durationWidth + costWidth)}`);
|
||||
|
||||
let resumableCount = 0;
|
||||
|
||||
for (const ws of workspaces) {
|
||||
const now = new Date();
|
||||
const endTime = ws.completedAt || now;
|
||||
const durationMs = endTime.getTime() - ws.createdAt.getTime();
|
||||
const duration = formatDuration(durationMs);
|
||||
const cost = `$${ws.costUsd.toFixed(2)}`;
|
||||
const isResumable = ws.status !== 'completed';
|
||||
|
||||
if (isResumable) {
|
||||
resumableCount++;
|
||||
}
|
||||
|
||||
const resumeTag = isResumable ? ' (resumable)' : '';
|
||||
|
||||
console.log(
|
||||
' ' +
|
||||
truncate(ws.name, nameWidth - 2).padEnd(nameWidth) +
|
||||
truncate(ws.url, urlWidth - 2).padEnd(urlWidth) +
|
||||
getStatusDisplay(ws.status).padEnd(statusWidth) +
|
||||
duration.padEnd(durationWidth) +
|
||||
cost.padEnd(costWidth) +
|
||||
resumeTag,
|
||||
);
|
||||
}
|
||||
|
||||
console.log();
|
||||
const summary = `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'} found`;
|
||||
const resumeSummary = resumableCount > 0 ? ` (${resumableCount} resumable)` : '';
|
||||
console.log(`${summary}${resumeSummary}`);
|
||||
|
||||
if (resumableCount > 0) {
|
||||
console.log('\nResume with: ./shannon start -u <url> -r <repo> -w <name>');
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
listWorkspaces().catch((err) => {
|
||||
console.error('Error listing workspaces:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user