mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-04 15:26:55 +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:
1 parent
1ae0a142f8
commit
d41ae9c20d
44 files changed
+2605
-801
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user