mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-07-14 16:47:26 +02:00
feat: typescript migration (#40)
* chore: initialize TypeScript configuration and build setup - Add tsconfig.json for root and mcp-server with strict type checking - Install typescript and @types/node as devDependencies - Add npm build script for TypeScript compilation - Update main entrypoint to compiled dist/shannon.js - Update Dockerfile to build TypeScript before running - Configure output directory and module resolution for Node.js * refactor: migrate codebase from JavaScript to TypeScript - Convert all 37 JavaScript files to TypeScript (.js -> .ts) - Add type definitions in src/types/ for agents, config, errors, session - Update mcp-server with proper TypeScript types - Move entry point from shannon.mjs to src/shannon.ts - Update tsconfig.json with rootDir: "./src" for cleaner dist output - Update Dockerfile to build TypeScript before runtime - Update package.json paths to use compiled dist/shannon.js No runtime behavior changes - pure type safety migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: update CLI references from ./shannon.mjs to shannon - Update help text in src/cli/ui.ts - Update usage examples in src/cli/command-handler.ts - Update setup message in src/shannon.ts - Update CLAUDE.md documentation with TypeScript file structure - Replace all ./shannon.mjs references with shannon command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove unnecessary eslint-disable comments ESLint is not configured in this project, making these comments redundant. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,23 +13,33 @@
|
||||
|
||||
import { AgentLogger } from './logger.js';
|
||||
import { MetricsTracker } from './metrics-tracker.js';
|
||||
import { initializeAuditStructure, formatTimestamp } from './utils.js';
|
||||
import { initializeAuditStructure, formatTimestamp, type SessionMetadata } from './utils.js';
|
||||
import { SessionMutex } from '../utils/concurrency.js';
|
||||
|
||||
// Global mutex instance
|
||||
const sessionMutex = new SessionMutex();
|
||||
|
||||
interface AgentEndResult {
|
||||
attemptNumber: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
checkpoint?: string;
|
||||
isFinalAttempt?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuditSession - Main audit system facade
|
||||
*/
|
||||
export class AuditSession {
|
||||
/**
|
||||
* @param {Object} sessionMetadata - Session metadata from Shannon store
|
||||
* @param {string} sessionMetadata.id - Session UUID
|
||||
* @param {string} sessionMetadata.webUrl - Target web URL
|
||||
* @param {string} [sessionMetadata.repoPath] - Target repository path
|
||||
*/
|
||||
constructor(sessionMetadata) {
|
||||
private sessionMetadata: SessionMetadata;
|
||||
private sessionId: string;
|
||||
private metricsTracker: MetricsTracker;
|
||||
private currentLogger: AgentLogger | null = null;
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata) {
|
||||
this.sessionMetadata = sessionMetadata;
|
||||
this.sessionId = sessionMetadata.id;
|
||||
|
||||
@@ -43,20 +53,13 @@ export class AuditSession {
|
||||
|
||||
// Components
|
||||
this.metricsTracker = new MetricsTracker(sessionMetadata);
|
||||
|
||||
// Active logger (one at a time per agent attempt)
|
||||
this.currentLogger = null;
|
||||
|
||||
// Initialization flag
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize audit session (creates directories, session.json)
|
||||
* Idempotent and race-safe
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return; // Already initialized
|
||||
}
|
||||
@@ -72,10 +75,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Ensure initialized (helper for lazy initialization)
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ensureInitialized() {
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
@@ -83,12 +84,12 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Start agent execution
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {string} promptContent - Full prompt content
|
||||
* @param {number} [attemptNumber=1] - Attempt number
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async startAgent(agentName, promptContent, attemptNumber = 1) {
|
||||
async startAgent(
|
||||
agentName: string,
|
||||
promptContent: string,
|
||||
attemptNumber: number = 1
|
||||
): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
// Save prompt snapshot (only on first attempt)
|
||||
@@ -107,17 +108,14 @@ export class AuditSession {
|
||||
await this.currentLogger.logEvent('agent_start', {
|
||||
agentName,
|
||||
attemptNumber,
|
||||
timestamp: formatTimestamp()
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event during agent execution
|
||||
* @param {string} eventType - Event type (tool_start, tool_end, llm_response, etc.)
|
||||
* @param {Object} eventData - Event data
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logEvent(eventType, eventData) {
|
||||
async logEvent(eventType: string, eventData: unknown): Promise<void> {
|
||||
if (!this.currentLogger) {
|
||||
throw new Error('No active logger. Call startAgent() first.');
|
||||
}
|
||||
@@ -127,18 +125,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* End agent execution (mutex-protected)
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {Object} result - Execution result
|
||||
* @param {number} result.attemptNumber - Attempt number
|
||||
* @param {number} result.duration_ms - Duration in milliseconds
|
||||
* @param {number} result.cost_usd - Cost in USD
|
||||
* @param {boolean} result.success - Whether attempt succeeded
|
||||
* @param {string} [result.error] - Error message (if failed)
|
||||
* @param {string} [result.checkpoint] - Git checkpoint hash (if succeeded)
|
||||
* @param {boolean} [result.isFinalAttempt=false] - Whether this is the final attempt
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async endAgent(agentName, result) {
|
||||
async endAgent(agentName: string, result: AgentEndResult): Promise<void> {
|
||||
// Log end event
|
||||
if (this.currentLogger) {
|
||||
await this.currentLogger.logEvent('agent_end', {
|
||||
@@ -146,7 +134,7 @@ export class AuditSession {
|
||||
success: result.success,
|
||||
duration_ms: result.duration_ms,
|
||||
cost_usd: result.cost_usd,
|
||||
timestamp: formatTimestamp()
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
|
||||
// Close logger
|
||||
@@ -169,10 +157,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Mark multiple agents as rolled back
|
||||
* @param {string[]} agentNames - Array of agent names
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markMultipleRolledBack(agentNames) {
|
||||
async markMultipleRolledBack(agentNames: string[]): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
@@ -186,10 +172,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Update session status
|
||||
* @param {string} status - New status (in-progress, completed, failed)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateSessionStatus(status) {
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
@@ -203,9 +187,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Get current metrics (read-only)
|
||||
* @returns {Promise<Object>} Current metrics
|
||||
*/
|
||||
async getMetrics() {
|
||||
async getMetrics(): Promise<unknown> {
|
||||
await this.ensureInitialized();
|
||||
return this.metricsTracker.getMetrics();
|
||||
}
|
||||
@@ -12,18 +12,33 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { generateLogPath, generatePromptPath, atomicWrite, formatTimestamp } from './utils.js';
|
||||
import {
|
||||
generateLogPath,
|
||||
generatePromptPath,
|
||||
atomicWrite,
|
||||
formatTimestamp,
|
||||
type SessionMetadata,
|
||||
} from './utils.js';
|
||||
|
||||
interface LogEvent {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentLogger - Manages append-only logging for a single agent execution
|
||||
*/
|
||||
export class AgentLogger {
|
||||
/**
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Name of the agent
|
||||
* @param {number} attemptNumber - Attempt number (1, 2, 3, ...)
|
||||
*/
|
||||
constructor(sessionMetadata, agentName, attemptNumber) {
|
||||
private sessionMetadata: SessionMetadata;
|
||||
private agentName: string;
|
||||
private attemptNumber: number;
|
||||
private timestamp: number;
|
||||
private logPath: string;
|
||||
private stream: fs.WriteStream | null = null;
|
||||
private isOpen: boolean = false;
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata, agentName: string, attemptNumber: number) {
|
||||
this.sessionMetadata = sessionMetadata;
|
||||
this.agentName = agentName;
|
||||
this.attemptNumber = attemptNumber;
|
||||
@@ -31,17 +46,12 @@ export class AgentLogger {
|
||||
|
||||
// Generate log file path
|
||||
this.logPath = generateLogPath(sessionMetadata, agentName, this.timestamp, attemptNumber);
|
||||
|
||||
// Create write stream (append mode)
|
||||
this.stream = null;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the log stream (creates file and opens stream)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isOpen) {
|
||||
return; // Already initialized
|
||||
}
|
||||
@@ -50,7 +60,7 @@ export class AgentLogger {
|
||||
this.stream = fs.createWriteStream(this.logPath, {
|
||||
flags: 'a', // Append mode
|
||||
encoding: 'utf8',
|
||||
autoClose: true
|
||||
autoClose: true,
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
@@ -61,10 +71,8 @@ export class AgentLogger {
|
||||
|
||||
/**
|
||||
* Write header to log file
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async writeHeader() {
|
||||
private async writeHeader(): Promise<void> {
|
||||
const header = [
|
||||
`========================================`,
|
||||
`Agent: ${this.agentName}`,
|
||||
@@ -72,7 +80,7 @@ export class AgentLogger {
|
||||
`Started: ${formatTimestamp(this.timestamp)}`,
|
||||
`Session: ${this.sessionMetadata.id}`,
|
||||
`Web URL: ${this.sessionMetadata.webUrl}`,
|
||||
`========================================\n`
|
||||
`========================================\n`,
|
||||
].join('\n');
|
||||
|
||||
return this.writeRaw(header);
|
||||
@@ -80,11 +88,8 @@ export class AgentLogger {
|
||||
|
||||
/**
|
||||
* Write raw text to log file with immediate flush
|
||||
* @private
|
||||
* @param {string} text - Text to write
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
writeRaw(text) {
|
||||
private writeRaw(text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isOpen || !this.stream) {
|
||||
reject(new Error('Logger not initialized'));
|
||||
@@ -100,8 +105,8 @@ export class AgentLogger {
|
||||
|
||||
if (needsDrain) {
|
||||
// Buffer is full, wait for drain
|
||||
const drainHandler = () => {
|
||||
this.stream.removeListener('drain', drainHandler);
|
||||
const drainHandler = (): void => {
|
||||
this.stream!.removeListener('drain', drainHandler);
|
||||
resolve();
|
||||
};
|
||||
this.stream.once('drain', drainHandler);
|
||||
@@ -115,15 +120,12 @@ export class AgentLogger {
|
||||
/**
|
||||
* Log an event (tool_start, tool_end, llm_response, etc.)
|
||||
* Events are logged as JSON for parseability
|
||||
* @param {string} eventType - Type of event
|
||||
* @param {Object} eventData - Event data
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logEvent(eventType, eventData) {
|
||||
const event = {
|
||||
async logEvent(eventType: string, eventData: unknown): Promise<void> {
|
||||
const event: LogEvent = {
|
||||
type: eventType,
|
||||
timestamp: formatTimestamp(),
|
||||
data: eventData
|
||||
data: eventData,
|
||||
};
|
||||
|
||||
const eventLine = `${JSON.stringify(event)}\n`;
|
||||
@@ -132,15 +134,14 @@ export class AgentLogger {
|
||||
|
||||
/**
|
||||
* Close the log stream
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async close() {
|
||||
async close(): Promise<void> {
|
||||
if (!this.isOpen || !this.stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.stream.end(() => {
|
||||
this.stream!.end(() => {
|
||||
this.isOpen = false;
|
||||
resolve();
|
||||
});
|
||||
@@ -150,12 +151,12 @@ export class AgentLogger {
|
||||
/**
|
||||
* Save prompt snapshot to prompts directory
|
||||
* Static method - doesn't require logger instance
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {string} promptContent - Full prompt content
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async savePrompt(sessionMetadata, agentName, promptContent) {
|
||||
static async savePrompt(
|
||||
sessionMetadata: SessionMetadata,
|
||||
agentName: string,
|
||||
promptContent: string
|
||||
): Promise<void> {
|
||||
const promptPath = generatePromptPath(sessionMetadata, agentName);
|
||||
|
||||
// Create header with metadata
|
||||
@@ -167,7 +168,7 @@ export class AgentLogger {
|
||||
`**Saved:** ${formatTimestamp()}`,
|
||||
``,
|
||||
`---`,
|
||||
``
|
||||
``,
|
||||
].join('\n');
|
||||
|
||||
const fullContent = header + promptContent;
|
||||
@@ -17,38 +17,92 @@ import {
|
||||
readJson,
|
||||
fileExists,
|
||||
formatTimestamp,
|
||||
calculatePercentage
|
||||
calculatePercentage,
|
||||
type SessionMetadata,
|
||||
} from './utils.js';
|
||||
import type { AgentName, PhaseName } from '../types/index.js';
|
||||
|
||||
interface AttemptData {
|
||||
attempt_number: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AgentMetrics {
|
||||
status: 'in-progress' | 'success' | 'failed' | 'rolled-back';
|
||||
attempts: AttemptData[];
|
||||
final_duration_ms: number;
|
||||
total_cost_usd: number;
|
||||
checkpoint?: string;
|
||||
rolled_back_at?: string;
|
||||
}
|
||||
|
||||
interface PhaseMetrics {
|
||||
duration_ms: number;
|
||||
duration_percentage: number;
|
||||
cost_usd: number;
|
||||
agent_count: number;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
session: {
|
||||
id: string;
|
||||
webUrl: string;
|
||||
repoPath?: string;
|
||||
status: 'in-progress' | 'completed' | 'failed';
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
metrics: {
|
||||
total_duration_ms: number;
|
||||
total_cost_usd: number;
|
||||
phases: Record<string, PhaseMetrics>;
|
||||
agents: Record<string, AgentMetrics>;
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentEndResult {
|
||||
attemptNumber: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
checkpoint?: string;
|
||||
isFinalAttempt?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveTimer {
|
||||
startTime: number;
|
||||
attemptNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MetricsTracker - Manages metrics for a session
|
||||
*/
|
||||
export class MetricsTracker {
|
||||
/**
|
||||
* @param {Object} sessionMetadata - Session metadata from Shannon store
|
||||
*/
|
||||
constructor(sessionMetadata) {
|
||||
private sessionMetadata: SessionMetadata;
|
||||
private sessionJsonPath: string;
|
||||
private data: SessionData | null = null;
|
||||
private activeTimers: Map<string, ActiveTimer> = new Map();
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata) {
|
||||
this.sessionMetadata = sessionMetadata;
|
||||
this.sessionJsonPath = generateSessionJsonPath(sessionMetadata);
|
||||
|
||||
// In-memory state (loaded from/synced to session.json)
|
||||
this.data = null;
|
||||
|
||||
// Active timers (agent name -> start time)
|
||||
this.activeTimers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session.json (idempotent)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
async initialize(): Promise<void> {
|
||||
// Check if session.json already exists
|
||||
const exists = await fileExists(this.sessionJsonPath);
|
||||
|
||||
if (exists) {
|
||||
// Load existing data
|
||||
this.data = await readJson(this.sessionJsonPath);
|
||||
this.data = await readJson<SessionData>(this.sessionJsonPath);
|
||||
} else {
|
||||
// Create new session.json
|
||||
this.data = this.createInitialData();
|
||||
@@ -58,72 +112,66 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Create initial session.json structure
|
||||
* @private
|
||||
* @returns {Object} Initial session data
|
||||
*/
|
||||
createInitialData() {
|
||||
return {
|
||||
private createInitialData(): SessionData {
|
||||
const sessionData: SessionData = {
|
||||
session: {
|
||||
id: this.sessionMetadata.id,
|
||||
webUrl: this.sessionMetadata.webUrl,
|
||||
repoPath: this.sessionMetadata.repoPath,
|
||||
status: 'in-progress',
|
||||
createdAt: this.sessionMetadata.createdAt || formatTimestamp()
|
||||
createdAt: (this.sessionMetadata as { createdAt?: string }).createdAt || formatTimestamp(),
|
||||
},
|
||||
metrics: {
|
||||
total_duration_ms: 0,
|
||||
total_cost_usd: 0,
|
||||
phases: {}, // Phase-level aggregations: { duration_ms, duration_percentage, cost_usd, agent_count }
|
||||
agents: {} // Agent-level metrics: { status, attempts[], final_duration_ms, total_cost_usd, checkpoint }
|
||||
}
|
||||
phases: {}, // Phase-level aggregations
|
||||
agents: {}, // Agent-level metrics
|
||||
},
|
||||
};
|
||||
// Only add repoPath if it exists
|
||||
if (this.sessionMetadata.repoPath) {
|
||||
sessionData.session.repoPath = this.sessionMetadata.repoPath;
|
||||
}
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking an agent execution
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {number} attemptNumber - Attempt number
|
||||
* @returns {void}
|
||||
*/
|
||||
startAgent(agentName, attemptNumber) {
|
||||
startAgent(agentName: string, attemptNumber: number): void {
|
||||
this.activeTimers.set(agentName, {
|
||||
startTime: Date.now(),
|
||||
attemptNumber
|
||||
attemptNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End agent execution and update metrics
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {Object} result - Agent execution result
|
||||
* @param {number} result.attemptNumber - Attempt number
|
||||
* @param {number} result.duration_ms - Duration in milliseconds
|
||||
* @param {number} result.cost_usd - Cost in USD
|
||||
* @param {boolean} result.success - Whether attempt succeeded
|
||||
* @param {string} [result.error] - Error message (if failed)
|
||||
* @param {string} [result.checkpoint] - Git checkpoint hash (if succeeded)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async endAgent(agentName, result) {
|
||||
async endAgent(agentName: string, result: AgentEndResult): Promise<void> {
|
||||
if (!this.data) {
|
||||
throw new Error('MetricsTracker not initialized');
|
||||
}
|
||||
|
||||
// Initialize agent metrics if not exists
|
||||
if (!this.data.metrics.agents[agentName]) {
|
||||
this.data.metrics.agents[agentName] = {
|
||||
status: 'in-progress',
|
||||
attempts: [],
|
||||
final_duration_ms: 0,
|
||||
total_cost_usd: 0 // Total cost across all attempts (including retries)
|
||||
total_cost_usd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const agent = this.data.metrics.agents[agentName];
|
||||
const agent = this.data.metrics.agents[agentName]!;
|
||||
|
||||
// Add attempt to array
|
||||
const attempt = {
|
||||
const attempt: AttemptData = {
|
||||
attempt_number: result.attemptNumber,
|
||||
duration_ms: result.duration_ms,
|
||||
cost_usd: result.cost_usd,
|
||||
success: result.success,
|
||||
timestamp: formatTimestamp()
|
||||
timestamp: formatTimestamp(),
|
||||
};
|
||||
|
||||
if (result.error) {
|
||||
@@ -162,15 +210,13 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Mark agent as rolled back
|
||||
* @param {string} agentName - Agent name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markRolledBack(agentName) {
|
||||
if (!this.data.metrics.agents[agentName]) {
|
||||
async markRolledBack(agentName: string): Promise<void> {
|
||||
if (!this.data || !this.data.metrics.agents[agentName]) {
|
||||
return; // Agent not tracked
|
||||
}
|
||||
|
||||
const agent = this.data.metrics.agents[agentName];
|
||||
const agent = this.data.metrics.agents[agentName]!;
|
||||
agent.status = 'rolled-back';
|
||||
agent.rolled_back_at = formatTimestamp();
|
||||
|
||||
@@ -182,13 +228,13 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Mark multiple agents as rolled back
|
||||
* @param {string[]} agentNames - Array of agent names
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markMultipleRolledBack(agentNames) {
|
||||
async markMultipleRolledBack(agentNames: string[]): Promise<void> {
|
||||
if (!this.data) return;
|
||||
|
||||
for (const agentName of agentNames) {
|
||||
if (this.data.metrics.agents[agentName]) {
|
||||
const agent = this.data.metrics.agents[agentName];
|
||||
const agent = this.data.metrics.agents[agentName]!;
|
||||
agent.status = 'rolled-back';
|
||||
agent.rolled_back_at = formatTimestamp();
|
||||
}
|
||||
@@ -200,10 +246,10 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Update session status
|
||||
* @param {string} status - New status (in-progress, completed, failed)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateSessionStatus(status) {
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise<void> {
|
||||
if (!this.data) return;
|
||||
|
||||
this.data.session.status = status;
|
||||
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
@@ -215,25 +261,24 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Recalculate aggregations (total duration, total cost, phases)
|
||||
* @private
|
||||
*/
|
||||
recalculateAggregations() {
|
||||
private recalculateAggregations(): void {
|
||||
if (!this.data) return;
|
||||
|
||||
const agents = this.data.metrics.agents;
|
||||
|
||||
// Only count successful agents (not rolled-back or failed)
|
||||
const successfulAgents = Object.entries(agents)
|
||||
.filter(([_, data]) => data.status === 'success');
|
||||
const successfulAgents = Object.entries(agents).filter(
|
||||
([, data]) => data.status === 'success'
|
||||
);
|
||||
|
||||
// Calculate total duration and cost
|
||||
const totalDuration = successfulAgents.reduce(
|
||||
(sum, [_, data]) => sum + data.final_duration_ms,
|
||||
(sum, [, data]) => sum + data.final_duration_ms,
|
||||
0
|
||||
);
|
||||
|
||||
const totalCost = successfulAgents.reduce(
|
||||
(sum, [_, data]) => sum + data.total_cost_usd,
|
||||
0
|
||||
);
|
||||
const totalCost = successfulAgents.reduce((sum, [, data]) => sum + data.total_cost_usd, 0);
|
||||
|
||||
this.data.metrics.total_duration_ms = totalDuration;
|
||||
this.data.metrics.total_cost_usd = totalCost;
|
||||
@@ -244,23 +289,22 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Calculate phase-level metrics
|
||||
* @private
|
||||
* @param {Array} successfulAgents - Array of [agentName, agentData] tuples
|
||||
* @returns {Object} Phase metrics
|
||||
*/
|
||||
calculatePhaseMetrics(successfulAgents) {
|
||||
const phases = {
|
||||
private calculatePhaseMetrics(
|
||||
successfulAgents: Array<[string, AgentMetrics]>
|
||||
): Record<string, PhaseMetrics> {
|
||||
const phases: Record<string, AgentMetrics[]> = {
|
||||
'pre-recon': [],
|
||||
'recon': [],
|
||||
recon: [],
|
||||
'vulnerability-analysis': [],
|
||||
'exploitation': [],
|
||||
'reporting': []
|
||||
exploitation: [],
|
||||
reporting: [],
|
||||
};
|
||||
|
||||
// Map agents to phases
|
||||
const agentPhaseMap = {
|
||||
const agentPhaseMap: Record<string, string> = {
|
||||
'pre-recon': 'pre-recon',
|
||||
'recon': 'recon',
|
||||
recon: 'recon',
|
||||
'injection-vuln': 'vulnerability-analysis',
|
||||
'xss-vuln': 'vulnerability-analysis',
|
||||
'auth-vuln': 'vulnerability-analysis',
|
||||
@@ -271,39 +315,33 @@ export class MetricsTracker {
|
||||
'auth-exploit': 'exploitation',
|
||||
'authz-exploit': 'exploitation',
|
||||
'ssrf-exploit': 'exploitation',
|
||||
'report': 'reporting'
|
||||
report: 'reporting',
|
||||
};
|
||||
|
||||
// Group agents by phase
|
||||
for (const [agentName, agentData] of successfulAgents) {
|
||||
const phase = agentPhaseMap[agentName];
|
||||
if (phase) {
|
||||
phases[phase].push(agentData);
|
||||
if (phase && phases[phase]) {
|
||||
phases[phase]!.push(agentData);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate metrics per phase
|
||||
const phaseMetrics = {};
|
||||
const totalDuration = this.data.metrics.total_duration_ms;
|
||||
const phaseMetrics: Record<string, PhaseMetrics> = {};
|
||||
const totalDuration = this.data!.metrics.total_duration_ms;
|
||||
|
||||
for (const [phaseName, agentList] of Object.entries(phases)) {
|
||||
if (agentList.length === 0) continue;
|
||||
|
||||
const phaseDuration = agentList.reduce(
|
||||
(sum, agent) => sum + agent.final_duration_ms,
|
||||
0
|
||||
);
|
||||
const phaseDuration = agentList.reduce((sum, agent) => sum + agent.final_duration_ms, 0);
|
||||
|
||||
const phaseCost = agentList.reduce(
|
||||
(sum, agent) => sum + agent.total_cost_usd,
|
||||
0
|
||||
);
|
||||
const phaseCost = agentList.reduce((sum, agent) => sum + agent.total_cost_usd, 0);
|
||||
|
||||
phaseMetrics[phaseName] = {
|
||||
duration_ms: phaseDuration,
|
||||
duration_percentage: calculatePercentage(phaseDuration, totalDuration),
|
||||
cost_usd: phaseCost,
|
||||
agent_count: agentList.length
|
||||
agent_count: agentList.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,26 +350,23 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Get current metrics
|
||||
* @returns {Object} Current metrics data
|
||||
*/
|
||||
getMetrics() {
|
||||
return JSON.parse(JSON.stringify(this.data));
|
||||
getMetrics(): SessionData {
|
||||
return JSON.parse(JSON.stringify(this.data)) as SessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save metrics to session.json (atomic write)
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save() {
|
||||
private async save(): Promise<void> {
|
||||
if (!this.data) return;
|
||||
await atomicWrite(this.sessionJsonPath, this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload metrics from disk
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reload() {
|
||||
this.data = await readJson(this.sessionJsonPath);
|
||||
async reload(): Promise<void> {
|
||||
this.data = await readJson<SessionData>(this.sessionJsonPath);
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,17 @@ const __dirname = path.dirname(__filename);
|
||||
export const SHANNON_ROOT = path.resolve(__dirname, '..', '..');
|
||||
export const AUDIT_LOGS_DIR = path.join(SHANNON_ROOT, 'audit-logs');
|
||||
|
||||
export interface SessionMetadata {
|
||||
id: string;
|
||||
webUrl: string;
|
||||
repoPath?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate standardized session identifier: {hostname}_{sessionId}
|
||||
* @param {Object} sessionMetadata - Session metadata from Shannon store
|
||||
* @param {string} sessionMetadata.id - UUID session ID
|
||||
* @param {string} sessionMetadata.webUrl - Target web URL
|
||||
* @returns {string} Formatted session identifier
|
||||
*/
|
||||
export function generateSessionIdentifier(sessionMetadata) {
|
||||
export function generateSessionIdentifier(sessionMetadata: SessionMetadata): string {
|
||||
const { id, webUrl } = sessionMetadata;
|
||||
const hostname = new URL(webUrl).hostname.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
return `${hostname}_${id}`;
|
||||
@@ -37,23 +40,21 @@ export function generateSessionIdentifier(sessionMetadata) {
|
||||
|
||||
/**
|
||||
* Generate path to audit log directory for a session
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @returns {string} Absolute path to session audit directory
|
||||
*/
|
||||
export function generateAuditPath(sessionMetadata) {
|
||||
export function generateAuditPath(sessionMetadata: SessionMetadata): string {
|
||||
const sessionIdentifier = generateSessionIdentifier(sessionMetadata);
|
||||
return path.join(AUDIT_LOGS_DIR, sessionIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to agent log file
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Name of the agent
|
||||
* @param {number} timestamp - Timestamp (ms since epoch)
|
||||
* @param {number} attemptNumber - Attempt number (1, 2, 3, ...)
|
||||
* @returns {string} Absolute path to agent log file
|
||||
*/
|
||||
export function generateLogPath(sessionMetadata, agentName, timestamp, attemptNumber) {
|
||||
export function generateLogPath(
|
||||
sessionMetadata: SessionMetadata,
|
||||
agentName: string,
|
||||
timestamp: number,
|
||||
attemptNumber: number
|
||||
): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
|
||||
return path.join(auditPath, 'agents', filename);
|
||||
@@ -61,36 +62,29 @@ export function generateLogPath(sessionMetadata, agentName, timestamp, attemptNu
|
||||
|
||||
/**
|
||||
* Generate path to prompt snapshot file
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Name of the agent
|
||||
* @returns {string} Absolute path to prompt file
|
||||
*/
|
||||
export function generatePromptPath(sessionMetadata, agentName) {
|
||||
export function generatePromptPath(sessionMetadata: SessionMetadata, agentName: string): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'prompts', `${agentName}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to session.json file
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @returns {string} Absolute path to session.json
|
||||
*/
|
||||
export function generateSessionJsonPath(sessionMetadata) {
|
||||
export function generateSessionJsonPath(sessionMetadata: SessionMetadata): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'session.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists (idempotent, race-safe)
|
||||
* @param {string} dirPath - Directory path to create
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function ensureDirectory(dirPath) {
|
||||
export async function ensureDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
} catch (error) {
|
||||
// Ignore EEXIST errors (race condition safe)
|
||||
if (error.code !== 'EEXIST') {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -99,11 +93,8 @@ export async function ensureDirectory(dirPath) {
|
||||
/**
|
||||
* Atomic write using temp file + rename pattern
|
||||
* Guarantees no partial writes or corruption on crash
|
||||
* @param {string} filePath - Target file path
|
||||
* @param {Object|string} data - Data to write (will be JSON.stringified if object)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function atomicWrite(filePath, data) {
|
||||
export async function atomicWrite(filePath: string, data: object | string): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
|
||||
@@ -117,7 +108,7 @@ export async function atomicWrite(filePath, data) {
|
||||
// Clean up temp file on failure
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch (cleanupError) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
@@ -126,10 +117,8 @@ export async function atomicWrite(filePath, data) {
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
* @param {number} ms - Duration in milliseconds
|
||||
* @returns {string} Formatted duration (e.g., "2m 34s", "45s", "1.2s")
|
||||
*/
|
||||
export function formatDuration(ms) {
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
@@ -146,40 +135,31 @@ export function formatDuration(ms) {
|
||||
|
||||
/**
|
||||
* Format timestamp to ISO 8601 string
|
||||
* @param {number} [timestamp] - Unix timestamp in ms (defaults to now)
|
||||
* @returns {string} ISO 8601 formatted string
|
||||
*/
|
||||
export function formatTimestamp(timestamp = Date.now()) {
|
||||
export function formatTimestamp(timestamp: number = Date.now()): string {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentage
|
||||
* @param {number} part - Part value
|
||||
* @param {number} total - Total value
|
||||
* @returns {number} Percentage (0-100)
|
||||
*/
|
||||
export function calculatePercentage(part, total) {
|
||||
export function calculatePercentage(part: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return (part / total) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse JSON file
|
||||
* @param {string} filePath - Path to JSON file
|
||||
* @returns {Promise<Object>} Parsed JSON data
|
||||
*/
|
||||
export async function readJson(filePath) {
|
||||
export async function readJson<T = unknown>(filePath: string): Promise<T> {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
return JSON.parse(content) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {Promise<boolean>} True if file exists
|
||||
*/
|
||||
export async function fileExists(filePath) {
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
@@ -191,10 +171,8 @@ export async function fileExists(filePath) {
|
||||
/**
|
||||
* Initialize audit directory structure for a session
|
||||
* Creates: audit-logs/{sessionId}/, agents/, prompts/
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function initializeAuditStructure(sessionMetadata) {
|
||||
export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise<void> {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const agentsPath = path.join(auditPath, 'agents');
|
||||
const promptsPath = path.join(auditPath, 'prompts');
|
||||
Reference in New Issue
Block a user