mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-08-25 04:32:35 +02:00
* 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
127 lines
4.6 KiB
TypeScript
127 lines
4.6 KiB
TypeScript
/**
|
|
* Thin Temporal client for reading one scan's state.
|
|
*
|
|
* A running scan is queried live (getProgress) and read via pendingActivities for
|
|
* the in-flight agents; a closed scan is read once from its result. Everything goes
|
|
* straight to the frontend on 127.0.0.1:7233 — the gRPC port the compose file
|
|
* publishes — so this needs Temporal up, but no worker of its own.
|
|
*/
|
|
|
|
import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from '@temporalio/client';
|
|
import { ACTIVITY_TO_AGENT, type PipelineState } from './scan/pipeline.js';
|
|
|
|
const ADDRESS = '127.0.0.1:7233';
|
|
const NAMESPACE = 'default';
|
|
|
|
export interface RunningAgent {
|
|
readonly agent: string;
|
|
readonly attempt: number;
|
|
readonly startedAt?: number;
|
|
readonly lastFailure?: string;
|
|
}
|
|
|
|
/** Convert a proto ITimestamp (seconds is a Long) to epoch millis. */
|
|
function timestampMs(
|
|
ts: { seconds?: { toString(): string } | number | null; nanos?: number | null } | null,
|
|
): number | undefined {
|
|
const seconds = ts?.seconds;
|
|
if (seconds == null) return undefined;
|
|
const secNum = typeof seconds === 'number' ? seconds : Number(seconds.toString());
|
|
return secNum * 1000 + (ts?.nanos ?? 0) / 1e6;
|
|
}
|
|
|
|
export interface ScanDescription {
|
|
/** WorkflowExecutionStatusName: RUNNING | COMPLETED | FAILED | CANCELLED | TERMINATED | TIMED_OUT | … */
|
|
readonly status: string;
|
|
readonly startedAt?: number;
|
|
readonly closedAt?: number;
|
|
readonly runningAgents: readonly RunningAgent[];
|
|
}
|
|
|
|
export type TerminalOutcome =
|
|
| { readonly kind: 'success'; readonly state: PipelineState }
|
|
| { readonly kind: 'failed'; readonly message: string };
|
|
|
|
let clientPromise: Promise<Client> | null = null;
|
|
|
|
function getClient(): Promise<Client> {
|
|
if (!clientPromise) {
|
|
clientPromise = Connection.connect({ address: ADDRESS }).then(
|
|
(connection) => new Client({ connection, namespace: NAMESPACE }),
|
|
);
|
|
}
|
|
return clientPromise;
|
|
}
|
|
|
|
/** Describe a scan: status, timing, and the agents currently running (from pendingActivities). Null if not found. */
|
|
export async function describeScan(workflowId: string): Promise<ScanDescription | null> {
|
|
const client = await getClient();
|
|
try {
|
|
const desc = await client.workflow.getHandle(workflowId).describe();
|
|
|
|
const runningAgents: RunningAgent[] = [];
|
|
for (const pending of desc.raw.pendingActivities ?? []) {
|
|
const agent = ACTIVITY_TO_AGENT[pending.activityType?.name ?? ''];
|
|
if (!agent) continue;
|
|
const lastFailure = pending.lastFailure?.message;
|
|
const startedAt = timestampMs(pending.scheduledTime ?? pending.lastStartedTime ?? null);
|
|
runningAgents.push({
|
|
agent,
|
|
attempt: pending.attempt ?? 1,
|
|
...(startedAt !== undefined ? { startedAt } : {}),
|
|
...(lastFailure ? { lastFailure } : {}),
|
|
});
|
|
}
|
|
|
|
return {
|
|
status: desc.status.name,
|
|
runningAgents,
|
|
...(desc.startTime ? { startedAt: desc.startTime.getTime() } : {}),
|
|
...(desc.closeTime ? { closedAt: desc.closeTime.getTime() } : {}),
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof WorkflowNotFoundError) return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Live progress of a running scan via the getProgress query. Null if the query can't be served (no worker). */
|
|
export async function queryProgress(workflowId: string): Promise<PipelineState | null> {
|
|
const client = await getClient();
|
|
try {
|
|
return await client.workflow.getHandle(workflowId).query<PipelineState>('getProgress');
|
|
} catch {
|
|
// The query needs a live worker; a just-closed scan may have none. Caller falls back to the result.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deepest message in a Temporal failure's cause chain — the real reason nested under generic
|
|
* wrappers (WorkflowFailedError → ActivityFailure → ApplicationFailure). Covers failed, cancelled,
|
|
* and terminated alike. Mirrors the SDK's `rootCause` (only exported from @temporalio/common).
|
|
*/
|
|
function rootFailureMessage(err: WorkflowFailedError): string {
|
|
let message = err.message;
|
|
let cause: unknown = err.cause;
|
|
while (cause instanceof Error && cause.message) {
|
|
message = cause.message;
|
|
cause = cause.cause;
|
|
}
|
|
return message;
|
|
}
|
|
|
|
/** Final state of a closed scan: success carries the full PipelineState, failure carries the message. */
|
|
export async function getTerminalOutcome(workflowId: string): Promise<TerminalOutcome> {
|
|
const client = await getClient();
|
|
try {
|
|
const state = (await client.workflow.getHandle(workflowId).result()) as PipelineState;
|
|
return { kind: 'success', state };
|
|
} catch (err) {
|
|
if (err instanceof WorkflowFailedError) {
|
|
return { kind: 'failed', message: rootFailureMessage(err) };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|