mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-16 23:15:32 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user