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:
ezl-keygraph
2026-08-18 15:46:25 +05:30
committed by GitHub
parent 1ae0a142f8
commit d41ae9c20d
44 changed files with 2605 additions and 801 deletions
+155
View File
@@ -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 };