@taicho-ai/graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # @taicho-ai/graph
2
+
3
+ A deterministic workflow graph for agent, check, human-gate, branch, and parallel nodes. Model and
4
+ UI effects are injected. The optional file journal provides append-only runs and durable gate resume.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@taicho-ai/graph",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": "./src/index.ts",
6
+ "scripts": {
7
+ "build": "bun build src/index.ts --outdir dist --target bun",
8
+ "test": "bun test src"
9
+ },
10
+ "dependencies": {
11
+ "@taicho-ai/contracts": "0.1.0",
12
+ "zod": "^4.4.3"
13
+ },
14
+ "description": "Deterministic workflow schema and executor for taicho, with an optional filesystem journal.",
15
+ "license": "MIT",
16
+ "homepage": "https://taicho.ai",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/taicho-ai/taicho.git",
20
+ "directory": "packages/graph"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "!src/**/*.test.ts",
25
+ "!src/**/*.test.tsx"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }
@@ -0,0 +1,255 @@
1
+ /** Plan 25: the workflow DRIVER — a sixth caller above executeRun.
2
+ *
3
+ * It holds the edge state (an artifact-name → handle map), walks a cursor over the steps, and dispatches
4
+ * per node kind. The engine owns step status: no node writes its own `done` — the driver writes it from
5
+ * the step-runner's real outcome, the checker verdict, or the human's choice. State flows step→step by
6
+ * artifact REFERENCE (a step's `produces` becomes the next step's `consumes` input), exactly as delegation
7
+ * hands work down today.
8
+ *
9
+ * The step-runner, checker, gate, and classifier are INJECTED so the orchestration is unit-testable without
10
+ * a live model; run.ts wires them to executeRun / runChecker / requestApproval.
11
+ *
12
+ * DURABLE SUSPENSION (Ph6): in `parkGates` mode a human gate PARKS instead of blocking — a marker persists,
13
+ * the run stops with status "parked", and `resumeWorkflow` later rebuilds the edge state FROM THE EVENTS
14
+ * (each done step's produced handle) and drives the rest. That is what lets an unattended/scheduled workflow
15
+ * wait for the captain across a restart. */
16
+ import {
17
+ reserveWorkflowRun, appendWorkflowEvent, foldWorkflowRun,
18
+ parkGate, readParkedGate, clearParkedGate,
19
+ } from "./file-journal";
20
+ import type { WorkflowDef, WorkflowNode, WorkflowRunState, StepStatus } from "./schema";
21
+
22
+ export type RunOutcome = "completed" | "blocked" | "failed" | "interrupted";
23
+ export interface AgentStepRun {
24
+ childRunId: string;
25
+ outcome: RunOutcome;
26
+ produced?: string;
27
+ }
28
+ type AgentNode = Extract<WorkflowNode, { kind: "agent" }>;
29
+ type CheckNode = Extract<WorkflowNode, { kind: "check" }>;
30
+ type HumanNode = Extract<WorkflowNode, { kind: "human" }>;
31
+ type BranchNode = Extract<WorkflowNode, { kind: "branch" }>;
32
+
33
+ export interface ReviewPacketItem { name: string; handle: string; }
34
+ /** What a human gate lays in front of the captain — handles + names, never bodies. */
35
+ export interface ReviewPacket { primary?: string; items: ReviewPacketItem[]; }
36
+ export interface GateDecision { choice: string; note?: string; }
37
+
38
+ /** The agent-step SHAPE runAgent receives — a top-level agent node, a parallel branch (no `kind`), or a
39
+ * synthetic join. Untagged, so all three are assignable without normalizing branches. */
40
+ export type AgentStepInput = { id: string; run: string; brief?: string; consumes: string[]; produces?: string; criteria?: string };
41
+
42
+ export interface WorkflowExecDeps {
43
+ ws: string;
44
+ /** Run one agent step to completion. run.ts wires this to executeRun; tests inject a recorder. */
45
+ runAgent: (a: { step: AgentStepInput; brief: string; inputs: string[]; runId: string; triggeredBy: string }) => Promise<AgentStepRun>;
46
+ /** Verify an artifact against criteria (run.ts wires to runChecker). Required if the def has a check. */
47
+ runCheck?: (a: { node: CheckNode; target?: string; attempt: number }) => Promise<{ pass: boolean; reasons: string[] }>;
48
+ /** Ask the captain (run.ts wires to requestApproval). Returns null when cancelled. Required for ATTENDED human gates. */
49
+ requestGate?: (a: { node: HumanNode; packet: ReviewPacket; runId: string }) => Promise<GateDecision | null>;
50
+ /** Classify the input into one of a branch's route labels. Required if the def has a branch. */
51
+ classify?: (a: { node: BranchNode; target?: string }) => Promise<string>;
52
+ /** Read a list artifact into its items — required for a `parallel over:` (map) step. */
53
+ listItems?: (handle: string) => Promise<string[]>;
54
+ /** Ph6: park a human gate (persist + stop) instead of blocking — for UNATTENDED (scheduled/headless) runs. */
55
+ parkGates?: boolean;
56
+ signal?: AbortSignal;
57
+ }
58
+
59
+ export interface WorkflowInput {
60
+ artifacts?: { name: string; handle: string }[];
61
+ }
62
+
63
+ const present = (h: string | undefined): h is string => !!h;
64
+
65
+ function composeBrief(def: WorkflowDef, node: { brief?: string }): string {
66
+ return [def.brief, node.brief].filter((s): s is string => !!s && s.trim().length > 0).join("\n\n");
67
+ }
68
+
69
+ function statusFor(outcome: RunOutcome): StepStatus {
70
+ switch (outcome) {
71
+ case "completed": return "done";
72
+ case "interrupted": return "interrupted";
73
+ default: return "failed"; // blocked | failed
74
+ }
75
+ }
76
+
77
+ function buildPacket(node: HumanNode, edge: Map<string, string>, lastProduced?: string): ReviewPacket {
78
+ const names = node.shows ?? [...edge.keys()];
79
+ const items = names.filter((n) => edge.has(n)).map((n) => ({ name: n, handle: edge.get(n)! }));
80
+ return { primary: lastProduced ?? items[items.length - 1]?.handle, items };
81
+ }
82
+
83
+ /** Rebuild the edge state (artifact NAME → handle) from a run's PERSISTED events — the key to resuming a
84
+ * parked run in a fresh process: each done agent/parallel step's produced handle re-maps to its name. */
85
+ function rebuildEdge(ws: string, def: WorkflowDef, runId: string): Map<string, string> {
86
+ const edge = new Map<string, string>();
87
+ for (const s of foldWorkflowRun(ws, def, runId).steps) {
88
+ if (!s.produced) continue;
89
+ const node = def.steps.find((n) => n.id === s.id);
90
+ const name = node && (node.kind === "agent" || node.kind === "parallel") ? node.produces : undefined;
91
+ if (name) edge.set(name, s.produced);
92
+ }
93
+ return edge;
94
+ }
95
+
96
+ interface DriveState {
97
+ runId: string;
98
+ edge: Map<string, string>;
99
+ cursor: string | null;
100
+ attempts: Map<string, number>;
101
+ lastProduced?: string;
102
+ }
103
+
104
+ /** The shared loop. Both a fresh run (executeWorkflow) and a resumed one (resumeWorkflow) call this. */
105
+ async function drive(deps: WorkflowExecDeps, def: WorkflowDef, state: DriveState): Promise<WorkflowRunState> {
106
+ const { ws } = deps;
107
+ const { runId, edge, attempts } = state;
108
+ const emit = (e: Parameters<typeof appendWorkflowEvent>[3]) => appendWorkflowEvent(ws, def.id, runId, e);
109
+ const inputsFor = (names: string[]) => names.map((n) => edge.get(n)).filter(present);
110
+ const indexById = new Map(def.steps.map((s, i) => [s.id, i] as const));
111
+ const successor = (idx: number): string | null => def.steps[idx + 1]?.id ?? null;
112
+
113
+ let cursor = state.cursor;
114
+ let lastProduced = state.lastProduced;
115
+ let ended: "completed" | "failed" | "interrupted" | "parked" = "completed";
116
+
117
+ while (cursor) {
118
+ const idx = indexById.get(cursor);
119
+ if (idx === undefined) throw new Error(`workflow "${def.id}" routes to unknown step "${cursor}"`);
120
+ const node = def.steps[idx]!;
121
+
122
+ if (deps.signal?.aborted) { emit({ step: node.id, status: "interrupted", runId }); ended = "interrupted"; break; }
123
+ emit({ step: node.id, status: "running", runId });
124
+
125
+ if (node.kind === "agent") {
126
+ const res = await deps.runAgent({
127
+ step: node, brief: composeBrief(def, node), inputs: inputsFor(node.consumes),
128
+ runId, triggeredBy: `workflow:${def.id}:${node.id}`,
129
+ });
130
+ if (node.produces && res.produced) { edge.set(node.produces, res.produced); lastProduced = res.produced; }
131
+ const status = statusFor(res.outcome);
132
+ emit({ step: node.id, runId: res.childRunId, produced: res.produced, status, note: res.outcome === "completed" ? undefined : `agent run ${res.outcome}` });
133
+ if (status !== "done") { ended = status === "interrupted" ? "interrupted" : "failed"; cursor = null; break; }
134
+ cursor = successor(idx);
135
+ continue;
136
+ }
137
+
138
+ if (node.kind === "check") {
139
+ if (!deps.runCheck) throw new Error(`workflow "${def.id}" has a check step "${node.id}" but no checker is wired`);
140
+ const attempt = (attempts.get(node.id) ?? 0) + 1;
141
+ attempts.set(node.id, attempt);
142
+ const target = node.of ? edge.get(node.of) : lastProduced;
143
+ const v = await deps.runCheck({ node, target, attempt });
144
+ if (v.pass) { emit({ step: node.id, status: "done", runId, attempt }); cursor = successor(idx); }
145
+ else if (node.on_fail && attempt < node.max_attempts) { emit({ step: node.id, status: "failed", runId, attempt, note: v.reasons.join("; ") || "check failed" }); cursor = node.on_fail; }
146
+ else { emit({ step: node.id, status: "failed", runId, attempt, note: v.reasons.join("; ") || "check failed, no attempts left" }); ended = "failed"; cursor = null; }
147
+ continue;
148
+ }
149
+
150
+ if (node.kind === "human") {
151
+ const packet = buildPacket(node, edge, lastProduced);
152
+ if (deps.parkGates) { // UNATTENDED — persist + stop; resumeWorkflow continues later.
153
+ parkGate(ws, def.id, runId, { step: node.id, title: node.human, choices: node.choices });
154
+ ended = "parked"; cursor = null; break;
155
+ }
156
+ if (!deps.requestGate) throw new Error(`workflow "${def.id}" has a human step "${node.id}" but no gate is wired`);
157
+ const d = await deps.requestGate({ node, packet, runId });
158
+ if (!d) { emit({ step: node.id, status: "interrupted", runId }); ended = "interrupted"; cursor = null; break; }
159
+ emit({ step: node.id, status: "done", runId, choice: d.choice, note: d.note });
160
+ cursor = node.routes[d.choice] ?? successor(idx);
161
+ continue;
162
+ }
163
+
164
+ if (node.kind === "branch") {
165
+ if (!deps.classify) throw new Error(`workflow "${def.id}" has a branch step "${node.id}" but no classifier is wired`);
166
+ const label = await deps.classify({ node, target: node.of ? edge.get(node.of) : lastProduced });
167
+ const next = node.routes[label];
168
+ emit({ step: node.id, status: "done", runId, choice: label, note: next ? undefined : `no route for "${label}", falling through` });
169
+ cursor = next ?? successor(idx);
170
+ continue;
171
+ }
172
+
173
+ if (node.kind === "parallel") {
174
+ // Build the fan-out: explicit `branches`, OR `over` a list artifact mapped through the `as` step.
175
+ let fan: { step: AgentStepInput; brief: string; inputs: string[]; tag: string }[];
176
+ if (node.over) {
177
+ if (!deps.listItems) throw new Error(`workflow "${def.id}": parallel 'over' needs a list reader (step "${node.id}")`);
178
+ if (!node.as) throw new Error(`workflow "${def.id}": parallel 'over' needs an 'as' step (step "${node.id}")`);
179
+ const src = edge.get(node.over);
180
+ const items = src ? await deps.listItems(src) : [];
181
+ const asStep = node.as;
182
+ fan = items.map((item, i) => ({
183
+ step: { ...asStep, id: `${node.id}_${i}` },
184
+ brief: `${composeBrief(def, asStep)}\n\nITEM ${i + 1}/${items.length}: ${item}`,
185
+ inputs: inputsFor(asStep.consumes),
186
+ tag: String(i),
187
+ }));
188
+ } else {
189
+ fan = (node.branches ?? []).map((b) => ({ step: b, brief: composeBrief(def, b), inputs: inputsFor(b.consumes), tag: b.id }));
190
+ }
191
+ const results = await Promise.all(fan.map((f) =>
192
+ deps.runAgent({ step: f.step, brief: f.brief, inputs: f.inputs, runId, triggeredBy: `workflow:${def.id}:${node.id}:${f.tag}` })));
193
+ const producedHandles: string[] = [];
194
+ fan.forEach((f, i) => {
195
+ const r = results[i]!;
196
+ if (r.produced) {
197
+ producedHandles.push(r.produced);
198
+ lastProduced = r.produced;
199
+ if (!node.over && f.step.produces) edge.set(f.step.produces, r.produced);
200
+ }
201
+ });
202
+ if (results.some((r) => r.outcome !== "completed")) { emit({ step: node.id, status: "failed", runId, note: "a parallel run failed" }); ended = "failed"; cursor = null; continue; }
203
+ if (node.join) {
204
+ const joinInputs = node.over ? producedHandles : (node.branches ?? []).map((b) => b.produces).filter(present).map((n) => edge.get(n)).filter(present);
205
+ const joinStep: AgentNode = { kind: "agent", id: `${node.id}__join`, run: node.join, consumes: [], produces: node.produces };
206
+ const jr = await deps.runAgent({ step: joinStep, brief: composeBrief(def, {}), inputs: joinInputs, runId, triggeredBy: `workflow:${def.id}:${node.id}:join` });
207
+ if (node.produces && jr.produced) { edge.set(node.produces, jr.produced); lastProduced = jr.produced; }
208
+ if (jr.outcome !== "completed") { emit({ step: node.id, status: "failed", runId, note: "the parallel join failed" }); ended = "failed"; cursor = null; continue; }
209
+ }
210
+ emit({ step: node.id, status: "done", runId, produced: node.produces ? edge.get(node.produces) : undefined });
211
+ cursor = successor(idx);
212
+ continue;
213
+ }
214
+
215
+ throw new Error(`workflow "${def.id}" reached an unhandled step kind`);
216
+ }
217
+
218
+ // A clean end (ran off the last step, or a branch/route/human sent the cursor to null) leaves any
219
+ // untaken steps `pending` — mark them `skipped` so the fold reads `done`, not stuck `running`. A parked/
220
+ // failed/interrupted run leaves them as-is.
221
+ if (ended === "completed") {
222
+ for (const s of foldWorkflowRun(ws, def, runId).steps) {
223
+ if (s.status === "pending") emit({ step: s.id, status: "skipped", runId });
224
+ }
225
+ }
226
+ return foldWorkflowRun(ws, def, runId);
227
+ }
228
+
229
+ export async function executeWorkflow(deps: WorkflowExecDeps, def: WorkflowDef, input?: WorkflowInput): Promise<WorkflowRunState> {
230
+ const runId = reserveWorkflowRun(deps.ws, def.id);
231
+ const edge = new Map<string, string>();
232
+ for (const a of input?.artifacts ?? []) edge.set(a.name, a.handle);
233
+ return drive(deps, def, { runId, edge, cursor: def.steps[0]?.id ?? null, attempts: new Map() });
234
+ }
235
+
236
+ /** Ph6: answer a parked human gate and drive the rest. Rebuilds the edge state from the persisted events
237
+ * (so it works in a fresh process after a restart), settles the gate with the choice, and continues. */
238
+ export async function resumeWorkflow(
239
+ deps: WorkflowExecDeps,
240
+ def: WorkflowDef,
241
+ runId: string,
242
+ choice: string,
243
+ note?: string,
244
+ ): Promise<WorkflowRunState> {
245
+ const parked = readParkedGate(deps.ws, def.id, runId);
246
+ if (!parked) throw new Error(`workflow run "${runId}" is not parked`);
247
+ const idx = def.steps.findIndex((n) => n.id === parked.step);
248
+ const node = def.steps[idx];
249
+ if (!node || node.kind !== "human") throw new Error(`parked step "${parked.step}" is not a human gate`);
250
+ clearParkedGate(deps.ws, def.id, runId);
251
+ appendWorkflowEvent(deps.ws, def.id, runId, { step: node.id, status: "done", runId, choice, note });
252
+ const edge = rebuildEdge(deps.ws, def, runId);
253
+ const cursor = node.routes[choice] ?? (def.steps[idx + 1]?.id ?? null);
254
+ return drive(deps, def, { runId, edge, cursor, attempts: new Map() });
255
+ }
@@ -0,0 +1,171 @@
1
+ /** Plan 25: workflow RUN state — the append-only event log, one directory per run.
2
+ *
3
+ * workflows/<wfId>/runs/<runId>/events.jsonl append-only step transitions, engine-written
4
+ *
5
+ * Current state = fold(events): the last event per step wins; a step with no event is pending. This is
6
+ * the Plan 18 plan discipline, minus the model-attempt/`rejected` split — the engine writes every event,
7
+ * so there is nothing to lie. reconcileWorkflowRuns is the boot half: a step left `running` means the
8
+ * process died in flight, so append `interrupted` (never rewrite history) and report it. */
9
+ import { mkdirSync, appendFileSync, readFileSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import {
12
+ WorkflowEvent, TERMINAL_STEP_STATUS,
13
+ type WorkflowDef, type FoldedStep, type WorkflowRunState, type WorkflowRunStatus, type StepStatus,
14
+ } from "./schema";
15
+
16
+ const workflowPaths = {
17
+ workflowsDir: (ws: string) => join(ws, "workflows"),
18
+ workflowRunsDir: (ws: string, id: string) => join(ws, "workflows", id, "runs"),
19
+ workflowRunDir: (ws: string, id: string, runId: string) => join(ws, "workflows", id, "runs", runId),
20
+ };
21
+
22
+ const eventsFile = (ws: string, wfId: string, runId: string) =>
23
+ join(workflowPaths.workflowRunDir(ws, wfId, runId), "events.jsonl");
24
+
25
+ /** Mint a unique run id and create its directory. Exclusive-create the dir, retrying on collision — the
26
+ * same race-free idiom as writePlan/saveArtifact, so two concurrent runs can't claim one id. */
27
+ export function reserveWorkflowRun(ws: string, wfId: string): string {
28
+ const runsDir = workflowPaths.workflowRunsDir(ws, wfId);
29
+ mkdirSync(runsDir, { recursive: true });
30
+ for (let n = 1; n < 100_000; n++) {
31
+ const runId = `wr_${wfId}_${n}`;
32
+ try {
33
+ mkdirSync(join(runsDir, runId)); // no recursive: fails with EEXIST if taken
34
+ return runId;
35
+ } catch (e) {
36
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
37
+ }
38
+ }
39
+ throw new Error(`could not reserve a workflow run id for "${wfId}" (100000 collisions)`);
40
+ }
41
+
42
+ /** Append one step transition. Stamps `ts` if absent. */
43
+ export function appendWorkflowEvent(
44
+ ws: string,
45
+ wfId: string,
46
+ runId: string,
47
+ event: Omit<WorkflowEvent, "ts"> & { ts?: string },
48
+ ): WorkflowEvent {
49
+ const e = WorkflowEvent.parse({ ...event, ts: event.ts ?? new Date().toISOString() });
50
+ mkdirSync(workflowPaths.workflowRunDir(ws, wfId, runId), { recursive: true });
51
+ appendFileSync(eventsFile(ws, wfId, runId), JSON.stringify(e) + "\n");
52
+ return e;
53
+ }
54
+
55
+ export function readWorkflowEvents(ws: string, wfId: string, runId: string): WorkflowEvent[] {
56
+ const f = eventsFile(ws, wfId, runId);
57
+ if (!existsSync(f)) return [];
58
+ const out: WorkflowEvent[] = [];
59
+ for (const line of readFileSync(f, "utf8").split("\n")) {
60
+ if (!line.trim()) continue;
61
+ try { out.push(WorkflowEvent.parse(JSON.parse(line))); }
62
+ catch { /* tolerate a partial final JSONL line after process interruption */ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** Last event per step wins. */
68
+ function foldEvents(events: WorkflowEvent[]): Map<string, WorkflowEvent> {
69
+ const byStep = new Map<string, WorkflowEvent>();
70
+ for (const e of events) byStep.set(e.step, e);
71
+ return byStep;
72
+ }
73
+
74
+ function overallStatus(steps: FoldedStep[]): WorkflowRunStatus {
75
+ if (steps.some((s) => s.status === "failed")) return "failed";
76
+ if (steps.some((s) => s.status === "interrupted")) return "interrupted";
77
+ if (steps.some((s) => s.status === "running" || s.status === "pending")) return "running";
78
+ return "done";
79
+ }
80
+
81
+ /** Current state = fold(events) over the definition's steps. A run with a parked human gate reads as
82
+ * `parked` (not `running`) so the boot reconcile + the /workflows UI can distinguish "waiting on you"
83
+ * from "executing". */
84
+ export function foldWorkflowRun(ws: string, def: WorkflowDef, runId: string): WorkflowRunState {
85
+ const latest = foldEvents(readWorkflowEvents(ws, def.id, runId));
86
+ const steps: FoldedStep[] = def.steps.map((s) => {
87
+ const e = latest.get(s.id);
88
+ return e
89
+ ? { id: s.id, kind: s.kind, status: e.status, produced: e.produced, choice: e.choice, note: e.note, updated: e.ts }
90
+ : { id: s.id, kind: s.kind, status: "pending" as StepStatus };
91
+ });
92
+ const done = steps.filter((s) => s.status === "done").length;
93
+ const failed = steps.filter((s) => s.status === "failed" || s.status === "interrupted").length;
94
+ const open = steps.filter((s) => !TERMINAL_STEP_STATUS.has(s.status)).length;
95
+ let status = overallStatus(steps);
96
+ if (status === "running" && readParkedGate(ws, def.id, runId)) status = "parked";
97
+ return { wfId: def.id, runId, steps, status, counts: { total: steps.length, done, open, failed } };
98
+ }
99
+
100
+ // ── durable gate suspension (Plan 25 Ph6) ─────────────────────────────────────────────────────────
101
+ // A workflow that reaches a human gate in an UNATTENDED run can't block on an in-memory Promise (the
102
+ // process may exit). Instead it PARKS: this marker persists next to the run's events, the run stops, and
103
+ // resumeWorkflow (core/workflow.ts) rebuilds the edge state from the events and continues once answered.
104
+
105
+ export interface ParkedGate {
106
+ step: string;
107
+ title: string;
108
+ choices: string[];
109
+ note?: string;
110
+ at: string;
111
+ }
112
+
113
+ const parkedFile = (ws: string, wfId: string, runId: string) => join(workflowPaths.workflowRunDir(ws, wfId, runId), "parked.json");
114
+
115
+ export function parkGate(ws: string, wfId: string, runId: string, gate: Omit<ParkedGate, "at">): ParkedGate {
116
+ const g: ParkedGate = { ...gate, at: new Date().toISOString() };
117
+ mkdirSync(workflowPaths.workflowRunDir(ws, wfId, runId), { recursive: true });
118
+ writeFileSync(parkedFile(ws, wfId, runId), JSON.stringify(g, null, 2));
119
+ return g;
120
+ }
121
+
122
+ export function readParkedGate(ws: string, wfId: string, runId: string): ParkedGate | null {
123
+ const f = parkedFile(ws, wfId, runId);
124
+ if (!existsSync(f)) return null;
125
+ try { return JSON.parse(readFileSync(f, "utf8")) as ParkedGate; }
126
+ catch { return null; }
127
+ }
128
+
129
+ export function clearParkedGate(ws: string, wfId: string, runId: string): void {
130
+ const f = parkedFile(ws, wfId, runId);
131
+ if (existsSync(f)) rmSync(f);
132
+ }
133
+
134
+ /** Every workflow run currently parked at a human gate — for the boot notice + the /workflows UI. */
135
+ export function listParkedGates(ws: string): { wfId: string; runId: string; gate: ParkedGate }[] {
136
+ const out: { wfId: string; runId: string; gate: ParkedGate }[] = [];
137
+ for (const wfId of subdirs(workflowPaths.workflowsDir(ws))) {
138
+ for (const runId of listWorkflowRunIds(ws, wfId)) {
139
+ const gate = readParkedGate(ws, wfId, runId);
140
+ if (gate) out.push({ wfId, runId, gate });
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ const subdirs = (dir: string): string[] =>
147
+ existsSync(dir) ? readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort() : [];
148
+
149
+ export function listWorkflowRunIds(ws: string, wfId: string): string[] {
150
+ return subdirs(workflowPaths.workflowRunsDir(ws, wfId));
151
+ }
152
+
153
+ /** Boot reconcile: a step left `running` means the process died in flight. Append `interrupted` and report
154
+ * it, without touching the definition — the step is still what the workflow meant to do. */
155
+ export function reconcileWorkflowRuns(ws: string): { wfId: string; runId: string; step: string }[] {
156
+ const interrupted: { wfId: string; runId: string; step: string }[] = [];
157
+ for (const wfId of subdirs(workflowPaths.workflowsDir(ws))) {
158
+ for (const runId of listWorkflowRunIds(ws, wfId)) {
159
+ const latest = foldEvents(readWorkflowEvents(ws, wfId, runId));
160
+ for (const [step, e] of latest) {
161
+ if (e.status !== "running") continue;
162
+ appendWorkflowEvent(ws, wfId, runId, {
163
+ step, status: "interrupted", runId: "boot",
164
+ note: "the process exited while this step was in flight",
165
+ });
166
+ interrupted.push({ wfId, runId, step });
167
+ }
168
+ }
169
+ }
170
+ return interrupted;
171
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./schema";
2
+ export * from "./executor";
3
+ export * from "./file-journal";
package/src/schema.ts ADDED
@@ -0,0 +1,185 @@
1
+ /** Plan 25: the structured, engine-executed team workflow.
2
+ *
3
+ * Lives in the YAML frontmatter of teams/<id>/workflow.md — the slot Plan 23's parseWorkflow already
4
+ * reserves and strips. The prose `## seat` lanes below it are untouched (Plan 23). A file with no
5
+ * `steps:` block is a prose-only Plan 23 workflow and loadWorkflowDefText returns null for it.
6
+ *
7
+ * A step is one of five KINDS, distinguished in YAML by which key it carries — a step with `run:` is an
8
+ * agent node, `check:` a check node, and so on. normalizeNode() detects the kind and rejects a step that
9
+ * carries more than one kind-key (or none), naming the id. The engine (core/workflow.ts) walks the steps;
10
+ * the model never routes. */
11
+ import { z } from "zod";
12
+ import { YAML } from "bun";
13
+
14
+ /** A step id — stable across versions, exactly like a Plan 18 PlanItem id. */
15
+ const StepId = z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, "step id must be lowercase letters, digits, _ or -");
16
+
17
+ /** run one agent (or a team id, resolved by team-routing). */
18
+ const AgentNode = z.object({
19
+ id: StepId,
20
+ run: z.string(),
21
+ brief: z.string().optional(),
22
+ consumes: z.array(z.string()).default([]),
23
+ produces: z.string().optional(),
24
+ criteria: z.string().optional(),
25
+ });
26
+
27
+ /** an automatic gate — verify an artifact against criteria (Plan 06 runChecker). */
28
+ const CheckNode = z.object({
29
+ id: StepId,
30
+ check: z.string(),
31
+ of: z.string().optional(),
32
+ on_fail: StepId.optional(),
33
+ max_attempts: z.number().int().positive().default(2),
34
+ });
35
+
36
+ /** a human gate — pause, present a packet, route on the chosen option. */
37
+ const HumanNode = z.object({
38
+ id: StepId,
39
+ human: z.string(),
40
+ present: z.string().optional(),
41
+ shows: z.array(z.string()).optional(),
42
+ choices: z.array(z.string()).min(1).default(["approve", "reject"]),
43
+ routes: z.record(z.string(), StepId).default({}),
44
+ });
45
+
46
+ /** fan-out then optional join. `over` maps each item of an artifact list; `branches` is explicit steps. */
47
+ const ParallelNode = z.object({
48
+ id: StepId,
49
+ over: z.string().optional(),
50
+ branches: z.array(AgentNode).optional(),
51
+ as: AgentNode.optional(),
52
+ join: z.string().optional(),
53
+ produces: z.string().optional(),
54
+ });
55
+
56
+ /** classify the input and jump to a labelled step. */
57
+ const BranchNode = z.object({
58
+ id: StepId,
59
+ branch: z.string(),
60
+ of: z.string().optional(),
61
+ routes: z.record(z.string(), StepId),
62
+ });
63
+
64
+ export type WorkflowNode =
65
+ | ({ kind: "agent" } & z.infer<typeof AgentNode>)
66
+ | ({ kind: "check" } & z.infer<typeof CheckNode>)
67
+ | ({ kind: "human" } & z.infer<typeof HumanNode>)
68
+ | ({ kind: "parallel" } & z.infer<typeof ParallelNode>)
69
+ | ({ kind: "branch" } & z.infer<typeof BranchNode>);
70
+ export type NodeKind = WorkflowNode["kind"];
71
+
72
+ export interface WorkflowDef {
73
+ id: string;
74
+ team: string;
75
+ version: number;
76
+ brief?: string;
77
+ steps: WorkflowNode[];
78
+ }
79
+
80
+ const WorkflowHead = z.object({
81
+ id: z.string().min(1),
82
+ team: z.string().min(1),
83
+ version: z.number().int().positive(),
84
+ brief: z.string().optional(),
85
+ });
86
+
87
+ /** Which kind-key(s) a raw step carries. `parallel` is signalled by `over` or `branches`. */
88
+ function discriminate(raw: Record<string, unknown>): NodeKind[] {
89
+ const kinds: NodeKind[] = [];
90
+ if ("run" in raw) kinds.push("agent");
91
+ if ("check" in raw) kinds.push("check");
92
+ if ("human" in raw) kinds.push("human");
93
+ if ("branch" in raw) kinds.push("branch");
94
+ if ("over" in raw || "branches" in raw) kinds.push("parallel");
95
+ return kinds;
96
+ }
97
+
98
+ function normalizeNode(raw: Record<string, unknown>): WorkflowNode {
99
+ const id = typeof raw.id === "string" ? raw.id : "?";
100
+ const kinds = discriminate(raw);
101
+ if (kinds.length === 0)
102
+ throw new Error(`workflow step "${id}" has no kind — it needs one of run/check/human/parallel/branch`);
103
+ if (kinds.length > 1)
104
+ throw new Error(`workflow step "${id}" carries more than one kind (${kinds.join(", ")}) — a step is exactly one`);
105
+ const kind = kinds[0]!;
106
+ switch (kind) {
107
+ case "agent": return { kind, ...AgentNode.parse(raw) };
108
+ case "check": return { kind, ...CheckNode.parse(raw) };
109
+ case "human": return { kind, ...HumanNode.parse(raw) };
110
+ case "parallel": return { kind, ...ParallelNode.parse(raw) };
111
+ case "branch": return { kind, ...BranchNode.parse(raw) };
112
+ }
113
+ }
114
+
115
+ /** Validate + normalize a raw workflow object (head fields already mapped: id/team/version/brief/steps). */
116
+ export function parseWorkflowDef(raw: unknown): WorkflowDef {
117
+ const r = (raw ?? {}) as Record<string, unknown>;
118
+ const head = WorkflowHead.parse(r);
119
+ const rawSteps = z.array(z.record(z.string(), z.unknown())).min(1, "a workflow needs at least one step").parse(r.steps);
120
+ const steps = rawSteps.map(normalizeNode);
121
+ const ids = new Set<string>();
122
+ for (const s of steps) {
123
+ if (ids.has(s.id)) throw new Error(`workflow "${head.id}" has a duplicate step id "${s.id}"`);
124
+ ids.add(s.id);
125
+ }
126
+ return { ...head, steps };
127
+ }
128
+
129
+ // ── run state ───────────────────────────────────────────────────────────────────────────────────
130
+ // A workflow RUN is an append-only event log; current state = fold(events). This mirrors Plan 18's plan
131
+ // event log, but SIMPLER: the ENGINE writes every event (a workflow step is never model-ticked), so there
132
+ // is no `by`/`rejected` split — no attempt can lie because no model attempt exists.
133
+
134
+ export const StepStatus = z.enum(["pending", "running", "done", "failed", "skipped", "interrupted"]);
135
+ export type StepStatus = z.infer<typeof StepStatus>;
136
+
137
+ /** Terminal for "is this step still open". */
138
+ export const TERMINAL_STEP_STATUS: ReadonlySet<StepStatus> = new Set<StepStatus>([
139
+ "done", "failed", "skipped", "interrupted",
140
+ ]);
141
+
142
+ export const WorkflowEvent = z.object({
143
+ step: z.string(),
144
+ status: StepStatus,
145
+ runId: z.string(), // the executeRun (or "boot") that wrote this event
146
+ produced: z.string().optional(), // artifact handle the step emitted (id@vN)
147
+ choice: z.string().optional(), // human/branch: the path taken
148
+ attempt: z.number().int().optional(),
149
+ note: z.string().optional(),
150
+ ts: z.string().datetime(),
151
+ });
152
+ export type WorkflowEvent = z.infer<typeof WorkflowEvent>;
153
+
154
+ export interface FoldedStep {
155
+ id: string;
156
+ kind: NodeKind;
157
+ status: StepStatus;
158
+ produced?: string;
159
+ choice?: string;
160
+ note?: string;
161
+ updated?: string;
162
+ }
163
+
164
+ export type WorkflowRunStatus = "running" | "done" | "failed" | "interrupted" | "parked";
165
+
166
+ export interface WorkflowRunState {
167
+ wfId: string;
168
+ runId: string;
169
+ steps: FoldedStep[];
170
+ status: WorkflowRunStatus;
171
+ counts: { total: number; done: number; open: number; failed: number };
172
+ }
173
+
174
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
175
+
176
+ /** Parse the structured workflow out of a workflow.md's text. `team` is injected from the file location
177
+ * (teams/<team>/workflow.md). Returns null for a Plan 23 prose-only file — one with no frontmatter, or
178
+ * frontmatter that carries no `steps:` block. The frontmatter key `workflow:` names the workflow (its id). */
179
+ export function loadWorkflowDefText(text: string, team: string): WorkflowDef | null {
180
+ const m = FRONTMATTER.exec(text);
181
+ if (!m) return null;
182
+ const fm = YAML.parse(m[1]!) as Record<string, unknown> | null;
183
+ if (!fm || !("steps" in fm)) return null;
184
+ return parseWorkflowDef({ id: fm.workflow, team, version: fm.version, brief: fm.brief, steps: fm.steps });
185
+ }