mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-05-25 01:54:14 +02:00
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:
|
||||
./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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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