Files
Codeman/src/workflow-run-watcher.ts
T
Codeman maintainer c15c19fab7 feat(ultracode): master-detail tab for Workflow/ultracode run visualization
Opt-in (showUltracodeAgents, default OFF) panel that visualizes ultracode /
Workflow-tool runs like Claude Code's "working agents" TUI: LEFT = runs + phases
(selectable tasks), RIGHT = each run's agents with model, live state, tokens
burned, and tool calls.

Standalone — ZERO edits to subagent-watcher.ts. A new workflow-run-watcher.ts
singleton globs the run-state tree (~/.claude/projects/*/*/workflows/wf_*.json,
disjoint from the transcript tree), strips the heavy script/scriptPath/result/logs
fields (174KB -> ~25KB/run), and emits workflow:run_* SSE events. The LEFT list
ships lightweight summaries (getLightState replay + SSE); the RIGHT pane fetches
the full run (with agents[]) via GET /api/workflows/:runId on selection.

Backend: workflow-run-watcher.ts, types/workflow-run.ts, config/workflow-config.ts,
3 SSE events, getLightState workflowRuns replay, GET /api/workflows[/:runId],
showUltracodeAgents schema key + boot-gate (default OFF) + live toggleService.
Frontend: ultracode-panel.js (debounced master-detail render, run/phase select),
header launcher (btn-ultracode-agents--hidden marker -> mobile-guard-exempt),
App Settings toggle (SYNCED, deliberately not in displayKeys).

Agent states on disk are start|progress|done (start=queued; done has
durationMs/resultPreview). Tests: workflow-run-watcher (9), workflow-routes (3).
Verified: tsc/lint/prettier/frontend-syntax/public-assets/mobile-header-guard
clean; full test:ci green (2986 passed); live server + Playwright e2e against 25
real runs (28-agent grid, phase filter, OFF hides launcher).

Design: docs/ultracode-agent-viz-plan.md (rev. 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:40:05 +02:00

384 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Workflow (ultracode) Run Watcher
*
* Watches `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` —
* the run-state JSON the Workflow tool writes for each ultracode run — and emits
* events powering the master-detail "working agents" view (tasks/phases on the
* LEFT, per-agent tokens/tool-calls on the RIGHT).
*
* Deliberately STANDALONE: it never imports from or touches subagent-watcher.ts.
* It globs the run-state tree (`.../workflows/wf_*.json`); subagent-watcher globs
* the disjoint, deeper transcript tree (`.../subagents/workflows/wf_<id>/agent-*`).
* Separate singletons, disjoint directories, no shared mutable state.
*
* Discovery is dual: a periodic poll (catches new run dirs + removals) plus a
* per-run-dir chokidar watcher (live within-run updates). A per-file mtime skip
* keeps the hot path cheap — the run JSON is rewritten on every agent tick.
*
* @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 RUN_FILE_PREFIX = 'wf_';
const RUN_FILE_SUFFIX = '.json';
/** 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;
function truncate(value: string | undefined, max: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.length > max ? `${value.slice(0, max)}…` : value;
}
/** 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;
}
/** 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>();
/** workflows-dir absolute path -> chokidar watcher (one per live run dir). */
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();
}
/** 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, dirs } = await this.discover();
// Install a live watcher for each run dir; tear down watchers for dirs that vanished.
for (const dir of dirs) this.ensureDirWatcher(dir);
for (const dir of Array.from(this.dirWatchers.keys())) {
if (!dirs.has(dir)) this.removeDirWatcher(dir);
}
const seenRunIds = new Set<string>();
for (const file of files) {
seenRunIds.add(file.runId);
await this.maybeParse(file);
}
// Removal by set-diff: a cached run whose file disappeared.
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);
this.emit('run_removed', { runId });
}
}
}
/** Walk projects/<projHash>/<sessionUuid>/workflows/ for wf_*.json files. */
private async discover(): Promise<{ files: DiscoveredRun[]; dirs: Set<string> }> {
const files: DiscoveredRun[] = [];
const dirs = new Set<string>();
let projectHashes: string[];
try {
projectHashes = await readdir(this.projectsDir);
} catch {
return { files, dirs };
}
for (const projectHash of projectHashes) {
let sessions: string[];
try {
sessions = await readdir(join(this.projectsDir, projectHash));
} catch {
continue;
}
for (const sessionUuid of sessions) {
const workflowsDir = join(this.projectsDir, projectHash, sessionUuid, WORKFLOWS_SUBDIR);
let names: string[];
try {
names = await readdir(workflowsDir);
} catch {
continue; // no workflows dir for this session — normal
}
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) dirs.add(workflowsDir);
}
}
return { files, dirs };
}
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);
}
/**
* 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);
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();