mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-22 01:30:54 +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 };
|
||||
Reference in New Issue
Block a user