mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @fileoverview Workflow (ultracode) run-watcher polling and cache configuration.
|
||||
*
|
||||
* Controls how frequently WorkflowRunWatcher polls
|
||||
* ~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json
|
||||
* and how many runs are cached in memory.
|
||||
*
|
||||
* Distinct from the Agent-Teams config (team-config.ts). The run-state JSON is
|
||||
* rewritten on every agent tick across a whole run (28+ agents), so the watcher
|
||||
* relies on a per-file mtime skip; the poll itself is just N stat() calls.
|
||||
*
|
||||
* @module config/workflow-config
|
||||
*/
|
||||
|
||||
/** Workflow run-state poll interval (ms). Short because a poll is just N mtime stats. */
|
||||
export const WORKFLOW_RUN_POLL_INTERVAL_MS = 10_000;
|
||||
|
||||
/** Max cached workflow runs (LRU eviction). */
|
||||
export const MAX_CACHED_WORKFLOW_RUNS = 100;
|
||||
|
||||
/**
|
||||
* Default recency window (minutes) for getRecentRuns(). Generous enough that a
|
||||
* recently-finished long run still appears in the LEFT-pane list — filtered on
|
||||
* last-activity, not start time, so multi-hour runs don't vanish.
|
||||
*/
|
||||
export const WORKFLOW_RUN_RECENT_WINDOW_MIN = 240;
|
||||
@@ -67,3 +67,4 @@ export * from './push.js';
|
||||
export * from './plan.js';
|
||||
export * from './orchestrator.js';
|
||||
export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @fileoverview Types for ultracode / Workflow-tool run visualization.
|
||||
*
|
||||
* A Workflow run persists its state to
|
||||
* `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_<runId>.json`
|
||||
* (a sibling of the deeper `subagents/workflows/wf_<runId>/agent-*.jsonl`
|
||||
* transcript tree that subagent-watcher tracks). This file is the single source
|
||||
* for the master-detail "working agents" view: a run's tasks/phases on the LEFT
|
||||
* and per-agent stats (tokens burned, tool calls) on the RIGHT.
|
||||
*
|
||||
* Field presence is STATE-DRIVEN and verified against real runs on disk:
|
||||
* - state 'start' (queued): no agentId/tokens/toolCalls/startedAt/durationMs/...
|
||||
* - state 'progress' (running): has agentId/tokens/toolCalls, no durationMs/resultPreview
|
||||
* - state 'done' (finished): all fields, incl. durationMs/resultPreview
|
||||
* Absent fields are genuinely ABSENT (never explicit null) — use `?:`, not null.
|
||||
*
|
||||
* @module types/workflow-run
|
||||
*/
|
||||
|
||||
/** One declared phase of a run (from the run JSON's top-level `phases[]`, 0-indexed). */
|
||||
export interface WorkflowRunPhase {
|
||||
/** Phase title; equals each member agent's `phaseTitle`. Always present. */
|
||||
title: string;
|
||||
/** Human description of the phase. Always present in `phases[]`. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent slot in a run, derived from `workflowProgress[]` entries where
|
||||
* `type === 'workflow_agent'`. Optional fields are absent until the agent
|
||||
* reaches the relevant lifecycle state (see module doc).
|
||||
*/
|
||||
export interface WorkflowAgentInfo {
|
||||
/** 1-based stable slot index, unique within the run. Always present. */
|
||||
index: number;
|
||||
/** Agent label, e.g. "probe:dompurify-config". Always present. */
|
||||
label: string;
|
||||
/** 1-based phase number; join via `run.phases[phaseIndex - 1]`. Always present. */
|
||||
phaseIndex: number;
|
||||
/** Phase title (=== run.phases[phaseIndex-1].title). Always present. */
|
||||
phaseTitle: string;
|
||||
/** Model id, e.g. "claude-opus-4-8[1m]". Always present. */
|
||||
model: string;
|
||||
/** Lifecycle state. Real on-disk values: 'start' | 'progress' | 'done'. Open union. */
|
||||
state: 'start' | 'progress' | 'done' | (string & {});
|
||||
/** Epoch ms the slot was queued. Always present. */
|
||||
queuedAt?: number;
|
||||
/** Epoch ms of the last progress tick. Always present once any progress occurs. */
|
||||
lastProgressAt?: number;
|
||||
/** Truncated prompt the agent was given. Always present. */
|
||||
promptPreview?: string;
|
||||
/**
|
||||
* Globally-unique agent id; equals the `agent-<agentId>.jsonl` transcript stem
|
||||
* (the Phase-4 correlation key). ABSENT while state === 'start'.
|
||||
*/
|
||||
agentId?: string;
|
||||
/** Epoch ms the agent began. Absent while 'start'. */
|
||||
startedAt?: number;
|
||||
/** Attempt counter. Absent while 'start'. */
|
||||
attempt?: number;
|
||||
/** Tokens burned so far (RIGHT pane). Absent while 'start'. */
|
||||
tokens?: number;
|
||||
/** Tool calls made so far (RIGHT pane). Absent while 'start'. */
|
||||
toolCalls?: number;
|
||||
/** Name of the most recent tool. Present for progress/done (occasionally absent). */
|
||||
lastToolName?: string;
|
||||
/** Short summary of the most recent tool call. May be absent even when 'done'. */
|
||||
lastToolSummary?: string;
|
||||
/** Total run time (ms). Present ONLY when 'done' — the live-vs-finished discriminator. */
|
||||
durationMs?: number;
|
||||
/** Truncated final result. Present ONLY when 'done'. */
|
||||
resultPreview?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-level info shipped to the browser.
|
||||
*
|
||||
* IMPORTANT: the on-disk JSON also carries `script` (15–660KB of embedded JS),
|
||||
* `scriptPath`, `result`, and `logs`. The watcher STRIPS all four before the
|
||||
* object is ever cached/broadcast — never let them reach SSE/getLightState/route.
|
||||
*/
|
||||
export interface WorkflowRunInfo {
|
||||
/** Run id (=== the wf_<runId>.json filename stem). Always present. */
|
||||
runId: string;
|
||||
/** Workflow name from `meta.name`. Always present. */
|
||||
workflowName?: string;
|
||||
/**
|
||||
* Run status. Real on-disk values seen: 'completed' | 'killed'.
|
||||
* 'running' | 'failed' are inferred (parse defensively; keep open union).
|
||||
*/
|
||||
status?: 'completed' | 'killed' | 'running' | 'failed' | (string & {});
|
||||
/** Concise human description (best LEFT-pane label). Always present. */
|
||||
summary?: string;
|
||||
/** Total agent slots, INCLUDING not-yet-started 'start' agents. */
|
||||
agentCount?: number;
|
||||
/** Total tokens across the run (partial mid-run). */
|
||||
totalTokens?: number;
|
||||
/** Total tool calls across the run (partial mid-run). */
|
||||
totalToolCalls?: number;
|
||||
/** Total run duration (ms). */
|
||||
durationMs?: number;
|
||||
/** Run start time (epoch MILLIS). */
|
||||
startTime?: number;
|
||||
/** ISO end/write timestamp. */
|
||||
timestamp?: string;
|
||||
/** Default model for the run. */
|
||||
defaultModel?: string;
|
||||
/** Background-task id that owns the run. */
|
||||
taskId?: string;
|
||||
/** Declared phases (0-indexed). */
|
||||
phases: WorkflowRunPhase[];
|
||||
/** Agents, derived from `workflowProgress` filtered to `type === 'workflow_agent'`. */
|
||||
agents: WorkflowAgentInfo[];
|
||||
/** Error message, present when status is 'killed'/'failed'. */
|
||||
error?: string;
|
||||
|
||||
// ----- Watcher-derived (NOT in the JSON body — captured from the file path) -----
|
||||
/** `<sessionUuid>` path segment (for per-session scoping). */
|
||||
sessionUuid: string;
|
||||
/** `<projHash>` path segment. */
|
||||
projectHash: string;
|
||||
/**
|
||||
* Most recent activity (epoch ms): max agent `lastProgressAt`, else `startTime`.
|
||||
* Drives recency filtering/sorting so finished long runs still surface.
|
||||
*/
|
||||
lastActivityAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight run projection (no `agents[]`) for the LEFT-pane list and the
|
||||
* getLightState reconnect snapshot. A full run with 28 agents serializes to
|
||||
* ~36KB; the snapshot ships dozens of runs, so it carries summaries only and the
|
||||
* RIGHT pane fetches the full run (`GET /api/workflows/:runId`) on selection.
|
||||
*/
|
||||
export type WorkflowRunSummary = Omit<WorkflowRunInfo, 'agents'>;
|
||||
@@ -221,6 +221,11 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'],
|
||||
[SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'],
|
||||
|
||||
// Workflow runs (ultracode)
|
||||
[SSE_EVENTS.WORKFLOW_RUN_DISCOVERED, '_onWorkflowRunDiscovered'],
|
||||
[SSE_EVENTS.WORKFLOW_RUN_UPDATED, '_onWorkflowRunUpdated'],
|
||||
[SSE_EVENTS.WORKFLOW_RUN_REMOVED, '_onWorkflowRunRemoved'],
|
||||
|
||||
// Images
|
||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||
@@ -339,6 +344,12 @@ class CodemanApp {
|
||||
this.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
|
||||
this.activeSubagentId = null; // Currently selected subagent for detail view
|
||||
this.subagentPanelVisible = false;
|
||||
|
||||
// Ultracode / Workflow run visualization (master-detail tab)
|
||||
this.workflowRuns = new Map(); // runId -> run summary (LEFT list)
|
||||
this.workflowRunDetails = new Map(); // runId -> full run with agents[] (RIGHT pane)
|
||||
this.activeWorkflowRunId = null;
|
||||
this.activeWorkflowPhaseIndex = null;
|
||||
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
|
||||
this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE;
|
||||
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
|
||||
@@ -2228,6 +2239,11 @@ class CodemanApp {
|
||||
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
|
||||
this.subagentActivity.clear();
|
||||
this.subagentToolResults.clear();
|
||||
// Clear ultracode workflow run state (re-seeded from data.workflowRuns below)
|
||||
if (this.workflowRuns) this.workflowRuns.clear();
|
||||
if (this.workflowRunDetails) this.workflowRunDetails.clear();
|
||||
this.activeWorkflowRunId = null;
|
||||
this.activeWorkflowPhaseIndex = null;
|
||||
// Clean up mobile/keyboard handlers and re-init (prevents listener accumulation on reconnect)
|
||||
MobileDetection.cleanup();
|
||||
KeyboardHandler.cleanup();
|
||||
@@ -2402,6 +2418,11 @@ class CodemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
// Seed ultracode workflow runs (LEFT-pane summaries) from the snapshot
|
||||
if (data.workflowRuns) {
|
||||
this.seedWorkflowRuns(data.workflowRuns);
|
||||
}
|
||||
|
||||
// Restore previously active session (survives page reload + SSE reconnect)
|
||||
// Must always re-select because handleInit clears terminal state above.
|
||||
// Reset activeSessionId so selectSession doesn't early-return.
|
||||
|
||||
@@ -350,6 +350,11 @@ const SSE_EVENTS = {
|
||||
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
||||
SUBAGENT_COMPLETED: 'subagent:completed',
|
||||
|
||||
// Workflow runs (ultracode / Workflow tool)
|
||||
WORKFLOW_RUN_DISCOVERED: 'workflow:run_discovered',
|
||||
WORKFLOW_RUN_UPDATED: 'workflow:run_updated',
|
||||
WORKFLOW_RUN_REMOVED: 'workflow:run_removed',
|
||||
|
||||
// Images
|
||||
IMAGE_DETECTED: 'image:detected',
|
||||
ATTACHMENT_DETECTED: 'attachment:detected',
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
@@ -600,6 +601,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ultracode / Workflow agents master-detail panel (opt-in via showUltracodeAgents) -->
|
||||
<div class="subagents-panel ultracode-agents-panel hidden" id="ultracodeAgentsPanel">
|
||||
<div class="subagents-panel-header">
|
||||
<div class="subagents-panel-title">
|
||||
Ultracode Agents <span id="ultracodeCountBadge" class="subagent-badge"></span>
|
||||
</div>
|
||||
<div class="subagents-panel-actions">
|
||||
<button class="btn-icon-sm" onclick="app.toggleUltracodeAgentsPanel()" title="Toggle panel">▲</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeUltracodeAgentsPanel()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subagents-panel-body">
|
||||
<div class="subagent-container">
|
||||
<div class="subagent-list ultracode-run-list" id="ultracodeRunList">
|
||||
<div class="subagent-empty">No ultracode runs detected</div>
|
||||
</div>
|
||||
<div class="subagent-detail ultracode-agent-grid" id="ultracodeAgentGrid">
|
||||
<div class="subagent-empty">Select a run to view its agents</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Options Modal (includes Respawn Settings) -->
|
||||
<div class="modal" id="sessionOptionsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
||||
@@ -1075,6 +1099,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
|
||||
<span class="settings-item-label">Ultracode Agents</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowUltracodeAgents">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Subagent Options Section -->
|
||||
<div class="settings-section-header">Subagent Options</div>
|
||||
@@ -1927,6 +1958,7 @@
|
||||
<script defer src="orchestrator-panel.js"></script>
|
||||
<script defer src="settings-ui.js"></script>
|
||||
<script defer src="panels-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -313,6 +313,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
@@ -1368,6 +1369,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
@@ -1682,6 +1684,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showProjectInsights: false,
|
||||
showFileBrowser: false,
|
||||
showSubagents: false,
|
||||
showUltracodeAgents: false,
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
@@ -1805,6 +1808,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
// from the mobile-header-buttons-policy guard.
|
||||
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
const ultracodeBtn = document.querySelector('.btn-ultracode-agents');
|
||||
if (ultracodeBtn) {
|
||||
ultracodeBtn.classList.toggle('btn-ultracode-agents--hidden', !showUltracodeAgents);
|
||||
}
|
||||
|
||||
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
|
||||
// Limits"). Server renders the initial state on reload; this handles a live
|
||||
// toggle from a settings save. Marker class (base is display:inline-flex
|
||||
@@ -1876,6 +1888,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
|
||||
// Ultracode agents panel visibility (SYNCED setting — not in displayKeys)
|
||||
const showUltracodeAgents = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
|
||||
const ultracodePanel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (ultracodePanel) {
|
||||
if (showUltracodeAgents) {
|
||||
ultracodePanel.classList.remove('hidden');
|
||||
} else {
|
||||
ultracodePanel.classList.remove('open');
|
||||
ultracodePanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// File browser panel visibility
|
||||
const fileBrowserPanel = document.getElementById('fileBrowserPanel');
|
||||
if (fileBrowserPanel) {
|
||||
|
||||
@@ -8355,6 +8355,200 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Ultracode Agents" header launcher — opt-in (App Settings → Display), hidden by
|
||||
default everywhere (so the mobile-header-buttons-policy guard auto-excludes it).
|
||||
Base inline-flex !important + a more-specific marker rule to hide. */
|
||||
.btn-ultracode-agents {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-ultracode-agents.btn-ultracode-agents--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ===== Ultracode / Workflow agents master-detail panel ===== */
|
||||
/* Reuses .subagents-panel dock behavior but anchors LEFT so it never overlaps the
|
||||
bottom-right subagents panel when both are open. */
|
||||
.ultracode-agents-panel {
|
||||
right: auto;
|
||||
left: 0.5rem;
|
||||
width: 640px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.ultracode-agents-panel {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* LEFT pane: run rows */
|
||||
.ultracode-run-item {
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
margin-bottom: 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ultracode-run-item:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.ultracode-run-item.selected {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
.ultracode-run-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.ultracode-run-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ultracode-run-stats {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.ultracode-status {
|
||||
font-size: 0.58rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
text-transform: lowercase;
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ultracode-status.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
.ultracode-status.active {
|
||||
background: var(--warning);
|
||||
color: black;
|
||||
}
|
||||
.ultracode-status.failed {
|
||||
background: var(--error, #b3261e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* LEFT pane: phase chips for the selected run */
|
||||
.ultracode-phase-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.ultracode-phase-chip {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ultracode-phase-chip:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.ultracode-phase-chip.selected {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* RIGHT pane: per-phase header + agent cards */
|
||||
.ultracode-phase-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ultracode-phase-sub {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.ultracode-agent-card {
|
||||
background: var(--bg-input);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.ultracode-agent-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.ultracode-agent-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ultracode-agent-state {
|
||||
font-size: 0.56rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
}
|
||||
.ultracode-agent-state.completed {
|
||||
background: var(--success);
|
||||
}
|
||||
.ultracode-agent-state.active {
|
||||
background: var(--warning);
|
||||
color: black;
|
||||
}
|
||||
.ultracode-agent-state.idle {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.ultracode-agent-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.ultracode-chip {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.ultracode-chip-tok {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ultracode-chip-tool {
|
||||
color: var(--text);
|
||||
}
|
||||
.ultracode-agent-sub {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||
default via the marker class below; the server strips it at render when the
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @fileoverview Ultracode / Workflow run visualization — master-detail dock panel.
|
||||
*
|
||||
* Mirrors Claude Code's "working agents" TUI: LEFT pane = runs and their phases
|
||||
* (selectable "tasks"), RIGHT pane = the selected run's agents with model, live
|
||||
* state, TOKENS burned, and TOOL CALLS. Opt-in via the `showUltracodeAgents`
|
||||
* setting; the launcher button + panel are hidden until enabled.
|
||||
*
|
||||
* Data: run SUMMARIES arrive via getLightState (`data.workflowRuns`) and the
|
||||
* `workflow:run_*` SSE events (LEFT list). The full run (with agents[]) is fetched
|
||||
* per-run from GET /api/workflows/:runId when a run is selected (RIGHT pane).
|
||||
*
|
||||
* Standalone: reads only the workflow-run endpoints; never touches subagent state.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @loadorder 11.5 (after panels-ui.js, before session-ui.js)
|
||||
*/
|
||||
/* global CodemanApp, SSE_EVENTS, escapeHtml */
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Ensure workflow state maps exist (lazy — constructor also seeds them). */
|
||||
_ensureWorkflowState() {
|
||||
if (!this.workflowRuns) this.workflowRuns = new Map(); // runId -> summary
|
||||
if (!this.workflowRunDetails) this.workflowRunDetails = new Map(); // runId -> full run (with agents)
|
||||
if (this.activeWorkflowRunId === undefined) this.activeWorkflowRunId = null;
|
||||
if (this.activeWorkflowPhaseIndex === undefined) this.activeWorkflowPhaseIndex = null;
|
||||
},
|
||||
|
||||
/** Seed the LEFT list from a getLightState snapshot (array of run summaries). */
|
||||
seedWorkflowRuns(summaries) {
|
||||
this._ensureWorkflowState();
|
||||
this.workflowRuns.clear();
|
||||
(summaries || []).forEach((s) => this.workflowRuns.set(s.runId, s));
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
|
||||
// ----- SSE handlers (wired in app.js _SSE_HANDLER_MAP) -----
|
||||
_onWorkflowRunDiscovered(data) {
|
||||
this._upsertWorkflowRun(data);
|
||||
},
|
||||
_onWorkflowRunUpdated(data) {
|
||||
this._upsertWorkflowRun(data);
|
||||
},
|
||||
_onWorkflowRunRemoved(data) {
|
||||
this._ensureWorkflowState();
|
||||
if (!data || !data.runId) return;
|
||||
this.workflowRuns.delete(data.runId);
|
||||
this.workflowRunDetails.delete(data.runId);
|
||||
if (this.activeWorkflowRunId === data.runId) this.activeWorkflowRunId = null;
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
|
||||
_upsertWorkflowRun(summary) {
|
||||
this._ensureWorkflowState();
|
||||
if (!summary || !summary.runId) return;
|
||||
this.workflowRuns.set(summary.runId, summary);
|
||||
// If the live-updating run is the one open in the detail pane, refresh its agents.
|
||||
if (this.activeWorkflowRunId === summary.runId) {
|
||||
this._fetchWorkflowRunDetail(summary.runId);
|
||||
}
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
|
||||
// ----- Panel open/close -----
|
||||
toggleUltracodeAgentsPanel() {
|
||||
const panel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (!panel) return;
|
||||
panel.classList.remove('hidden');
|
||||
panel.classList.toggle('open');
|
||||
if (panel.classList.contains('open')) this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
closeUltracodeAgentsPanel() {
|
||||
const panel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (panel) panel.classList.remove('open');
|
||||
},
|
||||
|
||||
// ----- Selection -----
|
||||
selectWorkflowRun(runId) {
|
||||
this._ensureWorkflowState();
|
||||
this.activeWorkflowRunId = runId;
|
||||
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
|
||||
this._fetchWorkflowRunDetail(runId);
|
||||
this.renderUltracodeAgentsPanel();
|
||||
},
|
||||
selectWorkflowPhase(phaseIndex) {
|
||||
this._ensureWorkflowState();
|
||||
// phaseIndex null => show all phases
|
||||
this.activeWorkflowPhaseIndex = phaseIndex === null || phaseIndex === undefined ? null : Number(phaseIndex);
|
||||
this._renderUltracodeDetail();
|
||||
},
|
||||
|
||||
async _fetchWorkflowRunDetail(runId) {
|
||||
try {
|
||||
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
|
||||
const env = await res.json();
|
||||
const run = env && env.success ? env.data : null;
|
||||
if (run) {
|
||||
this.workflowRunDetails.set(runId, run);
|
||||
if (this.activeWorkflowRunId === runId) this._renderUltracodeDetail();
|
||||
}
|
||||
} catch {
|
||||
/* transient — next update retries */
|
||||
}
|
||||
},
|
||||
|
||||
// ----- Render (debounced) -----
|
||||
renderUltracodeAgentsPanel() {
|
||||
clearTimeout(this._ultracodeRenderTimer);
|
||||
this._ultracodeRenderTimer = setTimeout(() => this._renderUltracodeAgentsPanelImmediate(), 150);
|
||||
},
|
||||
|
||||
_renderUltracodeAgentsPanelImmediate() {
|
||||
this._ensureWorkflowState();
|
||||
const panel = document.getElementById('ultracodeAgentsPanel');
|
||||
if (!panel) return;
|
||||
const badge = document.getElementById('ultracodeCountBadge');
|
||||
if (badge) badge.textContent = this.workflowRuns.size ? String(this.workflowRuns.size) : '';
|
||||
this._renderUltracodeRunList();
|
||||
this._renderUltracodeDetail();
|
||||
},
|
||||
|
||||
_renderUltracodeRunList() {
|
||||
const list = document.getElementById('ultracodeRunList');
|
||||
if (!list) return;
|
||||
const runs = Array.from(this.workflowRuns.values()).sort(
|
||||
(a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0)
|
||||
);
|
||||
if (!runs.length) {
|
||||
list.innerHTML = '<div class="subagent-empty">No ultracode runs detected</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = runs.map((r) => this._workflowRunRowHtml(r)).join('');
|
||||
},
|
||||
|
||||
_workflowRunRowHtml(r) {
|
||||
const active = r.runId === this.activeWorkflowRunId;
|
||||
const name = escapeHtml(r.workflowName || r.summary || r.runId);
|
||||
const status = String(r.status || '');
|
||||
const statusCls = this._workflowStatusClass(status);
|
||||
const stats = `${r.agentCount ?? 0} agents · ${this._fmtNum(r.totalTokens)} tok · ${r.totalToolCalls ?? 0} tools`;
|
||||
let phasesHtml = '';
|
||||
if (active && Array.isArray(r.phases) && r.phases.length) {
|
||||
const allActive = this.activeWorkflowPhaseIndex === null ? ' selected' : '';
|
||||
const chips = [
|
||||
`<div class="ultracode-phase-chip${allActive}" onclick="event.stopPropagation();app.selectWorkflowPhase(null)">All</div>`,
|
||||
];
|
||||
r.phases.forEach((p, i) => {
|
||||
const sel = this.activeWorkflowPhaseIndex === i + 1 ? ' selected' : '';
|
||||
chips.push(
|
||||
`<div class="ultracode-phase-chip${sel}" title="${escapeHtml(p.detail || '')}" onclick="event.stopPropagation();app.selectWorkflowPhase(${i + 1})">${escapeHtml(p.title || 'Phase ' + (i + 1))}</div>`
|
||||
);
|
||||
});
|
||||
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
|
||||
}
|
||||
return (
|
||||
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
|
||||
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
|
||||
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
|
||||
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
|
||||
phasesHtml +
|
||||
`</div>`
|
||||
);
|
||||
},
|
||||
|
||||
_renderUltracodeDetail() {
|
||||
const detail = document.getElementById('ultracodeAgentGrid');
|
||||
if (!detail) return;
|
||||
const runId = this.activeWorkflowRunId;
|
||||
if (!runId) {
|
||||
detail.innerHTML = '<div class="subagent-empty">Select a run to view its agents</div>';
|
||||
return;
|
||||
}
|
||||
const run = this.workflowRunDetails.get(runId);
|
||||
if (!run) {
|
||||
detail.innerHTML = '<div class="subagent-empty">Loading agents…</div>';
|
||||
return;
|
||||
}
|
||||
const phases = Array.isArray(run.phases) ? run.phases : [];
|
||||
let agents = Array.isArray(run.agents) ? run.agents : [];
|
||||
if (this.activeWorkflowPhaseIndex !== null) {
|
||||
agents = agents.filter((a) => a.phaseIndex === this.activeWorkflowPhaseIndex);
|
||||
}
|
||||
if (!agents.length) {
|
||||
detail.innerHTML = '<div class="subagent-empty">No agents in this view</div>';
|
||||
return;
|
||||
}
|
||||
// Group agents by phaseIndex, in phase order.
|
||||
const groups = new Map();
|
||||
agents.forEach((a) => {
|
||||
const key = a.phaseIndex || 0;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(a);
|
||||
});
|
||||
const orderedKeys = Array.from(groups.keys()).sort((a, b) => a - b);
|
||||
const html = orderedKeys
|
||||
.map((key) => {
|
||||
const group = groups.get(key);
|
||||
const title = (phases[key - 1] && phases[key - 1].title) || `Phase ${key}`;
|
||||
const tok = group.reduce((s, a) => s + (a.tokens || 0), 0);
|
||||
const tools = group.reduce((s, a) => s + (a.toolCalls || 0), 0);
|
||||
const header =
|
||||
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
|
||||
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
|
||||
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
|
||||
})
|
||||
.join('');
|
||||
detail.innerHTML = html;
|
||||
},
|
||||
|
||||
_workflowAgentCardHtml(a) {
|
||||
const state = String(a.state || 'start');
|
||||
const stateCls = this._workflowAgentStateClass(state);
|
||||
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
|
||||
const model = this._modelShort(a.model);
|
||||
const tokens = a.tokens === undefined ? '—' : this._fmtNum(a.tokens);
|
||||
const tools = a.toolCalls === undefined ? '—' : String(a.toolCalls);
|
||||
let secondary = '';
|
||||
if (state === 'done' && a.resultPreview) {
|
||||
secondary = escapeHtml(a.resultPreview);
|
||||
} else if (a.lastToolName) {
|
||||
secondary = escapeHtml(a.lastToolName + (a.lastToolSummary ? ' · ' + a.lastToolSummary : ''));
|
||||
}
|
||||
return (
|
||||
`<div class="ultracode-agent-card">` +
|
||||
`<div class="ultracode-agent-top">` +
|
||||
`<span class="ultracode-agent-label">${escapeHtml(a.label || 'agent')}</span>` +
|
||||
`<span class="ultracode-agent-state ${stateCls}">${escapeHtml(stateLabel)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="ultracode-agent-meta">` +
|
||||
`<span class="ultracode-chip" title="model">${escapeHtml(model)}</span>` +
|
||||
`<span class="ultracode-chip ultracode-chip-tok" title="tokens burned">${tokens} tok</span>` +
|
||||
`<span class="ultracode-chip ultracode-chip-tool" title="tool calls">${tools} tools</span>` +
|
||||
`</div>` +
|
||||
(secondary ? `<div class="ultracode-agent-sub">${secondary}</div>` : '') +
|
||||
`</div>`
|
||||
);
|
||||
},
|
||||
|
||||
// ----- helpers -----
|
||||
_workflowStatusClass(status) {
|
||||
if (status === 'completed') return 'completed';
|
||||
if (status === 'running') return 'active';
|
||||
if (status === 'killed' || status === 'failed') return 'failed';
|
||||
return '';
|
||||
},
|
||||
_workflowAgentStateClass(state) {
|
||||
if (state === 'done') return 'completed';
|
||||
if (state === 'progress') return 'active';
|
||||
return 'idle'; // start / queued
|
||||
},
|
||||
_modelShort(model) {
|
||||
if (!model) return '';
|
||||
return String(model)
|
||||
.replace(/^claude-/, '')
|
||||
.replace(/-\d{8}$/, '');
|
||||
},
|
||||
_fmtNum(n) {
|
||||
if (n === undefined || n === null) return '0';
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return String(n);
|
||||
},
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../schemas.js';
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { workflowRunWatcher } from '../../workflow-run-watcher.js';
|
||||
import { applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
@@ -540,6 +541,9 @@ export function registerSystemRoutes(
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF)
|
||||
toggleService((settings.showUltracodeAgents as boolean) ?? false, workflowRunWatcher, 'Workflow run watcher');
|
||||
|
||||
// Handle image watcher toggle dynamically
|
||||
toggleService((settings.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
|
||||
// Re-watch all active sessions that have image watcher enabled
|
||||
@@ -691,6 +695,27 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Workflow Run Monitoring (ultracode) ==========
|
||||
|
||||
// LEFT-pane list: lightweight run summaries (no agents[]).
|
||||
app.get('/api/workflows', async (req) => {
|
||||
const { minutes } = req.query as { minutes?: string };
|
||||
const runs = minutes
|
||||
? workflowRunWatcher.getRecentRunSummaries(parseInt(minutes, 10))
|
||||
: workflowRunWatcher.getAllRunSummaries();
|
||||
return { success: true, data: runs };
|
||||
});
|
||||
|
||||
// RIGHT-pane detail: full run incl. agents[] (tokens/toolCalls/state per agent).
|
||||
app.get('/api/workflows/:runId', async (req) => {
|
||||
const { runId } = req.params as { runId: string };
|
||||
const run = workflowRunWatcher.getRun(runId);
|
||||
if (!run) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Workflow run ${runId} not found`);
|
||||
}
|
||||
return { success: true, data: run };
|
||||
});
|
||||
|
||||
// ========== Subagent Monitoring ==========
|
||||
|
||||
app.get('/api/subagents', async (req) => {
|
||||
|
||||
@@ -348,6 +348,8 @@ export const SettingsUpdateSchema = z
|
||||
ralphTrackerEnabled: z.boolean().optional(),
|
||||
subagentTrackingEnabled: z.boolean().optional(),
|
||||
subagentActiveTabOnly: z.boolean().optional(),
|
||||
/** Ultracode/Workflow run visualization (default OFF). Gates workflowRunWatcher + the master-detail tab. SYNCED. */
|
||||
showUltracodeAgents: z.boolean().optional(),
|
||||
imageWatcherEnabled: z.boolean().optional(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
|
||||
+63
-1
@@ -44,7 +44,7 @@ import { dataPath } from '../config/instance.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState } from '../types.js';
|
||||
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState, WorkflowRunInfo } from '../types.js';
|
||||
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import { createMultiplexer } from '../mux-factory.js';
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
type SubagentToolResult,
|
||||
} from '../subagent-watcher.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import {
|
||||
buildDetectedAttachmentHistoryItem,
|
||||
@@ -259,6 +260,11 @@ export class WebServer extends EventEmitter {
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private workflowRunWatcherHandlers: {
|
||||
discovered: (info: WorkflowRunInfo) => void;
|
||||
updated: (info: WorkflowRunInfo) => void;
|
||||
removed: (data: { runId: string }) => void;
|
||||
} | null = null;
|
||||
private tunnelManager: TunnelManager = new TunnelManager();
|
||||
private authSessions: StaleExpirationMap<string, import('./ports/auth-port.js').AuthSessionRecord> | null = null;
|
||||
private authFailures: StaleExpirationMap<string, number> | null = null;
|
||||
@@ -338,6 +344,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Set up subagent watcher listeners
|
||||
this.setupSubagentWatcherListeners();
|
||||
this.setupWorkflowRunWatcherListeners();
|
||||
|
||||
// Set up image watcher listeners
|
||||
this.setupImageWatcherListeners();
|
||||
@@ -437,6 +444,31 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge WorkflowRunWatcher events → SSE. Broadcasts run SUMMARIES (no agents[])
|
||||
* to keep payloads small; the full agents[] is fetched per-run via
|
||||
* GET /api/workflows/:runId when the user selects a run.
|
||||
*/
|
||||
private setupWorkflowRunWatcherListeners(): void {
|
||||
this.workflowRunWatcherHandlers = {
|
||||
discovered: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunDiscovered, summarizeRun(info)),
|
||||
updated: (info: WorkflowRunInfo) => this.broadcast(SseEvent.WorkflowRunUpdated, summarizeRun(info)),
|
||||
removed: (data: { runId: string }) => this.broadcast(SseEvent.WorkflowRunRemoved, data),
|
||||
};
|
||||
workflowRunWatcher.on('run_discovered', this.workflowRunWatcherHandlers.discovered);
|
||||
workflowRunWatcher.on('run_updated', this.workflowRunWatcherHandlers.updated);
|
||||
workflowRunWatcher.on('run_removed', this.workflowRunWatcherHandlers.removed);
|
||||
}
|
||||
|
||||
private cleanupWorkflowRunWatcherListeners(): void {
|
||||
if (this.workflowRunWatcherHandlers) {
|
||||
workflowRunWatcher.off('run_discovered', this.workflowRunWatcherHandlers.discovered);
|
||||
workflowRunWatcher.off('run_updated', this.workflowRunWatcherHandlers.updated);
|
||||
workflowRunWatcher.off('run_removed', this.workflowRunWatcherHandlers.removed);
|
||||
this.workflowRunWatcherHandlers = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for image watcher.
|
||||
* Broadcasts image detection events to SSE clients for auto-popup.
|
||||
@@ -1669,6 +1701,7 @@ export class WebServer extends EventEmitter {
|
||||
respawnStatus,
|
||||
globalStats: this.store.getAggregateStats(activeSessionTokens),
|
||||
subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents
|
||||
workflowRuns: workflowRunWatcher.getAllRunSummaries(), // ultracode run summaries (no agents[]) for the LEFT list
|
||||
timestamp: now,
|
||||
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
|
||||
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
|
||||
@@ -1937,6 +1970,14 @@ export class WebServer extends EventEmitter {
|
||||
console.log('Subagent watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Start workflow run watcher for ultracode / Workflow run visualization (if enabled)
|
||||
if (await this.isWorkflowAgentTrackingEnabled()) {
|
||||
workflowRunWatcher.start();
|
||||
console.log('Workflow run watcher started - monitoring ~/.claude/projects for ultracode run activity');
|
||||
} else {
|
||||
console.log('Workflow run watcher disabled by user settings (showUltracodeAgents off)');
|
||||
}
|
||||
|
||||
// Start image watcher for auto-popup of screenshots (if enabled)
|
||||
if (await this.isImageWatcherEnabled()) {
|
||||
imageWatcher.start();
|
||||
@@ -1983,6 +2024,23 @@ export class WebServer extends EventEmitter {
|
||||
return true; // Default enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ultracode/workflow run tracking is enabled in settings (default: FALSE — opt-in).
|
||||
*/
|
||||
private async isWorkflowAgentTrackingEnabled(): Promise<boolean> {
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
return settings.showUltracodeAgents ?? false;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('Failed to read showUltracodeAgents setting:', err);
|
||||
}
|
||||
}
|
||||
return false; // Default disabled (opt-in)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image watcher is enabled in settings (default: false)
|
||||
*/
|
||||
@@ -2337,12 +2395,16 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Clean up watcher listeners to prevent memory leaks
|
||||
this.cleanupSubagentWatcherListeners();
|
||||
this.cleanupWorkflowRunWatcherListeners();
|
||||
this.cleanupImageWatcherListeners();
|
||||
this.cleanupTeamWatcherListeners();
|
||||
|
||||
// Stop subagent watcher
|
||||
subagentWatcher.stop();
|
||||
|
||||
// Stop workflow run watcher
|
||||
workflowRunWatcher.stop();
|
||||
|
||||
// Stop image watcher
|
||||
imageWatcher.stop();
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* - **Mux** (4): created, killed, died, statsUpdated
|
||||
* - **Respawn** (24): stateChanged, cycleStarted/Completed, step*, aiCheck*, planCheck*, timer*, log, ...
|
||||
* - **Subagents** (7): discovered, updated, tool_call, tool_result, progress, message, completed
|
||||
* - **Workflow runs** (3): run_discovered, run_updated, run_removed (ultracode / Workflow tool)
|
||||
* - **Scheduled** (6): created, updated, completed, stopped, log, deleted
|
||||
* - **Teams** (4): created, updated, removed, taskUpdated
|
||||
* - **Transcript** (4): complete, plan_mode, tool_start, tool_end
|
||||
@@ -215,6 +216,15 @@ export const SubagentMessage = 'subagent:message' as const;
|
||||
/** Subagent finished. */
|
||||
export const SubagentCompleted = 'subagent:completed' as const;
|
||||
|
||||
// ─── Workflow Runs (ultracode / Workflow tool) ───────────────────────────────
|
||||
|
||||
/** A workflow run was discovered (first time seen). Payload: WorkflowRunInfo. */
|
||||
export const WorkflowRunDiscovered = 'workflow:run_discovered' as const;
|
||||
/** A workflow run changed (agent state/token tick). Payload: WorkflowRunInfo. */
|
||||
export const WorkflowRunUpdated = 'workflow:run_updated' as const;
|
||||
/** A workflow run's file disappeared. Payload: { runId: string }. */
|
||||
export const WorkflowRunRemoved = 'workflow:run_removed' as const;
|
||||
|
||||
// ─── Scheduled Runs ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Scheduled run created. */
|
||||
@@ -446,6 +456,11 @@ export const SseEvent = {
|
||||
SubagentMessage,
|
||||
SubagentCompleted,
|
||||
|
||||
// Workflow runs (ultracode)
|
||||
WorkflowRunDiscovered,
|
||||
WorkflowRunUpdated,
|
||||
WorkflowRunRemoved,
|
||||
|
||||
// Scheduled runs
|
||||
ScheduledCreated,
|
||||
ScheduledUpdated,
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @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();
|
||||
Reference in New Issue
Block a user