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:
ajmallesh
2026-08-26 20:13:03 -07:00
parent 242f85f158
commit f5e7143619
43 changed files with 2694 additions and 1177 deletions
+87 -12
View File
@@ -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);