Skip to content

Commit a499c2e

Browse files
committed
feat(workflow): project workflow read model
1 parent 332aa8e commit a499c2e

3 files changed

Lines changed: 374 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"dev": "node scripts/dev-server.mjs",
2929
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
3030
"start": "node dist/cli.js serve",
31-
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts",
31+
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts",
3232
"typecheck": "tsc -p tsconfig.json --noEmit"
3333
},
3434
"keywords": [],

src/workflow-view.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import assert from "node:assert/strict";
2+
import { buildWorkflowRunView } from "./workflow-view.js";
3+
import type {
4+
WorkflowAgentCallRecord,
5+
WorkflowEventRecord,
6+
WorkflowRunRecord,
7+
} from "./workflow-types.js";
8+
9+
const run: WorkflowRunRecord = {
10+
id: "wfr_view",
11+
name: "Review auth",
12+
source: "named",
13+
scriptPath: "/tmp/review-auth.js",
14+
scriptHash: "abc",
15+
workspaceRoot: "/tmp/project",
16+
argsJson: "null",
17+
status: "running",
18+
cancelRequested: false,
19+
createdAt: "2026-07-26T10:00:00.000Z",
20+
startedAt: "2026-07-26T10:00:01.000Z",
21+
updatedAt: "2026-07-26T10:00:05.000Z",
22+
};
23+
24+
const calls: WorkflowAgentCallRecord[] = [
25+
{
26+
runId: run.id,
27+
callIndex: 0,
28+
cacheKey: "a",
29+
prompt: "Inspect auth",
30+
provider: "codex",
31+
label: "Inspect auth",
32+
phase: "Planning",
33+
status: "completed",
34+
fromCache: false,
35+
isolation: "shared",
36+
createdAt: "2026-07-26T10:00:02.000Z",
37+
startedAt: "2026-07-26T10:00:02.000Z",
38+
completedAt: "2026-07-26T10:00:03.000Z",
39+
updatedAt: "2026-07-26T10:00:03.000Z",
40+
},
41+
{
42+
runId: run.id,
43+
callIndex: 1,
44+
cacheKey: "b",
45+
prompt: "Patch auth",
46+
provider: "claude",
47+
label: "Patch auth",
48+
phase: "Implementation",
49+
status: "running",
50+
fromCache: false,
51+
isolation: "worktree",
52+
worktreePath: "/tmp/worktree",
53+
createdAt: "2026-07-26T10:00:04.000Z",
54+
startedAt: "2026-07-26T10:00:04.000Z",
55+
updatedAt: "2026-07-26T10:00:04.000Z",
56+
},
57+
{
58+
runId: run.id,
59+
callIndex: 2,
60+
cacheKey: "c",
61+
prompt: "Cached review",
62+
provider: "claude",
63+
status: "from_cache",
64+
fromCache: true,
65+
replayMatch: "same_index",
66+
replayedFromRunId: "wfr_old",
67+
replayedFromCallIndex: 2,
68+
isolation: "shared",
69+
createdAt: "2026-07-26T10:00:04.000Z",
70+
completedAt: "2026-07-26T10:00:04.000Z",
71+
updatedAt: "2026-07-26T10:00:04.000Z",
72+
},
73+
];
74+
75+
const events: WorkflowEventRecord[] = [
76+
{
77+
runId: run.id,
78+
seq: 1,
79+
type: "phase_started",
80+
phase: "Planning",
81+
dataJson: JSON.stringify({ title: "Planning" }),
82+
createdAt: "2026-07-26T10:00:01.000Z",
83+
},
84+
{
85+
runId: run.id,
86+
seq: 2,
87+
type: "phase_started",
88+
phase: "Implementation",
89+
dataJson: JSON.stringify({ title: "Implementation" }),
90+
createdAt: "2026-07-26T10:00:04.000Z",
91+
},
92+
{
93+
runId: run.id,
94+
seq: 3,
95+
type: "log",
96+
phase: "Implementation",
97+
dataJson: JSON.stringify({ message: "Running tests" }),
98+
createdAt: "2026-07-26T10:00:05.000Z",
99+
},
100+
];
101+
102+
const view = buildWorkflowRunView(run, calls, events);
103+
assert.equal(view.currentPhase, "Implementation");
104+
assert.equal(view.calls.completed, 1);
105+
assert.equal(view.calls.running, 1);
106+
assert.equal(view.calls.cached, 1);
107+
assert.equal(view.calls.observed, 3);
108+
assert.deepEqual(view.phases.map((phase) => phase.title), ["Planning", "Implementation"]);
109+
assert.equal(view.phases[1]?.calls[0]?.worktreePath, "/tmp/worktree");
110+
assert.equal(view.unphasedCalls[0]?.replayedFromRunId, "wfr_old");
111+
assert.equal(view.recentActivity.at(-1)?.detail, "Running tests");
112+
assert.equal(view.latestEventSeq, 3);
113+
114+
console.log("workflow-view.test.ts: ok");

