mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-14 05:59:06 +02:00
feat(logging): trace tool calls and write a log per agent
Record complete tool-call arguments in the workflow log and project each agent's events into its own durable log. Add agent listing and agent-specific log tailing while preserving byte-exact output and draining log handles before activities return.
This commit is contained in:
+170
-23
@@ -9,6 +9,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { watch } from 'chokidar';
|
||||
import { fail } from '../errors.js';
|
||||
@@ -18,8 +19,69 @@ import { resolveWorkflowId } from '../session.js';
|
||||
import { waitForWorkflowClose } from '../temporal-client.js';
|
||||
import { stdoutIsTerminal } from '../tty.js';
|
||||
|
||||
/** Read a byte range from a file and return it as a UTF-8 string. */
|
||||
function readRange(filePath: string, start: number, end: number): string {
|
||||
const TERMINAL_HEADINGS = new Set(['Scan COMPLETED', 'Scan PARTIAL', 'Scan FAILED', 'Scan CANCELLED']);
|
||||
|
||||
// The combined log resets completion on the bare `RESUMED` heading; a per-agent file carries the
|
||||
// distinct `--- RESUMED (<workflow id>) ---` boundary that WorkflowLogger.logResumeBoundary writes
|
||||
// (kept distinct per resume so it stays idempotent per file). Both mean a new execution began, so a
|
||||
// `--agent` tail must clear a stale terminal marker on either, matching the combined tail.
|
||||
const AGENT_RESUME_BOUNDARY = /^--- RESUMED \(.+\) ---$/u;
|
||||
|
||||
function isResumeBoundary(line: string): boolean {
|
||||
return line === 'RESUMED' || AGENT_RESUME_BOUNDARY.test(line);
|
||||
}
|
||||
|
||||
/** Tracks only complete structural lines while output remains byte-for-byte unchanged. */
|
||||
export class LogCompletionState {
|
||||
private pendingLine = '';
|
||||
private terminalIsLastMarker = false;
|
||||
private failureIsLastMarker = false;
|
||||
|
||||
ingest(chunk: string): void {
|
||||
const lines = `${this.pendingLine}${chunk}`.split('\n');
|
||||
this.pendingLine = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (isResumeBoundary(line)) {
|
||||
this.terminalIsLastMarker = false;
|
||||
this.failureIsLastMarker = false;
|
||||
} else if (TERMINAL_HEADINGS.has(line)) {
|
||||
this.terminalIsLastMarker = true;
|
||||
this.failureIsLastMarker = line === 'Scan FAILED';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isComplete(): boolean {
|
||||
return this.terminalIsLastMarker;
|
||||
}
|
||||
|
||||
hasFailureMarker(): boolean {
|
||||
return this.failureIsLastMarker;
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the forced-stop marker after the worker has exited, unless this execution already ended. */
|
||||
export function appendCancellationFallback(logFile: string): void {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const state = new LogCompletionState();
|
||||
try {
|
||||
state.ingest(fs.readFileSync(logFile, 'utf8'));
|
||||
} catch {
|
||||
// A pre-registration stop may not have created the file yet.
|
||||
}
|
||||
if (state.isComplete()) return;
|
||||
|
||||
const descriptor = fs.openSync(logFile, 'a', 0o600);
|
||||
try {
|
||||
fs.writeSync(descriptor, '\nScan CANCELLED\n');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a byte range without decoding across an arbitrary live-write boundary. */
|
||||
function readRange(filePath: string, start: number, end: number): Buffer {
|
||||
const length = end - start;
|
||||
const buffer = Buffer.alloc(length);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
@@ -28,7 +90,7 @@ function readRange(filePath: string, start: number, end: number): string {
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return buffer.toString('utf-8');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
|
||||
@@ -76,9 +138,6 @@ export interface TailResult {
|
||||
readonly sawFailure: boolean;
|
||||
}
|
||||
|
||||
// The worker writes this exact line at the head of its terminal failure summary.
|
||||
const FAILURE_MARKER = /^Scan FAILED$/m;
|
||||
|
||||
/**
|
||||
* Stream a scan's log to the terminal until the workflow closes (completion comes from Temporal,
|
||||
* or Ctrl-C). A Temporal outage is warned about and, if sustained, ends the tail with a diagnostic.
|
||||
@@ -88,24 +147,25 @@ const FAILURE_MARKER = /^Scan FAILED$/m;
|
||||
export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Promise<TailResult> {
|
||||
return new Promise((resolve) => {
|
||||
let position = 0;
|
||||
const completion = new LogCompletionState();
|
||||
let done = false;
|
||||
let sawFailure = false;
|
||||
const controller = new AbortController();
|
||||
let watcher: ReturnType<typeof watch> | undefined;
|
||||
const completionDecoder = new StringDecoder('utf8');
|
||||
|
||||
/** Output any new content appended since the last read. */
|
||||
function flush(): void {
|
||||
function flush(): boolean {
|
||||
try {
|
||||
const { size } = fs.statSync(logFile);
|
||||
if (size <= position) return;
|
||||
if (size <= position) return completion.isComplete();
|
||||
const data = readRange(logFile, position, size);
|
||||
process.stdout.write(data);
|
||||
position = size;
|
||||
if (!sawFailure && FAILURE_MARKER.test(data)) {
|
||||
sawFailure = true;
|
||||
}
|
||||
completion.ingest(completionDecoder.write(data));
|
||||
return completion.isComplete();
|
||||
} catch {
|
||||
// File not present yet or transiently unreadable — nothing to flush this round.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,22 +173,32 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
|
||||
if (done) return;
|
||||
done = true;
|
||||
controller.abort();
|
||||
process.off('SIGINT', finish);
|
||||
const result = { sawFailure: completion.hasFailureMarker() };
|
||||
if (watcher) {
|
||||
watcher.close().finally(() => resolve({ sawFailure }));
|
||||
watcher.close().finally(() => resolve(result));
|
||||
// Safety net — resolve anyway if watcher.close() stalls.
|
||||
setTimeout(() => resolve({ sawFailure }), 1000).unref();
|
||||
setTimeout(() => resolve(result), 1000).unref();
|
||||
} else {
|
||||
resolve({ sawFailure });
|
||||
resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Output existing content, then stream anything appended.
|
||||
flush();
|
||||
// 1. Output existing content, then stream anything appended. A per-agent file can be created
|
||||
// after the watcher starts, so `add` is handled too and streams it from its first line.
|
||||
watcher = watch(logFile, { persistent: true });
|
||||
watcher.on('change', () => flush());
|
||||
const onFsEvent = (): void => {
|
||||
if (flush() && !opts.workflowId) finish();
|
||||
};
|
||||
watcher.on('change', onFsEvent);
|
||||
watcher.on('add', onFsEvent);
|
||||
if (flush() && !opts.workflowId) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Ctrl-C stops watching.
|
||||
process.on('SIGINT', finish);
|
||||
process.once('SIGINT', finish);
|
||||
|
||||
// 3. Temporal decides completion. Without a workflow id, the tail relies on Ctrl-C alone.
|
||||
if (opts.workflowId) {
|
||||
@@ -162,11 +232,51 @@ export function tailUntilComplete(logFile: string, opts: TailOptions = {}): Prom
|
||||
});
|
||||
}
|
||||
|
||||
export function logs(workspaceId: string): void {
|
||||
const logFile = resolveLogFile(workspaceId);
|
||||
const workflowId = resolveWorkflowId(workspaceId);
|
||||
console.error(stdoutIsTerminal() ? `Tailing scan log: ${logFile}` : 'Tailing scan log');
|
||||
/** The `.shannon/agents/` directory that sits beside a scan's combined workflow.log. */
|
||||
function agentsDirFor(logFile: string): string {
|
||||
return path.join(path.dirname(logFile), 'agents');
|
||||
}
|
||||
|
||||
/** List the per-agent log names available for a scan (filename stems, sorted), or an empty list. */
|
||||
export function listAgentLogNames(logFile: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(agentsDirFor(logFile))
|
||||
.filter((entry) => entry.endsWith('.log'))
|
||||
.map((entry) => entry.slice(0, -'.log'.length))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent name to its per-agent log path. The name must be a closed-charset basename, and
|
||||
* the resolved file must stay inside the agents directory: traversal and symlink escapes are
|
||||
* rejected. Returns undefined when the name is structurally invalid or escapes the directory.
|
||||
*/
|
||||
export function resolveAgentLogFile(logFile: string, agentName: string): string | undefined {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(agentName)) return undefined;
|
||||
const agentsDir = agentsDirFor(logFile);
|
||||
const target = path.join(agentsDir, `${agentName}.log`);
|
||||
try {
|
||||
const realDir = fs.realpathSync(agentsDir);
|
||||
const realTarget = fs.realpathSync(target);
|
||||
if (realTarget !== path.join(realDir, `${agentName}.log`)) return undefined;
|
||||
} catch {
|
||||
// The file does not exist yet (scan still starting); the closed-charset check already proved
|
||||
// the path cannot traverse out of the agents directory, so it is safe to watch for creation.
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export interface LogsOptions {
|
||||
readonly agent?: string;
|
||||
readonly listAgents?: boolean;
|
||||
}
|
||||
|
||||
function tailFileToExit(logFile: string, workflowId: string | undefined, label: string): void {
|
||||
console.error(stdoutIsTerminal() ? `${label}: ${logFile}` : label);
|
||||
let unreachable = false;
|
||||
tailUntilComplete(logFile, {
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
@@ -175,3 +285,40 @@ export function logs(workspaceId: string): void {
|
||||
},
|
||||
}).finally(() => process.exit(unreachable ? 1 : 0));
|
||||
}
|
||||
|
||||
export function logs(workspaceId: string, options: LogsOptions = {}): void {
|
||||
const logFile = resolveLogFile(workspaceId);
|
||||
|
||||
if (options.listAgents) {
|
||||
const names = listAgentLogNames(logFile);
|
||||
if (names.length === 0) {
|
||||
console.error('No per-agent logs for this scan yet.');
|
||||
process.exit(0);
|
||||
}
|
||||
for (const name of names) console.log(name);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const workflowId = resolveWorkflowId(workspaceId);
|
||||
|
||||
if (options.agent !== undefined) {
|
||||
const agentFile = resolveAgentLogFile(logFile, options.agent);
|
||||
if (agentFile === undefined) {
|
||||
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(listAgentLogNames(logFile)));
|
||||
}
|
||||
const known = listAgentLogNames(logFile);
|
||||
// If the directory already lists agents, a name not among them is a typo, not a not-yet-created
|
||||
// file; fail loudly rather than tailing a path that will never appear.
|
||||
if (known.length > 0 && !known.includes(options.agent)) {
|
||||
fail(`No agent log named: ${options.agent}`, '', 'Available agents:', ...withBullets(known));
|
||||
}
|
||||
tailFileToExit(agentFile, workflowId, `Tailing ${options.agent} log`);
|
||||
return;
|
||||
}
|
||||
|
||||
tailFileToExit(logFile, workflowId, 'Tailing scan log');
|
||||
}
|
||||
|
||||
function withBullets(names: readonly string[]): string[] {
|
||||
return names.length === 0 ? [' (none yet)'] : names.map((name) => ` - ${name}`);
|
||||
}
|
||||
|
||||
@@ -3,24 +3,29 @@
|
||||
* Never touches infra or data; to wipe Temporal state entirely, use `shannon reset`.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import * as p from '@clack/prompts';
|
||||
import { confirmOrExit } from '../confirm.js';
|
||||
import {
|
||||
anyRunningScanWorkflow,
|
||||
cancelWorkflow,
|
||||
ensureDocker,
|
||||
isTemporalReady,
|
||||
isWorkflowRunning,
|
||||
runningContainers,
|
||||
runningScanWorkspaces,
|
||||
scanFilter,
|
||||
stopContainers,
|
||||
terminateAllWorkflows,
|
||||
terminateWorkflow,
|
||||
WORKER_FILTER,
|
||||
} from '../docker.js';
|
||||
import { fail, failUsage, warn } from '../errors.js';
|
||||
import { getWorkspacesDir } from '../home.js';
|
||||
import { commandPrefix } from '../mode.js';
|
||||
import { resolveRunFile } from '../paths.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { resolveDefaultWorkspace } from '../workspaces.js';
|
||||
import { appendCancellationFallback } from './logs.js';
|
||||
|
||||
export interface StopOptions {
|
||||
all: boolean;
|
||||
@@ -28,11 +33,77 @@ export interface StopOptions {
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
const CANCELLATION_GRACE_MS = 10_000;
|
||||
const CANCELLATION_POLL_MS = 250;
|
||||
|
||||
export interface StopTarget {
|
||||
readonly workspace: string;
|
||||
readonly workflowId?: string;
|
||||
readonly workflowRunning: boolean;
|
||||
}
|
||||
|
||||
export interface StopLifecycle {
|
||||
readonly cancel: (workflowId: string) => boolean;
|
||||
readonly isRunning: (workflowId: string) => boolean;
|
||||
readonly terminate: (workflowId: string) => boolean;
|
||||
readonly containers: (workspace: string) => string[];
|
||||
readonly stopContainers: (ids: string[]) => Promise<void>;
|
||||
readonly appendFallback: (workspace: string) => void;
|
||||
readonly wait: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const stopLifecycle: StopLifecycle = {
|
||||
cancel: cancelWorkflow,
|
||||
isRunning: isWorkflowRunning,
|
||||
terminate: (workflowId) => terminateWorkflow(workflowId, 'Stopped after cancellation grace period'),
|
||||
containers: (workspace) => runningContainers(scanFilter(workspace)),
|
||||
stopContainers,
|
||||
appendFallback: (workspace) => {
|
||||
const logFile = resolveRunFile(path.join(getWorkspacesDir(), workspace), 'workflow.log');
|
||||
appendCancellationFallback(logFile);
|
||||
},
|
||||
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
||||
};
|
||||
|
||||
/** Cancel first; terminate and write the fallback heading only when graceful closure misses its deadline. */
|
||||
export async function stopTargetCancelFirst(
|
||||
target: StopTarget,
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
graceMs: number = CANCELLATION_GRACE_MS,
|
||||
pollMs: number = CANCELLATION_POLL_MS,
|
||||
): Promise<'graceful' | 'forced'> {
|
||||
let forced = !target.workflowRunning || target.workflowId === undefined;
|
||||
if (!forced && target.workflowId !== undefined) {
|
||||
lifecycle.cancel(target.workflowId);
|
||||
const deadline = Date.now() + graceMs;
|
||||
while (lifecycle.isRunning(target.workflowId) && Date.now() < deadline) {
|
||||
await lifecycle.wait(pollMs);
|
||||
}
|
||||
forced = lifecycle.isRunning(target.workflowId);
|
||||
if (forced) lifecycle.terminate(target.workflowId);
|
||||
}
|
||||
|
||||
await lifecycle.stopContainers(lifecycle.containers(target.workspace));
|
||||
if (forced && lifecycle.containers(target.workspace).length === 0) {
|
||||
lifecycle.appendFallback(target.workspace);
|
||||
}
|
||||
return forced ? 'forced' : 'graceful';
|
||||
}
|
||||
|
||||
/** Apply the same captured-target lifecycle concurrently for `stop --all`. */
|
||||
export function stopTargetsCancelFirst(
|
||||
targets: readonly StopTarget[],
|
||||
lifecycle: StopLifecycle = stopLifecycle,
|
||||
graceMs: number = CANCELLATION_GRACE_MS,
|
||||
pollMs: number = CANCELLATION_POLL_MS,
|
||||
): Promise<readonly ('graceful' | 'forced')[]> {
|
||||
return Promise.all(targets.map((target) => stopTargetCancelFirst(target, lifecycle, graceMs, pollMs)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a single scan. Terminating the workflow both clears Temporal's record and
|
||||
* brings the container down (the worker waits on the workflow result), so that runs
|
||||
* first; `docker stop` is the fallback for the pre-registration window and an
|
||||
* unreachable Temporal. The stop is then verified rather than assumed.
|
||||
* Stop a single scan. Cooperative cancellation gets the first ten seconds so the
|
||||
* workflow can flush its terminal log; termination and a host-written heading are
|
||||
* the fallback for the pre-registration window or an unavailable finalizer.
|
||||
*/
|
||||
async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
const workflowId = resolveWorkflowId(workspace);
|
||||
@@ -56,10 +127,7 @@ async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Stopping scan ${workspace}`);
|
||||
|
||||
if (workflowId && workflowRunning) {
|
||||
terminateWorkflow(workflowId, `Stopped via shannon stop ${workspace}`);
|
||||
}
|
||||
await stopContainers(runningContainers(filter));
|
||||
await stopTargetCancelFirst({ workspace, ...(workflowId !== undefined && { workflowId }), workflowRunning });
|
||||
|
||||
const stillRunning = runningContainers(filter);
|
||||
if (stillRunning.length > 0) {
|
||||
@@ -78,6 +146,14 @@ async function stopSingleScan(workspace: string, yes: boolean): Promise<void> {
|
||||
async function stopAllScans(yes: boolean): Promise<void> {
|
||||
const temporalUp = isTemporalReady();
|
||||
const initial = runningContainers(WORKER_FILTER);
|
||||
const targets = [...new Set(runningScanWorkspaces())].map((workspace): StopTarget => {
|
||||
const workflowId = resolveWorkflowId(workspace);
|
||||
return {
|
||||
workspace,
|
||||
...(workflowId !== undefined && { workflowId }),
|
||||
workflowRunning: Boolean(workflowId && temporalUp && isWorkflowRunning(workflowId)),
|
||||
};
|
||||
});
|
||||
|
||||
// Resolve what is running before prompting, so we never confirm a no-op.
|
||||
if (initial.length === 0) {
|
||||
@@ -90,9 +166,8 @@ async function stopAllScans(yes: boolean): Promise<void> {
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Stopping all scans');
|
||||
|
||||
if (temporalUp) {
|
||||
terminateAllWorkflows('Stopped via shannon stop --all');
|
||||
}
|
||||
await stopTargetsCancelFirst(targets);
|
||||
// Keep the legacy safety net for a worker whose workspace label was unavailable.
|
||||
await stopContainers(runningContainers(WORKER_FILTER));
|
||||
|
||||
const stillRunning = runningContainers(WORKER_FILTER);
|
||||
|
||||
+5
-12
@@ -519,6 +519,11 @@ export async function stopContainers(ids: string[]): Promise<void> {
|
||||
await Promise.all(ids.map((id) => spawnQuiet('docker', ['stop', id])));
|
||||
}
|
||||
|
||||
/** Request cooperative cancellation so the workflow can run its terminal finalizer. */
|
||||
export function cancelWorkflow(workflowId: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'cancel', '--workflow-id', workflowId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a Temporal workflow so a stopped scan doesn't linger as a running
|
||||
* workflow with no worker. Best-effort: returns false if Temporal is unreachable
|
||||
@@ -528,18 +533,6 @@ export function terminateWorkflow(workflowId: string, reason: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'terminate', '--workflow-id', workflowId, '--reason', reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate every running pentest workflow in one batch, so `stop --all` doesn't
|
||||
* leave workflows running with no worker. Best-effort: returns false if Temporal
|
||||
* is unreachable. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function terminateAllWorkflows(reason: string): boolean {
|
||||
return runQuiet(
|
||||
'docker',
|
||||
temporalCmd('workflow', 'terminate', '--query', RUNNING_SCAN_QUERY, '--reason', reason, '--yes'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a specific workflow is still in the Running state. Re-querying this after
|
||||
* a terminate verifies it actually took effect, rather than trusting the terminate
|
||||
|
||||
+14
-3
@@ -94,6 +94,7 @@ function renderUsage(prefix: string, mode: Mode): string {
|
||||
[`${prefix} stop --all [--yes]`, 'Stop all scans (Temporal stays up)'],
|
||||
[`${prefix} reset`, 'Stop everything and wipe all Temporal data'],
|
||||
[`${prefix} logs [<workspace>]`, "Show a scan's live log (default: running or most recent)"],
|
||||
[`${prefix} logs [<workspace>] --agent <name>`, "Tail one agent's log; --list-agents to list them"],
|
||||
[
|
||||
`${prefix} status [<workspace>] [--json]`,
|
||||
'Live phase/agent progress of one scan (default: running or most recent)',
|
||||
@@ -294,9 +295,19 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case 'logs': {
|
||||
const { positionals } = parseArgs(rest, { maxPositionals: 1 });
|
||||
const workspaceId = resolveViewingWorkspace(positionals[0], `Usage: ${commandPrefix()} logs [<workspace>]`);
|
||||
logs(workspaceId);
|
||||
const { flags, values, positionals } = parseArgs(rest, {
|
||||
booleans: { listAgents: ['--list-agents'] },
|
||||
values: { agent: ['--agent'] },
|
||||
maxPositionals: 1,
|
||||
});
|
||||
const workspaceId = resolveViewingWorkspace(
|
||||
positionals[0],
|
||||
`Usage: ${commandPrefix()} logs [<workspace>] [--agent <name>] [--list-agents]`,
|
||||
);
|
||||
logs(workspaceId, {
|
||||
...(values.agent !== undefined && { agent: values.agent }),
|
||||
...(flags.listAgents && { listAgents: true }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
pipelineForState,
|
||||
} from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
import { safeFailureDetail, safeOperationKey, safeOperationLabel } from './safe-fields.js';
|
||||
|
||||
export type RunState = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
@@ -77,11 +78,9 @@ function agentState(name: string, state: PipelineState | null, running: Set<stri
|
||||
*/
|
||||
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)
|
||||
);
|
||||
const hasFailure =
|
||||
failed !== undefined || byAgent.get(name)?.lastFailure !== undefined || state?.failedAgent === name;
|
||||
return safeFailureDetail(hasFailure);
|
||||
}
|
||||
|
||||
/** Scan wall-clock elapsed ms: recorded duration for a closed scan, live elapsed for a running one. */
|
||||
@@ -229,7 +228,7 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
|
||||
label: runner.label,
|
||||
status: 'running' as const,
|
||||
...(runner.startedAt !== undefined && { startedAt: runner.startedAt }),
|
||||
...(runner.lastFailure !== undefined && { error: runner.lastFailure }),
|
||||
...(runner.lastFailure !== undefined && { error: safeFailureDetail(true) }),
|
||||
}));
|
||||
const operationalAgents: DerivedAgent[] = [...persistedOperations, ...unpersistedRunning].map((operation) => {
|
||||
const runner = byAgent.get(operation.key);
|
||||
@@ -237,15 +236,15 @@ export function derivePipeline(input: RenderInput, now: number): DerivedPhase[]
|
||||
const persistedDurationMs = 'durationMs' in operation ? (operation.durationMs ?? null) : null;
|
||||
const detail = operationState === 'running' ? stepByFamily.get(operationFamilyKey(operation.key)) : undefined;
|
||||
return {
|
||||
name: operation.key,
|
||||
label: operation.label,
|
||||
name: safeOperationKey(operation.key),
|
||||
label: safeOperationLabel(operation.label),
|
||||
state: operationState,
|
||||
durationMs: operationState === 'completed' ? persistedDurationMs : null,
|
||||
runningElapsedMs:
|
||||
operationState === 'running' && operation.startedAt !== undefined ? now - operation.startedAt : null,
|
||||
attempt: operationState === 'running' ? (runner?.attempt ?? null) : null,
|
||||
...(detail !== undefined && { detail }),
|
||||
...(operation.error !== undefined && { error: operation.error }),
|
||||
...(operation.error !== undefined && { error: safeFailureDetail(true) }),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { commandPrefix } from '../mode.js';
|
||||
import type { RunningAgent } from '../temporal-client.js';
|
||||
import { derivePipeline, isTerminal, type RunState, scanElapsedMs } from './derive.js';
|
||||
import type { PipelineState } from './pipeline.js';
|
||||
import { safeAgenticSast, safeCliIdentifier, safePartialReasons, safeTerminalFailure } from './safe-fields.js';
|
||||
|
||||
export interface RenderInput {
|
||||
readonly workspace: string;
|
||||
@@ -67,7 +68,7 @@ function truncate(text: string, max: number): string {
|
||||
/** 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;
|
||||
return workflowId ? `${base}/namespaces/default/workflows/${safeCliIdentifier(workflowId)}` : base;
|
||||
}
|
||||
|
||||
// === Glyphs & status ===
|
||||
@@ -214,7 +215,7 @@ export function renderScan(input: RenderInput, opts: RenderOptions): string {
|
||||
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}`];
|
||||
return [` ${paint('Scan:', COLORS.bold, opts.color)} ${safeCliIdentifier(input.workspace).padEnd(22)} ${meta}`];
|
||||
}
|
||||
|
||||
/** Aligned label column for the footer's Logs / Temporal rows. */
|
||||
@@ -239,7 +240,7 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
|
||||
// A partial scan names each durable degradation reason through its safe message,
|
||||
// so the operator never has to guess why the badge is not "completed".
|
||||
const reasons = input.state.partialReasons ?? [];
|
||||
const reasons = safePartialReasons(input.state.partialReasons ?? []);
|
||||
if (reasons.length > 0) {
|
||||
lines.push('', ` ${paint('Why this scan is partial:', COLORS.yellow, opts.color)}`);
|
||||
for (const reason of reasons) {
|
||||
@@ -247,7 +248,7 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
}
|
||||
// The safe message names what degraded; these three name the agentic-SAST failure
|
||||
// behind it, under the same labels the scan log and worker output use.
|
||||
const agenticSast = input.state.agenticSast;
|
||||
const agenticSast = safeAgenticSast(input.state.agenticSast);
|
||||
if (agenticSast?.status === 'failed') {
|
||||
if (agenticSast.failedStageLabel !== undefined) {
|
||||
lines.push(paint(` Agentic SAST stopped at: ${agenticSast.failedStageLabel}`, COLORS.dim, opts.color));
|
||||
@@ -268,11 +269,13 @@ function footerLines(input: RenderInput, opts: RenderOptions): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
const logsValue = `${prefix} logs ${input.workspace}`;
|
||||
const logsValue = `${prefix} logs ${safeCliIdentifier(input.workspace)}`;
|
||||
const temporalValue = temporalDashboardUrl(input.workflowId);
|
||||
|
||||
if (isTerminal(input.temporalStatus)) {
|
||||
const reason = input.failureMessage ?? input.state?.error ?? 'no result recorded';
|
||||
const hasRecordedFailure =
|
||||
input.failureMessage !== undefined || (input.state !== null && input.state.error !== null);
|
||||
const reason = safeTerminalFailure(hasRecordedFailure) ?? 'no result recorded';
|
||||
return [
|
||||
footerDivider(opts),
|
||||
paint(
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/** Closed-field projection for Temporal values displayed by the CLI. */
|
||||
|
||||
import type { PartialReasonView, PipelineState } from './pipeline.js';
|
||||
|
||||
const CLASS_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
||||
injection: 'Injection',
|
||||
xss: 'Cross-Site Scripting',
|
||||
auth: 'Authentication',
|
||||
authz: 'Authorization',
|
||||
ssrf: 'Server-Side Request Forgery',
|
||||
miscellaneous: 'Miscellaneous',
|
||||
});
|
||||
|
||||
const STAGE_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
||||
architecture: 'architecture mapping',
|
||||
'threat-model': 'threat modelling',
|
||||
plan: 'review planning',
|
||||
research: 'deep code research',
|
||||
dedupe: 'duplicate merging',
|
||||
review: 'independent review',
|
||||
critic: 'viability critique',
|
||||
confirm: 'static confirmation',
|
||||
calibrate: 'risk calibration',
|
||||
export: 'findings export',
|
||||
workflow: 'orchestration',
|
||||
});
|
||||
|
||||
const TERMINAL_STAGE_NAMES = new Set([
|
||||
'architecture',
|
||||
'threat model',
|
||||
'planning',
|
||||
'audit wave',
|
||||
'deduplication',
|
||||
'review',
|
||||
'critic',
|
||||
'confirmation',
|
||||
'calibration',
|
||||
'export',
|
||||
'orchestration',
|
||||
]);
|
||||
|
||||
const CAPELLA_FAILURE_MESSAGES = new Set([
|
||||
'Provider authentication failed. Verify the configured credential.',
|
||||
'Agentic SAST configuration is invalid.',
|
||||
'Agentic SAST received invalid input.',
|
||||
'An agentic SAST step returned an unusable result.',
|
||||
'An agentic SAST step failed.',
|
||||
'Agentic SAST infrastructure failed before producing a usable result.',
|
||||
'Agentic SAST had not finished when the scan stopped.',
|
||||
]);
|
||||
|
||||
const OPERATION_LABELS = new Set([
|
||||
'Agentic SAST',
|
||||
'Miscellaneous findings',
|
||||
'Reconcile injection',
|
||||
'Reconcile xss',
|
||||
'Reconcile auth',
|
||||
'Reconcile authz',
|
||||
'Reconcile ssrf',
|
||||
'Reconcile miscellaneous',
|
||||
'Prepare reconciliation',
|
||||
'Enrich observations',
|
||||
'Form exploit tasks',
|
||||
'Materialize exploit tasks',
|
||||
'Publish reconciliation',
|
||||
'Renumber injection',
|
||||
'Renumber xss',
|
||||
'Renumber auth',
|
||||
'Renumber authz',
|
||||
'Renumber ssrf',
|
||||
'Renumber miscellaneous',
|
||||
'Initialize report state',
|
||||
'Assemble report inputs',
|
||||
'Compact report findings',
|
||||
'Saving report progress',
|
||||
'Finalize report outputs',
|
||||
'Finalize report without SARIF',
|
||||
'Saving final report state',
|
||||
'Surface customer report',
|
||||
]);
|
||||
|
||||
function safeClassName(value: string | undefined): string | undefined {
|
||||
return value === undefined ? undefined : CLASS_NAMES[value];
|
||||
}
|
||||
|
||||
function safeStageName(value: string | undefined): string | undefined {
|
||||
return value === undefined ? undefined : STAGE_NAMES[value];
|
||||
}
|
||||
|
||||
function reasonMessage(reason: PartialReasonView): string | undefined {
|
||||
const className = safeClassName(reason.vulnerabilityClass);
|
||||
switch (reason.code) {
|
||||
case 'agentic_sast_failed': {
|
||||
const stageName = safeStageName(reason.stage);
|
||||
return stageName === undefined
|
||||
? 'Agentic SAST failed, so the pentest continued without its findings.'
|
||||
: `Agentic SAST failed during ${stageName}, so the pentest continued without its findings.`;
|
||||
}
|
||||
case 'agentic_sast_reduced':
|
||||
return 'Agentic SAST completed with reduced coverage.';
|
||||
case 'class_pipeline_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} could not be fully assessed. The other classes completed. Re-running this workspace retries only the part that failed.`;
|
||||
case 'class_reconciliation_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} findings could not be grouped into test cases, so that class was not exploited. Its analysis results are still in the report.`;
|
||||
case 'report_renumber_failed':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} findings kept their working reference numbers, so numbering in the report may have gaps. The findings themselves are complete.`;
|
||||
case 'report_compaction_failed':
|
||||
return 'Finding reference numbers in the report may have gaps. Every finding is present; only the numbering is affected.';
|
||||
case 'report_class_omitted':
|
||||
return className === undefined
|
||||
? undefined
|
||||
: `${className} was assessed but could not be included in the final report.`;
|
||||
case 'report_sarif_failed':
|
||||
return 'Report SARIF could not be generated. JSON and Markdown remain available.';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function safePartialReasons(reasons: readonly PartialReasonView[]): readonly PartialReasonView[] {
|
||||
return reasons.flatMap((reason) => {
|
||||
const message = reasonMessage(reason);
|
||||
if (message === undefined) return [];
|
||||
const vulnerabilityClass =
|
||||
safeClassName(reason.vulnerabilityClass) === undefined ? undefined : reason.vulnerabilityClass;
|
||||
const stage = safeStageName(reason.stage) === undefined ? undefined : reason.stage;
|
||||
return [
|
||||
{
|
||||
code: reason.code,
|
||||
message,
|
||||
...(vulnerabilityClass !== undefined && { vulnerabilityClass }),
|
||||
...(stage !== undefined && { stage }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function safeAgenticSast(value: PipelineState['agenticSast']):
|
||||
| {
|
||||
readonly status: string;
|
||||
readonly failedStageLabel?: string;
|
||||
readonly error?: string;
|
||||
readonly errorCode?: string;
|
||||
}
|
||||
| undefined {
|
||||
if (value === undefined || !['disabled', 'running', 'succeeded', 'failed'].includes(value.status)) return undefined;
|
||||
const failedStageLabel = TERMINAL_STAGE_NAMES.has(value.failedStageLabel ?? '') ? value.failedStageLabel : undefined;
|
||||
let error: string | undefined;
|
||||
if (value.error !== undefined && CAPELLA_FAILURE_MESSAGES.has(value.error)) {
|
||||
error = value.error;
|
||||
} else if (value.status === 'failed') {
|
||||
error = 'An agentic SAST step failed.';
|
||||
}
|
||||
const errorCode =
|
||||
value.errorCode !== undefined && /^[A-Z][A-Z0-9_]{0,63}$/u.test(value.errorCode) ? value.errorCode : undefined;
|
||||
return {
|
||||
status: value.status,
|
||||
...(failedStageLabel !== undefined && { failedStageLabel }),
|
||||
...(error !== undefined && { error }),
|
||||
...(errorCode !== undefined && { errorCode }),
|
||||
};
|
||||
}
|
||||
|
||||
export function safeOperationLabel(value: string): string {
|
||||
return OPERATION_LABELS.has(value) ? value : 'Background task';
|
||||
}
|
||||
|
||||
export function safeOperationKey(value: string): string {
|
||||
if (
|
||||
/^(?:agentic-sast|miscellaneous-pipeline|report:(?:initialize|assemble|compact|checkpoint|finalize|finalize-degraded|terminal|surface))$/u.test(
|
||||
value,
|
||||
) ||
|
||||
/^(?:reconciliation|report:renumber):(?:injection|xss|auth|authz|ssrf|miscellaneous)$/u.test(value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return 'background-task';
|
||||
}
|
||||
|
||||
export function safeCliIdentifier(value: string): string {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value) ? value : 'unknown';
|
||||
}
|
||||
|
||||
export function safeTemporalStatus(value: string): string {
|
||||
return [
|
||||
'RUNNING',
|
||||
'UNSPECIFIED',
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'CANCELLED',
|
||||
'CANCELED',
|
||||
'TERMINATED',
|
||||
'TIMED_OUT',
|
||||
'CONTINUED_AS_NEW',
|
||||
].includes(value)
|
||||
? value
|
||||
: 'UNKNOWN';
|
||||
}
|
||||
|
||||
export function safeFailureDetail(hasFailure: true): string;
|
||||
export function safeFailureDetail(hasFailure: false): undefined;
|
||||
export function safeFailureDetail(hasFailure: boolean): string | undefined;
|
||||
export function safeFailureDetail(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'This scan step could not be completed.' : undefined;
|
||||
}
|
||||
|
||||
export function safeTerminalFailure(hasFailure: boolean): string | undefined {
|
||||
return hasFailure ? 'The scan could not be completed.' : undefined;
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import type { DerivedPhase } from './derive.js';
|
||||
import { derivePipeline, isTerminal, scanElapsedMs } from './derive.js';
|
||||
import type { PartialReasonView } from './pipeline.js';
|
||||
import type { RenderInput } from './render.js';
|
||||
import {
|
||||
safeAgenticSast,
|
||||
safeCliIdentifier,
|
||||
safePartialReasons,
|
||||
safeTemporalStatus,
|
||||
safeTerminalFailure,
|
||||
} from './safe-fields.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';
|
||||
@@ -61,19 +68,20 @@ function deriveStatus(input: RenderInput): ScanStatus {
|
||||
/** Build the JSON snapshot for a scan at instant `now`. */
|
||||
export function toStatusJson(input: RenderInput, now: number): StatusJson {
|
||||
const elapsedMs = scanElapsedMs(input, now);
|
||||
const partialReasons = input.state?.partialReasons ?? [];
|
||||
const agenticSast = input.state?.agenticSast;
|
||||
const partialReasons = safePartialReasons(input.state?.partialReasons ?? []);
|
||||
const agenticSast = safeAgenticSast(input.state?.agenticSast);
|
||||
const usageAccountingComplete = input.state?.summary?.usageAccountingComplete;
|
||||
const failureMessage = safeTerminalFailure(input.failureMessage !== undefined);
|
||||
|
||||
return {
|
||||
workspace: input.workspace,
|
||||
...(input.workflowId !== undefined && { workflowId: input.workflowId }),
|
||||
workspace: safeCliIdentifier(input.workspace),
|
||||
...(input.workflowId !== undefined && { workflowId: safeCliIdentifier(input.workflowId) }),
|
||||
status: deriveStatus(input),
|
||||
temporalStatus: input.temporalStatus,
|
||||
temporalStatus: safeTemporalStatus(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 }),
|
||||
...(failureMessage !== undefined && { failureMessage }),
|
||||
...(partialReasons.length > 0 && { partialReasons }),
|
||||
// Present only when agentic SAST actually ran; a disabled scan omits the key entirely.
|
||||
...(agenticSast !== undefined &&
|
||||
|
||||
Reference in New Issue
Block a user