From ee5d7b80a0d8b281d52a08195c3a2f70241b6a5e Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Fri, 13 Feb 2026 20:53:18 +0530 Subject: [PATCH] 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); +});