mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-17 07:25:35 +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:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Pure derivation of a scan's per-agent and per-phase state from its Temporal snapshot.
|
||||
*
|
||||
* This is the single source of truth for "what state is each agent in" — both the
|
||||
* human progress tree (render.ts) and the machine-readable snapshot (status-json.ts)
|
||||
* consume it, so the two views can never disagree about whether an agent is running,
|
||||
* skipped, or still pending. No glyphs, no color, no formatting live here.
|
||||
*/
|
||||
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentClass, PIPELINE, type PipelineState } from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
|
||||
export type RunState = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
/** One agent's resolved state plus the raw metrics/timing a consumer needs to present it. Null metrics
|
||||
* mean the value doesn't apply to the current state (e.g. duration only for completed agents). */
|
||||
export interface DerivedAgent {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly state: RunState;
|
||||
readonly durationMs: number | null;
|
||||
readonly runningElapsedMs: number | null;
|
||||
readonly attempt: number | null;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface DerivedPhase {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly parallel: boolean;
|
||||
readonly state: RunState;
|
||||
readonly agents: readonly DerivedAgent[];
|
||||
}
|
||||
|
||||
/** Terminal = anything other than an open, running execution. */
|
||||
export function isTerminal(status: string): boolean {
|
||||
return status !== 'RUNNING' && status !== 'UNSPECIFIED';
|
||||
}
|
||||
|
||||
function isFailedAgent(name: string, state: PipelineState | null): boolean {
|
||||
return !!state && (state.failedAgent === name || state.failedPipelines.some((f) => f.vulnType === agentClass(name)));
|
||||
}
|
||||
|
||||
/** An agent has entered play once it is running, has metrics, or has failed. */
|
||||
function isAgentActive(name: string, state: PipelineState | null, running: Set<string>): boolean {
|
||||
return running.has(name) || !!state?.agentMetrics[name] || isFailedAgent(name, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one agent's state. "Ran" is signalled by a metrics entry, not by
|
||||
* completedAgents — the workflow lists conditionally-skipped agents (e.g. exploit
|
||||
* agents when there is nothing to exploit) as completed but records no metrics for
|
||||
* them. `resolved` is true once we've moved past this agent's phase (the scan is
|
||||
* terminal, or a later phase is already active), at which point a metric-less,
|
||||
* non-running agent is skipped rather than still pending.
|
||||
*/
|
||||
function agentState(name: string, state: PipelineState | null, running: Set<string>, resolved: boolean): RunState {
|
||||
if (running.has(name)) return 'running';
|
||||
if (isFailedAgent(name, state)) return 'failed';
|
||||
if (state?.agentMetrics[name]) return 'completed';
|
||||
return resolved ? 'skipped' : 'pending';
|
||||
}
|
||||
|
||||
function agentError(name: string, state: PipelineState | null, byAgent: Map<string, RunningAgent>): string | undefined {
|
||||
const failed = state?.failedPipelines.find((f) => f.vulnType === agentClass(name));
|
||||
return (
|
||||
failed?.error ??
|
||||
byAgent.get(name)?.lastFailure ??
|
||||
(state?.failedAgent === name ? (state.error ?? undefined) : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/** Scan wall-clock elapsed ms: recorded duration for a closed scan, live elapsed for a running one. */
|
||||
export function scanElapsedMs(input: RenderInput, now: number): number | undefined {
|
||||
if (isTerminal(input.temporalStatus)) {
|
||||
if (input.state?.summary) return input.state.summary.totalDurationMs;
|
||||
if (input.endedAt !== undefined && input.startedAt !== undefined) return input.endedAt - input.startedAt;
|
||||
return undefined;
|
||||
}
|
||||
return input.startedAt !== undefined ? now - input.startedAt : undefined;
|
||||
}
|
||||
|
||||
/** Collapse a phase's agent states into a single state for the phase line. */
|
||||
export function phaseGlyphState(states: readonly RunState[]): RunState {
|
||||
if (states.some((s) => s === 'running')) return 'running';
|
||||
if (states.some((s) => s === 'failed')) return 'failed';
|
||||
if (states.every((s) => s === 'skipped')) return 'skipped';
|
||||
if (states.every((s) => s === 'completed' || s === 'skipped')) return 'completed';
|
||||
if (states.some((s) => s === 'completed')) return 'running';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute each agent's RunState. This is the drift-prone part shared by every view.
|
||||
*
|
||||
* The pipeline is sequential across phases: the last phase with any active agent is the
|
||||
* frontier. Earlier phases with nothing active were skipped (e.g. exploitation when no
|
||||
* class had anything to exploit), not still pending.
|
||||
*/
|
||||
export function deriveAgentStates(input: RenderInput): Map<string, RunState> {
|
||||
const runningSet = new Set(input.running.map((r) => r.agent));
|
||||
const terminal = isTerminal(input.temporalStatus);
|
||||
|
||||
let frontier = -1;
|
||||
PIPELINE.forEach((phase, idx) => {
|
||||
if (phase.agents.some((a) => isAgentActive(a.name, input.state, runningSet))) frontier = idx;
|
||||
});
|
||||
|
||||
const states = new Map<string, RunState>();
|
||||
for (const [phaseIdx, phase] of PIPELINE.entries()) {
|
||||
const resolved = terminal || phaseIdx < frontier;
|
||||
for (const agent of phase.agents) {
|
||||
states.set(agent.name, agentState(agent.name, input.state, runningSet, resolved));
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full structured view of the pipeline: every agent's state plus the raw
|
||||
* metrics/timing needed to present it, and each phase's collapsed state.
|
||||
*/
|
||||
export function derivePipeline(input: RenderInput, now: number): DerivedPhase[] {
|
||||
const states = deriveAgentStates(input);
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
|
||||
return PIPELINE.map((phase) => {
|
||||
const agents = phase.agents.map((a): DerivedAgent => {
|
||||
const state = states.get(a.name) ?? 'pending';
|
||||
const metrics = input.state?.agentMetrics[a.name];
|
||||
const runner = byAgent.get(a.name);
|
||||
const error = agentError(a.name, input.state, byAgent);
|
||||
return {
|
||||
name: a.name,
|
||||
label: a.label,
|
||||
state,
|
||||
durationMs: state === 'completed' && metrics ? metrics.durationMs : null,
|
||||
runningElapsedMs: state === 'running' && runner?.startedAt !== undefined ? now - runner.startedAt : null,
|
||||
attempt: state === 'running' && runner ? runner.attempt : null,
|
||||
...(error !== undefined && { error }),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
parallel: phase.parallel,
|
||||
state: phaseGlyphState(agents.map((ag) => ag.state)),
|
||||
agents,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export { agentError };
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Static description of the Shannon scan pipeline, plus the worker types the CLI
|
||||
* reads back from Temporal.
|
||||
*
|
||||
* The CLI cannot import from the worker package, so this mirrors it. Keep in sync with:
|
||||
* - apps/worker/src/types/agents.ts (agent names / ordering)
|
||||
* - apps/worker/src/session-manager.ts (phase membership)
|
||||
* - apps/worker/src/temporal/activities.ts (the run*Agent activity names → `activityType`)
|
||||
* - apps/worker/src/temporal/shared.ts (PipelineState / PipelineSummary)
|
||||
* - apps/worker/src/types/metrics.ts (AgentMetrics)
|
||||
*/
|
||||
|
||||
export interface AgentSpec {
|
||||
/** Canonical agent name as it appears in PipelineState.completedAgents / agentMetrics. */
|
||||
readonly name: string;
|
||||
/** Short label for the progress tree. */
|
||||
readonly label: string;
|
||||
/** Temporal activity type name — how a running agent shows up in pendingActivities. */
|
||||
readonly activityType: string;
|
||||
}
|
||||
|
||||
export interface PhaseSpec {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly parallel: boolean;
|
||||
readonly agents: readonly AgentSpec[];
|
||||
}
|
||||
|
||||
/** The pipeline phases in execution order, each with its agents. */
|
||||
export const PIPELINE: readonly PhaseSpec[] = [
|
||||
{
|
||||
// Preflight login check. Only authenticated scans record metrics here; a non-auth scan
|
||||
// records none, so it renders as skipped — like Exploitation when nothing is exploitable.
|
||||
key: 'auth-validation',
|
||||
label: 'Authentication',
|
||||
parallel: false,
|
||||
agents: [{ name: 'validate-authentication', label: 'auth', activityType: 'runAuthenticationValidation' }],
|
||||
},
|
||||
{
|
||||
key: 'pre-recon',
|
||||
label: 'Pre-Recon',
|
||||
parallel: false,
|
||||
agents: [{ name: 'pre-recon', label: 'pre-recon', activityType: 'runPreReconAgent' }],
|
||||
},
|
||||
{
|
||||
key: 'recon',
|
||||
label: 'Recon',
|
||||
parallel: false,
|
||||
agents: [{ name: 'recon', label: 'recon', activityType: 'runReconAgent' }],
|
||||
},
|
||||
{
|
||||
key: 'vulnerability-analysis',
|
||||
label: 'Vulnerability Analysis',
|
||||
parallel: true,
|
||||
agents: [
|
||||
{ name: 'injection-vuln', label: 'injection', activityType: 'runInjectionVulnAgent' },
|
||||
{ name: 'xss-vuln', label: 'xss', activityType: 'runXssVulnAgent' },
|
||||
{ name: 'auth-vuln', label: 'auth', activityType: 'runAuthVulnAgent' },
|
||||
{ name: 'ssrf-vuln', label: 'ssrf', activityType: 'runSsrfVulnAgent' },
|
||||
{ name: 'authz-vuln', label: 'authz', activityType: 'runAuthzVulnAgent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'exploitation',
|
||||
label: 'Exploitation',
|
||||
parallel: true,
|
||||
agents: [
|
||||
{ name: 'injection-exploit', label: 'injection', activityType: 'runInjectionExploitAgent' },
|
||||
{ name: 'xss-exploit', label: 'xss', activityType: 'runXssExploitAgent' },
|
||||
{ name: 'auth-exploit', label: 'auth', activityType: 'runAuthExploitAgent' },
|
||||
{ name: 'ssrf-exploit', label: 'ssrf', activityType: 'runSsrfExploitAgent' },
|
||||
{ name: 'authz-exploit', label: 'authz', activityType: 'runAuthzExploitAgent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'reporting',
|
||||
label: 'Reporting',
|
||||
parallel: false,
|
||||
agents: [{ name: 'report', label: 'report', activityType: 'runReportAgent' }],
|
||||
},
|
||||
];
|
||||
|
||||
/** Temporal activity type name → canonical agent name, for mapping pendingActivities. */
|
||||
export const ACTIVITY_TO_AGENT: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
PIPELINE.flatMap((phase) => phase.agents.map((agent) => [agent.activityType, agent.name])),
|
||||
);
|
||||
|
||||
/** The vuln/exploit class of an agent (e.g. "authz-vuln" → "authz"), for failedPipelines matching. */
|
||||
export function agentClass(name: string): string {
|
||||
return name.replace(/-(vuln|exploit)$/, '');
|
||||
}
|
||||
|
||||
// === Worker types read back from Temporal (mirror of shared.ts / metrics.ts) ===
|
||||
|
||||
export interface AgentMetrics {
|
||||
readonly durationMs: number;
|
||||
readonly costUsd: number | null;
|
||||
readonly numTurns: number | null;
|
||||
readonly model?: string;
|
||||
readonly skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface PipelineSummary {
|
||||
readonly totalCostUsd: number;
|
||||
readonly totalDurationMs: number; // Wall-clock (end - start)
|
||||
readonly totalTurns: number;
|
||||
readonly agentCount: number;
|
||||
}
|
||||
|
||||
export type PipelineStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'partial';
|
||||
|
||||
export interface PipelineState {
|
||||
readonly status: PipelineStatus;
|
||||
readonly currentPhase: string | null;
|
||||
readonly currentAgent: string | null;
|
||||
readonly completedAgents: string[];
|
||||
readonly failedPipelines: { vulnType: string; error: string }[];
|
||||
readonly failedAgent: string | null;
|
||||
readonly error: string | null;
|
||||
readonly startTime: number;
|
||||
readonly agentMetrics: Record<string, AgentMetrics>;
|
||||
readonly summary: PipelineSummary | null;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Renders a scan's Temporal state into the terminal progress tree.
|
||||
*
|
||||
* The same PipelineState drives both the live view (from the getProgress query) and
|
||||
* the final view (from the workflow result); the running-agents overlay (from
|
||||
* pendingActivities) supplies the in-flight set and retry counts the state lacks.
|
||||
* Colors and Unicode glyphs are gated by the caller so the frame degrades off a TTY.
|
||||
*/
|
||||
|
||||
import { BOLD, DIM, GOLD, paint, RED, YELLOW } from '../colors.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { agentError, deriveAgentStates, isTerminal, phaseGlyphState, type RunState, scanElapsedMs } from './derive.js';
|
||||
import { PIPELINE, type PipelineState } from './pipeline.js';
|
||||
|
||||
export interface RenderInput {
|
||||
readonly workspace: string;
|
||||
/** Temporal workflow id backing this scan (differs from workspace on a resume); used for the dashboard link. */
|
||||
readonly workflowId?: string;
|
||||
/** Temporal WorkflowExecutionStatusName: RUNNING | COMPLETED | FAILED | CANCELLED | TERMINATED | … */
|
||||
readonly temporalStatus: string;
|
||||
/** Progress (live) or result (terminal). Null when unavailable, e.g. a hard failure with no result. */
|
||||
readonly state: PipelineState | null;
|
||||
readonly running: readonly RunningAgent[];
|
||||
readonly startedAt?: number;
|
||||
readonly endedAt?: number;
|
||||
/** Failure text when a failed scan has no readable state. */
|
||||
readonly failureMessage?: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
readonly now: number;
|
||||
readonly color: boolean;
|
||||
readonly unicode: boolean;
|
||||
/** True for the live view (adds a watch footer); false for the final/one-shot frame. */
|
||||
readonly live: boolean;
|
||||
/** Animation tick — advances the running-agent spinner. Ignored for static frames. */
|
||||
readonly frame: number;
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
red: RED,
|
||||
gold: GOLD,
|
||||
yellow: YELLOW,
|
||||
dim: DIM,
|
||||
bold: BOLD,
|
||||
} as const;
|
||||
|
||||
// === Formatting ===
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${secs}s`;
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/** Temporal Web UI, published by compose on 8233; deep-links to the workflow when its id is known. */
|
||||
function temporalDashboardUrl(workflowId: string | undefined): string {
|
||||
const base = 'http://localhost:8233';
|
||||
return workflowId ? `${base}/namespaces/default/workflows/${workflowId}` : base;
|
||||
}
|
||||
|
||||
// === Glyphs & status ===
|
||||
|
||||
const GLYPH_UNICODE: Record<RunState, string> = {
|
||||
pending: '○',
|
||||
running: '⟳',
|
||||
completed: '●',
|
||||
failed: '✗',
|
||||
skipped: '·',
|
||||
};
|
||||
const GLYPH_ASCII: Record<RunState, string> = {
|
||||
pending: '.',
|
||||
running: '>',
|
||||
completed: '+',
|
||||
failed: 'x',
|
||||
skipped: '-',
|
||||
};
|
||||
const STATE_COLOR: Record<RunState, string> = {
|
||||
pending: COLORS.dim,
|
||||
running: COLORS.gold,
|
||||
completed: COLORS.gold,
|
||||
failed: COLORS.red,
|
||||
skipped: COLORS.dim,
|
||||
};
|
||||
|
||||
/** Braille spinner frames for running agents — the clack loader style. */
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
function glyph(state: RunState, opts: RenderOptions): string {
|
||||
if (state === 'running' && opts.unicode) {
|
||||
const spin = SPINNER_FRAMES[opts.frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0];
|
||||
return paint(spin, STATE_COLOR.running, opts.color);
|
||||
}
|
||||
const symbol = opts.unicode ? GLYPH_UNICODE[state] : GLYPH_ASCII[state];
|
||||
return paint(symbol, STATE_COLOR[state], opts.color);
|
||||
}
|
||||
|
||||
/** Badge text + color for the scan as a whole, preferring the workflow's own status when known. */
|
||||
function statusBadge(input: RenderInput, opts: RenderOptions): string {
|
||||
const workflowStatus = input.state?.status;
|
||||
if (!isTerminal(input.temporalStatus)) return paint('running', COLORS.gold, opts.color);
|
||||
if (workflowStatus === 'partial') return paint('partial', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'COMPLETED') return paint('completed', COLORS.gold, opts.color);
|
||||
if (input.temporalStatus === 'TERMINATED') return paint('stopped', COLORS.yellow, opts.color);
|
||||
if (input.temporalStatus === 'CANCELLED' || input.temporalStatus === 'CANCELED') {
|
||||
return paint('cancelled', COLORS.yellow, opts.color);
|
||||
}
|
||||
if (input.temporalStatus === 'TIMED_OUT') return paint('timed out', COLORS.red, opts.color);
|
||||
return paint('FAILED', COLORS.red, opts.color);
|
||||
}
|
||||
|
||||
// === Line builders ===
|
||||
|
||||
function agentMeta(
|
||||
state: RunState,
|
||||
metrics: { durationMs: number } | undefined,
|
||||
runner: RunningAgent | undefined,
|
||||
error: string | undefined,
|
||||
opts: RenderOptions,
|
||||
): string {
|
||||
if (state === 'completed') {
|
||||
const duration = metrics?.durationMs != null ? formatDuration(metrics.durationMs) : 'done';
|
||||
return paint(duration, COLORS.dim, opts.color);
|
||||
}
|
||||
if (state === 'running') {
|
||||
const parts = ['running'];
|
||||
if (runner?.startedAt !== undefined) parts.push(formatDuration(opts.now - runner.startedAt));
|
||||
if (runner && runner.attempt > 1) parts.push(`retry ${runner.attempt}`);
|
||||
return paint(parts.join(' · '), COLORS.gold, opts.color);
|
||||
}
|
||||
if (state === 'failed') {
|
||||
const detail = error ? ` · ${truncate(error, 46)}` : '';
|
||||
return paint(`failed${detail}`, COLORS.red, opts.color);
|
||||
}
|
||||
if (state === 'skipped') return paint('skipped', COLORS.dim, opts.color);
|
||||
return paint('queued', COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
function phaseMeta(states: readonly RunState[], inPlay: number, parallel: boolean, opts: RenderOptions): string {
|
||||
if (states.every((s) => s === 'pending')) return paint('pending', COLORS.dim, opts.color);
|
||||
if (states.every((s) => s === 'skipped')) return paint('skipped', COLORS.dim, opts.color);
|
||||
if (states.some((s) => s === 'failed') && !states.some((s) => s === 'running')) {
|
||||
return paint('failed', COLORS.red, opts.color);
|
||||
}
|
||||
if (!parallel) return '';
|
||||
const done = states.filter((s) => s === 'completed').length;
|
||||
const allDone = states.every((s) => s === 'completed' || s === 'skipped');
|
||||
return paint(`${done}/${inPlay} done`, allDone ? COLORS.gold : COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
/** Render the full progress frame as one string (no trailing newline). */
|
||||
export function renderScan(input: RenderInput, opts: RenderOptions): string {
|
||||
const byAgent = new Map(input.running.map((r) => [r.agent, r]));
|
||||
const stateMap = deriveAgentStates(input);
|
||||
const lines: string[] = ['', ...headerLines(input, opts), ''];
|
||||
|
||||
const metaFor = (name: string, state: RunState): string =>
|
||||
agentMeta(state, input.state?.agentMetrics[name], byAgent.get(name), agentError(name, input.state, byAgent), opts);
|
||||
// Only agents that have actually entered play are shown; pending/skipped ones stay hidden.
|
||||
const inPlay = (s: RunState): boolean => s === 'running' || s === 'completed' || s === 'failed';
|
||||
|
||||
for (const phase of PIPELINE) {
|
||||
const states = phase.agents.map((a) => stateMap.get(a.name) ?? 'pending');
|
||||
const playing = states.filter(inPlay).length;
|
||||
const phaseRunState: RunState = phaseGlyphState(states);
|
||||
|
||||
// A single-agent phase carries that agent's own duration/cost on the phase line once it
|
||||
// starts; a parallel phase gets a "k/N done" summary over the agents in play.
|
||||
const first = phase.agents[0];
|
||||
const firstState = states[0];
|
||||
const phaseMetaStr =
|
||||
!phase.parallel && first && firstState && inPlay(firstState)
|
||||
? metaFor(first.name, firstState)
|
||||
: phaseMeta(states, playing, phase.parallel, opts);
|
||||
lines.push(` ${glyph(phaseRunState, opts)} ${phase.label.padEnd(26)}${phaseMetaStr}`);
|
||||
|
||||
if (!phase.parallel) continue;
|
||||
for (let i = 0; i < phase.agents.length; i++) {
|
||||
const agent = phase.agents[i];
|
||||
const state = states[i];
|
||||
if (!agent || !state || !inPlay(state)) continue;
|
||||
lines.push(` ${glyph(state, opts)} ${agent.label.padEnd(18)}${metaFor(agent.name, state)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(...footerLines(input, opts));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function headerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
const elapsedMs = scanElapsedMs(input, opts.now);
|
||||
const meta = [statusBadge(input, opts), elapsedMs !== undefined ? formatDuration(elapsedMs) : '—'].join(' · ');
|
||||
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${input.workspace.padEnd(22)} ${meta}`];
|
||||
}
|
||||
|
||||
/** Aligned label column for the footer's Logs / Temporal rows. */
|
||||
const FOOTER_LABEL_WIDTH = 12;
|
||||
|
||||
/** A thin rule that sets the footer apart from the phase list above it. */
|
||||
function footerDivider(opts: RenderOptions): string {
|
||||
return paint(` ${(opts.unicode ? '─' : '-').repeat(60)}`, COLORS.dim, opts.color);
|
||||
}
|
||||
|
||||
/** One footer row: an accent-colored label in a fixed column, then its value in the default color. */
|
||||
function footerRow(label: string, value: string, opts: RenderOptions): string {
|
||||
return ` ${paint(label.padEnd(FOOTER_LABEL_WIDTH), COLORS.gold, opts.color)}${value}`;
|
||||
}
|
||||
|
||||
function footerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
const prefix = commandPrefix();
|
||||
|
||||
if (isTerminal(input.temporalStatus) && input.state?.summary) {
|
||||
const wall = formatDuration(input.state.summary.totalDurationMs);
|
||||
return ['', ` Time Taken ${wall}`];
|
||||
}
|
||||
|
||||
const logsValue = `${prefix} logs ${input.workspace}`;
|
||||
const temporalValue = temporalDashboardUrl(input.workflowId);
|
||||
|
||||
if (isTerminal(input.temporalStatus)) {
|
||||
const reason = input.failureMessage ?? input.state?.error ?? 'no result recorded';
|
||||
return [
|
||||
footerDivider(opts),
|
||||
paint(
|
||||
` ${input.temporalStatus === 'TERMINATED' ? 'Stopped' : 'Ended'} — ${truncate(reason, 240)}`,
|
||||
COLORS.dim,
|
||||
opts.color,
|
||||
),
|
||||
footerRow('Logs', logsValue, opts),
|
||||
footerRow('Temporal', temporalValue, opts),
|
||||
];
|
||||
}
|
||||
|
||||
const lines = [footerDivider(opts), footerRow('Logs', logsValue, opts), footerRow('Temporal', temporalValue, opts)];
|
||||
if (opts.live) lines.push('', paint(' Ctrl-C stops watching — the scan keeps running.', COLORS.dim, opts.color));
|
||||
return lines;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Machine-readable snapshot of one scan, for `shannon status --json`.
|
||||
*
|
||||
* A point-in-time view built from the same derivation the human progress tree uses
|
||||
* (derive.ts), so the JSON and the rendered tree can never disagree about an agent's
|
||||
* state. One invocation is one snapshot — callers that want to track progress poll it.
|
||||
*/
|
||||
|
||||
import type { DerivedPhase } from './derive.js';
|
||||
import { derivePipeline, isTerminal, scanElapsedMs } from './derive.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
|
||||
/** Coarse scan status token, mirroring the human status badge in machine-friendly form. */
|
||||
export type ScanStatus = 'running' | 'completed' | 'partial' | 'failed' | 'stopped' | 'cancelled' | 'timed_out';
|
||||
|
||||
export interface StatusJson {
|
||||
readonly workspace: string;
|
||||
/** Temporal workflow id backing this scan (differs from workspace on a resume). */
|
||||
readonly workflowId?: string;
|
||||
/** Coarse outcome: `running` until the scan closes, then its terminal status. */
|
||||
readonly status: ScanStatus;
|
||||
/** Raw Temporal WorkflowExecutionStatusName, for callers that need the source status. */
|
||||
readonly temporalStatus: string;
|
||||
/** Wall-clock elapsed ms (live for a running scan, final for a closed one), or null when unknown. */
|
||||
readonly elapsedMs: number | null;
|
||||
readonly startedAt?: string;
|
||||
readonly endedAt?: string;
|
||||
/** Failure text when a failed scan left no readable state. */
|
||||
readonly failureMessage?: string;
|
||||
readonly phases: readonly DerivedPhase[];
|
||||
}
|
||||
|
||||
/** Map the raw Temporal status (and workflow status) onto the coarse machine token. */
|
||||
function deriveStatus(input: RenderInput): ScanStatus {
|
||||
if (!isTerminal(input.temporalStatus)) return 'running';
|
||||
if (input.state?.status === 'partial') return 'partial';
|
||||
|
||||
switch (input.temporalStatus) {
|
||||
case 'COMPLETED':
|
||||
return 'completed';
|
||||
case 'TERMINATED':
|
||||
return 'stopped';
|
||||
case 'CANCELLED':
|
||||
case 'CANCELED':
|
||||
return 'cancelled';
|
||||
case 'TIMED_OUT':
|
||||
return 'timed_out';
|
||||
default:
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the JSON snapshot for a scan at instant `now`. */
|
||||
export function toStatusJson(input: RenderInput, now: number): StatusJson {
|
||||
const elapsedMs = scanElapsedMs(input, now);
|
||||
|
||||
return {
|
||||
workspace: input.workspace,
|
||||
...(input.workflowId !== undefined && { workflowId: input.workflowId }),
|
||||
status: deriveStatus(input),
|
||||
temporalStatus: input.temporalStatus,
|
||||
elapsedMs: elapsedMs ?? null,
|
||||
...(input.startedAt !== undefined && { startedAt: new Date(input.startedAt).toISOString() }),
|
||||
...(input.endedAt !== undefined && { endedAt: new Date(input.endedAt).toISOString() }),
|
||||
...(input.failureMessage !== undefined && { failureMessage: input.failureMessage }),
|
||||
phases: derivePipeline(input, now),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user