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:
@@ -36,6 +36,7 @@ show_help() {
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
./shannon start URL=<url> REPO=<name> Start a pentest workflow
|
./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 logs ID=<workflow-id> Tail logs for a specific workflow
|
||||||
./shannon query ID=<workflow-id> Query workflow progress
|
./shannon query ID=<workflow-id> Query workflow progress
|
||||||
./shannon stop Stop all containers
|
./shannon stop Stop all containers
|
||||||
@@ -45,6 +46,7 @@ Options for 'start':
|
|||||||
REPO=<name> Folder name under ./repos/ (e.g. REPO=repo-name)
|
REPO=<name> Folder name under ./repos/ (e.g. REPO=repo-name)
|
||||||
CONFIG=<path> Configuration file (YAML)
|
CONFIG=<path> Configuration file (YAML)
|
||||||
OUTPUT=<path> Output directory for reports (default: ./audit-logs/)
|
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
|
PIPELINE_TESTING=true Use minimal prompts for fast testing
|
||||||
ROUTER=true Route requests through claude-code-router (multi-model support)
|
ROUTER=true Route requests through claude-code-router (multi-model support)
|
||||||
|
|
||||||
@@ -53,8 +55,10 @@ Options for 'stop':
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
./shannon start URL=https://example.com REPO=repo-name
|
./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 CONFIG=./config.yaml
|
||||||
./shannon start URL=https://example.com REPO=repo-name OUTPUT=./my-reports
|
./shannon start URL=https://example.com REPO=repo-name OUTPUT=./my-reports
|
||||||
|
./shannon workspaces
|
||||||
./shannon logs ID=example.com_shannon-1234567890
|
./shannon logs ID=example.com_shannon-1234567890
|
||||||
./shannon query ID=shannon-1234567890
|
./shannon query ID=shannon-1234567890
|
||||||
./shannon stop CLEAN=true
|
./shannon stop CLEAN=true
|
||||||
@@ -76,6 +80,7 @@ parse_args() {
|
|||||||
PIPELINE_TESTING=*) PIPELINE_TESTING="${arg#PIPELINE_TESTING=}" ;;
|
PIPELINE_TESTING=*) PIPELINE_TESTING="${arg#PIPELINE_TESTING=}" ;;
|
||||||
REBUILD=*) REBUILD="${arg#REBUILD=}" ;;
|
REBUILD=*) REBUILD="${arg#REBUILD=}" ;;
|
||||||
ROUTER=*) ROUTER="${arg#ROUTER=}" ;;
|
ROUTER=*) ROUTER="${arg#ROUTER=}" ;;
|
||||||
|
WORKSPACE=*) WORKSPACE="${arg#WORKSPACE=}" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -224,6 +229,7 @@ cmd_start() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
[ "$PIPELINE_TESTING" = "true" ] && ARGS="$ARGS --pipeline-testing"
|
[ "$PIPELINE_TESTING" = "true" ] && ARGS="$ARGS --pipeline-testing"
|
||||||
|
[ -n "$WORKSPACE" ] && ARGS="$ARGS --workspace $WORKSPACE"
|
||||||
|
|
||||||
# Run the client to submit workflow
|
# Run the client to submit workflow
|
||||||
docker compose -f "$COMPOSE_FILE" $COMPOSE_OVERRIDE exec -T worker \
|
docker compose -f "$COMPOSE_FILE" $COMPOSE_OVERRIDE exec -T worker \
|
||||||
@@ -283,6 +289,14 @@ cmd_query() {
|
|||||||
node dist/temporal/query.js "$ID"
|
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() {
|
cmd_stop() {
|
||||||
parse_args "$@"
|
parse_args "$@"
|
||||||
|
|
||||||
@@ -307,6 +321,10 @@ case "${1:-help}" in
|
|||||||
shift
|
shift
|
||||||
cmd_query "$@"
|
cmd_query "$@"
|
||||||
;;
|
;;
|
||||||
|
workspaces)
|
||||||
|
shift
|
||||||
|
cmd_workspaces
|
||||||
|
;;
|
||||||
stop)
|
stop)
|
||||||
shift
|
shift
|
||||||
cmd_stop "$@"
|
cmd_stop "$@"
|
||||||
|
|||||||
+53
-25
@@ -106,6 +106,14 @@ async function terminateExistingWorkflows(
|
|||||||
return terminated;
|
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 {
|
function showUsage(): void {
|
||||||
console.log(chalk.cyan.bold('\nShannon Temporal Client'));
|
console.log(chalk.cyan.bold('\nShannon Temporal Client'));
|
||||||
console.log(chalk.gray('Start a pentest pipeline workflow\n'));
|
console.log(chalk.gray('Start a pentest pipeline workflow\n'));
|
||||||
@@ -212,35 +220,54 @@ async function startPipeline(): Promise<void> {
|
|||||||
let terminatedWorkflows: string[] = [];
|
let terminatedWorkflows: string[] = [];
|
||||||
let workflowId: string;
|
let workflowId: string;
|
||||||
let sessionId: string; // Workspace name (persistent directory)
|
let sessionId: string; // Workspace name (persistent directory)
|
||||||
|
let isResume = false;
|
||||||
|
|
||||||
// === Resume Mode ===
|
|
||||||
if (resumeFromWorkspace) {
|
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 sessionPath = path.join('./audit-logs', resumeFromWorkspace, 'session.json');
|
||||||
const session = await readJson<SessionJson>(sessionPath);
|
const workspaceExists = await fileExists(sessionPath);
|
||||||
|
|
||||||
if (session.session.webUrl !== webUrl) {
|
if (workspaceExists) {
|
||||||
console.error(chalk.red('ERROR: URL mismatch with workspace'));
|
// === Resume Mode: existing workspace ===
|
||||||
console.error(` Workspace URL: ${session.session.webUrl}`);
|
isResume = true;
|
||||||
console.error(` Provided URL: ${webUrl}`);
|
console.log(chalk.cyan('=== RESUME MODE ==='));
|
||||||
process.exit(1);
|
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 {
|
} else {
|
||||||
// === New Workflow ===
|
// === New Auto-Named Workflow ===
|
||||||
const hostname = sanitizeHostname(webUrl);
|
const hostname = sanitizeHostname(webUrl);
|
||||||
workflowId = customWorkflowId || `${hostname}_shannon-${Date.now()}`;
|
workflowId = customWorkflowId || `${hostname}_shannon-${Date.now()}`;
|
||||||
sessionId = workflowId;
|
sessionId = workflowId;
|
||||||
@@ -250,10 +277,11 @@ async function startPipeline(): Promise<void> {
|
|||||||
webUrl,
|
webUrl,
|
||||||
repoPath,
|
repoPath,
|
||||||
workflowId, // Add for audit correlation
|
workflowId, // Add for audit correlation
|
||||||
|
sessionId, // Workspace directory name
|
||||||
...(configPath && { configPath }),
|
...(configPath && { configPath }),
|
||||||
...(outputPath && { outputPath }),
|
...(outputPath && { outputPath }),
|
||||||
...(pipelineTestingMode && { pipelineTestingMode }),
|
...(pipelineTestingMode && { pipelineTestingMode }),
|
||||||
...(resumeFromWorkspace && { resumeFromWorkspace }),
|
...(isResume && resumeFromWorkspace && { resumeFromWorkspace }),
|
||||||
...(terminatedWorkflows.length > 0 && { terminatedWorkflows }),
|
...(terminatedWorkflows.length > 0 && { terminatedWorkflows }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,7 +291,7 @@ async function startPipeline(): Promise<void> {
|
|||||||
const outputDir = `${effectiveDisplayPath}/${sessionId}`;
|
const outputDir = `${effectiveDisplayPath}/${sessionId}`;
|
||||||
|
|
||||||
console.log(chalk.green.bold(`✓ Workflow started: ${workflowId}`));
|
console.log(chalk.green.bold(`✓ Workflow started: ${workflowId}`));
|
||||||
if (resumeFromWorkspace) {
|
if (isResume) {
|
||||||
console.log(chalk.gray(` (Resuming workspace: ${sessionId})`));
|
console.log(chalk.gray(` (Resuming workspace: ${sessionId})`));
|
||||||
}
|
}
|
||||||
console.log();
|
console.log();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface PipelineInput {
|
|||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
pipelineTestingMode?: boolean;
|
pipelineTestingMode?: boolean;
|
||||||
workflowId?: string; // Added by client, used for audit correlation
|
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
|
resumeFromWorkspace?: string; // Workspace name to resume from
|
||||||
terminatedWorkflows?: string[]; // Workflows terminated during resume
|
terminatedWorkflows?: string[]; // Workflows terminated during resume
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export async function pentestPipelineWorkflow(
|
|||||||
// Activities require workflowId (non-optional), PipelineInput has it optional
|
// Activities require workflowId (non-optional), PipelineInput has it optional
|
||||||
// Use spread to conditionally include optional properties (exactOptionalPropertyTypes)
|
// Use spread to conditionally include optional properties (exactOptionalPropertyTypes)
|
||||||
// sessionId is workspace name for resume, or workflowId for new runs
|
// 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 = {
|
const activityInput: ActivityInput = {
|
||||||
webUrl: input.webUrl,
|
webUrl: input.webUrl,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user