@osolmaz/pi-workflows 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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/extension/executor.d.ts +58 -0
  4. package/dist/extension/executor.js +201 -0
  5. package/dist/extension/executor.js.map +1 -0
  6. package/dist/extension/index.d.ts +17 -0
  7. package/dist/extension/index.js +504 -0
  8. package/dist/extension/index.js.map +1 -0
  9. package/dist/extension/widget.d.ts +21 -0
  10. package/dist/extension/widget.js +142 -0
  11. package/dist/extension/widget.js.map +1 -0
  12. package/dist/render/ansi.d.ts +16 -0
  13. package/dist/render/ansi.js +42 -0
  14. package/dist/render/ansi.js.map +1 -0
  15. package/dist/render/canvas.d.ts +40 -0
  16. package/dist/render/canvas.js +177 -0
  17. package/dist/render/canvas.js.map +1 -0
  18. package/dist/render/format.d.ts +3 -0
  19. package/dist/render/format.js +17 -0
  20. package/dist/render/format.js.map +1 -0
  21. package/dist/render/graph-render.d.ts +22 -0
  22. package/dist/render/graph-render.js +520 -0
  23. package/dist/render/graph-render.js.map +1 -0
  24. package/dist/render/graph.d.ts +46 -0
  25. package/dist/render/graph.js +272 -0
  26. package/dist/render/graph.js.map +1 -0
  27. package/dist/viewer/cli.d.ts +10 -0
  28. package/dist/viewer/cli.js +132 -0
  29. package/dist/viewer/cli.js.map +1 -0
  30. package/dist/viewer/render.d.ts +19 -0
  31. package/dist/viewer/render.js +162 -0
  32. package/dist/viewer/render.js.map +1 -0
  33. package/dist/viewer/tui.d.ts +11 -0
  34. package/dist/viewer/tui.js +140 -0
  35. package/dist/viewer/tui.js.map +1 -0
  36. package/dist/viewer/watch.d.ts +9 -0
  37. package/dist/viewer/watch.js +46 -0
  38. package/dist/viewer/watch.js.map +1 -0
  39. package/dist/workflows/decision.d.ts +25 -0
  40. package/dist/workflows/decision.js +96 -0
  41. package/dist/workflows/decision.js.map +1 -0
  42. package/dist/workflows/definition.d.ts +9 -0
  43. package/dist/workflows/definition.js +61 -0
  44. package/dist/workflows/definition.js.map +1 -0
  45. package/dist/workflows/engine.d.ts +65 -0
  46. package/dist/workflows/engine.js +574 -0
  47. package/dist/workflows/engine.js.map +1 -0
  48. package/dist/workflows/errors.d.ts +9 -0
  49. package/dist/workflows/errors.js +24 -0
  50. package/dist/workflows/errors.js.map +1 -0
  51. package/dist/workflows/graph.d.ts +17 -0
  52. package/dist/workflows/graph.js +127 -0
  53. package/dist/workflows/graph.js.map +1 -0
  54. package/dist/workflows/index.d.ts +11 -0
  55. package/dist/workflows/index.js +11 -0
  56. package/dist/workflows/index.js.map +1 -0
  57. package/dist/workflows/json.d.ts +14 -0
  58. package/dist/workflows/json.js +134 -0
  59. package/dist/workflows/json.js.map +1 -0
  60. package/dist/workflows/loader.d.ts +28 -0
  61. package/dist/workflows/loader.js +94 -0
  62. package/dist/workflows/loader.js.map +1 -0
  63. package/dist/workflows/schema.d.ts +7 -0
  64. package/dist/workflows/schema.js +176 -0
  65. package/dist/workflows/schema.js.map +1 -0
  66. package/dist/workflows/shell.d.ts +9 -0
  67. package/dist/workflows/shell.js +177 -0
  68. package/dist/workflows/shell.js.map +1 -0
  69. package/dist/workflows/store.d.ts +35 -0
  70. package/dist/workflows/store.js +181 -0
  71. package/dist/workflows/store.js.map +1 -0
  72. package/dist/workflows/text.d.ts +10 -0
  73. package/dist/workflows/text.js +32 -0
  74. package/dist/workflows/text.js.map +1 -0
  75. package/dist/workflows/types.d.ts +280 -0
  76. package/dist/workflows/types.js +2 -0
  77. package/dist/workflows/types.js.map +1 -0
  78. package/docs/development.md +130 -0
  79. package/docs/run-bundles.md +114 -0
  80. package/docs/workflows.md +311 -0
  81. package/examples/workflows/autoimplement.workflow.ts +92 -0
  82. package/examples/workflows/autoresearch.workflow.ts +139 -0
  83. package/examples/workflows/branch.workflow.ts +63 -0
  84. package/examples/workflows/echo.workflow.ts +23 -0
  85. package/examples/workflows/elegant-solution.workflow.ts +95 -0
  86. package/examples/workflows/shell.workflow.ts +31 -0
  87. package/examples/workflows/two-turn.workflow.ts +64 -0
  88. package/package.json +80 -0
  89. package/src/extension/executor.ts +251 -0
  90. package/src/extension/index.ts +627 -0
  91. package/src/extension/widget.ts +183 -0
  92. package/src/render/ansi.ts +47 -0
  93. package/src/render/canvas.ts +196 -0
  94. package/src/render/format.ts +19 -0
  95. package/src/render/graph-render.ts +738 -0
  96. package/src/render/graph.ts +341 -0
  97. package/src/viewer/cli.ts +150 -0
  98. package/src/viewer/render.ts +236 -0
  99. package/src/viewer/tui.ts +159 -0
  100. package/src/viewer/watch.ts +55 -0
  101. package/src/workflows/decision.ts +127 -0
  102. package/src/workflows/definition.ts +104 -0
  103. package/src/workflows/engine.ts +793 -0
  104. package/src/workflows/errors.ts +27 -0
  105. package/src/workflows/graph.ts +161 -0
  106. package/src/workflows/index.ts +76 -0
  107. package/src/workflows/json.ts +155 -0
  108. package/src/workflows/loader.ts +123 -0
  109. package/src/workflows/schema.ts +218 -0
  110. package/src/workflows/shell.ts +199 -0
  111. package/src/workflows/store.ts +234 -0
  112. package/src/workflows/text.ts +34 -0
  113. package/src/workflows/types.ts +318 -0
