feat: add named workspaces and workspace listing

Support WORKSPACE=<name> 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.
This commit is contained in:
ezl-keygraph
2026-02-13 20:53:18 +05:30
parent f932fad2ed
commit ee5d7b80a0
5 changed files with 258 additions and 26 deletions
+18
View File
@@ -36,6 +36,7 @@ show_help() {
Usage:
./shannon start URL=<url> REPO=<name> Start a pentest workflow
./shannon workspaces List all workspaces
./shannon logs ID=<workflow-id> Tail logs for a specific workflow
./shannon query ID=<workflow-id> Query workflow progress
./shannon stop Stop all containers
@@ -45,6 +46,7 @@ Options for 'start':
REPO=<name> Folder name under ./repos/ (e.g. REPO=repo-name)
CONFIG=<path> Configuration file (YAML)
OUTPUT=<path> Output directory for reports (default: ./audit-logs/)
WORKSPACE=<name> 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 "$@"
+53 -25
View File
@@ -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<void> {
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<SessionJson>(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<SessionJson>(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<void> {
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<void> {
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();
+1
View File
@@ -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
}
+1 -1
View File
@@ -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,
+185
View File
@@ -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<void> {
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=<url> REPO=<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=<url> REPO=<repo> WORKSPACE=<name>'));
}
console.log();
}
listWorkspaces().catch((err) => {
console.error(chalk.red('Error listing workspaces:'), err);
process.exit(1);
});