From f932fad2edae6b1e539646740062ce0dfbe0a6f0 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Fri, 13 Feb 2026 20:26:16 +0530 Subject: [PATCH 01/10] feat: add workflow resume from workspace via --workspace flag When a workflow is interrupted (VM crash, Ctrl+C, Docker restart), it can now be resumed by passing the workspace name. The system reads session.json to determine which agents completed, validates deliverables exist on disk, restores the git checkpoint, and skips already-completed agents. - Add --workspace CLI flag and auto-terminate conflicting workflows - Add loadResumeState, restoreGitCheckpoint, recordResumeAttempt activities - Add skip logic for all 5 pipeline phases including parallel execution - Separate sessionId (persistent directory) from workflowId (execution ID) - Track resume attempts in session.json for audit trail - Derive AgentName type from ALL_AGENTS array to eliminate duplication - Add getDeliverablePath mapping for deliverable validation --- src/audit/audit-session.ts | 30 ++++- src/audit/metrics-tracker.ts | 71 +++++++++- src/temporal/activities.ts | 238 +++++++++++++++++++++++++++++++-- src/temporal/client.ts | 126 +++++++++++++++++- src/temporal/shared.ts | 10 ++ src/temporal/workflows.ts | 247 ++++++++++++++++++++++++++--------- src/types/agents.ts | 66 ++++++++-- 7 files changed, 691 insertions(+), 97 deletions(-) diff --git a/src/audit/audit-session.ts b/src/audit/audit-session.ts index bdfb3c5..6e09133 100644 --- a/src/audit/audit-session.ts +++ b/src/audit/audit-session.ts @@ -64,8 +64,10 @@ export class AuditSession { /** * Initialize audit session (creates directories, session.json) * Idempotent and race-safe + * + * @param workflowId - Optional workflow ID for tracking original or resume workflows */ - async initialize(): Promise { + async initialize(workflowId?: string): Promise { if (this.initialized) { return; // Already initialized } @@ -74,7 +76,7 @@ export class AuditSession { await initializeAuditStructure(this.sessionMetadata); // Initialize metrics tracker (loads or creates session.json) - await this.metricsTracker.initialize(); + await this.metricsTracker.initialize(workflowId); // Initialize workflow logger await this.workflowLogger.initialize(); @@ -252,4 +254,28 @@ export class AuditSession { await this.ensureInitialized(); await this.workflowLogger.logWorkflowComplete(summary); } + + /** + * Add a resume attempt to the session + * Call this when a workflow is resuming from an existing workspace + * + * @param workflowId - The new workflow ID for this resume attempt + * @param terminatedWorkflows - IDs of workflows that were terminated + * @param checkpointHash - Git checkpoint hash that was restored + */ + async addResumeAttempt( + workflowId: string, + terminatedWorkflows: string[], + checkpointHash?: string + ): Promise { + await this.ensureInitialized(); + + const unlock = await sessionMutex.lock(this.sessionId); + try { + await this.metricsTracker.reload(); + await this.metricsTracker.addResumeAttempt(workflowId, terminatedWorkflows, checkpointHash); + } finally { + unlock(); + } + } } diff --git a/src/audit/metrics-tracker.ts b/src/audit/metrics-tracker.ts index 1096fe0..4462d90 100644 --- a/src/audit/metrics-tracker.ts +++ b/src/audit/metrics-tracker.ts @@ -46,6 +46,13 @@ interface PhaseMetrics { agent_count: number; } +export interface ResumeAttempt { + workflowId: string; + timestamp: string; + terminatedPrevious?: string; + resumedFromCheckpoint?: string; +} + interface SessionData { session: { id: string; @@ -54,6 +61,8 @@ interface SessionData { status: 'in-progress' | 'completed' | 'failed'; createdAt: string; completedAt?: string; + originalWorkflowId?: string; // First workflow that created this workspace + resumeAttempts?: ResumeAttempt[]; // Track all resume attempts }; metrics: { total_duration_ms: number; @@ -95,8 +104,10 @@ export class MetricsTracker { /** * Initialize session.json (idempotent) + * + * @param workflowId - Optional workflow ID to set as originalWorkflowId for new sessions */ - async initialize(): Promise { + async initialize(workflowId?: string): Promise { // Check if session.json already exists const exists = await fileExists(this.sessionJsonPath); @@ -105,21 +116,24 @@ export class MetricsTracker { this.data = await readJson(this.sessionJsonPath); } else { // Create new session.json - this.data = this.createInitialData(); + this.data = this.createInitialData(workflowId); await this.save(); } } /** * Create initial session.json structure + * + * @param workflowId - Optional workflow ID to set as originalWorkflowId */ - private createInitialData(): SessionData { + private createInitialData(workflowId?: string): SessionData { const sessionData: SessionData = { session: { id: this.sessionMetadata.id, webUrl: this.sessionMetadata.webUrl, status: 'in-progress', createdAt: (this.sessionMetadata as { createdAt?: string }).createdAt || formatTimestamp(), + resumeAttempts: [], }, metrics: { total_duration_ms: 0, @@ -128,6 +142,12 @@ export class MetricsTracker { agents: {}, // Agent-level metrics }, }; + + // Set originalWorkflowId if provided (for new workspaces) + if (workflowId) { + sessionData.session.originalWorkflowId = workflowId; + } + // Only add repoPath if it exists if (this.sessionMetadata.repoPath) { sessionData.session.repoPath = this.sessionMetadata.repoPath; @@ -229,6 +249,51 @@ export class MetricsTracker { await this.save(); } + /** + * Add a resume attempt to the session + * + * @param workflowId - The new workflow ID for this resume attempt + * @param terminatedWorkflows - IDs of workflows that were terminated + * @param checkpointHash - Git checkpoint hash that was restored + */ + async addResumeAttempt( + workflowId: string, + terminatedWorkflows: string[], + checkpointHash?: string + ): Promise { + if (!this.data) { + throw new Error('MetricsTracker not initialized'); + } + + // Ensure originalWorkflowId is set (backfill if missing from old sessions) + if (!this.data.session.originalWorkflowId) { + this.data.session.originalWorkflowId = this.data.session.id; + } + + // Ensure resumeAttempts array exists + if (!this.data.session.resumeAttempts) { + this.data.session.resumeAttempts = []; + } + + // Add new resume attempt + const resumeAttempt: ResumeAttempt = { + workflowId, + timestamp: formatTimestamp(), + }; + + if (terminatedWorkflows.length > 0) { + resumeAttempt.terminatedPrevious = terminatedWorkflows.join(','); + } + + if (checkpointHash) { + resumeAttempt.resumedFromCheckpoint = checkpointHash; + } + + this.data.session.resumeAttempts.push(resumeAttempt); + + await this.save(); + } + /** * Recalculate aggregations (total duration, total cost, phases) */ diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index a351a94..40fcf4f 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -72,9 +72,14 @@ import { getPromptNameForAgent } from '../types/agents.js'; import { AuditSession } from '../audit/index.js'; import type { WorkflowSummary } from '../audit/workflow-logger.js'; import type { AgentName } from '../types/agents.js'; -import type { AgentMetrics } from './shared.js'; +import { getDeliverablePath, ALL_AGENTS } from '../types/agents.js'; +import type { AgentMetrics, ResumeState } from './shared.js'; import type { DistributedConfig } from '../types/config.js'; -import { copyDeliverablesToAudit, type SessionMetadata } from '../audit/utils.js'; +import { copyDeliverablesToAudit, type SessionMetadata, readJson, fileExists } from '../audit/utils.js'; +import type { ResumeAttempt } from '../audit/metrics-tracker.js'; +import { executeGitCommandWithRetry } from '../utils/git-manager.js'; +import path from 'path'; +import fs from 'fs/promises'; const HEARTBEAT_INTERVAL_MS = 2000; // Must be < heartbeatTimeout (10min production, 5min testing) @@ -89,6 +94,7 @@ export interface ActivityInput { outputPath?: string; pipelineTestingMode?: boolean; workflowId: string; + sessionId: string; // Workspace name (for resume) or workflowId (for new runs) } /** @@ -142,8 +148,9 @@ async function runAgentActivity( } // 2. Build session metadata for audit + // Use sessionId (workspace name) for directory, workflowId for tracking const sessionMetadata: SessionMetadata = { - id: workflowId, + id: input.sessionId, webUrl, repoPath, ...(outputPath && { outputPath }), @@ -151,7 +158,7 @@ async function runAgentActivity( // 3. Initialize audit session (idempotent, safe across retries) const auditSession = new AuditSession(sessionMetadata); - await auditSession.initialize(); + await auditSession.initialize(workflowId); // 4. Load prompt const promptName = getPromptNameForAgent(agentName); @@ -449,6 +456,219 @@ export async function checkExploitationQueue( }; } +// === Resume Activities === + +/** + * Session.json structure for resume state loading + */ +interface SessionJson { + session: { + id: string; + webUrl: string; + repoPath?: string; + originalWorkflowId?: string; + resumeAttempts?: ResumeAttempt[]; + }; + metrics: { + agents: Record; + }; +} + +/** + * Load resume state from an existing workspace. + * Validates workspace exists, URL matches, and determines which agents to skip. + * + * @throws ApplicationFailure.nonRetryable if workspace not found or URL mismatch + */ +export async function loadResumeState( + workspaceName: string, + expectedUrl: string, + expectedRepoPath: string +): Promise { + const sessionPath = path.join('./audit-logs', workspaceName, 'session.json'); + + // Validate workspace exists + const exists = await fileExists(sessionPath); + if (!exists) { + throw ApplicationFailure.nonRetryable( + `Workspace not found: ${workspaceName}\nExpected path: ${sessionPath}`, + 'WorkspaceNotFoundError' + ); + } + + // Load session.json + let session: SessionJson; + try { + session = await readJson(sessionPath); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + throw ApplicationFailure.nonRetryable( + `Corrupted session.json in workspace ${workspaceName}: ${errorMsg}`, + 'CorruptedSessionError' + ); + } + + // Validate URL matches + if (session.session.webUrl !== expectedUrl) { + throw ApplicationFailure.nonRetryable( + `URL mismatch with workspace\n Workspace URL: ${session.session.webUrl}\n Provided URL: ${expectedUrl}`, + 'URLMismatchError' + ); + } + + // Find completed agents (status === 'success' AND deliverable exists) + const completedAgents: string[] = []; + const agents = session.metrics.agents; + + for (const agentName of ALL_AGENTS) { + const agentData = agents[agentName]; + + // Skip if agent never ran or didn't succeed + if (!agentData || agentData.status !== 'success') { + continue; + } + + // Validate deliverable exists + const deliverablePath = getDeliverablePath(agentName, expectedRepoPath); + const deliverableExists = await fileExists(deliverablePath); + + if (!deliverableExists) { + console.log( + chalk.yellow(`Agent ${agentName} shows success but deliverable missing, will re-run`) + ); + continue; + } + + // Agent completed successfully and deliverable exists + completedAgents.push(agentName); + } + + // Find latest checkpoint from completed agents + const checkpoints = completedAgents + .map((name) => agents[name]?.checkpoint) + .filter((hash): hash is string => hash != null); + + if (checkpoints.length === 0) { + throw ApplicationFailure.nonRetryable( + `No successful agent checkpoints found in workspace ${workspaceName}`, + 'NoCheckpointsError' + ); + } + + // Find most recent commit among checkpoints + const checkpointHash = await findLatestCommit(expectedRepoPath, checkpoints); + + const originalWorkflowId = session.session.originalWorkflowId || session.session.id; + + console.log(chalk.cyan(`=== RESUME STATE ===`)); + console.log(`Workspace: ${workspaceName}`); + console.log(`Completed agents: ${completedAgents.length}`); + console.log(`Checkpoint: ${checkpointHash}`); + + return { + workspaceName, + originalUrl: session.session.webUrl, + completedAgents, + checkpointHash, + originalWorkflowId, + }; +} + +/** + * Find the most recent commit among a list of commit hashes. + * Uses git rev-list to determine which commit is newest. + */ +async function findLatestCommit(repoPath: string, commitHashes: string[]): Promise { + if (commitHashes.length === 1) { + const hash = commitHashes[0]; + if (!hash) { + throw new Error('Empty commit hash in array'); + } + return hash; + } + + // Use git rev-list to find the most recent commit among all hashes + const result = await executeGitCommandWithRetry( + ['git', 'rev-list', '--max-count=1', ...commitHashes], + repoPath, + 'find latest commit' + ); + + return result.stdout.trim(); +} + +/** + * Restore git workspace to a checkpoint and clean up partial deliverables. + * + * @param repoPath - Repository path + * @param checkpointHash - Git commit hash to reset to + * @param incompleteAgents - Agents that didn't complete (will have deliverables cleaned up) + */ +export async function restoreGitCheckpoint( + repoPath: string, + checkpointHash: string, + incompleteAgents: AgentName[] +): Promise { + console.log(chalk.blue(`Restoring git workspace to ${checkpointHash}...`)); + + // Git reset to checkpoint + await executeGitCommandWithRetry( + ['git', 'reset', '--hard', checkpointHash], + repoPath, + 'reset to checkpoint for resume' + ); + await executeGitCommandWithRetry( + ['git', 'clean', '-fd'], + repoPath, + 'clean untracked files for resume' + ); + + + // Explicitly delete deliverables for incomplete agents + for (const agentName of incompleteAgents) { + const deliverablePath = getDeliverablePath(agentName, repoPath); + try { + const exists = await fileExists(deliverablePath); + if (exists) { + console.log(chalk.yellow(`Cleaning partial deliverable: ${agentName}`)); + await fs.unlink(deliverablePath); + } + } catch (error) { + // Non-fatal, just log + console.log(chalk.gray(`Note: Failed to delete ${deliverablePath}: ${error}`)); + } + } + + console.log(chalk.green('Workspace restored to clean state')); +} + +/** + * Record a resume attempt in session.json. + * Tracks the new workflow ID, terminated workflows, and checkpoint hash. + */ +export async function recordResumeAttempt( + input: ActivityInput, + terminatedWorkflows: string[], + checkpointHash: string +): Promise { + const { webUrl, repoPath, outputPath, sessionId, workflowId } = input; + + const sessionMetadata: SessionMetadata = { + id: sessionId, + webUrl, + repoPath, + ...(outputPath && { outputPath }), + }; + + const auditSession = new AuditSession(sessionMetadata); + await auditSession.initialize(); + + await auditSession.addResumeAttempt(workflowId, terminatedWorkflows, checkpointHash); +} + /** * Log phase transition to the unified workflow log. * Called at phase boundaries for per-workflow logging. @@ -458,10 +678,10 @@ export async function logPhaseTransition( phase: string, event: 'start' | 'complete' ): Promise { - const { webUrl, repoPath, outputPath, workflowId } = input; + const { webUrl, repoPath, outputPath, sessionId } = input; const sessionMetadata: SessionMetadata = { - id: workflowId, + id: sessionId, webUrl, repoPath, ...(outputPath && { outputPath }), @@ -485,16 +705,16 @@ export async function logWorkflowComplete( input: ActivityInput, summary: WorkflowSummary ): Promise { - const { webUrl, repoPath, outputPath, workflowId } = input; + const { webUrl, repoPath, outputPath, sessionId, workflowId } = input; const sessionMetadata: SessionMetadata = { - id: workflowId, + id: sessionId, webUrl, repoPath, ...(outputPath && { outputPath }), }; const auditSession = new AuditSession(sessionMetadata); - await auditSession.initialize(); + await auditSession.initialize(workflowId); await auditSession.logWorkflowComplete(summary); } diff --git a/src/temporal/client.ts b/src/temporal/client.ts index 945af42..c1b47a1 100644 --- a/src/temporal/client.ts +++ b/src/temporal/client.ts @@ -26,19 +26,86 @@ * TEMPORAL_ADDRESS - Temporal server address (default: localhost:7233) */ -import { Connection, Client } from '@temporalio/client'; +import { Connection, Client, WorkflowNotFoundError } from '@temporalio/client'; import dotenv from 'dotenv'; import chalk from 'chalk'; import { displaySplashScreen } from '../splash-screen.js'; import { sanitizeHostname } from '../audit/utils.js'; +import { readJson, fileExists } from '../audit/utils.js'; +import path from 'path'; // Import types only - these don't pull in workflow runtime code import type { PipelineInput, PipelineState, PipelineProgress } from './shared.js'; +/** + * Session.json structure for resume validation + */ +interface SessionJson { + session: { + id: string; + webUrl: string; + originalWorkflowId?: string; + resumeAttempts?: Array<{ workflowId: string }>; + }; +} + dotenv.config(); // Query name must match the one defined in workflows.ts const PROGRESS_QUERY = 'getProgress'; +/** + * Terminate any running workflows associated with a workspace. + * Returns the list of terminated workflow IDs. + */ +async function terminateExistingWorkflows( + client: Client, + workspaceName: string +): Promise { + const sessionPath = path.join('./audit-logs', workspaceName, 'session.json'); + + if (!(await fileExists(sessionPath))) { + throw new Error( + `Workspace not found: ${workspaceName}\n` + + `Expected path: ${sessionPath}` + ); + } + + const session = await readJson(sessionPath); + + // Collect all workflow IDs associated with this workspace + const workflowIds = [ + session.session.originalWorkflowId || session.session.id, + ...(session.session.resumeAttempts?.map((r) => r.workflowId) || []), + ].filter((id): id is string => id != null); + + const terminated: string[] = []; + + for (const wfId of workflowIds) { + try { + const handle = client.workflow.getHandle(wfId); + const description = await handle.describe(); + + if (description.status.name === 'RUNNING') { + console.log(chalk.yellow(`Terminating running workflow: ${wfId}`)); + await handle.terminate('Superseded by resume workflow'); + terminated.push(wfId); + console.log(chalk.green(`Terminated: ${wfId}`)); + } else { + console.log(chalk.gray(`Workflow already ${description.status.name}: ${wfId}`)); + } + } catch (error) { + if (error instanceof WorkflowNotFoundError) { + console.log(chalk.gray(`Workflow not found (already cleaned up): ${wfId}`)); + } else { + console.log(chalk.red(`Failed to terminate ${wfId}: ${error}`)); + // Continue anyway - don't block resume on termination failure + } + } + } + + return terminated; +} + function showUsage(): void { console.log(chalk.cyan.bold('\nShannon Temporal Client')); console.log(chalk.gray('Start a pentest pipeline workflow\n')); @@ -50,6 +117,7 @@ function showUsage(): void { console.log(' --config Configuration file path'); console.log(' --output Output directory for audit logs'); console.log(' --pipeline-testing Use minimal prompts for fast testing'); + console.log(' --workspace Resume from existing workspace'); console.log( ' --workflow-id Custom workflow ID (default: shannon-)' ); @@ -78,6 +146,7 @@ async function startPipeline(): Promise { let pipelineTestingMode = false; let customWorkflowId: string | undefined; let waitForCompletion = false; + let resumeFromWorkspace: string | undefined; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -107,6 +176,12 @@ async function startPipeline(): Promise { } } else if (arg === '--pipeline-testing') { pipelineTestingMode = true; + } else if (arg === '--workspace') { + const nextArg = args[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + resumeFromWorkspace = nextArg; + i++; + } } else if (arg === '--wait') { waitForCompletion = true; } else if (arg && !arg.startsWith('-')) { @@ -134,26 +209,67 @@ async function startPipeline(): Promise { const client = new Client({ connection }); try { - const hostname = sanitizeHostname(webUrl); - const workflowId = customWorkflowId || `${hostname}_shannon-${Date.now()}`; + let terminatedWorkflows: string[] = []; + let workflowId: string; + let sessionId: string; // Workspace name (persistent directory) + + // === Resume Mode === + if (resumeFromWorkspace) { + console.log(chalk.cyan('=== RESUME MODE ===')); + console.log(`Workspace: ${resumeFromWorkspace}\n`); + + // Terminate any running workflows for this workspace + terminatedWorkflows = await terminateExistingWorkflows(client, resumeFromWorkspace); + + if (terminatedWorkflows.length > 0) { + console.log(chalk.yellow(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`)); + } + + // Validate URL matches workspace + const sessionPath = path.join('./audit-logs', resumeFromWorkspace, 'session.json'); + const session = await readJson(sessionPath); + + if (session.session.webUrl !== webUrl) { + console.error(chalk.red('ERROR: URL mismatch with workspace')); + console.error(` Workspace URL: ${session.session.webUrl}`); + console.error(` Provided URL: ${webUrl}`); + process.exit(1); + } + + // Generate resume workflow ID + workflowId = `${resumeFromWorkspace}_resume_${Date.now()}`; + sessionId = resumeFromWorkspace; + } else { + // === New Workflow === + const hostname = sanitizeHostname(webUrl); + workflowId = customWorkflowId || `${hostname}_shannon-${Date.now()}`; + sessionId = workflowId; + } const input: PipelineInput = { webUrl, repoPath, + workflowId, // Add for audit correlation ...(configPath && { configPath }), ...(outputPath && { outputPath }), ...(pipelineTestingMode && { pipelineTestingMode }), + ...(resumeFromWorkspace && { resumeFromWorkspace }), + ...(terminatedWorkflows.length > 0 && { terminatedWorkflows }), }; - // Determine output directory for display + // Determine output directory for display (use sessionId for persistent directory) // Use displayOutputPath (host path) if provided, otherwise fall back to outputPath or default const effectiveDisplayPath = displayOutputPath || outputPath || './audit-logs'; - const outputDir = `${effectiveDisplayPath}/${workflowId}`; + const outputDir = `${effectiveDisplayPath}/${sessionId}`; console.log(chalk.green.bold(`✓ Workflow started: ${workflowId}`)); + if (resumeFromWorkspace) { + console.log(chalk.gray(` (Resuming workspace: ${sessionId})`)); + } console.log(); console.log(chalk.white(' Target: ') + chalk.cyan(webUrl)); console.log(chalk.white(' Repository: ') + chalk.cyan(repoPath)); + console.log(chalk.white(' Workspace: ') + chalk.cyan(sessionId)); if (configPath) { console.log(chalk.white(' Config: ') + chalk.cyan(configPath)); } diff --git a/src/temporal/shared.ts b/src/temporal/shared.ts index 9120bfc..3ab7f92 100644 --- a/src/temporal/shared.ts +++ b/src/temporal/shared.ts @@ -9,6 +9,16 @@ export interface PipelineInput { outputPath?: string; pipelineTestingMode?: boolean; workflowId?: string; // Added by client, used for audit correlation + resumeFromWorkspace?: string; // Workspace name to resume from + terminatedWorkflows?: string[]; // Workflows terminated during resume +} + +export interface ResumeState { + workspaceName: string; + originalUrl: string; + completedAgents: string[]; + checkpointHash: string; + originalWorkflowId: string; } export interface AgentMetrics { diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index d7d16b0..75b10e2 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -38,8 +38,11 @@ import { type PipelineSummary, type VulnExploitPipelineResult, type AgentMetrics, + type ResumeState, } from './shared.js'; import type { VulnType } from '../queue-validation.js'; +import type { AgentName } from '../types/agents.js'; +import { ALL_AGENTS } from '../types/agents.js'; // Retry configuration for production (long intervals for billing recovery) const PRODUCTION_RETRY = { @@ -127,10 +130,14 @@ export async function pentestPipelineWorkflow( // Build ActivityInput with required workflowId for audit correlation // Activities require workflowId (non-optional), PipelineInput has it optional // Use spread to conditionally include optional properties (exactOptionalPropertyTypes) + // sessionId is workspace name for resume, or workflowId for new runs + const sessionId = input.resumeFromWorkspace || workflowId; + const activityInput: ActivityInput = { webUrl: input.webUrl, repoPath: input.repoPath, workflowId, + sessionId, ...(input.configPath !== undefined && { configPath: input.configPath }), ...(input.outputPath !== undefined && { outputPath: input.outputPath }), ...(input.pipelineTestingMode !== undefined && { @@ -138,23 +145,70 @@ export async function pentestPipelineWorkflow( }), }; + // === RESUME LOGIC === + let resumeState: ResumeState | null = null; + + if (input.resumeFromWorkspace) { + // Load resume state from existing workspace + resumeState = await a.loadResumeState( + input.resumeFromWorkspace, + input.webUrl, + input.repoPath + ); + + // Restore git checkpoint and clean up partial deliverables + const incompleteAgents = ALL_AGENTS.filter( + (agentName) => !resumeState!.completedAgents.includes(agentName) + ) as AgentName[]; + + await a.restoreGitCheckpoint( + input.repoPath, + resumeState.checkpointHash, + incompleteAgents + ); + + // Record resume attempt in session.json + await a.recordResumeAttempt( + activityInput, + input.terminatedWorkflows || [], + resumeState.checkpointHash + ); + + console.log('Resume state loaded and workspace restored'); + } + + // Helper to check if an agent should be skipped + const shouldSkip = (agentName: string): boolean => { + return resumeState?.completedAgents.includes(agentName) ?? false; + }; + try { // === Phase 1: Pre-Reconnaissance === - state.currentPhase = 'pre-recon'; - state.currentAgent = 'pre-recon'; - await a.logPhaseTransition(activityInput, 'pre-recon', 'start'); - state.agentMetrics['pre-recon'] = - await a.runPreReconAgent(activityInput); - state.completedAgents.push('pre-recon'); - await a.logPhaseTransition(activityInput, 'pre-recon', 'complete'); + if (!shouldSkip('pre-recon')) { + state.currentPhase = 'pre-recon'; + state.currentAgent = 'pre-recon'; + await a.logPhaseTransition(activityInput, 'pre-recon', 'start'); + state.agentMetrics['pre-recon'] = + await a.runPreReconAgent(activityInput); + state.completedAgents.push('pre-recon'); + await a.logPhaseTransition(activityInput, 'pre-recon', 'complete'); + } else { + console.log('Skipping pre-recon (already complete)'); + state.completedAgents.push('pre-recon'); + } // === Phase 2: Reconnaissance === - state.currentPhase = 'recon'; - state.currentAgent = 'recon'; - await a.logPhaseTransition(activityInput, 'recon', 'start'); - state.agentMetrics['recon'] = await a.runReconAgent(activityInput); - state.completedAgents.push('recon'); - await a.logPhaseTransition(activityInput, 'recon', 'complete'); + if (!shouldSkip('recon')) { + state.currentPhase = 'recon'; + state.currentAgent = 'recon'; + await a.logPhaseTransition(activityInput, 'recon', 'start'); + state.agentMetrics['recon'] = await a.runReconAgent(activityInput); + state.completedAgents.push('recon'); + await a.logPhaseTransition(activityInput, 'recon', 'complete'); + } else { + console.log('Skipping recon (already complete)'); + state.completedAgents.push('recon'); + } // === Phases 3-4: Vulnerability Analysis + Exploitation (Pipelined) === // Each vuln type runs as an independent pipeline: @@ -165,22 +219,34 @@ export async function pentestPipelineWorkflow( state.currentAgent = 'pipelines'; await a.logPhaseTransition(activityInput, 'vulnerability-exploitation', 'start'); - // Helper: Run a single vuln→exploit pipeline + // Helper: Run a single vuln→exploit pipeline with skip logic async function runVulnExploitPipeline( vulnType: VulnType, runVulnAgent: () => Promise, runExploitAgent: () => Promise ): Promise { - // Step 1: Run vulnerability agent - const vulnMetrics = await runVulnAgent(); + const vulnAgentName = `${vulnType}-vuln`; + const exploitAgentName = `${vulnType}-exploit`; - // Step 2: Check exploitation queue (starts immediately after vuln) + // Step 1: Run vulnerability agent (or skip if completed) + let vulnMetrics: AgentMetrics | null = null; + if (!shouldSkip(vulnAgentName)) { + vulnMetrics = await runVulnAgent(); + } else { + console.log(`Skipping ${vulnAgentName} (already complete)`); + } + + // Step 2: Check exploitation queue (only if vuln agent ran or completed previously) const decision = await a.checkExploitationQueue(activityInput, vulnType); - // Step 3: Conditionally run exploit agent + // Step 3: Conditionally run exploit agent (skip if already completed) let exploitMetrics: AgentMetrics | null = null; if (decision.shouldExploit) { - exploitMetrics = await runExploitAgent(); + if (!shouldSkip(exploitAgentName)) { + exploitMetrics = await runExploitAgent(); + } else { + console.log(`Skipping ${exploitAgentName} (already complete)`); + } } return { @@ -195,35 +261,75 @@ export async function pentestPipelineWorkflow( }; } - // Run all 5 pipelines in parallel with graceful failure handling + // Determine which pipelines to run (skip if both vuln and exploit completed) + const pipelinesToRun: Array> = []; + + // Only run pipeline if at least one agent (vuln or exploit) is incomplete + const pipelineConfigs: Array<{ + vulnType: VulnType; + vulnAgent: string; + exploitAgent: string; + runVuln: () => Promise; + runExploit: () => Promise; + }> = [ + { + vulnType: 'injection', + vulnAgent: 'injection-vuln', + exploitAgent: 'injection-exploit', + runVuln: () => a.runInjectionVulnAgent(activityInput), + runExploit: () => a.runInjectionExploitAgent(activityInput), + }, + { + vulnType: 'xss', + vulnAgent: 'xss-vuln', + exploitAgent: 'xss-exploit', + runVuln: () => a.runXssVulnAgent(activityInput), + runExploit: () => a.runXssExploitAgent(activityInput), + }, + { + vulnType: 'auth', + vulnAgent: 'auth-vuln', + exploitAgent: 'auth-exploit', + runVuln: () => a.runAuthVulnAgent(activityInput), + runExploit: () => a.runAuthExploitAgent(activityInput), + }, + { + vulnType: 'ssrf', + vulnAgent: 'ssrf-vuln', + exploitAgent: 'ssrf-exploit', + runVuln: () => a.runSsrfVulnAgent(activityInput), + runExploit: () => a.runSsrfExploitAgent(activityInput), + }, + { + vulnType: 'authz', + vulnAgent: 'authz-vuln', + exploitAgent: 'authz-exploit', + runVuln: () => a.runAuthzVulnAgent(activityInput), + runExploit: () => a.runAuthzExploitAgent(activityInput), + }, + ]; + + for (const config of pipelineConfigs) { + const vulnComplete = shouldSkip(config.vulnAgent); + const exploitComplete = shouldSkip(config.exploitAgent); + + // Only run pipeline if at least one agent needs to run + if (!vulnComplete || !exploitComplete) { + pipelinesToRun.push( + runVulnExploitPipeline(config.vulnType, config.runVuln, config.runExploit) + ); + } else { + console.log( + `Skipping entire ${config.vulnType} pipeline (both agents complete)` + ); + // Still need to mark them as completed in state + state.completedAgents.push(config.vulnAgent, config.exploitAgent); + } + } + + // Run pipelines in parallel with graceful failure handling // Promise.allSettled ensures other pipelines continue if one fails - const pipelineResults = await Promise.allSettled([ - runVulnExploitPipeline( - 'injection', - () => a.runInjectionVulnAgent(activityInput), - () => a.runInjectionExploitAgent(activityInput) - ), - runVulnExploitPipeline( - 'xss', - () => a.runXssVulnAgent(activityInput), - () => a.runXssExploitAgent(activityInput) - ), - runVulnExploitPipeline( - 'auth', - () => a.runAuthVulnAgent(activityInput), - () => a.runAuthExploitAgent(activityInput) - ), - runVulnExploitPipeline( - 'ssrf', - () => a.runSsrfVulnAgent(activityInput), - () => a.runSsrfExploitAgent(activityInput) - ), - runVulnExploitPipeline( - 'authz', - () => a.runAuthzVulnAgent(activityInput), - () => a.runAuthzExploitAgent(activityInput) - ), - ]); + const pipelineResults = await Promise.allSettled(pipelinesToRun); // Aggregate results from all pipelines const failedPipelines: string[] = []; @@ -231,16 +337,24 @@ export async function pentestPipelineWorkflow( if (result.status === 'fulfilled') { const { vulnType, vulnMetrics, exploitMetrics } = result.value; - // Record vuln agent metrics + // Record vuln agent + const vulnAgentName = `${vulnType}-vuln`; if (vulnMetrics) { - state.agentMetrics[`${vulnType}-vuln`] = vulnMetrics; - state.completedAgents.push(`${vulnType}-vuln`); + state.agentMetrics[vulnAgentName] = vulnMetrics; + state.completedAgents.push(vulnAgentName); + } else if (shouldSkip(vulnAgentName)) { + // Agent was skipped because already complete + state.completedAgents.push(vulnAgentName); } - // Record exploit agent metrics (if it ran) + // Record exploit agent (if it ran) + const exploitAgentName = `${vulnType}-exploit`; if (exploitMetrics) { - state.agentMetrics[`${vulnType}-exploit`] = exploitMetrics; - state.completedAgents.push(`${vulnType}-exploit`); + state.agentMetrics[exploitAgentName] = exploitMetrics; + state.completedAgents.push(exploitAgentName); + } else if (shouldSkip(exploitAgentName)) { + // Agent was skipped because already complete + state.completedAgents.push(exploitAgentName); } } else { // Pipeline failed - log error but continue with others @@ -266,21 +380,26 @@ export async function pentestPipelineWorkflow( await a.logPhaseTransition(activityInput, 'vulnerability-exploitation', 'complete'); // === Phase 5: Reporting === - state.currentPhase = 'reporting'; - state.currentAgent = 'report'; - await a.logPhaseTransition(activityInput, 'reporting', 'start'); + if (!shouldSkip('report')) { + state.currentPhase = 'reporting'; + state.currentAgent = 'report'; + await a.logPhaseTransition(activityInput, 'reporting', 'start'); - // First, assemble the concatenated report from exploitation evidence files - await a.assembleReportActivity(activityInput); + // First, assemble the concatenated report from exploitation evidence files + await a.assembleReportActivity(activityInput); - // Then run the report agent to add executive summary and clean up - state.agentMetrics['report'] = await a.runReportAgent(activityInput); - state.completedAgents.push('report'); + // Then run the report agent to add executive summary and clean up + state.agentMetrics['report'] = await a.runReportAgent(activityInput); + state.completedAgents.push('report'); - // Inject model metadata into the final report - await a.injectReportMetadataActivity(activityInput); + // Inject model metadata into the final report + await a.injectReportMetadataActivity(activityInput); - await a.logPhaseTransition(activityInput, 'reporting', 'complete'); + await a.logPhaseTransition(activityInput, 'reporting', 'complete'); + } else { + console.log('Skipping report (already complete)'); + state.completedAgents.push('report'); + } // === Complete === state.status = 'completed'; diff --git a/src/types/agents.ts b/src/types/agents.ts index a47256f..481346a 100644 --- a/src/types/agents.ts +++ b/src/types/agents.ts @@ -8,20 +8,33 @@ * Agent type definitions */ -export type AgentName = - | 'pre-recon' - | 'recon' - | 'injection-vuln' - | 'xss-vuln' - | 'auth-vuln' - | 'ssrf-vuln' - | 'authz-vuln' - | 'injection-exploit' - | 'xss-exploit' - | 'auth-exploit' - | 'ssrf-exploit' - | 'authz-exploit' - | 'report'; +import path from 'path'; + +/** + * List of all agents in execution order. + * Used for iteration during resume state checking. + */ +export const ALL_AGENTS = [ + 'pre-recon', + 'recon', + 'injection-vuln', + 'xss-vuln', + 'auth-vuln', + 'ssrf-vuln', + 'authz-vuln', + 'injection-exploit', + 'xss-exploit', + 'auth-exploit', + 'ssrf-exploit', + 'authz-exploit', + 'report', +] as const; + +/** + * Agent name type derived from ALL_AGENTS. + * This ensures type safety and prevents drift between type and array. + */ +export type AgentName = typeof ALL_AGENTS[number]; export type PromptName = | 'pre-recon-code' @@ -82,3 +95,28 @@ export function getPromptNameForAgent(agentName: AgentName): PromptName { return mappings[agentName]; } + +/** + * Maps an agent name to its deliverable file path. + * Must match mcp-server/src/types/deliverables.ts:DELIVERABLE_FILENAMES + */ +export function getDeliverablePath(agentName: AgentName, repoPath: string): string { + const deliverableMap: Record = { + 'pre-recon': 'code_analysis_deliverable.md', + 'recon': 'recon_deliverable.md', + 'injection-vuln': 'injection_analysis_deliverable.md', + 'xss-vuln': 'xss_analysis_deliverable.md', + 'auth-vuln': 'auth_analysis_deliverable.md', + 'ssrf-vuln': 'ssrf_analysis_deliverable.md', + 'authz-vuln': 'authz_analysis_deliverable.md', + 'injection-exploit': 'injection_exploitation_evidence.md', + 'xss-exploit': 'xss_exploitation_evidence.md', + 'auth-exploit': 'auth_exploitation_evidence.md', + 'ssrf-exploit': 'ssrf_exploitation_evidence.md', + 'authz-exploit': 'authz_exploitation_evidence.md', + 'report': 'comprehensive_security_assessment_report.md', + }; + + const filename = deliverableMap[agentName]; + return path.join(repoPath, 'deliverables', filename); +} From ee5d7b80a0d8b281d52a08195c3a2f70241b6a5e Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Fri, 13 Feb 2026 20:53:18 +0530 Subject: [PATCH 02/10] feat: add named workspaces and workspace listing Support WORKSPACE= flag for friendly workspace names that auto-resume if they exist or create a new named workspace otherwise. Add ./shannon workspaces command to list all workspaces with status, duration, and cost. --- shannon | 18 ++++ src/temporal/client.ts | 78 +++++++++++----- src/temporal/shared.ts | 1 + src/temporal/workflows.ts | 2 +- src/temporal/workspaces.ts | 185 +++++++++++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 src/temporal/workspaces.ts diff --git a/shannon b/shannon index d141ddc..55f406e 100755 --- a/shannon +++ b/shannon @@ -36,6 +36,7 @@ show_help() { Usage: ./shannon start URL= REPO= Start a pentest workflow + ./shannon workspaces List all workspaces ./shannon logs ID= Tail logs for a specific workflow ./shannon query ID= Query workflow progress ./shannon stop Stop all containers @@ -45,6 +46,7 @@ Options for 'start': REPO= Folder name under ./repos/ (e.g. REPO=repo-name) CONFIG= Configuration file (YAML) OUTPUT= Output directory for reports (default: ./audit-logs/) + WORKSPACE= Named workspace (auto-resumes if exists, creates if new) PIPELINE_TESTING=true Use minimal prompts for fast testing ROUTER=true Route requests through claude-code-router (multi-model support) @@ -53,8 +55,10 @@ Options for 'stop': Examples: ./shannon start URL=https://example.com REPO=repo-name + ./shannon start URL=https://example.com REPO=repo-name WORKSPACE=q1-audit ./shannon start URL=https://example.com REPO=repo-name CONFIG=./config.yaml ./shannon start URL=https://example.com REPO=repo-name OUTPUT=./my-reports + ./shannon workspaces ./shannon logs ID=example.com_shannon-1234567890 ./shannon query ID=shannon-1234567890 ./shannon stop CLEAN=true @@ -76,6 +80,7 @@ parse_args() { PIPELINE_TESTING=*) PIPELINE_TESTING="${arg#PIPELINE_TESTING=}" ;; REBUILD=*) REBUILD="${arg#REBUILD=}" ;; ROUTER=*) ROUTER="${arg#ROUTER=}" ;; + WORKSPACE=*) WORKSPACE="${arg#WORKSPACE=}" ;; esac done } @@ -224,6 +229,7 @@ cmd_start() { fi [ "$PIPELINE_TESTING" = "true" ] && ARGS="$ARGS --pipeline-testing" + [ -n "$WORKSPACE" ] && ARGS="$ARGS --workspace $WORKSPACE" # Run the client to submit workflow docker compose -f "$COMPOSE_FILE" $COMPOSE_OVERRIDE exec -T worker \ @@ -283,6 +289,14 @@ cmd_query() { node dist/temporal/query.js "$ID" } +cmd_workspaces() { + # Ensure containers are running (need worker to execute node) + ensure_containers + + docker compose -f "$COMPOSE_FILE" $COMPOSE_OVERRIDE exec -T worker \ + node dist/temporal/workspaces.js +} + cmd_stop() { parse_args "$@" @@ -307,6 +321,10 @@ case "${1:-help}" in shift cmd_query "$@" ;; + workspaces) + shift + cmd_workspaces + ;; stop) shift cmd_stop "$@" diff --git a/src/temporal/client.ts b/src/temporal/client.ts index c1b47a1..b81ecc4 100644 --- a/src/temporal/client.ts +++ b/src/temporal/client.ts @@ -106,6 +106,14 @@ async function terminateExistingWorkflows( return terminated; } +/** + * Validate workspace name: alphanumeric, hyphens, underscores, 1-128 chars, + * must start with alphanumeric. + */ +function isValidWorkspaceName(name: string): boolean { + return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(name); +} + function showUsage(): void { console.log(chalk.cyan.bold('\nShannon Temporal Client')); console.log(chalk.gray('Start a pentest pipeline workflow\n')); @@ -212,35 +220,54 @@ async function startPipeline(): Promise { let terminatedWorkflows: string[] = []; let workflowId: string; let sessionId: string; // Workspace name (persistent directory) + let isResume = false; - // === Resume Mode === if (resumeFromWorkspace) { - console.log(chalk.cyan('=== RESUME MODE ===')); - console.log(`Workspace: ${resumeFromWorkspace}\n`); - - // Terminate any running workflows for this workspace - terminatedWorkflows = await terminateExistingWorkflows(client, resumeFromWorkspace); - - if (terminatedWorkflows.length > 0) { - console.log(chalk.yellow(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`)); - } - - // Validate URL matches workspace const sessionPath = path.join('./audit-logs', resumeFromWorkspace, 'session.json'); - const session = await readJson(sessionPath); + const workspaceExists = await fileExists(sessionPath); - if (session.session.webUrl !== webUrl) { - console.error(chalk.red('ERROR: URL mismatch with workspace')); - console.error(` Workspace URL: ${session.session.webUrl}`); - console.error(` Provided URL: ${webUrl}`); - process.exit(1); + if (workspaceExists) { + // === Resume Mode: existing workspace === + isResume = true; + console.log(chalk.cyan('=== RESUME MODE ===')); + console.log(`Workspace: ${resumeFromWorkspace}\n`); + + // Terminate any running workflows for this workspace + terminatedWorkflows = await terminateExistingWorkflows(client, resumeFromWorkspace); + + if (terminatedWorkflows.length > 0) { + console.log(chalk.yellow(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`)); + } + + // Validate URL matches workspace + const session = await readJson(sessionPath); + + if (session.session.webUrl !== webUrl) { + console.error(chalk.red('ERROR: URL mismatch with workspace')); + console.error(` Workspace URL: ${session.session.webUrl}`); + console.error(` Provided URL: ${webUrl}`); + process.exit(1); + } + + // Generate resume workflow ID + workflowId = `${resumeFromWorkspace}_resume_${Date.now()}`; + sessionId = resumeFromWorkspace; + } else { + // === New Named Workspace === + if (!isValidWorkspaceName(resumeFromWorkspace)) { + console.error(chalk.red(`ERROR: Invalid workspace name: "${resumeFromWorkspace}"`)); + console.error(chalk.gray(' Must be 1-128 characters, alphanumeric/hyphens/underscores, starting with alphanumeric')); + process.exit(1); + } + + console.log(chalk.cyan('=== NEW NAMED WORKSPACE ===')); + console.log(`Workspace: ${resumeFromWorkspace}\n`); + + workflowId = `${resumeFromWorkspace}_shannon-${Date.now()}`; + sessionId = resumeFromWorkspace; } - - // Generate resume workflow ID - workflowId = `${resumeFromWorkspace}_resume_${Date.now()}`; - sessionId = resumeFromWorkspace; } else { - // === New Workflow === + // === New Auto-Named Workflow === const hostname = sanitizeHostname(webUrl); workflowId = customWorkflowId || `${hostname}_shannon-${Date.now()}`; sessionId = workflowId; @@ -250,10 +277,11 @@ async function startPipeline(): Promise { webUrl, repoPath, workflowId, // Add for audit correlation + sessionId, // Workspace directory name ...(configPath && { configPath }), ...(outputPath && { outputPath }), ...(pipelineTestingMode && { pipelineTestingMode }), - ...(resumeFromWorkspace && { resumeFromWorkspace }), + ...(isResume && resumeFromWorkspace && { resumeFromWorkspace }), ...(terminatedWorkflows.length > 0 && { terminatedWorkflows }), }; @@ -263,7 +291,7 @@ async function startPipeline(): Promise { const outputDir = `${effectiveDisplayPath}/${sessionId}`; console.log(chalk.green.bold(`✓ Workflow started: ${workflowId}`)); - if (resumeFromWorkspace) { + if (isResume) { console.log(chalk.gray(` (Resuming workspace: ${sessionId})`)); } console.log(); diff --git a/src/temporal/shared.ts b/src/temporal/shared.ts index 3ab7f92..5280645 100644 --- a/src/temporal/shared.ts +++ b/src/temporal/shared.ts @@ -9,6 +9,7 @@ export interface PipelineInput { outputPath?: string; pipelineTestingMode?: boolean; workflowId?: string; // Added by client, used for audit correlation + sessionId?: string; // Workspace directory name (distinct from workflowId for named workspaces) resumeFromWorkspace?: string; // Workspace name to resume from terminatedWorkflows?: string[]; // Workflows terminated during resume } diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index 75b10e2..bed783e 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -131,7 +131,7 @@ export async function pentestPipelineWorkflow( // Activities require workflowId (non-optional), PipelineInput has it optional // Use spread to conditionally include optional properties (exactOptionalPropertyTypes) // sessionId is workspace name for resume, or workflowId for new runs - const sessionId = input.resumeFromWorkspace || workflowId; + const sessionId = input.sessionId || input.resumeFromWorkspace || workflowId; const activityInput: ActivityInput = { webUrl: input.webUrl, diff --git a/src/temporal/workspaces.ts b/src/temporal/workspaces.ts new file mode 100644 index 0000000..4f46cd0 --- /dev/null +++ b/src/temporal/workspaces.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +/** + * Workspace listing tool for Shannon. + * + * Reads audit-logs/ directories, parses session.json files, and displays + * a formatted table of all workspaces with status, duration, and cost. + * + * Usage: + * node dist/temporal/workspaces.js + * + * Environment: + * AUDIT_LOGS_DIR - Override audit-logs directory (default: ./audit-logs) + */ + +import fs from 'fs/promises'; +import path from 'path'; +import chalk from 'chalk'; + +interface SessionJson { + session: { + id: string; + webUrl: string; + status: 'in-progress' | 'completed' | 'failed'; + createdAt: string; + completedAt?: string; + }; + metrics: { + total_cost_usd: number; + }; +} + +interface WorkspaceInfo { + name: string; + url: string; + status: 'in-progress' | 'completed' | 'failed'; + createdAt: Date; + completedAt: Date | null; + costUsd: number; +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m`; + } + return `${seconds}s`; +} + +function getStatusDisplay(status: string): string { + switch (status) { + case 'completed': + return chalk.green(status); + case 'in-progress': + return chalk.yellow(status); + case 'failed': + return chalk.red(status); + default: + return status; + } +} + +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + '\u2026'; +} + +async function listWorkspaces(): Promise { + const auditDir = process.env.AUDIT_LOGS_DIR || './audit-logs'; + + let entries: string[]; + try { + entries = await fs.readdir(auditDir); + } catch { + console.log(chalk.yellow('No audit-logs directory found.')); + console.log(chalk.gray(`Expected: ${auditDir}`)); + return; + } + + const workspaces: WorkspaceInfo[] = []; + + for (const entry of entries) { + const sessionPath = path.join(auditDir, entry, 'session.json'); + try { + const content = await fs.readFile(sessionPath, 'utf8'); + const data = JSON.parse(content) as SessionJson; + + workspaces.push({ + name: entry, + url: data.session.webUrl, + status: data.session.status, + createdAt: new Date(data.session.createdAt), + completedAt: data.session.completedAt ? new Date(data.session.completedAt) : null, + costUsd: data.metrics.total_cost_usd, + }); + } catch { + // Skip directories without valid session.json + } + } + + if (workspaces.length === 0) { + console.log(chalk.yellow('\nNo workspaces found.')); + console.log(chalk.gray('Run a pipeline first: ./shannon start URL= REPO=')); + return; + } + + // Sort by creation date (most recent first) + workspaces.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + console.log(chalk.cyan.bold('\n=== Shannon Workspaces ===\n')); + + // Column widths + const nameWidth = 30; + const urlWidth = 30; + const statusWidth = 14; + const durationWidth = 10; + const costWidth = 10; + + // Header + console.log( + chalk.gray( + ' ' + + 'WORKSPACE'.padEnd(nameWidth) + + 'URL'.padEnd(urlWidth) + + 'STATUS'.padEnd(statusWidth) + + 'DURATION'.padEnd(durationWidth) + + 'COST'.padEnd(costWidth) + ) + ); + console.log(chalk.gray(' ' + '\u2500'.repeat(nameWidth + urlWidth + statusWidth + durationWidth + costWidth))); + + let resumableCount = 0; + + for (const ws of workspaces) { + const now = new Date(); + const endTime = ws.completedAt || now; + const durationMs = endTime.getTime() - ws.createdAt.getTime(); + const duration = formatDuration(durationMs); + const cost = `$${ws.costUsd.toFixed(2)}`; + const isResumable = ws.status !== 'completed'; + + if (isResumable) { + resumableCount++; + } + + const resumeTag = isResumable ? chalk.cyan(' (resumable)') : ''; + + console.log( + ' ' + + chalk.white(truncate(ws.name, nameWidth - 2).padEnd(nameWidth)) + + chalk.gray(truncate(ws.url, urlWidth - 2).padEnd(urlWidth)) + + getStatusDisplay(ws.status).padEnd(statusWidth + 10) + // +10 for chalk escape codes + chalk.gray(duration.padEnd(durationWidth)) + + chalk.gray(cost.padEnd(costWidth)) + + resumeTag + ); + } + + console.log(); + const summary = `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'} found`; + const resumeSummary = resumableCount > 0 ? ` (${resumableCount} resumable)` : ''; + console.log(chalk.gray(`${summary}${resumeSummary}`)); + + if (resumableCount > 0) { + console.log(chalk.gray('\nResume with: ./shannon start URL= REPO= WORKSPACE=')); + } + + console.log(); +} + +listWorkspaces().catch((err) => { + console.error(chalk.red('Error listing workspaces:'), err); + process.exit(1); +}); From f017a4143614a975531d59b38972244a05520702 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Fri, 13 Feb 2026 22:09:07 +0530 Subject: [PATCH 03/10] fix: set originalWorkflowId in logPhaseTransition and remove path import from agents.ts logPhaseTransition was the first activity to create session.json but didn't pass workflowId, so originalWorkflowId was never set. This caused terminateExistingWorkflows to look up the workspace name instead of the actual workflow ID during resume. Also remove path import from types/agents.ts to fix Temporal workflow bundle determinism error. --- src/temporal/activities.ts | 4 ++-- src/types/agents.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 40fcf4f..2e544dd 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -678,7 +678,7 @@ export async function logPhaseTransition( phase: string, event: 'start' | 'complete' ): Promise { - const { webUrl, repoPath, outputPath, sessionId } = input; + const { webUrl, repoPath, outputPath, sessionId, workflowId } = input; const sessionMetadata: SessionMetadata = { id: sessionId, @@ -688,7 +688,7 @@ export async function logPhaseTransition( }; const auditSession = new AuditSession(sessionMetadata); - await auditSession.initialize(); + await auditSession.initialize(workflowId); if (event === 'start') { await auditSession.logPhaseStart(phase); diff --git a/src/types/agents.ts b/src/types/agents.ts index 481346a..041e0f3 100644 --- a/src/types/agents.ts +++ b/src/types/agents.ts @@ -8,8 +8,6 @@ * Agent type definitions */ -import path from 'path'; - /** * List of all agents in execution order. * Used for iteration during resume state checking. @@ -118,5 +116,5 @@ export function getDeliverablePath(agentName: AgentName, repoPath: string): stri }; const filename = deliverableMap[agentName]; - return path.join(repoPath, 'deliverables', filename); + return `${repoPath}/deliverables/${filename}`; } From dbcb4587ee0fe381436363202c68ca98805c19b5 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Fri, 13 Feb 2026 22:41:07 +0530 Subject: [PATCH 04/10] fix: update session.json status on workflow completion logWorkflowComplete wrote to workflow.log but never called updateSessionStatus, leaving all workspaces stuck as "in-progress" in session.json. Also derive audit path for model injection instead of requiring explicit outputPath. --- src/temporal/activities.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 2e544dd..a7d5bda 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -393,13 +393,12 @@ export async function assembleReportActivity(input: ActivityInput): Promise { - const { repoPath, outputPath } = input; - if (!outputPath) { - console.log(chalk.yellow('⚠️ No output path provided, skipping model injection')); - return; - } + const { repoPath, sessionId, outputPath } = input; + const effectiveOutputPath = outputPath + ? path.join(outputPath, sessionId) + : path.join('./audit-logs', sessionId); try { - await injectModelIntoReport(repoPath, outputPath); + await injectModelIntoReport(repoPath, effectiveOutputPath); } catch (error) { const err = error as Error; console.log(chalk.yellow(`⚠️ Error injecting model into report: ${err.message}`)); @@ -716,5 +715,6 @@ export async function logWorkflowComplete( const auditSession = new AuditSession(sessionMetadata); await auditSession.initialize(workflowId); + await auditSession.updateSessionStatus(summary.status); await auditSession.logWorkflowComplete(summary); } From 7f9c5cc496c3d047ba6c7077bc23c92c3303a62f Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Sat, 14 Feb 2026 01:20:42 +0530 Subject: [PATCH 05/10] fix: copy deliverables to audit-logs once at workflow end instead of per-agent Moves the copyDeliverablesToAudit call from runAgentActivity (called after every agent) to logWorkflowComplete (called once at workflow end). This prevents intermediate agent runs from copying incomplete or rogue deliverables into the audit trail. --- src/temporal/activities.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index a7d5bda..5cf902f 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -258,13 +258,6 @@ async function runAgentActivity( }); await commitGitSuccess(repoPath, agentName); - // 9.5. Copy deliverables to audit-logs (non-fatal) - try { - await copyDeliverablesToAudit(sessionMetadata, repoPath); - } catch (copyErr) { - console.error(`Failed to copy deliverables to audit-logs for ${agentName}:`, copyErr); - } - // 10. Return metrics return { durationMs: Date.now() - startTime, @@ -717,4 +710,11 @@ export async function logWorkflowComplete( await auditSession.initialize(workflowId); await auditSession.updateSessionStatus(summary.status); await auditSession.logWorkflowComplete(summary); + + // Copy all deliverables to audit-logs once at workflow end (non-fatal) + try { + await copyDeliverablesToAudit(sessionMetadata, repoPath); + } catch (copyErr) { + console.error('Failed to copy deliverables to audit-logs:', copyErr); + } } From 1b696cac1bb3a3b85338ec9a225eec10bdc1275c Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Sat, 14 Feb 2026 02:52:11 +0530 Subject: [PATCH 06/10] fix: store checkpoint as success commit hash and show cumulative metrics - Swap commitGitSuccess/getGitCommitHash order so checkpoint in session.json points to the success commit (which contains deliverables) instead of the pre-agent marker commit - Simplify restoreGitCheckpoint: git reset --hard now naturally preserves completed agent deliverables, removing the in-memory backup/restore - Show cumulative cost/duration in workflow.log from session.json - Fill in per-agent metrics for skipped agents in workflow.log breakdown - Display cumulative cost in client output for resume runs --- CLAUDE.md | 9 +++++++- README.md | 34 ++++++++++++++++++++++++++++++ src/temporal/activities.ts | 43 +++++++++++++++++++++++++++++++------- src/temporal/client.ts | 17 ++++++++++++++- 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cf0f788..41572ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,12 @@ git clone https://github.com/org/repo.git ./repos/my-repo ./shannon start URL= REPO=my-repo ./shannon start URL= REPO=my-repo CONFIG=./configs/my-config.yaml +# Workspaces & Resume +./shannon start URL= REPO=my-repo WORKSPACE=my-audit # New named workspace +./shannon start URL= REPO=my-repo WORKSPACE=my-audit # Resume (same command) +./shannon start URL= REPO=my-repo WORKSPACE= # Resume auto-named run +./shannon workspaces # List all workspaces + # Monitor ./shannon logs # Real-time worker logs ./shannon query ID= # Query workflow progress @@ -31,7 +37,7 @@ git clone https://github.com/org/repo.git ./repos/my-repo npm run build ``` -**Options:** `CONFIG=` (YAML config), `OUTPUT=` (default: `./audit-logs/`), `PIPELINE_TESTING=true` (minimal prompts, 10s retries), `REBUILD=true` (force Docker rebuild), `ROUTER=true` (multi-model routing via [claude-code-router](https://github.com/musistudio/claude-code-router)) +**Options:** `CONFIG=` (YAML config), `OUTPUT=` (default: `./audit-logs/`), `WORKSPACE=` (named workspace; auto-resumes if exists), `PIPELINE_TESTING=true` (minimal prompts, 10s retries), `REBUILD=true` (force Docker rebuild), `ROUTER=true` (multi-model routing via [claude-code-router](https://github.com/musistudio/claude-code-router)) ## Architecture @@ -67,6 +73,7 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig - **SDK Integration** — Uses `@anthropic-ai/claude-agent-sdk` with `maxTurns: 10_000` and `bypassPermissions` mode. Playwright MCP for browser automation, TOTP generation via MCP tool. Login flow template at `prompts/shared/login-instructions.txt` supports form, SSO, API, and basic auth - **Audit System** — Crash-safe append-only logging in `audit-logs/{hostname}_{sessionId}/`. Tracks session metrics, per-agent logs, prompts, and deliverables - **Deliverables** — Saved to `deliverables/` in the target repo via the `save_deliverable` MCP tool +- **Workspaces & Resume** — Named workspaces via `WORKSPACE=` or auto-named from URL+timestamp. Resume passes `--workspace` to the Temporal client (`src/temporal/client.ts`), which loads `session.json` to detect completed agents. `loadResumeState()` in `src/temporal/activities.ts` validates deliverable existence, restores git checkpoints, and cleans up incomplete deliverables. Workspace listing via `src/temporal/workspaces.ts` ## Development Notes diff --git a/README.md b/README.md index 89e2bfa..db39f0a 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Shannon is available in two editions: - [Monitoring Progress](#monitoring-progress) - [Stopping Shannon](#stopping-shannon) - [Usage Examples](#usage-examples) + - [Workspaces and Resuming](#workspaces-and-resuming) - [Configuration (Optional)](#configuration-optional) - [[EXPERIMENTAL - UNSUPPORTED] Router Mode (Alternative Providers)](#experimental---unsupported-router-mode-alternative-providers) - [Output and Results](#output-and-results) @@ -167,8 +168,41 @@ open http://localhost:8233 # Custom output directory ./shannon start URL=https://example.com REPO=repo-name OUTPUT=./my-reports + +# Named workspace +./shannon start URL=https://example.com REPO=repo-name WORKSPACE=q1-audit + +# List all workspaces +./shannon workspaces ``` +### Workspaces and Resuming + +Shannon supports **workspaces** that allow you to resume interrupted or failed runs without re-running completed agents. + +**How it works:** +- Every run creates a workspace in `audit-logs/` (auto-named by default, e.g. `example-com_shannon-1771007534808`) +- Use `WORKSPACE=` to give your run a custom name for easier reference +- To resume any run, pass its workspace name via `WORKSPACE=` — Shannon detects which agents completed successfully and picks up where it left off +- Each agent's progress is checkpointed via git commits, so resumed runs start from a clean, validated state + +```bash +# Start with a named workspace +./shannon start URL=https://example.com REPO=repo-name WORKSPACE=my-audit + +# Resume the same workspace (skips completed agents) +./shannon start URL=https://example.com REPO=repo-name WORKSPACE=my-audit + +# Resume an auto-named workspace from a previous run +./shannon start URL=https://example.com REPO=repo-name WORKSPACE=example-com_shannon-1771007534808 + +# List all workspaces and their status +./shannon workspaces +``` + +> [!NOTE] +> The `URL` must match the original workspace URL when resuming. Shannon will reject mismatched URLs to prevent cross-target contamination. + ### Prepare Your Repository Shannon expects target repositories to be placed under the `./repos/` directory at the project root. The `REPO` flag refers to a folder name inside `./repos/`. Copy the repository you want to scan into `./repos/`, or clone it directly there: diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 5cf902f..454701b 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -246,7 +246,8 @@ async function runAgentActivity( throw new Error(`Agent ${agentName} failed output validation`); } - // 9. Success - commit and log + // 9. Success - commit deliverables, then capture checkpoint hash + await commitGitSuccess(repoPath, agentName); const commitHash = await getGitCommitHash(repoPath); await auditSession.endAgent(agentName, { attemptNumber, @@ -256,7 +257,6 @@ async function runAgentActivity( model: result.model, ...(commitHash && { checkpoint: commitHash }), }); - await commitGitSuccess(repoPath, agentName); // 10. Return metrics return { @@ -606,7 +606,8 @@ export async function restoreGitCheckpoint( ): Promise { console.log(chalk.blue(`Restoring git workspace to ${checkpointHash}...`)); - // Git reset to checkpoint + // Checkpoint hash points to the success commit (after commitGitSuccess), + // so git reset --hard naturally preserves all completed agent deliverables. await executeGitCommandWithRetry( ['git', 'reset', '--hard', checkpointHash], repoPath, @@ -618,8 +619,7 @@ export async function restoreGitCheckpoint( 'clean untracked files for resume' ); - - // Explicitly delete deliverables for incomplete agents + // Clean up any partial deliverables from incomplete agents for (const agentName of incompleteAgents) { const deliverablePath = getDeliverablePath(agentName, repoPath); try { @@ -629,7 +629,6 @@ export async function restoreGitCheckpoint( await fs.unlink(deliverablePath); } } catch (error) { - // Non-fatal, just log console.log(chalk.gray(`Note: Failed to delete ${deliverablePath}: ${error}`)); } } @@ -709,7 +708,37 @@ export async function logWorkflowComplete( const auditSession = new AuditSession(sessionMetadata); await auditSession.initialize(workflowId); await auditSession.updateSessionStatus(summary.status); - await auditSession.logWorkflowComplete(summary); + + // Use cumulative metrics from session.json (includes all resume attempts) + const sessionData = await auditSession.getMetrics() as { + metrics: { + total_duration_ms: number; + total_cost_usd: number; + agents: Record; + }; + }; + + // Fill in metrics for skipped agents (completed in previous runs) + const agentMetrics = { ...summary.agentMetrics }; + for (const agentName of summary.completedAgents) { + if (!agentMetrics[agentName]) { + const agentData = sessionData.metrics.agents[agentName]; + if (agentData) { + agentMetrics[agentName] = { + durationMs: agentData.final_duration_ms, + costUsd: agentData.total_cost_usd, + }; + } + } + } + + const cumulativeSummary: WorkflowSummary = { + ...summary, + totalDurationMs: sessionData.metrics.total_duration_ms, + totalCostUsd: sessionData.metrics.total_cost_usd, + agentMetrics, + }; + await auditSession.logWorkflowComplete(cumulativeSummary); // Copy all deliverables to audit-logs once at workflow end (non-fatal) try { diff --git a/src/temporal/client.ts b/src/temporal/client.ts index b81ecc4..3ea690a 100644 --- a/src/temporal/client.ts +++ b/src/temporal/client.ts @@ -46,6 +46,9 @@ interface SessionJson { originalWorkflowId?: string; resumeAttempts?: Array<{ workflowId: string }>; }; + metrics: { + total_cost_usd: number; + }; } dotenv.config(); @@ -356,7 +359,19 @@ async function startPipeline(): Promise { console.log(chalk.gray(`Duration: ${Math.floor(result.summary.totalDurationMs / 1000)}s`)); console.log(chalk.gray(`Agents completed: ${result.summary.agentCount}`)); console.log(chalk.gray(`Total turns: ${result.summary.totalTurns}`)); - console.log(chalk.gray(`Total cost: $${result.summary.totalCostUsd.toFixed(4)}`)); + console.log(chalk.gray(`Run cost: $${result.summary.totalCostUsd.toFixed(4)}`)); + + // Show cumulative cost from session.json (includes all resume attempts) + if (isResume) { + try { + const session = await readJson( + path.join('./audit-logs', sessionId, 'session.json') + ); + console.log(chalk.gray(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`)); + } catch { + // Non-fatal, skip cumulative cost display + } + } } } catch (error) { clearInterval(progressInterval); From 2cf237d6382be3535064fe5df7a8fc199c51e6a8 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Sat, 14 Feb 2026 02:56:57 +0530 Subject: [PATCH 07/10] fix: resolve resume workflow ID in logs command Strip _resume_* suffix to find the original workspace log file when tailing logs for a resumed workflow. --- shannon | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/shannon b/shannon index 55f406e..6d2c154 100755 --- a/shannon +++ b/shannon @@ -254,10 +254,16 @@ cmd_logs() { if [ -f "./audit-logs/${ID}/workflow.log" ]; then WORKFLOW_LOG="./audit-logs/${ID}/workflow.log" else - # Search for the workflow directory (handles custom OUTPUT paths) - FOUND=$(find . -maxdepth 3 -path "*/${ID}/workflow.log" -type f 2>/dev/null | head -1) - if [ -n "$FOUND" ]; then - WORKFLOW_LOG="$FOUND" + # For resume workflow IDs (e.g. workspace_resume_123), check the original workspace + WORKSPACE_ID="${ID%%_resume_*}" + if [ "$WORKSPACE_ID" != "$ID" ] && [ -f "./audit-logs/${WORKSPACE_ID}/workflow.log" ]; then + WORKFLOW_LOG="./audit-logs/${WORKSPACE_ID}/workflow.log" + else + # Search for the workflow directory (handles custom OUTPUT paths) + FOUND=$(find . -maxdepth 3 -path "*/${ID}/workflow.log" -type f 2>/dev/null | head -1) + if [ -n "$FOUND" ]; then + WORKFLOW_LOG="$FOUND" + fi fi fi From 759c8d80932d8e9ef7b7cf51c385c7c5332433bd Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Mon, 16 Feb 2026 20:25:09 +0530 Subject: [PATCH 08/10] fix: resolve named workspace workflow ID in logs command Strip _shannon-* suffix from workflow IDs so logs command finds audit-logs stored under the workspace name. --- shannon | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/shannon b/shannon index 6d2c154..2dc3cda 100755 --- a/shannon +++ b/shannon @@ -258,7 +258,17 @@ cmd_logs() { WORKSPACE_ID="${ID%%_resume_*}" if [ "$WORKSPACE_ID" != "$ID" ] && [ -f "./audit-logs/${WORKSPACE_ID}/workflow.log" ]; then WORKFLOW_LOG="./audit-logs/${WORKSPACE_ID}/workflow.log" - else + fi + + # For named workspace IDs (e.g. workspace_shannon-123), check the workspace name + if [ -z "$WORKFLOW_LOG" ]; then + WORKSPACE_ID="${ID%%_shannon-*}" + if [ "$WORKSPACE_ID" != "$ID" ] && [ -f "./audit-logs/${WORKSPACE_ID}/workflow.log" ]; then + WORKFLOW_LOG="./audit-logs/${WORKSPACE_ID}/workflow.log" + fi + fi + + if [ -z "$WORKFLOW_LOG" ]; then # Search for the workflow directory (handles custom OUTPUT paths) FOUND=$(find . -maxdepth 3 -path "*/${ID}/workflow.log" -type f 2>/dev/null | head -1) if [ -n "$FOUND" ]; then From 539bd873cc8f62cf43caa83f7cafa2f21f511071 Mon Sep 17 00:00:00 2001 From: ajmallesh Date: Mon, 16 Feb 2026 09:26:12 -0800 Subject: [PATCH 09/10] fix: improve resume edge cases and shell quoting - Early exit when all agents already completed instead of running empty workflow - Descriptive error when deliverables missing from disk despite session.json success - Quote $WORKSPACE in shannon CLI to prevent word splitting --- src/temporal/activities.ts | 11 ++++++++++- src/temporal/workflows.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 454701b..6f76244 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -544,8 +544,17 @@ export async function loadResumeState( .filter((hash): hash is string => hash != null); if (checkpoints.length === 0) { + const successAgents = Object.entries(agents) + .filter(([, data]) => data.status === 'success') + .map(([name]) => name); + throw ApplicationFailure.nonRetryable( - `No successful agent checkpoints found in workspace ${workspaceName}`, + `Cannot resume workspace ${workspaceName}: ` + + (successAgents.length > 0 + ? `${successAgents.length} agent(s) show success in session.json (${successAgents.join(', ')}) ` + + `but their deliverable files are missing from disk. ` + + `Start a fresh run instead.` + : `No agents completed successfully. Start a fresh run instead.`), 'NoCheckpointsError' ); } diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index bed783e..3bc2804 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -167,6 +167,15 @@ export async function pentestPipelineWorkflow( incompleteAgents ); + // Check if all agents are already complete + if (resumeState.completedAgents.length === ALL_AGENTS.length) { + console.log(`All ${ALL_AGENTS.length} agents already completed. Nothing to resume.`); + state.status = 'completed'; + state.completedAgents = [...resumeState.completedAgents]; + state.summary = computeSummary(state); + return state; + } + // Record resume attempt in session.json await a.recordResumeAttempt( activityInput, From 45e9f305eaf259c0c4a33bf5ac2b6f07266102a8 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Tue, 17 Feb 2026 00:19:24 +0530 Subject: [PATCH 10/10] refactor: remove ./shannon query CLI command Query functionality is redundant with the Temporal Web UI at http://localhost:8233. Removes query.ts, CLI handler, npm script, and all documentation references. --- CLAUDE.md | 3 - package.json | 3 +- shannon | 21 +----- src/temporal/client.ts | 1 - src/temporal/query.ts | 158 ----------------------------------------- 5 files changed, 2 insertions(+), 184 deletions(-) delete mode 100644 src/temporal/query.ts diff --git a/CLAUDE.md b/CLAUDE.md index 41572ca..70555c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,6 @@ git clone https://github.com/org/repo.git ./repos/my-repo # Monitor ./shannon logs # Real-time worker logs -./shannon query ID= # Query workflow progress # Temporal Web UI: http://localhost:8233 # Stop @@ -57,8 +56,6 @@ Durable workflow orchestration with crash recovery, queryable progress, intellig - `src/temporal/worker.ts` — Worker entry point - `src/temporal/client.ts` — CLI client for starting workflows - `src/temporal/shared.ts` — Types, interfaces, query definitions -- `src/temporal/query.ts` — Query tool for progress inspection - ### Five-Phase Pipeline 1. **Pre-Recon** (`pre-recon`) — External scans (nmap, subfinder, whatweb) + source code analysis diff --git a/package.json b/package.json index c38b5dd..78a50ee 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "temporal:server": "docker compose -f docker/docker-compose.temporal.yml up temporal -d", "temporal:server:stop": "docker compose -f docker/docker-compose.temporal.yml down", "temporal:worker": "node dist/temporal/worker.js", - "temporal:start": "node dist/temporal/client.js", - "temporal:query": "node dist/temporal/query.js" + "temporal:start": "node dist/temporal/client.js" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.38", diff --git a/shannon b/shannon index 2dc3cda..ab136e6 100755 --- a/shannon +++ b/shannon @@ -38,7 +38,6 @@ Usage: ./shannon start URL= REPO= Start a pentest workflow ./shannon workspaces List all workspaces ./shannon logs ID= Tail logs for a specific workflow - ./shannon query ID= Query workflow progress ./shannon stop Stop all containers ./shannon help Show this help message @@ -60,7 +59,6 @@ Examples: ./shannon start URL=https://example.com REPO=repo-name OUTPUT=./my-reports ./shannon workspaces ./shannon logs ID=example.com_shannon-1234567890 - ./shannon query ID=shannon-1234567890 ./shannon stop CLEAN=true Monitor workflows at http://localhost:8233 @@ -287,24 +285,11 @@ cmd_logs() { echo " - Workflow hasn't started yet" echo " - Workflow ID is incorrect" echo "" - echo "Check: ./shannon query ID=$ID for workflow details" + echo "Check the Temporal Web UI at http://localhost:8233 for workflow details" exit 1 fi } -cmd_query() { - parse_args "$@" - - if [ -z "$ID" ]; then - echo "ERROR: ID is required" - echo "Usage: ./shannon query ID=" - exit 1 - fi - - docker compose -f "$COMPOSE_FILE" $COMPOSE_OVERRIDE exec -T worker \ - node dist/temporal/query.js "$ID" -} - cmd_workspaces() { # Ensure containers are running (need worker to execute node) ensure_containers @@ -333,10 +318,6 @@ case "${1:-help}" in shift cmd_logs "$@" ;; - query) - shift - cmd_query "$@" - ;; workspaces) shift cmd_workspaces diff --git a/src/temporal/client.ts b/src/temporal/client.ts index 3ea690a..243197e 100644 --- a/src/temporal/client.ts +++ b/src/temporal/client.ts @@ -326,7 +326,6 @@ async function startPipeline(): Promise { console.log(chalk.bold('Monitor progress:')); console.log(chalk.white(' Web UI: ') + chalk.blue(`http://localhost:8233/namespaces/default/workflows/${workflowId}`)); console.log(chalk.white(' Logs: ') + chalk.gray(`./shannon logs ID=${workflowId}`)); - console.log(chalk.white(' Query: ') + chalk.gray(`./shannon query ID=${workflowId}`)); console.log(); console.log(chalk.bold('Output:')); console.log(chalk.white(' Reports: ') + chalk.cyan(outputDir)); diff --git a/src/temporal/query.ts b/src/temporal/query.ts deleted file mode 100644 index bf058a7..0000000 --- a/src/temporal/query.ts +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env node -// Copyright (C) 2025 Keygraph, Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License version 3 -// as published by the Free Software Foundation. - -/** - * Temporal query tool for inspecting Shannon workflow progress. - * - * Queries a running or completed workflow and displays its state. - * - * Usage: - * npm run temporal:query -- - * # or - * node dist/temporal/query.js - * - * Environment: - * TEMPORAL_ADDRESS - Temporal server address (default: localhost:7233) - */ - -import { Connection, Client } from '@temporalio/client'; -import dotenv from 'dotenv'; -import chalk from 'chalk'; - -dotenv.config(); - -// Query name must match the one defined in workflows.ts -const PROGRESS_QUERY = 'getProgress'; - -// Types duplicated from shared.ts to avoid importing workflow APIs -interface AgentMetrics { - durationMs: number; - inputTokens: number | null; - outputTokens: number | null; - costUsd: number | null; - numTurns: number | null; - model?: string | undefined; -} - -interface PipelineProgress { - status: 'running' | 'completed' | 'failed'; - currentPhase: string | null; - currentAgent: string | null; - completedAgents: string[]; - failedAgent: string | null; - error: string | null; - startTime: number; - agentMetrics: Record; - workflowId: string; - elapsedMs: number; -} - -function showUsage(): void { - console.log(chalk.cyan.bold('\nShannon Temporal Query Tool')); - console.log(chalk.gray('Query progress of a running workflow\n')); - console.log(chalk.yellow('Usage:')); - console.log(' node dist/temporal/query.js \n'); - console.log(chalk.yellow('Examples:')); - console.log(' node dist/temporal/query.js shannon-1704672000000\n'); -} - -function getStatusColor(status: string): string { - switch (status) { - case 'running': - return chalk.yellow(status); - case 'completed': - return chalk.green(status); - case 'failed': - return chalk.red(status); - default: - return status; - } -} - -function formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; -} - -async function queryWorkflow(): Promise { - const workflowId = process.argv[2]; - - if (!workflowId || workflowId === '--help' || workflowId === '-h') { - showUsage(); - process.exit(workflowId ? 0 : 1); - } - - const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; - - const connection = await Connection.connect({ address }); - const client = new Client({ connection }); - - try { - const handle = client.workflow.getHandle(workflowId); - const progress = await handle.query(PROGRESS_QUERY); - - console.log(chalk.cyan.bold('\nWorkflow Progress')); - console.log(chalk.gray('\u2500'.repeat(40))); - console.log(`${chalk.white('Workflow ID:')} ${progress.workflowId}`); - console.log(`${chalk.white('Status:')} ${getStatusColor(progress.status)}`); - console.log( - `${chalk.white('Current Phase:')} ${progress.currentPhase || 'none'}` - ); - console.log( - `${chalk.white('Current Agent:')} ${progress.currentAgent || 'none'}` - ); - console.log(`${chalk.white('Elapsed:')} ${formatDuration(progress.elapsedMs)}`); - console.log( - `${chalk.white('Completed:')} ${progress.completedAgents.length}/13 agents` - ); - - if (progress.completedAgents.length > 0) { - console.log(chalk.gray('\nCompleted agents:')); - for (const agent of progress.completedAgents) { - const metrics = progress.agentMetrics[agent]; - const duration = metrics ? formatDuration(metrics.durationMs) : 'unknown'; - const cost = metrics?.costUsd ? `$${metrics.costUsd.toFixed(4)}` : ''; - const model = metrics?.model ? ` [${metrics.model}]` : ''; - console.log( - chalk.green(` - ${agent}`) + - chalk.blue(model) + - chalk.gray(` (${duration}${cost ? ', ' + cost : ''})`) - ); - } - } - - if (progress.error) { - console.log(chalk.red(`\nError: ${progress.error}`)); - console.log(chalk.red(`Failed agent: ${progress.failedAgent}`)); - } - - console.log(); - } catch (error) { - const err = error as Error; - if (err.message?.includes('not found')) { - console.log(chalk.red(`Workflow not found: ${workflowId}`)); - } else { - console.error(chalk.red('Query failed:'), err.message); - } - process.exit(1); - } finally { - await connection.close(); - } -} - -queryWorkflow().catch((err) => { - console.error(chalk.red('Query error:'), err); - process.exit(1); -});