mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
889 lines
34 KiB
TypeScript
889 lines
34 KiB
TypeScript
/**
|
||
* @fileoverview Workflow (ultracode) Run Watcher
|
||
*
|
||
* Emits events powering the master-detail "working agents" view (tasks/phases on
|
||
* the LEFT, per-agent tokens/tool-calls on the RIGHT) AND the floating run
|
||
* windows, from TWO disk sources per run:
|
||
*
|
||
* 1. COMPLETION artifact — `…/workflows/wf_<id>.json`. The Workflow runtime
|
||
* writes this with the FULL run state (phases, per-agent tokens/tool-calls,
|
||
* result), but — as of the mid-2026 runtime — only when the run FINISHES
|
||
* (always a terminal status). It is the authoritative, detailed record.
|
||
* 2. LIVE transcript dir — `…/subagents/workflows/wf_<id>/` (agent-*.jsonl +
|
||
* journal.jsonl). This appears WHILE a run is in flight, before any
|
||
* `wf_<id>.json` exists. From it we synthesize a minimal ACTIVE run
|
||
* (status 'running', agent slots keyed by agentId, lastActivityAt from file
|
||
* mtimes) so the floating window pops DURING the run instead of only after.
|
||
*
|
||
* Precedence: when a completion `wf_<id>.json` exists it ALWAYS supersedes the
|
||
* synthesized live record (same runId), so a finished run shows full detail and
|
||
* the normal finish→auto-close flow runs. Without source 2 the floating-window
|
||
* feature is dead for live runs (the completion file only lands at the end, so
|
||
* the watcher would never see a run while it is active).
|
||
*
|
||
* Still STANDALONE: it never imports from or touches subagent-watcher.ts. It
|
||
* independently reads the same `subagents/workflows/` tree subagent-watcher uses,
|
||
* but as a separate singleton with no shared mutable state.
|
||
*
|
||
* Discovery is dual: a periodic poll (catches new run dirs + removals) plus a
|
||
* per-dir chokidar watcher (live updates). A per-source mtime skip keeps the hot
|
||
* path cheap — the completion JSON / live dir is re-read only when its mtime moves.
|
||
*
|
||
* @module workflow-run-watcher
|
||
*/
|
||
|
||
import { EventEmitter } from 'node:events';
|
||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||
import { homedir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
import { watch as chokidarWatch, type FSWatcher as ChokidarWatcher } from 'chokidar';
|
||
|
||
import type { WorkflowRunInfo, WorkflowRunSummary, WorkflowAgentInfo, WorkflowRunPhase } from './types/workflow-run.js';
|
||
import { LRUMap } from './utils/lru-map.js';
|
||
import {
|
||
WORKFLOW_RUN_POLL_INTERVAL_MS,
|
||
MAX_CACHED_WORKFLOW_RUNS,
|
||
WORKFLOW_RUN_RECENT_WINDOW_MIN,
|
||
} from './config/workflow-config.js';
|
||
|
||
const WORKFLOWS_SUBDIR = 'workflows';
|
||
const SUBAGENTS_SUBDIR = 'subagents';
|
||
const RUN_FILE_PREFIX = 'wf_';
|
||
const RUN_FILE_SUFFIX = '.json';
|
||
const LIVE_JOURNAL_FILE = 'journal.jsonl';
|
||
const LIVE_AGENT_PREFIX = 'agent-';
|
||
const LIVE_TRANSCRIPT_SUFFIX = '.jsonl';
|
||
const SCRIPTS_SUBDIR = 'scripts';
|
||
|
||
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
|
||
const PROMPT_PREVIEW_MAX = 200;
|
||
const RESULT_PREVIEW_MAX = 240;
|
||
/** Cap a live script read so a runaway/huge embedded script can't blow memory. */
|
||
const SCRIPT_READ_MAX_BYTES = 512 * 1024;
|
||
/** LRU cap on cached per-agent transcript stats across all live runs. */
|
||
const MAX_CACHED_AGENT_STATS = 2000;
|
||
|
||
function truncate(value: string | undefined, max: number): string | undefined {
|
||
if (typeof value !== 'string') return undefined;
|
||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||
}
|
||
|
||
/** First text from a transcript message's `content` (string or content-block array). */
|
||
function extractMessageText(content: unknown): string | undefined {
|
||
if (typeof content === 'string') return content.trim() || undefined;
|
||
if (Array.isArray(content)) {
|
||
for (const block of content) {
|
||
if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') {
|
||
const t = (block as { text?: string }).text;
|
||
if (typeof t === 'string' && t.trim()) return t.trim();
|
||
}
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Isolate the `meta = { … }` object literal from a workflow script so that later
|
||
* declarations (e.g. JSON-Schema objects with their own `description:`/`title:`
|
||
* fields, or prose containing `phases: [`) can't be mistaken for meta. Brace-matches
|
||
* naively — a `{`/`}` inside a string value can still confuse it, in which case we
|
||
* return undefined and the caller falls back to scanning the whole body. Best-effort.
|
||
*/
|
||
function extractMetaBlock(body: string): string | undefined {
|
||
const m = /(?:^|[^A-Za-z0-9_])(?:export\s+const|const|let|var)\s+meta\s*=\s*\{/.exec(body);
|
||
if (!m) return undefined;
|
||
const open = body.indexOf('{', m.index);
|
||
if (open < 0) return undefined;
|
||
let depth = 0;
|
||
for (let i = open; i < body.length; i++) {
|
||
if (body[i] === '{') depth++;
|
||
else if (body[i] === '}' && --depth === 0) return body.slice(open, i + 1);
|
||
}
|
||
return undefined; // unterminated (truncated script) — caller falls back to whole body
|
||
}
|
||
|
||
/**
|
||
* Best-effort extraction of a single/double-quoted `key: '...'` value from a workflow
|
||
* `meta` literal (the script is JS we must not eval). The key is anchored to a key
|
||
* position (`{`/`,`/whitespace before it) so e.g. `description` can't match the tail
|
||
* of `..._description`; pass the scoped meta block so later same-named schema fields
|
||
* can't win. Returns undefined on no match.
|
||
*/
|
||
function extractMetaString(body: string, key: string): string | undefined {
|
||
const re = new RegExp(`(?:^|[{,\\s])${key}\\s*:\\s*(['"])((?:\\\\.|(?!\\1).)*)\\1`);
|
||
const m = re.exec(body);
|
||
return m ? m[2].replace(/\\(['"\\`])/g, '$1') : undefined;
|
||
}
|
||
|
||
/** Best-effort `meta.phases: [{title, detail}, …]` extraction (pass the scoped meta block). */
|
||
function extractMetaPhases(body: string): WorkflowRunPhase[] {
|
||
const keyMatch = /(?:^|[^A-Za-z0-9_])phases\s*:\s*\[/.exec(body);
|
||
if (!keyMatch) return [];
|
||
const open = body.indexOf('[', keyMatch.index);
|
||
if (open < 0) return [];
|
||
// Brace-match to the array's closing ] so nested arrays don't end it early.
|
||
let depth = 0;
|
||
let end = -1;
|
||
for (let i = open; i < body.length; i++) {
|
||
if (body[i] === '[') depth++;
|
||
else if (body[i] === ']' && --depth === 0) {
|
||
end = i;
|
||
break;
|
||
}
|
||
}
|
||
if (end < 0) return [];
|
||
const block = body.slice(open, end + 1);
|
||
const phases: WorkflowRunPhase[] = [];
|
||
const re = /title\s*:\s*(['"])((?:\\.|(?!\1).)*)\1(?:\s*,\s*detail\s*:\s*(['"])((?:\\.|(?!\3).)*)\3)?/g;
|
||
let m: RegExpExecArray | null;
|
||
while ((m = re.exec(block)) !== null && phases.length < 20) {
|
||
phases.push({
|
||
title: m[2].replace(/\\(['"\\`])/g, '$1'),
|
||
detail: (m[4] || '').replace(/\\(['"\\`])/g, '$1'),
|
||
});
|
||
}
|
||
return phases;
|
||
}
|
||
|
||
/** Drop the heavy `agents[]` for list/snapshot use. */
|
||
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
|
||
const { agents: _agents, ...summary } = info;
|
||
void _agents;
|
||
return summary;
|
||
}
|
||
|
||
interface DiscoveredRun {
|
||
filePath: string;
|
||
projectHash: string;
|
||
sessionUuid: string;
|
||
runId: string;
|
||
}
|
||
|
||
/** An in-flight run discovered from its `subagents/workflows/wf_<id>/` transcript dir. */
|
||
interface DiscoveredLiveRun {
|
||
dirPath: string;
|
||
projectHash: string;
|
||
sessionUuid: string;
|
||
runId: string;
|
||
}
|
||
|
||
/** Per-agent stats parsed from one `agent-<id>.jsonl` transcript (mtime-cached). */
|
||
interface AgentTranscriptStats {
|
||
/** Tokens for the agent's LAST usage-bearing message (in+out+cache) ≈ the completion-JSON `tokens`. */
|
||
tokens: number;
|
||
/** Count of `tool_use` blocks across the transcript (matches the completion-JSON `toolCalls`). */
|
||
toolCalls: number;
|
||
/** Most-recent model id seen. */
|
||
model: string;
|
||
/** Name of the most-recent `tool_use` block. */
|
||
lastToolName?: string;
|
||
/** First user-message text, truncated — a hint of what the agent was asked. */
|
||
promptPreview?: string;
|
||
}
|
||
|
||
/** Workflow meta derived live from `workflows/scripts/<name>-<runId>.js`. */
|
||
interface LiveWorkflowMeta {
|
||
workflowName?: string;
|
||
summary?: string;
|
||
phases: WorkflowRunPhase[];
|
||
}
|
||
|
||
/** A live agent file on disk (transcript and/or meta), keyed by its `<id>` stem. */
|
||
interface LiveAgentFile {
|
||
agentId: string;
|
||
transcriptPath?: string;
|
||
transcriptMtime?: number;
|
||
}
|
||
|
||
/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */
|
||
interface RawProgressEntry {
|
||
type?: string;
|
||
index?: number;
|
||
label?: string;
|
||
phaseIndex?: number;
|
||
phaseTitle?: string;
|
||
model?: string;
|
||
state?: string;
|
||
queuedAt?: number;
|
||
lastProgressAt?: number;
|
||
promptPreview?: string;
|
||
agentId?: string;
|
||
startedAt?: number;
|
||
attempt?: number;
|
||
tokens?: number;
|
||
toolCalls?: number;
|
||
lastToolName?: string;
|
||
lastToolSummary?: string;
|
||
durationMs?: number;
|
||
resultPreview?: string;
|
||
}
|
||
|
||
export class WorkflowRunWatcher extends EventEmitter {
|
||
private projectsDir: string;
|
||
private pollTimer: NodeJS.Timeout | null = null;
|
||
private _isRunning = false;
|
||
|
||
/** runId -> latest parsed run info (LRU-bounded). */
|
||
private runs = new LRUMap<string, WorkflowRunInfo>({ maxSize: MAX_CACHED_WORKFLOW_RUNS });
|
||
/** absolute run-file path -> last seen mtimeMs (skip unchanged files). */
|
||
private fileMtimes = new Map<string, number>();
|
||
/** runId -> absolute run-file path (for mtime cleanup on removal). */
|
||
private runIdToPath = new Map<string, string>();
|
||
/** absolute live transcript-dir path -> newest member mtimeMs (skip unchanged live runs). */
|
||
private liveDirMtimes = new Map<string, number>();
|
||
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
|
||
private runIdToLiveDir = new Map<string, string>();
|
||
/** transcript abs path -> { mtimeMs, stats }; re-parse a transcript only when its mtime moves. */
|
||
private agentStatCache = new LRUMap<string, { mtimeMs: number; stats: AgentTranscriptStats }>({
|
||
maxSize: MAX_CACHED_AGENT_STATS,
|
||
});
|
||
/** runId -> derived script meta (immutable per run; re-derived only until a name is found). */
|
||
private liveMetaCache = new Map<string, LiveWorkflowMeta>();
|
||
/** journal abs path -> { mtimeMs, parsed }; re-parse the journal only when it grows. */
|
||
private journalCache = new Map<
|
||
string,
|
||
{ mtimeMs: number; parsed: { startedOrder: string[]; doneIds: Set<string> } }
|
||
>();
|
||
/** watched-dir absolute path -> chokidar watcher (workflows/ + subagents/workflows/). */
|
||
private dirWatchers = new Map<string, ChokidarWatcher>();
|
||
|
||
constructor(projectsDir?: string) {
|
||
super();
|
||
this.projectsDir = projectsDir || join(homedir(), '.claude', 'projects');
|
||
this.setMaxListeners(50);
|
||
}
|
||
|
||
// ========== Public API ==========
|
||
|
||
isRunning(): boolean {
|
||
return this._isRunning;
|
||
}
|
||
|
||
start(): void {
|
||
if (this._isRunning) return;
|
||
this._isRunning = true;
|
||
this.poll();
|
||
this.pollTimer = setInterval(() => this.poll(), WORKFLOW_RUN_POLL_INTERVAL_MS);
|
||
}
|
||
|
||
stop(): void {
|
||
this._isRunning = false;
|
||
if (this.pollTimer) {
|
||
clearInterval(this.pollTimer);
|
||
this.pollTimer = null;
|
||
}
|
||
for (const watcher of this.dirWatchers.values()) {
|
||
watcher.close().catch(() => {}); // best-effort teardown
|
||
}
|
||
this.dirWatchers.clear();
|
||
this.runs.clear();
|
||
this.fileMtimes.clear();
|
||
this.runIdToPath.clear();
|
||
this.liveDirMtimes.clear();
|
||
this.runIdToLiveDir.clear();
|
||
this.agentStatCache.clear();
|
||
this.liveMetaCache.clear();
|
||
this.journalCache.clear();
|
||
}
|
||
|
||
/** All cached runs (no recency filter), most-recently-active first. */
|
||
getAllRuns(): WorkflowRunInfo[] {
|
||
return Array.from(this.runs.values()).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
||
}
|
||
|
||
/** Runs active within the last `minutes`, most-recently-active first. */
|
||
getRecentRuns(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunInfo[] {
|
||
const cutoff = Date.now() - minutes * 60_000;
|
||
return this.getAllRuns().filter((r) => r.lastActivityAt >= cutoff);
|
||
}
|
||
|
||
/**
|
||
* Lightweight summaries (no agents[]) of ALL cached runs, most-recently-active
|
||
* first. This is the LEFT-pane list + getLightState snapshot source: the cache
|
||
* is LRU-bounded (MAX_CACHED_WORKFLOW_RUNS), so it's already size-capped, and a
|
||
* run-browser should show past runs — NOT hide everything older than a window.
|
||
*/
|
||
getAllRunSummaries(): WorkflowRunSummary[] {
|
||
return this.getAllRuns().map(summarizeRun);
|
||
}
|
||
|
||
/** Summaries filtered to the last `minutes` of activity (opt-in via ?minutes). */
|
||
getRecentRunSummaries(minutes: number = WORKFLOW_RUN_RECENT_WINDOW_MIN): WorkflowRunSummary[] {
|
||
return this.getRecentRuns(minutes).map(summarizeRun);
|
||
}
|
||
|
||
getRun(runId: string): WorkflowRunInfo | undefined {
|
||
return this.runs.get(runId);
|
||
}
|
||
|
||
getStats(): { runCount: number; running: number; agentCount: number } {
|
||
let running = 0;
|
||
let agentCount = 0;
|
||
for (const run of this.runs.values()) {
|
||
if (run.status === 'running') running++;
|
||
agentCount += run.agents.length;
|
||
}
|
||
return { runCount: this.runs.size, running, agentCount };
|
||
}
|
||
|
||
// ========== Private ==========
|
||
|
||
private poll(): void {
|
||
this.pollAsync().catch(() => {
|
||
// Filesystem may be transiently unavailable; the next poll retries.
|
||
});
|
||
}
|
||
|
||
private async pollAsync(): Promise<void> {
|
||
const { files, liveDirs, watchDirs } = await this.discover();
|
||
|
||
// Install a live watcher for each watched dir; tear down watchers for dirs that vanished.
|
||
for (const dir of watchDirs) this.ensureDirWatcher(dir);
|
||
for (const dir of Array.from(this.dirWatchers.keys())) {
|
||
if (!watchDirs.has(dir)) this.removeDirWatcher(dir);
|
||
}
|
||
|
||
const seenRunIds = new Set<string>();
|
||
const realRunIds = new Set<string>();
|
||
for (const file of files) {
|
||
seenRunIds.add(file.runId);
|
||
realRunIds.add(file.runId);
|
||
await this.maybeParse(file);
|
||
}
|
||
|
||
// In-flight runs: synthesize from the transcript tree ONLY while no completion
|
||
// wf_*.json exists yet — the real file (full detail + terminal status) supersedes.
|
||
for (const live of liveDirs) {
|
||
if (realRunIds.has(live.runId)) continue;
|
||
seenRunIds.add(live.runId);
|
||
await this.maybeParseLive(live);
|
||
}
|
||
|
||
// Removal by set-diff: a cached run discoverable from neither source.
|
||
for (const runId of Array.from(this.runs.keys())) {
|
||
if (!seenRunIds.has(runId)) {
|
||
this.runs.delete(runId);
|
||
const path = this.runIdToPath.get(runId);
|
||
if (path) this.fileMtimes.delete(path);
|
||
this.runIdToPath.delete(runId);
|
||
const liveDir = this.runIdToLiveDir.get(runId);
|
||
if (liveDir) {
|
||
this.liveDirMtimes.delete(liveDir);
|
||
// Drop cached transcript stats + journal parse under this run's live dir.
|
||
const prefix = liveDir.endsWith('/') ? liveDir : liveDir + '/';
|
||
for (const key of Array.from(this.agentStatCache.keys())) {
|
||
if (key.startsWith(prefix)) this.agentStatCache.delete(key);
|
||
}
|
||
for (const key of Array.from(this.journalCache.keys())) {
|
||
if (key.startsWith(prefix)) this.journalCache.delete(key);
|
||
}
|
||
}
|
||
this.liveMetaCache.delete(runId);
|
||
this.runIdToLiveDir.delete(runId);
|
||
this.emit('run_removed', { runId });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Walk projects/<projHash>/<sessionUuid>/ for both run sources:
|
||
* - completion files: `workflows/wf_*.json`
|
||
* - in-flight runs: `subagents/workflows/wf_<id>/` (transcript dirs)
|
||
* Returns the dirs to chokidar-watch (so a new run/file is caught sub-poll).
|
||
*/
|
||
private async discover(): Promise<{
|
||
files: DiscoveredRun[];
|
||
liveDirs: DiscoveredLiveRun[];
|
||
watchDirs: Set<string>;
|
||
}> {
|
||
const files: DiscoveredRun[] = [];
|
||
const liveDirs: DiscoveredLiveRun[] = [];
|
||
const watchDirs = new Set<string>();
|
||
|
||
let projectHashes: string[];
|
||
try {
|
||
projectHashes = await readdir(this.projectsDir);
|
||
} catch {
|
||
return { files, liveDirs, watchDirs };
|
||
}
|
||
|
||
for (const projectHash of projectHashes) {
|
||
let sessions: string[];
|
||
try {
|
||
sessions = await readdir(join(this.projectsDir, projectHash));
|
||
} catch {
|
||
continue;
|
||
}
|
||
for (const sessionUuid of sessions) {
|
||
const sessionDir = join(this.projectsDir, projectHash, sessionUuid);
|
||
|
||
// (1) Completion artifacts: workflows/wf_*.json
|
||
const workflowsDir = join(sessionDir, WORKFLOWS_SUBDIR);
|
||
try {
|
||
const names = await readdir(workflowsDir);
|
||
let hasRun = false;
|
||
for (const name of names) {
|
||
if (!name.startsWith(RUN_FILE_PREFIX) || !name.endsWith(RUN_FILE_SUFFIX)) continue;
|
||
hasRun = true;
|
||
files.push({
|
||
filePath: join(workflowsDir, name),
|
||
projectHash,
|
||
sessionUuid,
|
||
runId: name.slice(0, -RUN_FILE_SUFFIX.length),
|
||
});
|
||
}
|
||
if (hasRun) watchDirs.add(workflowsDir);
|
||
} catch {
|
||
// no workflows dir for this session — normal
|
||
}
|
||
|
||
// (2) In-flight runs: subagents/workflows/wf_*/
|
||
const liveParent = join(sessionDir, SUBAGENTS_SUBDIR, WORKFLOWS_SUBDIR);
|
||
try {
|
||
const names = await readdir(liveParent);
|
||
let hasLive = false;
|
||
for (const name of names) {
|
||
if (!name.startsWith(RUN_FILE_PREFIX) || name.endsWith(RUN_FILE_SUFFIX)) continue; // wf_<id> dir, not a file
|
||
hasLive = true;
|
||
liveDirs.push({
|
||
dirPath: join(liveParent, name),
|
||
projectHash,
|
||
sessionUuid,
|
||
runId: name,
|
||
});
|
||
}
|
||
if (hasLive) watchDirs.add(liveParent);
|
||
} catch {
|
||
// no subagents/workflows dir for this session — normal
|
||
}
|
||
}
|
||
}
|
||
return { files, liveDirs, watchDirs };
|
||
}
|
||
|
||
private async maybeParse(file: DiscoveredRun): Promise<void> {
|
||
let mtime: number;
|
||
try {
|
||
mtime = (await stat(file.filePath)).mtimeMs;
|
||
} catch {
|
||
return; // vanished between discover and stat
|
||
}
|
||
if (this.fileMtimes.get(file.filePath) === mtime) return;
|
||
this.fileMtimes.set(file.filePath, mtime);
|
||
|
||
const info = await this.parseFile(file);
|
||
if (!info) return;
|
||
|
||
const existed = this.runs.has(info.runId);
|
||
this.runs.set(info.runId, info);
|
||
this.runIdToPath.set(info.runId, file.filePath);
|
||
this.emit(existed ? 'run_updated' : 'run_discovered', info);
|
||
}
|
||
|
||
/**
|
||
* Re-synthesize an in-flight run from its transcript dir. Cheap pass first: stat
|
||
* the dir members and skip entirely when the newest mtime is unchanged, so an idle
|
||
* poll never reads a single (large) transcript. Only on a real change do we parse —
|
||
* and even then per-transcript stats come from an mtime-keyed cache, so only the
|
||
* transcripts that actually grew are re-read.
|
||
*/
|
||
private async maybeParseLive(live: DiscoveredLiveRun): Promise<void> {
|
||
let entries: string[];
|
||
try {
|
||
entries = await readdir(live.dirPath);
|
||
} catch {
|
||
return; // vanished between discover and read
|
||
}
|
||
|
||
// Cheap pass: newest member mtime + the agent-file set (no transcript reads yet).
|
||
let newestMtime = 0;
|
||
let journalPath: string | null = null;
|
||
let journalMtime = 0;
|
||
const agentFiles = new Map<string, LiveAgentFile>();
|
||
for (const name of entries) {
|
||
if (name !== LIVE_JOURNAL_FILE && !name.startsWith(LIVE_AGENT_PREFIX)) continue;
|
||
const full = join(live.dirPath, name);
|
||
let m = 0;
|
||
try {
|
||
m = (await stat(full)).mtimeMs;
|
||
} catch {
|
||
continue; // entry vanished — ignore
|
||
}
|
||
if (m > newestMtime) newestMtime = m;
|
||
if (name === LIVE_JOURNAL_FILE) {
|
||
journalPath = full;
|
||
journalMtime = m;
|
||
continue;
|
||
}
|
||
// agent-<stem>.jsonl (transcript) or agent-<stem>.meta.json (queued-slot marker)
|
||
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
|
||
if (!stem) continue;
|
||
const slot = agentFiles.get(stem) || { agentId: stem };
|
||
if (name.endsWith(LIVE_TRANSCRIPT_SUFFIX)) {
|
||
slot.transcriptPath = full;
|
||
slot.transcriptMtime = m;
|
||
}
|
||
agentFiles.set(stem, slot);
|
||
}
|
||
if (agentFiles.size === 0) return; // nothing to show yet
|
||
|
||
// Skip the expensive parse when nothing changed since the last synthesis.
|
||
if (this.liveDirMtimes.get(live.dirPath) === newestMtime) return;
|
||
this.liveDirMtimes.set(live.dirPath, newestMtime);
|
||
|
||
const info = await this.parseLiveDir(live, agentFiles, journalPath, journalMtime, newestMtime);
|
||
if (!info) return;
|
||
|
||
const existed = this.runs.has(info.runId);
|
||
this.runs.set(info.runId, info);
|
||
this.runIdToLiveDir.set(info.runId, live.dirPath);
|
||
this.emit(existed ? 'run_updated' : 'run_discovered', info);
|
||
}
|
||
|
||
/**
|
||
* Build an ACTIVE WorkflowRunInfo from a live transcript dir, ENRICHED so the
|
||
* floating window / panel show real data mid-run rather than empty slots:
|
||
* - per-agent tokens + tool-calls + model + last tool, parsed from each
|
||
* `agent-<id>.jsonl` (mtime-cached — only changed transcripts are re-read);
|
||
* - per-agent state: 'done' once the journal logs a `result` (→ green badge),
|
||
* 'progress' once it logs `started` or a transcript exists (→ yellow), else 'start';
|
||
* - run name/summary/phases from the live `workflows/scripts/<name>-<runId>.js`
|
||
* (the completion wf_<id>.json, which carries these, only lands at the end);
|
||
* - run totals = sums of the per-agent stats.
|
||
*/
|
||
private async parseLiveDir(
|
||
live: DiscoveredLiveRun,
|
||
agentFiles: Map<string, LiveAgentFile>,
|
||
journalPath: string | null,
|
||
journalMtime: number,
|
||
newestMtime: number
|
||
): Promise<WorkflowRunInfo | null> {
|
||
const { startedOrder, doneIds } = journalPath
|
||
? await this.readJournal(journalPath, journalMtime)
|
||
: { startedOrder: [] as string[], doneIds: new Set<string>() };
|
||
const startIndex = new Map<string, number>();
|
||
startedOrder.forEach((id, i) => startIndex.set(id, i));
|
||
|
||
// Order agents by journal launch order; not-yet-started ones trail (sorted by id).
|
||
const ids = Array.from(agentFiles.keys()).sort((a, b) => {
|
||
const ia = startIndex.has(a) ? (startIndex.get(a) as number) : Number.MAX_SAFE_INTEGER;
|
||
const ib = startIndex.has(b) ? (startIndex.get(b) as number) : Number.MAX_SAFE_INTEGER;
|
||
if (ia !== ib) return ia - ib;
|
||
return a < b ? -1 : a > b ? 1 : 0;
|
||
});
|
||
|
||
let totalTokens = 0;
|
||
let totalToolCalls = 0;
|
||
let defaultModel = '';
|
||
const agents: WorkflowAgentInfo[] = [];
|
||
for (let i = 0; i < ids.length; i++) {
|
||
const id = ids[i];
|
||
const file = agentFiles.get(id) as LiveAgentFile;
|
||
const stats = file.transcriptPath
|
||
? await this.agentTranscriptStats(file.transcriptPath, file.transcriptMtime || 0)
|
||
: null;
|
||
const state = doneIds.has(id) ? 'done' : startIndex.has(id) || file.transcriptPath ? 'progress' : 'start';
|
||
if (stats) {
|
||
totalTokens += stats.tokens;
|
||
totalToolCalls += stats.toolCalls;
|
||
if (stats.model && !defaultModel) defaultModel = stats.model;
|
||
}
|
||
agents.push({
|
||
index: i + 1,
|
||
label: `agent ${i + 1}`,
|
||
phaseIndex: 1,
|
||
phaseTitle: '',
|
||
model: stats?.model || '',
|
||
state,
|
||
agentId: id,
|
||
tokens: stats ? stats.tokens : undefined,
|
||
toolCalls: stats ? stats.toolCalls : undefined,
|
||
lastToolName: stats?.lastToolName,
|
||
promptPreview: stats?.promptPreview,
|
||
});
|
||
}
|
||
|
||
// Script meta is immutable for the life of a run, so derive it at most once.
|
||
// Re-derive only while we still lack a name (the script can land a poll or two
|
||
// after the first transcripts, which seed the run before the script exists).
|
||
let meta = this.liveMetaCache.get(live.runId);
|
||
if (!meta || !meta.workflowName) {
|
||
meta = await this.deriveLiveWorkflowMeta(live);
|
||
this.liveMetaCache.set(live.runId, meta);
|
||
}
|
||
|
||
return {
|
||
runId: live.runId,
|
||
workflowName: meta.workflowName,
|
||
summary: meta.summary,
|
||
status: 'running',
|
||
agentCount: agents.length,
|
||
totalTokens,
|
||
totalToolCalls,
|
||
defaultModel: defaultModel || undefined,
|
||
phases: meta.phases,
|
||
agents,
|
||
sessionUuid: live.sessionUuid,
|
||
projectHash: live.projectHash,
|
||
lastActivityAt: newestMtime || 0,
|
||
};
|
||
}
|
||
|
||
/** Parse one agent transcript for token/tool stats; cached on the file's mtime. */
|
||
private async agentTranscriptStats(path: string, mtimeMs: number): Promise<AgentTranscriptStats | null> {
|
||
// peek (not get) on the hit-check so a cache HIT doesn't churn the LRU — every
|
||
// active agent is looked up each poll, and get() would delete+reinsert each time.
|
||
const cached = this.agentStatCache.peek(path);
|
||
if (cached && cached.mtimeMs === mtimeMs) return cached.stats;
|
||
let text: string;
|
||
try {
|
||
text = await readFile(path, 'utf-8');
|
||
} catch {
|
||
return null; // vanished mid-poll
|
||
}
|
||
let tokens = 0;
|
||
let toolCalls = 0;
|
||
let model = '';
|
||
let lastToolName: string | undefined;
|
||
let promptPreview: string | undefined;
|
||
for (const line of text.split('\n')) {
|
||
if (!line) continue;
|
||
let entry: { type?: string; message?: unknown };
|
||
try {
|
||
entry = JSON.parse(line) as { type?: string; message?: unknown };
|
||
} catch {
|
||
continue; // tolerate a partially-written trailing line
|
||
}
|
||
const msg = entry.message as
|
||
| {
|
||
model?: string;
|
||
usage?: {
|
||
input_tokens?: number;
|
||
output_tokens?: number;
|
||
cache_read_input_tokens?: number;
|
||
cache_creation_input_tokens?: number;
|
||
};
|
||
content?: Array<{ type?: string; name?: string; text?: string }> | string;
|
||
}
|
||
| undefined;
|
||
if (!promptPreview && entry.type === 'user' && msg) {
|
||
promptPreview = truncate(extractMessageText(msg.content), PROMPT_PREVIEW_MAX);
|
||
}
|
||
if (!msg || typeof msg !== 'object') continue;
|
||
if (typeof msg.model === 'string' && msg.model) model = msg.model;
|
||
const u = msg.usage;
|
||
if (u) {
|
||
const total =
|
||
(u.input_tokens || 0) +
|
||
(u.output_tokens || 0) +
|
||
(u.cache_read_input_tokens || 0) +
|
||
(u.cache_creation_input_tokens || 0);
|
||
// Anthropic usage already reflects cumulative context, so the agent's running
|
||
// token total ≈ its LATEST usage-bearing message — last non-zero wins.
|
||
if (total > 0) tokens = total;
|
||
}
|
||
if (Array.isArray(msg.content)) {
|
||
for (const block of msg.content) {
|
||
if (block && block.type === 'tool_use' && typeof block.name === 'string') {
|
||
toolCalls++;
|
||
lastToolName = block.name;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const stats: AgentTranscriptStats = { tokens, toolCalls, model, lastToolName, promptPreview };
|
||
this.agentStatCache.set(path, { mtimeMs, stats });
|
||
return stats;
|
||
}
|
||
|
||
/**
|
||
* Read the run journal: agent ids in `started` order plus the set that already
|
||
* logged a terminal `result`. (`started` lines appear in launch order.)
|
||
*/
|
||
private async readJournal(
|
||
journalPath: string,
|
||
mtimeMs: number
|
||
): Promise<{ startedOrder: string[]; doneIds: Set<string> }> {
|
||
const cached = this.journalCache.get(journalPath);
|
||
if (cached && cached.mtimeMs === mtimeMs) return cached.parsed;
|
||
const startedOrder: string[] = [];
|
||
const seenStarted = new Set<string>();
|
||
const doneIds = new Set<string>();
|
||
let text: string;
|
||
try {
|
||
text = await readFile(journalPath, 'utf-8');
|
||
} catch {
|
||
return { startedOrder, doneIds }; // journal not written yet
|
||
}
|
||
for (const line of text.split('\n')) {
|
||
if (!line) continue;
|
||
try {
|
||
const ev = JSON.parse(line) as { type?: string; agentId?: string };
|
||
if (!ev || typeof ev.agentId !== 'string') continue;
|
||
if (ev.type === 'started' && !seenStarted.has(ev.agentId)) {
|
||
seenStarted.add(ev.agentId);
|
||
startedOrder.push(ev.agentId);
|
||
} else if (ev.type === 'result') {
|
||
doneIds.add(ev.agentId);
|
||
}
|
||
} catch {
|
||
// tolerate a partially-written trailing line
|
||
}
|
||
}
|
||
const parsed = { startedOrder, doneIds };
|
||
this.journalCache.set(journalPath, { mtimeMs, parsed });
|
||
return parsed;
|
||
}
|
||
|
||
/**
|
||
* Derive name/summary/phases for a live run from its persisted script file
|
||
* `…/workflows/scripts/<name>-<runId>.js`. The filename yields a reliable name;
|
||
* `meta.description`/`meta.phases` are best-effort regex extractions (the script is
|
||
* JS we must not eval), so any parse miss simply leaves those optional fields absent.
|
||
*/
|
||
private async deriveLiveWorkflowMeta(live: DiscoveredLiveRun): Promise<LiveWorkflowMeta> {
|
||
const empty: LiveWorkflowMeta = { phases: [] };
|
||
const scriptsDir = join(this.projectsDir, live.projectHash, live.sessionUuid, WORKFLOWS_SUBDIR, SCRIPTS_SUBDIR);
|
||
let names: string[];
|
||
try {
|
||
names = await readdir(scriptsDir);
|
||
} catch {
|
||
return empty;
|
||
}
|
||
const suffix = `-${live.runId}.js`;
|
||
const fileName = names.find((n) => n.endsWith(suffix)) || names.find((n) => n.includes(live.runId));
|
||
if (!fileName) return empty;
|
||
const nameFromFile = fileName.endsWith(suffix) ? fileName.slice(0, -suffix.length) : fileName.replace(/\.js$/, '');
|
||
let body = '';
|
||
try {
|
||
body = (await readFile(join(scriptsDir, fileName), 'utf-8')).slice(0, SCRIPT_READ_MAX_BYTES);
|
||
} catch {
|
||
return { workflowName: nameFromFile || undefined, phases: [] };
|
||
}
|
||
// Scope name/summary/phases parsing to the `meta` literal so later script
|
||
// declarations (schemas, prose) can't be mistaken for it.
|
||
const metaBlock = extractMetaBlock(body) || body;
|
||
return {
|
||
workflowName: nameFromFile || extractMetaString(metaBlock, 'name') || undefined,
|
||
summary: extractMetaString(metaBlock, 'description') || undefined,
|
||
phases: extractMetaPhases(metaBlock),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Parse a wf_<runId>.json into WorkflowRunInfo, STRIPPING the heavyweight
|
||
* `script`/`scriptPath`/`result`/`logs` fields (the embedded script alone is
|
||
* 15–660KB) so they never reach the cache, SSE, or routes.
|
||
*/
|
||
private async parseFile(file: DiscoveredRun): Promise<WorkflowRunInfo | null> {
|
||
let raw: Record<string, unknown>;
|
||
try {
|
||
raw = JSON.parse(await readFile(file.filePath, 'utf-8')) as Record<string, unknown>;
|
||
} catch {
|
||
return null; // mid-write or malformed — next mtime change re-parses
|
||
}
|
||
if (!raw || typeof raw !== 'object') return null;
|
||
|
||
const progress = Array.isArray(raw.workflowProgress) ? (raw.workflowProgress as RawProgressEntry[]) : [];
|
||
const agents: WorkflowAgentInfo[] = progress
|
||
.filter((e) => e && e.type === 'workflow_agent')
|
||
.map((e) => this.toAgent(e));
|
||
|
||
const phases: WorkflowRunPhase[] = Array.isArray(raw.phases)
|
||
? (raw.phases as Array<Record<string, unknown>>).map((p) => ({
|
||
title: typeof p.title === 'string' ? p.title : '',
|
||
detail: typeof p.detail === 'string' ? p.detail : '',
|
||
}))
|
||
: [];
|
||
|
||
let lastProgress = 0;
|
||
for (const a of agents) {
|
||
if (typeof a.lastProgressAt === 'number' && a.lastProgressAt > lastProgress) lastProgress = a.lastProgressAt;
|
||
}
|
||
const startTime = typeof raw.startTime === 'number' ? raw.startTime : undefined;
|
||
const lastActivityAt = lastProgress || startTime || 0;
|
||
|
||
return {
|
||
runId: typeof raw.runId === 'string' ? raw.runId : file.runId,
|
||
workflowName: typeof raw.workflowName === 'string' ? raw.workflowName : undefined,
|
||
status: typeof raw.status === 'string' ? raw.status : undefined,
|
||
summary: typeof raw.summary === 'string' ? raw.summary : undefined,
|
||
agentCount: typeof raw.agentCount === 'number' ? raw.agentCount : undefined,
|
||
totalTokens: typeof raw.totalTokens === 'number' ? raw.totalTokens : undefined,
|
||
totalToolCalls: typeof raw.totalToolCalls === 'number' ? raw.totalToolCalls : undefined,
|
||
durationMs: typeof raw.durationMs === 'number' ? raw.durationMs : undefined,
|
||
startTime,
|
||
timestamp: typeof raw.timestamp === 'string' ? raw.timestamp : undefined,
|
||
defaultModel: typeof raw.defaultModel === 'string' ? raw.defaultModel : undefined,
|
||
taskId: typeof raw.taskId === 'string' ? raw.taskId : undefined,
|
||
error: typeof raw.error === 'string' ? raw.error : undefined,
|
||
phases,
|
||
agents,
|
||
sessionUuid: file.sessionUuid,
|
||
projectHash: file.projectHash,
|
||
lastActivityAt,
|
||
};
|
||
}
|
||
|
||
private toAgent(e: RawProgressEntry): WorkflowAgentInfo {
|
||
return {
|
||
index: typeof e.index === 'number' ? e.index : 0,
|
||
label: typeof e.label === 'string' ? e.label : '',
|
||
phaseIndex: typeof e.phaseIndex === 'number' ? e.phaseIndex : 0,
|
||
phaseTitle: typeof e.phaseTitle === 'string' ? e.phaseTitle : '',
|
||
model: typeof e.model === 'string' ? e.model : '',
|
||
state: typeof e.state === 'string' ? e.state : 'start',
|
||
queuedAt: e.queuedAt,
|
||
lastProgressAt: e.lastProgressAt,
|
||
promptPreview: truncate(e.promptPreview, PROMPT_PREVIEW_MAX),
|
||
agentId: e.agentId,
|
||
startedAt: e.startedAt,
|
||
attempt: e.attempt,
|
||
tokens: e.tokens,
|
||
toolCalls: e.toolCalls,
|
||
lastToolName: e.lastToolName,
|
||
lastToolSummary: truncate(e.lastToolSummary, RESULT_PREVIEW_MAX),
|
||
durationMs: e.durationMs,
|
||
resultPreview: truncate(e.resultPreview, RESULT_PREVIEW_MAX),
|
||
};
|
||
}
|
||
|
||
private ensureDirWatcher(workflowsDir: string): void {
|
||
if (this.dirWatchers.has(workflowsDir)) return;
|
||
try {
|
||
const watcher = chokidarWatch(workflowsDir, {
|
||
depth: 0,
|
||
awaitWriteFinish: { stabilityThreshold: 200 },
|
||
ignoreInitial: true,
|
||
persistent: false,
|
||
});
|
||
const handler = () => this.poll();
|
||
watcher.on('add', handler);
|
||
watcher.on('change', handler);
|
||
watcher.on('unlink', handler);
|
||
// subagents/workflows/ children are wf_<id>/ DIRS — catch their add/remove too.
|
||
watcher.on('addDir', handler);
|
||
watcher.on('unlinkDir', handler);
|
||
watcher.on('error', () => {
|
||
// chokidar surfaced an error for this dir — drop the watcher; poll still covers it.
|
||
this.removeDirWatcher(workflowsDir);
|
||
});
|
||
this.dirWatchers.set(workflowsDir, watcher);
|
||
} catch {
|
||
// Watch setup failed — periodic poll still discovers changes.
|
||
}
|
||
}
|
||
|
||
private removeDirWatcher(workflowsDir: string): void {
|
||
const watcher = this.dirWatchers.get(workflowsDir);
|
||
if (watcher) {
|
||
watcher.close().catch(() => {});
|
||
this.dirWatchers.delete(workflowsDir);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Process-wide singleton (mirrors subagentWatcher / imageWatcher). */
|
||
export const workflowRunWatcher = new WorkflowRunWatcher();
|