@@ -0,0 +1,280 @@
1
+ export type MaybePromise<T> = T | Promise<T>;
2
+ /**
3
+ * Context passed to node callbacks (prompt builders, compute/action runners,
4
+ * validators). `outputs` maps node ids to their accepted outputs; `results`
5
+ * maps node ids to the full result record of their latest attempt.
6
+ */
7
+ export type WorkflowNodeContext<TInput = unknown> = {
8
+ input: TInput;
9
+ outputs: Record<string, unknown>;
10
+ results: Record<string, WorkflowNodeResult>;
11
+ state: WorkflowRunState;
12
+ /**
13
+ * Aborted when the node times out or the run is cancelled. Long-running
14
+ * callbacks should observe it (pass it to fetch/spawn or check
15
+ * `signal.aborted`) so side effects stop when the engine gives up on the
16
+ * node.
17
+ */
18
+ signal: AbortSignal;
19
+ };
20
+ export type WorkflowNodeCommon = {
21
+ /** Per-node timeout. Falls back to the engine default (15 minutes). */
22
+ timeoutMs?: number;
23
+ /** Short human-readable label shown in the viewer while the node runs. */
24
+ statusDetail?: string;
25
+ };
26
+ /**
27
+ * Edges route between nodes. A node has at most one outgoing edge: either a
28
+ * plain `to` edge or a `switch` edge that routes on a JSON path into the
29
+ * node's output (`$.field`, `$output.field`) or result (`$result.outcome`).
30
+ */
31
+ export type WorkflowEdge = {
32
+ from: string;
33
+ to: string;
34
+ } | {
35
+ from: string;
36
+ switch: {
37
+ on: string;
38
+ cases: Record<string, string>;
39
+ };
40
+ };
41
+ /**
42
+ * A model-shaped step. The engine sends the prompt into the pi conversation
43
+ * and the model completes the step by calling the `workflow` tool with a JSON
44
+ * output. `expectedOutput` is appended to the step contract so the model
45
+ * knows what shape to submit. `validate` may reject (throw) or normalize the
46
+ * submitted output; rejections are surfaced to the model so it can retry
47
+ * within the same step.
48
+ */
49
+ export type AgentNodeDefinition = WorkflowNodeCommon & {
50
+ nodeType: "agent";
51
+ prompt: (context: WorkflowNodeContext) => MaybePromise<string>;
52
+ expectedOutput?: string;
53
+ validate?: (output: unknown, context: WorkflowNodeContext) => MaybePromise<unknown>;
54
+ };
55
+ /** A pure local function: shape inputs, route, format, derive values. */
56
+ export type ComputeNodeDefinition = WorkflowNodeCommon & {
57
+ nodeType: "compute";
58
+ run: (context: WorkflowNodeContext) => MaybePromise<unknown>;
59
+ };
60
+ /** A deterministic runtime-owned step implemented as a local function. */
61
+ export type FunctionActionNodeDefinition = WorkflowNodeCommon & {
62
+ nodeType: "action";
63
+ run: (context: WorkflowNodeContext) => MaybePromise<unknown>;
64
+ };
65
+ export type ShellActionExecution = {
66
+ command: string;
67
+ args?: string[];
68
+ cwd?: string;
69
+ env?: Record<string, string>;
70
+ stdin?: string;
71
+ shell?: boolean | string;
72
+ allowNonZeroExit?: boolean;
73
+ timeoutMs?: number;
74
+ /** Cap on captured stdout/stderr each, default 1,000,000 characters. */
75
+ maxOutputChars?: number;
76
+ };
77
+ export type ShellActionResult = {
78
+ command: string;
79
+ args: string[];
80
+ cwd: string;
81
+ stdout: string;
82
+ stderr: string;
83
+ exitCode: number | null;
84
+ signal: NodeJS.Signals | null;
85
+ durationMs: number;
86
+ };
87
+ /** A deterministic runtime-owned step implemented as a shell command. */
88
+ export type ShellActionNodeDefinition = WorkflowNodeCommon & {
89
+ nodeType: "action";
90
+ exec: (context: WorkflowNodeContext) => MaybePromise<ShellActionExecution>;
91
+ parse?: (result: ShellActionResult, context: WorkflowNodeContext) => MaybePromise<unknown>;
92
+ };
93
+ export type ActionNodeDefinition = FunctionActionNodeDefinition | ShellActionNodeDefinition;
94
+ /**
95
+ * A pause point. The run terminates with status `waiting` so a human (or an
96
+ * external trigger) can decide how to continue. The optional `run` callback
97
+ * produces the checkpoint's output before the run pauses.
98
+ */
99
+ export type CheckpointNodeDefinition = WorkflowNodeCommon & {
100
+ nodeType: "checkpoint";
101
+ summary?: string;
102
+ run?: (context: WorkflowNodeContext) => MaybePromise<unknown>;
103
+ };
104
+ export type WorkflowNodeDefinition = AgentNodeDefinition | ComputeNodeDefinition | ActionNodeDefinition | CheckpointNodeDefinition;
105
+ export type WorkflowPresentationContext = {
106
+ /** Final persisted state of the workflow run. */
107
+ state: WorkflowRunState;
108
+ /** Convenience alias for `state.finalOutput`. */
109
+ finalOutput: unknown;
110
+ /** Aborted if a new run starts, the session closes, or prompt generation times out. */
111
+ signal: AbortSignal;
112
+ };
113
+ export type WorkflowDefinition = {
114
+ name: string;
115
+ /** Optional human-readable run title (static or derived from input). */
116
+ title?: string | ((context: {
117
+ input: unknown;
118
+ workflowName: string;
119
+ }) => MaybePromise<string | undefined>);
120
+ /**
121
+ * Optional instructions for a normal assistant response after the run ends.
122
+ * The Pi extension resolves this only after the final state is persisted;
123
+ * the engine and run bundle remain presentation-agnostic.
124
+ */
125
+ presentationPrompt?: string | ((context: WorkflowPresentationContext) => MaybePromise<string | undefined>);
126
+ startAt: string;
127
+ nodes: Record<string, WorkflowNodeDefinition>;
128
+ edges: WorkflowEdge[];
129
+ /** Guard against unbounded loops. Defaults to the engine's maxSteps. */
130
+ maxSteps?: number;
131
+ };
132
+ export type WorkflowNodeOutcome = "ok" | "timed_out" | "failed" | "cancelled";
133
+ export type WorkflowNodeResult = {
134
+ attemptId: string;
135
+ nodeId: string;
136
+ nodeType: WorkflowNodeDefinition["nodeType"];
137
+ outcome: WorkflowNodeOutcome;
138
+ startedAt: string;
139
+ finishedAt: string;
140
+ durationMs: number;
141
+ output?: unknown;
142
+ error?: string;
143
+ };
144
+ export type WorkflowActionReceipt = {
145
+ actionType: "shell" | "function";
146
+ command?: string;
147
+ args?: string[];
148
+ cwd?: string;
149
+ exitCode?: number | null;
150
+ signal?: NodeJS.Signals | null;
151
+ durationMs?: number;
152
+ };
153
+ export type WorkflowStepRecord = {
154
+ attemptId: string;
155
+ nodeId: string;
156
+ nodeType: WorkflowNodeDefinition["nodeType"];
157
+ outcome: WorkflowNodeOutcome;
158
+ startedAt: string;
159
+ finishedAt: string;
160
+ promptText: string | null;
161
+ output: unknown;
162
+ error?: string;
163
+ action?: WorkflowActionReceipt;
164
+ };
165
+ export type WorkflowRunStatus = "running" | "waiting" | "completed" | "failed" | "timed_out" | "cancelled";
166
+ export type WorkflowRunState = {
167
+ runId: string;
168
+ workflowName: string;
169
+ runTitle?: string;
170
+ workflowPath?: string;
171
+ startedAt: string;
172
+ finishedAt?: string;
173
+ updatedAt: string;
174
+ status: WorkflowRunStatus;
175
+ input: unknown;
176
+ outputs: Record<string, unknown>;
177
+ results: Record<string, WorkflowNodeResult>;
178
+ steps: WorkflowStepRecord[];
179
+ currentNode?: string;
180
+ currentAttemptId?: string;
181
+ currentNodeType?: WorkflowNodeDefinition["nodeType"];
182
+ currentNodeStartedAt?: string;
183
+ statusDetail?: string;
184
+ /** True while the run is held at a step boundary by a pause request. */
185
+ paused?: boolean;
186
+ waitingOn?: string;
187
+ finalOutput?: unknown;
188
+ error?: string;
189
+ };
190
+ export type WorkflowNodeSnapshot = {
191
+ nodeType: WorkflowNodeDefinition["nodeType"];
192
+ timeoutMs?: number;
193
+ statusDetail?: string;
194
+ summary?: string;
195
+ expectedOutput?: string;
196
+ actionExecution?: "function" | "shell";
197
+ };
198
+ export type WorkflowDefinitionSnapshot = {
199
+ schema: "pi-workflows.definition-snapshot.v1";
200
+ name: string;
201
+ startAt: string;
202
+ nodes: Record<string, WorkflowNodeSnapshot>;
203
+ edges: WorkflowEdge[];
204
+ };
205
+ export type WorkflowTraceEvent = {
206
+ seq: number;
207
+ at: string;
208
+ scope: "run" | "node" | "agent" | "action";
209
+ type: string;
210
+ runId: string;
211
+ nodeId?: string;
212
+ attemptId?: string;
213
+ payload: Record<string, unknown>;
214
+ };
215
+ export type WorkflowTraceEventDraft = Omit<WorkflowTraceEvent, "seq" | "at" | "runId">;
216
+ export type WorkflowRunManifest = {
217
+ schema: "pi-workflows.run-bundle.v1";
218
+ runId: string;
219
+ workflowName: string;
220
+ runTitle?: string;
221
+ workflowPath?: string;
222
+ startedAt: string;
223
+ finishedAt?: string;
224
+ status: WorkflowRunStatus;
225
+ traceSchema: "pi-workflows.trace-event.v1";
226
+ paths: {
227
+ workflow: string;
228
+ state: string;
229
+ trace: string;
230
+ };
231
+ };
232
+ export type WorkflowRunResult = {
233
+ runDir: string;
234
+ state: WorkflowRunState;
235
+ };
236
+ /** The step contract handed to the executor alongside the prompt. */
237
+ export type AgentStepContract = {
238
+ runId: string;
239
+ workflowName: string;
240
+ nodeId: string;
241
+ attemptId: string;
242
+ expectedOutput?: string;
243
+ };
244
+ export type AgentStepRequest = {
245
+ contract: AgentStepContract;
246
+ prompt: string;
247
+ /**
248
+ * Validate a submission from the model. Returns the normalized output or an
249
+ * error message the executor should surface to the model for retry.
250
+ */
251
+ accept: (output: unknown) => Promise<{
252
+ ok: true;
253
+ value: unknown;
254
+ } | {
255
+ ok: false;
256
+ error: string;
257
+ }>;
258
+ };
259
+ export type AgentStepSubmission = {
260
+ output: unknown;
261
+ };
262
+ /**
263
+ * Runs one agent step to completion. Implementations deliver the prompt to
264
+ * the model and resolve once a submission has been accepted via `accept`.
265
+ * Must reject with an `AbortError`-like error when `signal` aborts.
266
+ */
267
+ export interface AgentStepExecutor {
268
+ runAgentStep(request: AgentStepRequest, signal: AbortSignal): Promise<AgentStepSubmission>;
269
+ }
270
+ export type WorkflowEngineOptions = {
271
+ executor: AgentStepExecutor;
272
+ /** Root directory for run bundles. Defaults to `~/.pi/agent/workflows/runs`. */
273
+ outputRoot?: string;
274
+ /** Default per-node timeout. Defaults to 15 minutes. */
275
+ defaultNodeTimeoutMs?: number;
276
+ /** Guard against unbounded graph loops. Defaults to 100 executed steps. */
277
+ maxSteps?: number;
278
+ /** Observer invoked after every persisted trace event. */
279
+ onEvent?: (event: WorkflowTraceEvent, state: WorkflowRunState) => void;
280
+ };
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/workflows/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,130 @@
1
+ # Development guide
2
+
3
+ This document covers the standards for working on pi-workflows itself. For
4
+ authoring workflows, see [workflows.md](workflows.md).
5
+
6
+ ## Layout and boundaries
7
+
8
+ ```
9
+ src/workflows/ engine core: definitions, graph, engine, store, loader
10
+ src/extension/ pi integration: /workflow command, workflow tool, widget
11
+ src/viewer/ standalone TUI viewer over run bundles
12
+ ```
13
+
14
+ The dependency direction is enforced by `slophammer.yml`. `src/workflows`
15
+ imports nothing outside itself and never imports pi. `src/extension` and
16
+ `src/viewer` may import `src/workflows` and never each other. The viewer
17
+ observes runs purely through the bundle files, so it works from any process.
18
+
19
+ Within `src/render`, `graph.ts` computes a pure layered layout (ported from
20
+ the acpx replay viewer: labelled switch expansion, DFS back-edge detection,
21
+ longest-path layering, barycenter ordering, virtual pass-through cells for
22
+ long edges), `canvas.ts` is a character grid that merges box-drawing
23
+ characters by connectivity, and `graph-render.ts` turns a run bundle plus a
24
+ replay position into the drawn graph in one of two node styles: `box`
25
+ (bordered nodes, used by the viewer and the in-pi widget) or `line`
26
+ (single-line nodes). The widget windows the boxed graph around the active
27
+ node to stay inside pi's 10-line widget cap; `shift+↑`/`shift+↓` shortcuts
28
+ (registered through pi's `registerShortcut`) scroll that window manually, and
29
+ the scroll resets to follow mode when the run records a new step. `render.ts`
30
+ in `src/viewer`
31
+ composes the full detail view (header, graph, step timeline, step inspector)
32
+ and stays pure so tests can assert on rendered lines.
33
+
34
+ The renderer is built so that overlaps cannot corrupt the drawing: every
35
+ back edge owns exclusive lane rows and an exclusive gutter column, multiple
36
+ edges leaving one node fan out over separate exit columns, and labels are
37
+ drawn last through `textOverRun`/`textIfEmpty`, which refuse to overwrite
38
+ anything but a plain horizontal run or empty cells. `test/helpers/graph-verify.ts`
39
+ enforces this structurally: it re-parses the rendered characters, checks
40
+ every node box is unbroken, and traces every declared edge through the
41
+ actual box-drawing characters from source box to target arrow.
42
+ `test/graph-verify.test.ts` runs that verifier over 60 seeded random
43
+ workflow shapes at every replay position; if a rendering change breaks a
44
+ line, misplaces an arrow, or lets a label damage an edge, those tests fail
45
+ with the offending drawing in the assertion message.
46
+
47
+ Inside the engine, the pi-facing seam is the `AgentStepExecutor` interface.
48
+ The extension implements it on top of the live conversation
49
+ (`src/extension/executor.ts`), and tests implement it with a scripted fake
50
+ (`test/helpers.ts`). Anything that would couple the engine to pi belongs on
51
+ the extension side of that seam.
52
+
53
+ ## Toolchain
54
+
55
+ Node 22+, ESM, TypeScript strict (including `exactOptionalPropertyTypes`).
56
+ Formatting is oxfmt, linting is oxlint with warnings denied, tests are vitest
57
+ with istanbul coverage. The single gate is:
58
+
59
+ ```bash
60
+ npm run check # format:check + lint + typecheck + build + test:coverage
61
+ ```
62
+
63
+ Coverage thresholds are 85% lines/functions/branches/statements, configured
64
+ in `vitest.config.ts`. The istanbul provider is deliberate. Workflow files are
65
+ loaded through jiti at runtime, and the v8 provider mismapped those modules;
66
+ istanbul instruments through the vitest transform pipeline only.
67
+
68
+ Slophammer runs in CI (coverage, complexity max 8, DRY max 0 findings,
69
+ dependency boundaries). Run it locally with:
70
+
71
+ ```bash
72
+ npx slophammer-ts@latest dry .
73
+ npx slophammer-ts@latest check . --only ts.dependency-boundaries-required
74
+ ```
75
+
76
+ ## End-to-end tests
77
+
78
+ ```bash
79
+ npm run test:e2e
80
+ ```
81
+
82
+ The E2E suite (`test/e2e/`) is non-destructive and fully local. It starts a
83
+ mock OpenAI-compatible server (`test/e2e/mock-openai.ts`) whose scripted
84
+ "model" answers each step contract with a `workflow` tool call, then spawns
85
+ the real pi CLI from `devDependencies` in RPC mode with:
86
+
87
+ - `PI_CODING_AGENT_DIR` pointed at a temp agent dir containing a `models.json`
88
+ for the mock provider,
89
+ - `PI_WORKFLOWS_RUNS_DIR` pointed at a temp runs dir,
90
+ - the extension loaded from source with `-e src/extension/index.ts`.
91
+
92
+ It drives `/workflow` over the RPC protocol and asserts on the resulting run
93
+ bundle, then renders the finished run through the viewer CLI. Nothing outside
94
+ the temp directories is touched, and no real model is called.
95
+
96
+ ## Publishing
97
+
98
+ The npm package is `@osolmaz/pi-workflows`. The first version must be published
99
+ locally because npm cannot configure a trusted publisher until the package
100
+ exists:
101
+
102
+ ```bash
103
+ npm publish --access public
104
+ ```
105
+
106
+ After that first publish, configure npm trusted publishing for the
107
+ `osolmaz/pi-workflows` repository, `.github/workflows/publish.yml`, and the
108
+ `npm` GitHub environment. The workflow does not use a stored npm token.
109
+
110
+ For later versions:
111
+
112
+ 1. Update `version` in `package.json` and `package-lock.json`, then merge that
113
+ change into the default branch.
114
+ 2. Publish a GitHub Release whose tag is `v<version>`, such as `v0.2.0`.
115
+ 3. Wait for the **Publish npm package** workflow to finish and verify the new
116
+ version on npm.
117
+
118
+ The workflow rejects mismatched tags, commits outside the default branch, and
119
+ versions already present on npm. It runs the full checks and end-to-end tests
120
+ before `npm publish --provenance`.
121
+
122
+ ## Conventions
123
+
124
+ - Conventional Commits for commit messages and PR titles.
125
+ - Persisted JSON uses camelCase keys and versioned `schema` identifiers; see
126
+ [run-bundles.md](run-bundles.md). Breaking a persisted shape means bumping
127
+ the schema version string.
128
+ - Every exported API of the engine (`src/workflows/index.ts`) is covered by
129
+ unit tests; new node types or edge semantics need tests in `test/` and a
130
+ section in [workflows.md](workflows.md).
@@ -0,0 +1,114 @@
1
+ # Run bundle format
2
+
3
+ Every workflow run persists to its own directory, called a run bundle. The
4
+ bundle is the contract between the engine and anything that observes runs,
5
+ including the bundled terminal viewer. This document specifies the format so
6
+ other tools can consume it.
7
+
8
+ ## Location and layout
9
+
10
+ Bundles live under `~/.pi/agent/workflows/runs/` by default. The
11
+ `PI_WORKFLOWS_RUNS_DIR` environment variable overrides the location for both
12
+ the engine and the viewer, which is how the test suite keeps runs inside
13
+ temporary directories.
14
+
15
+ ```
16
+ ~/.pi/agent/workflows/runs/
17
+ 20260719T023912Z-autoimplement-3f2a9c1b/
18
+ manifest.json # pi-workflows.run-bundle.v1
19
+ workflow.json # pi-workflows.definition-snapshot.v1
20
+ state.json # full run projection
21
+ trace.ndjson # pi-workflows.trace-event.v1, append-only
22
+ ```
23
+
24
+ Run ids are `<UTC timestamp>-<workflow slug>-<8 hex chars>`, so lexical order
25
+ is chronological order.
26
+
27
+ ## Write discipline
28
+
29
+ Every JSON file in the bundle is written atomically (write to a temp file in
30
+ the same directory, then rename), so a reader never sees a partial document. `trace.ndjson` is append-only, one JSON object
31
+ per line, with writes serialized per file. After a run reaches a terminal
32
+ status (`completed`, `failed`, `timed_out`, `cancelled`, or `waiting`), the
33
+ bundle no longer changes.
34
+
35
+ A live viewer needs only two behaviors. Treat `state.json` as the current
36
+ projection and re-read it on any file change, and treat `trace.ndjson` as the
37
+ event timeline when history matters.
38
+
39
+ ## manifest.json
40
+
41
+ Identity and pointers, kept in sync with the state on every snapshot:
42
+
43
+ ```json
44
+ {
45
+ "schema": "pi-workflows.run-bundle.v1",
46
+ "runId": "20260719T023912Z-autoimplement-3f2a9c1b",
47
+ "workflowName": "autoimplement",
48
+ "runTitle": "autoimplement: fix the flaky test",
49
+ "workflowPath": "/repo/.pi/workflows/autoimplement.workflow.ts",
50
+ "startedAt": "2026-07-19T02:39:12.412Z",
51
+ "finishedAt": "2026-07-19T02:41:03.977Z",
52
+ "status": "completed",
53
+ "traceSchema": "pi-workflows.trace-event.v1",
54
+ "paths": { "workflow": "workflow.json", "state": "state.json", "trace": "trace.ndjson" }
55
+ }
56
+ ```
57
+
58
+ ## workflow.json
59
+
60
+ A serializable snapshot of the graph taken at run start. Functions such as
61
+ prompts and validators are not serialized. Each node keeps only its metadata
62
+ (`nodeType`, `timeoutMs`, `statusDetail`, `expectedOutput`, `summary`,
63
+ `actionExecution`), and edges are copied verbatim. The snapshot is what lets
64
+ the viewer draw all nodes, including ones that have not run yet.
65
+
66
+ ## state.json
67
+
68
+ The full run projection (`WorkflowRunState` in
69
+ [`src/workflows/types.ts`](../src/workflows/types.ts)). The `status` field is
70
+ one of `running`, `waiting`, `completed`, `failed`, `timed_out`, or
71
+ `cancelled`. While a node is executing, `currentNode`, `currentNodeType`,
72
+ `currentNodeStartedAt`, and `statusDetail` describe it, and they disappear
73
+ when the node finishes. While a pause request holds the run at a step
74
+ boundary, `paused` is `true` (with matching `run_paused`/`run_resumed` trace
75
+ events); it disappears when the run resumes or ends.
76
+
77
+ Per-node data lives in `outputs` (the accepted output of each finished node,
78
+ where the latest attempt wins on loops) and in `results` (the full result
79
+ record including the outcome and timing). The ordered history is `steps`,
80
+ with one record per node execution that includes the prompt text for agent
81
+ steps and an action receipt with the command, exit code, and duration for
82
+ action steps. When a run pauses at a checkpoint, `waitingOn` names the
83
+ checkpoint node. Terminal runs carry `finalOutput` on success and `error` on
84
+ failure.
85
+
86
+ ## trace.ndjson
87
+
88
+ One event per line, monotonically sequenced per run:
89
+
90
+ ```json
91
+ {
92
+ "seq": 3,
93
+ "at": "2026-07-19T02:39:14.101Z",
94
+ "scope": "agent",
95
+ "type": "agent_prompt_sent",
96
+ "runId": "...",
97
+ "nodeId": "implement",
98
+ "attemptId": "...",
99
+ "payload": { "prompt": "..." }
100
+ }
101
+ ```
102
+
103
+ Event types: `run_started`, `node_started`, `agent_prompt_sent`,
104
+ `node_finished`, `node_failed`, and a terminal `run_<status>`. The `scope`
105
+ field (`run`, `node`, `agent`, `action`) groups them. Consumers should ignore
106
+ unknown event types so new ones can be added within the same schema version.
107
+
108
+ ## Versioning
109
+
110
+ Each file carries a versioned schema identifier such as
111
+ `pi-workflows.run-bundle.v1`, and the identifier changes only on breaking
112
+ shape changes. Readers should check `manifest.json`'s `schema` field and skip
113
+ bundles they do not understand, which is exactly what the bundled viewer does
114
+ with unreadable directories.