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,128 @@
|
||||
/**
|
||||
* Route tests for the ultracode workflow endpoints:
|
||||
* GET /api/workflows → run summaries (no agents[])
|
||||
* GET /api/workflows/:runId → full run (with agents[]) or 404
|
||||
*
|
||||
* Uses app.inject() — no real ports. The workflow-run-watcher singleton is mocked
|
||||
* so we control the returned data (positive + not-found) without touching ~/.claude.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// ── Mocks required by registerSystemRoutes ──────────────────────────
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: { readFile: vi.fn(async () => '{}'), writeFile: vi.fn(async () => undefined) },
|
||||
}));
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
|
||||
});
|
||||
vi.mock('../../src/subagent-watcher.js', () => ({
|
||||
subagentWatcher: {
|
||||
getSubagents: vi.fn(() => []),
|
||||
getRecentSubagents: vi.fn(() => []),
|
||||
isRunning: vi.fn(() => true),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/image-watcher.js', () => ({
|
||||
imageWatcher: { isRunning: vi.fn(() => false), start: vi.fn(), stop: vi.fn(), watchSession: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/session-lifecycle-log.js', () => ({
|
||||
getLifecycleLog: vi.fn(() => ({ log: vi.fn(), query: vi.fn(async () => []) })),
|
||||
}));
|
||||
vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
|
||||
isOpenCodeAvailable: vi.fn(() => false),
|
||||
resolveOpenCodeDir: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const SUMMARY = {
|
||||
runId: 'wf_abc123',
|
||||
workflowName: 'review-open-prs',
|
||||
status: 'completed',
|
||||
summary: 'Deep review',
|
||||
agentCount: 2,
|
||||
totalTokens: 1000,
|
||||
totalToolCalls: 10,
|
||||
phases: [{ title: 'Review', detail: 'one per PR' }],
|
||||
sessionUuid: 'sess-1',
|
||||
projectHash: 'proj-1',
|
||||
lastActivityAt: 123,
|
||||
};
|
||||
const FULL_RUN = {
|
||||
...SUMMARY,
|
||||
agents: [
|
||||
{
|
||||
index: 1,
|
||||
label: 'review:pr-1',
|
||||
phaseIndex: 1,
|
||||
phaseTitle: 'Review',
|
||||
model: 'opus',
|
||||
state: 'done',
|
||||
tokens: 500,
|
||||
toolCalls: 5,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
label: 'review:pr-2',
|
||||
phaseIndex: 1,
|
||||
phaseTitle: 'Review',
|
||||
model: 'opus',
|
||||
state: 'done',
|
||||
tokens: 500,
|
||||
toolCalls: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mock('../../src/workflow-run-watcher.js', () => ({
|
||||
workflowRunWatcher: {
|
||||
getAllRunSummaries: vi.fn(() => [SUMMARY]),
|
||||
getRecentRunSummaries: vi.fn(() => [SUMMARY]),
|
||||
getRun: vi.fn((runId: string) => (runId === 'wf_abc123' ? FULL_RUN : undefined)),
|
||||
isRunning: vi.fn(() => false),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('workflow routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('GET /api/workflows returns the envelope with summaries (no agents[])', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].runId).toBe('wf_abc123');
|
||||
expect('agents' in body.data[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/workflows/:runId returns the full run with agents[]', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_abc123' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.agents).toHaveLength(2);
|
||||
expect(body.data.agents[0].tokens).toBe(500);
|
||||
});
|
||||
|
||||
it('GET /api/workflows/:runId 404s for an unknown run', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/workflows/wf_nope' });
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Tests for WorkflowRunWatcher — parses wf_<runId>.json run-state into
|
||||
* WorkflowRunInfo for the ultracode master-detail view.
|
||||
*
|
||||
* Drives the real discover→parse path against a synthetic on-disk fixture in a
|
||||
* temp projects dir (never the shared singleton, never ~/.claude).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { WorkflowRunWatcher } from '../src/workflow-run-watcher.js';
|
||||
import type { WorkflowRunInfo } from '../src/types/workflow-run.js';
|
||||
|
||||
const PROJECT_HASH = '-home-arkon-default-claudeman';
|
||||
const SESSION_UUID = '388113c8-cd01-4e80-93a8-3be66ab1519b';
|
||||
const RUN_ID = 'wf_test1234-abc';
|
||||
|
||||
/** A run JSON shaped like a real (killed) run: all three agent states + the bloat fields. */
|
||||
function sampleRunJson() {
|
||||
return {
|
||||
runId: RUN_ID,
|
||||
timestamp: '2026-06-15T00:00:00.000Z',
|
||||
taskId: 'task_abc',
|
||||
// --- bloat fields that MUST be stripped ---
|
||||
script: 'export const meta = {};\n'.repeat(5000), // ~110KB
|
||||
scriptPath: '/tmp/whatever.js',
|
||||
result: { plan: { huge: 'object' } },
|
||||
logs: ['line1', 'line2'],
|
||||
// --- real fields ---
|
||||
agentCount: 3,
|
||||
durationMs: 795173,
|
||||
summary: 'Deep adversarial review of open PRs',
|
||||
workflowName: 'review-open-prs',
|
||||
status: 'killed',
|
||||
error: 'user stopped the task',
|
||||
startTime: 1781466999000,
|
||||
defaultModel: 'claude-opus-4-8[1m]',
|
||||
totalTokens: 109703,
|
||||
totalToolCalls: 44,
|
||||
phases: [
|
||||
{ title: 'Review', detail: 'one deep reviewer per PR' },
|
||||
{ title: 'Probe', detail: 'targeted security/correctness probes' },
|
||||
{ title: 'Verify', detail: 'adversarially verify each finding' },
|
||||
],
|
||||
workflowProgress: [
|
||||
{ type: 'workflow_phase', index: 0, phaseIndex: 1, phaseTitle: 'Review' },
|
||||
{
|
||||
type: 'workflow_agent',
|
||||
index: 1,
|
||||
label: 'probe:dompurify-config',
|
||||
phaseIndex: 2,
|
||||
phaseTitle: 'Probe',
|
||||
agentId: 'a6c0e282c3f5ac0bf',
|
||||
model: 'claude-opus-4-8[1m]',
|
||||
state: 'done',
|
||||
startedAt: 1781467000002,
|
||||
queuedAt: 1781466999962,
|
||||
attempt: 1,
|
||||
lastToolName: 'StructuredOutput',
|
||||
lastToolSummary: 'Does the profile setting make the allowlist dead config',
|
||||
promptPreview: 'You are reviewing a pull request...',
|
||||
lastProgressAt: 1781467524143,
|
||||
tokens: 104703,
|
||||
toolCalls: 41,
|
||||
durationMs: 524140,
|
||||
resultPreview: '{"verdict":"concern"}',
|
||||
},
|
||||
{
|
||||
type: 'workflow_agent',
|
||||
index: 2,
|
||||
label: 'review:pr-127',
|
||||
phaseIndex: 1,
|
||||
phaseTitle: 'Review',
|
||||
agentId: 'a1234567890abcdef',
|
||||
model: 'claude-opus-4-8[1m]',
|
||||
state: 'progress',
|
||||
startedAt: 1781467010000,
|
||||
queuedAt: 1781466999970,
|
||||
attempt: 1,
|
||||
lastToolName: 'Read',
|
||||
promptPreview: 'Review PR 127...',
|
||||
lastProgressAt: 1781467600000,
|
||||
tokens: 5000,
|
||||
toolCalls: 3,
|
||||
},
|
||||
{
|
||||
type: 'workflow_agent',
|
||||
index: 3,
|
||||
label: 'verify:finding-x',
|
||||
phaseIndex: 3,
|
||||
phaseTitle: 'Verify',
|
||||
model: 'claude-opus-4-8[1m]',
|
||||
state: 'start',
|
||||
queuedAt: 1781466999980,
|
||||
promptPreview: 'Verify finding x...',
|
||||
lastProgressAt: 1781466999980,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('WorkflowRunWatcher', () => {
|
||||
let projectsDir: string;
|
||||
let watcher: WorkflowRunWatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-test-'));
|
||||
const workflowsDir = join(projectsDir, PROJECT_HASH, SESSION_UUID, 'workflows');
|
||||
await mkdir(workflowsDir, { recursive: true });
|
||||
await writeFile(join(workflowsDir, `${RUN_ID}.json`), JSON.stringify(sampleRunJson()), 'utf-8');
|
||||
watcher = new WorkflowRunWatcher(projectsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
watcher.stop();
|
||||
await rm(projectsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Start the watcher and resolve with the first discovered run. */
|
||||
function firstRun(): Promise<WorkflowRunInfo> {
|
||||
return new Promise<WorkflowRunInfo>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('timed out waiting for run_discovered')), 5000);
|
||||
watcher.once('run_discovered', (info: WorkflowRunInfo) => {
|
||||
clearTimeout(timer);
|
||||
resolve(info);
|
||||
});
|
||||
watcher.start();
|
||||
});
|
||||
}
|
||||
|
||||
it('discovers and parses a run, deriving session/project from the path', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.runId).toBe(RUN_ID);
|
||||
expect(info.workflowName).toBe('review-open-prs');
|
||||
expect(info.status).toBe('killed');
|
||||
expect(info.error).toBe('user stopped the task');
|
||||
expect(info.sessionUuid).toBe(SESSION_UUID);
|
||||
expect(info.projectHash).toBe(PROJECT_HASH);
|
||||
expect(info.totalTokens).toBe(109703);
|
||||
expect(info.totalToolCalls).toBe(44);
|
||||
});
|
||||
|
||||
it('keeps only workflow_agent entries (drops workflow_phase markers)', async () => {
|
||||
const info = await firstRun();
|
||||
expect(info.agents).toHaveLength(3);
|
||||
expect(info.phases).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('STRIPS the heavyweight script/scriptPath/result/logs fields', async () => {
|
||||
const info = await firstRun();
|
||||
const asAny = info as unknown as Record<string, unknown>;
|
||||
expect('script' in asAny).toBe(false);
|
||||
expect('scriptPath' in asAny).toBe(false);
|
||||
expect('result' in asAny).toBe(false);
|
||||
expect('logs' in asAny).toBe(false);
|
||||
// The serialized run that reaches a client must be small.
|
||||
expect(JSON.stringify(info).length).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('carries tokens/toolCalls/durationMs on a done agent', async () => {
|
||||
const info = await firstRun();
|
||||
const done = info.agents.find((a) => a.state === 'done')!;
|
||||
expect(done.agentId).toBe('a6c0e282c3f5ac0bf');
|
||||
expect(done.tokens).toBe(104703);
|
||||
expect(done.toolCalls).toBe(41);
|
||||
expect(done.durationMs).toBe(524140);
|
||||
expect(done.resultPreview).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits agentId/tokens/toolCalls/durationMs on a start (queued) agent', async () => {
|
||||
const info = await firstRun();
|
||||
const queued = info.agents.find((a) => a.state === 'start')!;
|
||||
expect(queued.agentId).toBeUndefined();
|
||||
expect(queued.tokens).toBeUndefined();
|
||||
expect(queued.toolCalls).toBeUndefined();
|
||||
expect(queued.durationMs).toBeUndefined();
|
||||
expect(queued.label).toBe('verify:finding-x');
|
||||
});
|
||||
|
||||
it('a progress agent has tokens but no durationMs (live discriminator)', async () => {
|
||||
const info = await firstRun();
|
||||
const running = info.agents.find((a) => a.state === 'progress')!;
|
||||
expect(running.tokens).toBe(5000);
|
||||
expect(running.toolCalls).toBe(3);
|
||||
expect(running.durationMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('phase join: agent.phaseIndex-1 indexes run.phases', async () => {
|
||||
const info = await firstRun();
|
||||
for (const agent of info.agents) {
|
||||
expect(info.phases[agent.phaseIndex - 1].title).toBe(agent.phaseTitle);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the run via getAllRuns/getRun after discovery', async () => {
|
||||
await firstRun();
|
||||
expect(watcher.getAllRuns()).toHaveLength(1);
|
||||
expect(watcher.getRun(RUN_ID)?.runId).toBe(RUN_ID);
|
||||
expect(watcher.getStats().agentCount).toBe(3);
|
||||
});
|
||||
|
||||
it('getRecentRunSummaries omits agents[] (lightweight snapshot)', async () => {
|
||||
await firstRun();
|
||||
const summaries = watcher.getRecentRunSummaries(100000);
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect('agents' in summaries[0]).toBe(false);
|
||||
expect(summaries[0].runId).toBe(RUN_ID);
|
||||
expect(summaries[0].agentCount).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user