src/workflow-view.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { resolve } from "node:path";
2+
import { parseWorkflowEventPayload } from "./workflow-contracts.js";
3+
import type { WorkflowStore } from "./workflow-store.js";
4+
import type {
5+
WorkflowAgentCallRecord,
6+
WorkflowAgentCallStatus,
7+
WorkflowErrorKind,
8+
WorkflowEventRecord,
9+
WorkflowEventType,
10+
WorkflowRunRecord,
11+
WorkflowRunSource,
12+
WorkflowRunStatus,
13+
} from "./workflow-types.js";
14+
15+
export const ACTIVE_WORKFLOW_STATUSES = ["starting", "running"] as const satisfies readonly WorkflowRunStatus[];
16+
17+
export interface WorkflowCallCounts {
18+
running: number;
19+
completed: number;
20+
cached: number;
21+
failed: number;
22+
cancelled: number;
23+
observed: number;
24+
}
25+
26+
export interface WorkflowCallView {
27+
callIndex: number;
28+
status: WorkflowAgentCallStatus;
29+
provider: string;
30+
model?: string;
31+
effort?: string;
32+
label?: string;
33+
phase?: string;
34+
isolation: "shared" | "worktree";
35+
worktreePath?: string;
36+
dirty?: boolean;
37+
fromCache: boolean;
38+
replayMatch?: "same_index" | "compatible_key";
39+
replayedFromRunId?: string;
40+
replayedFromCallIndex?: number;
41+
replayReason?: string;
42+
error?: string;
43+
errorKind?: WorkflowErrorKind;
44+
startedAt?: string;
45+
completedAt?: string;
46+
updatedAt: string;
47+
}
48+
49+
export interface WorkflowPhaseView {
50+
title: string;
51+
calls: WorkflowCallView[];
52+
}
53+
54+
export interface WorkflowActivityView {
55+
seq: number;
56+
type: WorkflowEventType;
57+
phase?: string;
58+
label?: string;
59+
detail?: string;
60+
createdAt: string;
61+
}
62+
63+
export interface WorkflowRunView {
64+
id: string;
65+
name: string;
66+
status: WorkflowRunStatus;
67+
source: WorkflowRunSource;
68+
scriptPath: string;
69+
scriptHash: string;
70+
workspaceRoot: string;
71+
resumedFromRunId?: string;
72+
currentPhase?: string;
73+
calls: WorkflowCallCounts;
74+
phases: WorkflowPhaseView[];
75+
unphasedCalls: WorkflowCallView[];
76+
recentActivity: WorkflowActivityView[];
77+
latestEventSeq: number;
78+
version: string;
79+
error?: string;
80+
errorKind?: WorkflowErrorKind;
81+
createdAt: string;
82+
startedAt?: string;
83+
completedAt?: string;
84+
updatedAt: string;
85+
}
86+
87+
export interface WorkflowProjectView {
88+
workspaceRoot: string;
89+
runs: WorkflowRunView[];
90+
version: string;
91+
}
92+
93+
export function loadWorkflowProjectView(
94+
store: WorkflowStore,
95+
workspaceRoot: string,
96+
options: {
97+
statuses?: WorkflowRunStatus[];
98+
limit?: number;
99+
eventLimit?: number;
100+
} = {},
101+
): WorkflowProjectView {
102+
const root = resolve(workspaceRoot);
103+
const runs = store
104+
.listRunsForWorkspace(root, {
105+
statuses: options.statuses,
106+
limit: options.limit,
107+
})
108+
.map((run) =>
109+
buildWorkflowRunView(
110+
run,
111+
store.listAgentCalls(run.id),
112+
store.listEvents(run.id, options.eventLimit ?? 100),
113+
),
114+
);
115+
116+
return {
117+
workspaceRoot: root,
118+
runs,
119+
version: runs.map((run) => `${run.id}:${run.version}`).join("|"),
120+
};
121+
}
122+
123+
export function buildWorkflowRunView(
124+
run: WorkflowRunRecord,
125+
calls: WorkflowAgentCallRecord[],
126+
events: WorkflowEventRecord[],
127+
): WorkflowRunView {
128+
const callViews = calls.map(toCallView);
129+
const phaseOrder: string[] = [];
130+
let currentPhase: string | undefined;
131+
132+
for (const event of events) {
133+
if (event.type !== "phase_started") continue;
134+
const title = event.phase ?? parsePhaseTitle(event);
135+
if (!title) continue;
136+
currentPhase = title;
137+
if (!phaseOrder.includes(title)) phaseOrder.push(title);
138+
}
139+
for (const call of callViews) {
140+
if (call.phase && !phaseOrder.includes(call.phase)) phaseOrder.push(call.phase);
141+
}
142+
143+
const phases = phaseOrder.map((title) => ({
144+
title,
145+
calls: callViews.filter((call) => call.phase === title),
146+
}));
147+
const latestEventSeq = events.at(-1)?.seq ?? 0;
148+
const latestCallUpdate = calls.reduce(
149+
(latest, call) => call.updatedAt > latest ? call.updatedAt : latest,
150+
run.updatedAt,
151+
);
152+
153+
return {
154+
id: run.id,
155+
name: run.name,
156+
status: run.status,
157+
source: run.source,
158+
scriptPath: run.scriptPath,
159+
scriptHash: run.scriptHash,
160+
workspaceRoot: run.workspaceRoot,
161+
resumedFromRunId: run.resumedFromRunId,
162+
currentPhase,
163+
calls: countCalls(callViews),
164+
phases,
165+
unphasedCalls: callViews.filter((call) => !call.phase),
166+
recentActivity: events.map(toActivityView),
167+
latestEventSeq,
168+
version: `${run.updatedAt}:${latestCallUpdate}:${latestEventSeq}`,
169+
error: run.error,
170+
errorKind: run.errorKind,
171+
createdAt: run.createdAt,
172+
startedAt: run.startedAt,
173+
completedAt: run.completedAt,
174+
updatedAt: run.updatedAt,
175+
};
176+
}
177+
178+
function toCallView(call: WorkflowAgentCallRecord): WorkflowCallView {
179+
return {
180+
callIndex: call.callIndex,
181+
status: call.status,
182+
provider: call.provider,
183+
model: call.model,
184+
effort: call.effort,
185+
label: call.label,
186+
phase: call.phase,
187+
isolation: call.isolation,
188+
worktreePath: call.worktreePath,
189+
dirty: call.dirty,
190+
fromCache: call.fromCache,
191+
replayMatch: call.replayMatch,
192+
replayedFromRunId: call.replayedFromRunId,
193+
replayedFromCallIndex: call.replayedFromCallIndex,
194+
replayReason: call.replayReason,
195+
error: call.error,
196+
errorKind: call.errorKind,
197+
startedAt: call.startedAt,
198+
completedAt: call.completedAt,
199+
updatedAt: call.updatedAt,
200+
};
201+
}
202+
203+
function countCalls(calls: WorkflowCallView[]): WorkflowCallCounts {
204+
const counts: WorkflowCallCounts = {
205+
running: 0,
206+
completed: 0,
207+
cached: 0,
208+
failed: 0,
209+
cancelled: 0,
210+
observed: calls.length,
211+
};
212+
for (const call of calls) {
213+
if (call.status === "running") counts.running += 1;
214+
else if (call.status === "completed") counts.completed += 1;
215+
else if (call.status === "from_cache") counts.cached += 1;
216+
else if (call.status === "failed") counts.failed += 1;
217+
else if (call.status === "cancelled") counts.cancelled += 1;
218+
}
219+
return counts;
220+
}
221+
222+
function toActivityView(event: WorkflowEventRecord): WorkflowActivityView {
223+
return {
224+
seq: event.seq,
225+
type: event.type,
226+
phase: event.phase,
227+
label: event.label,
228+
detail: activityDetail(event),
229+
createdAt: event.createdAt,
230+
};
231+
}
232+
233+
function activityDetail(event: WorkflowEventRecord): string | undefined {
234+
try {
235+
if (event.type === "log") {
236+
return parseWorkflowEventPayload("log", JSON.parse(event.dataJson) as unknown).message;
237+
}
238+
if (event.type === "agent_call_failed") {
239+
return parseWorkflowEventPayload(
240+
"agent_call_failed",
241+
JSON.parse(event.dataJson) as unknown,
242+
).error;
243+
}
244+
} catch {
245+
return undefined;
246+
}
247+
return undefined;
248+
}
249+
250+
function parsePhaseTitle(event: WorkflowEventRecord): string | undefined {
251+
try {
252+
return parseWorkflowEventPayload(
253+
"phase_started",
254+
JSON.parse(event.dataJson) as unknown,
255+
).title;
256+
} catch {
257+
return undefined;
258+
}
259+
}

0 commit comments

Comments
 (0)