@osolmaz/pi-workflows 0.14.0 → 0.15.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 (235) hide show
  1. package/README.md +78 -110
  2. package/dist/builtins/autoimplement.workflow.js +3 -1
  3. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  4. package/dist/builtins/change-verification.workflow.js +7 -2
  5. package/dist/builtins/change-verification.workflow.js.map +1 -1
  6. package/dist/builtins/metadata.d.ts +22 -0
  7. package/dist/builtins/metadata.js +10 -0
  8. package/dist/builtins/metadata.js.map +1 -0
  9. package/dist/builtins/monitor.workflow.js +4 -1
  10. package/dist/builtins/monitor.workflow.js.map +1 -1
  11. package/dist/builtins/sanity-check.workflow.js +4 -1
  12. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  13. package/dist/builtins/workspace-preparation.workflow.js +3 -1
  14. package/dist/builtins/workspace-preparation.workflow.js.map +1 -1
  15. package/dist/controllers/index.d.ts +0 -1
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +51 -0
  19. package/dist/controllers/sqlite.js +433 -57
  20. package/dist/controllers/sqlite.js.map +1 -1
  21. package/dist/controllers/store.d.ts +1 -0
  22. package/dist/extension/controller-command.d.ts +22 -0
  23. package/dist/extension/controller-command.js +32 -0
  24. package/dist/extension/controller-command.js.map +1 -0
  25. package/dist/extension/index.d.ts +2 -9
  26. package/dist/extension/index.js +876 -3447
  27. package/dist/extension/index.js.map +1 -1
  28. package/dist/extension/message-card.d.ts +13 -0
  29. package/dist/extension/message-card.js +46 -0
  30. package/dist/extension/message-card.js.map +1 -0
  31. package/dist/extension/step-message.d.ts +5 -6
  32. package/dist/extension/step-message.js +41 -52
  33. package/dist/extension/step-message.js.map +1 -1
  34. package/dist/host/child-worker-supervisor.d.ts +50 -0
  35. package/dist/host/child-worker-supervisor.js +159 -0
  36. package/dist/host/child-worker-supervisor.js.map +1 -0
  37. package/dist/host/client.d.ts +48 -0
  38. package/dist/host/client.js +216 -0
  39. package/dist/host/client.js.map +1 -0
  40. package/dist/host/controller-worker-entry.d.ts +2 -0
  41. package/dist/host/controller-worker-entry.js +244 -0
  42. package/dist/host/controller-worker-entry.js.map +1 -0
  43. package/dist/host/controller-worker-protocol.d.ts +36 -0
  44. package/dist/host/controller-worker-protocol.js +49 -0
  45. package/dist/host/controller-worker-protocol.js.map +1 -0
  46. package/dist/host/controller-worker-supervisor.d.ts +21 -0
  47. package/dist/host/controller-worker-supervisor.js +54 -0
  48. package/dist/host/controller-worker-supervisor.js.map +1 -0
  49. package/dist/host/host-entry.d.ts +2 -0
  50. package/dist/host/host-entry.js +23 -0
  51. package/dist/host/host-entry.js.map +1 -0
  52. package/dist/host/processes.d.ts +17 -12
  53. package/dist/host/processes.js +154 -50
  54. package/dist/host/processes.js.map +1 -1
  55. package/dist/host/protocol.d.ts +38 -0
  56. package/dist/host/protocol.js +156 -0
  57. package/dist/host/protocol.js.map +1 -0
  58. package/dist/host/resolver-entry.d.ts +49 -0
  59. package/dist/host/resolver-entry.js +149 -0
  60. package/dist/host/resolver-entry.js.map +1 -0
  61. package/dist/host/rpc-executor.d.ts +14 -3
  62. package/dist/host/rpc-executor.js +63 -30
  63. package/dist/host/rpc-executor.js.map +1 -1
  64. package/dist/host/runner.d.ts +95 -29
  65. package/dist/host/runner.js +2311 -348
  66. package/dist/host/runner.js.map +1 -1
  67. package/dist/host/state.d.ts +174 -0
  68. package/dist/host/state.js +689 -0
  69. package/dist/host/state.js.map +1 -0
  70. package/dist/host/worker-entry.d.ts +10 -0
  71. package/dist/host/worker-entry.js +446 -0
  72. package/dist/host/worker-entry.js.map +1 -0
  73. package/dist/host/worker-protocol.d.ts +31 -0
  74. package/dist/host/worker-protocol.js +122 -0
  75. package/dist/host/worker-protocol.js.map +1 -0
  76. package/dist/host/worker-store.d.ts +80 -0
  77. package/dist/host/worker-store.js +91 -0
  78. package/dist/host/worker-store.js.map +1 -0
  79. package/dist/host/worker-supervisor.d.ts +22 -0
  80. package/dist/host/worker-supervisor.js +54 -0
  81. package/dist/host/worker-supervisor.js.map +1 -0
  82. package/dist/render/canvas.d.ts +2 -1
  83. package/dist/render/canvas.js +40 -15
  84. package/dist/render/canvas.js.map +1 -1
  85. package/dist/render/graph-render.d.ts +7 -3
  86. package/dist/render/graph-render.js +125 -74
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.js +3 -1
  89. package/dist/state/database.js.map +1 -1
  90. package/dist/state/index.d.ts +1 -0
  91. package/dist/state/index.js +1 -0
  92. package/dist/state/index.js.map +1 -1
  93. package/dist/state/schema.d.ts +1 -1
  94. package/dist/state/schema.js +171 -3
  95. package/dist/state/schema.js.map +1 -1
  96. package/dist/state/viewer.d.ts +46 -0
  97. package/dist/state/viewer.js +249 -0
  98. package/dist/state/viewer.js.map +1 -0
  99. package/dist/viewer/cli.d.ts +1 -1
  100. package/dist/viewer/cli.js +47 -18
  101. package/dist/viewer/cli.js.map +1 -1
  102. package/dist/workflows/composition.js +25 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +3 -1
  105. package/dist/workflows/definition.js +25 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +1 -0
  108. package/dist/workflows/engine.js +157 -42
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -1
  111. package/dist/workflows/errors.js +4 -7
  112. package/dist/workflows/errors.js.map +1 -1
  113. package/dist/workflows/human-decision.d.ts +3 -0
  114. package/dist/workflows/human-decision.js +31 -0
  115. package/dist/workflows/human-decision.js.map +1 -1
  116. package/dist/workflows/index.d.ts +1 -1
  117. package/dist/workflows/index.js +1 -1
  118. package/dist/workflows/index.js.map +1 -1
  119. package/dist/workflows/schema.js +17 -6
  120. package/dist/workflows/schema.js.map +1 -1
  121. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  122. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  123. package/dist/workflows/session-reducer.js.map +1 -0
  124. package/dist/workflows/store.d.ts +87 -1
  125. package/dist/workflows/store.js +892 -93
  126. package/dist/workflows/store.js.map +1 -1
  127. package/dist/workflows/tool-input.d.ts +0 -26
  128. package/dist/workflows/tool-input.js +2 -50
  129. package/dist/workflows/tool-input.js.map +1 -1
  130. package/dist/workflows/types.d.ts +36 -4
  131. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  132. package/docs/CONTROLLERS.md +11 -11
  133. package/docs/DEFERRED_TURNS.md +23 -2
  134. package/docs/SQLITE_STATE.md +41 -31
  135. package/docs/WORKFLOW_HOST.md +452 -0
  136. package/docs/development.md +46 -30
  137. package/docs/live-replay-protocol.md +129 -100
  138. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +42 -0
  139. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  140. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  141. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  142. package/docs/tui-viewer.md +18 -3
  143. package/docs/workflows.md +162 -210
  144. package/examples/workflows/command-batch.workflow.ts +2 -0
  145. package/examples/workflows/shell.workflow.ts +2 -1
  146. package/herdr-plugin.toml +1 -1
  147. package/package.json +1 -1
  148. package/src/builtins/autoimplement.workflow.ts +3 -0
  149. package/src/builtins/change-verification.workflow.ts +7 -2
  150. package/src/builtins/metadata.ts +9 -0
  151. package/src/builtins/monitor.workflow.ts +4 -0
  152. package/src/builtins/sanity-check.workflow.ts +4 -0
  153. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  154. package/src/controllers/index.ts +0 -5
  155. package/src/controllers/sqlite.ts +709 -76
  156. package/src/controllers/store.ts +1 -0
  157. package/src/extension/controller-command.ts +45 -0
  158. package/src/extension/index.ts +944 -4163
  159. package/src/extension/message-card.ts +61 -0
  160. package/src/extension/step-message.ts +58 -63
  161. package/src/host/child-worker-supervisor.ts +183 -0
  162. package/src/host/client.ts +293 -0
  163. package/src/host/controller-worker-entry.ts +311 -0
  164. package/src/host/controller-worker-protocol.ts +104 -0
  165. package/src/host/controller-worker-supervisor.ts +79 -0
  166. package/src/host/host-entry.ts +23 -0
  167. package/src/host/processes.ts +171 -54
  168. package/src/host/protocol.ts +196 -0
  169. package/src/host/resolver-entry.ts +241 -0
  170. package/src/host/rpc-executor.ts +76 -34
  171. package/src/host/runner.ts +2813 -422
  172. package/src/host/state.ts +1160 -0
  173. package/src/host/worker-entry.ts +533 -0
  174. package/src/host/worker-protocol.ts +165 -0
  175. package/src/host/worker-store.ts +229 -0
  176. package/src/host/worker-supervisor.ts +74 -0
  177. package/src/render/canvas.ts +44 -10
  178. package/src/render/graph-render.ts +145 -90
  179. package/src/state/database.ts +2 -1
  180. package/src/state/index.ts +14 -0
  181. package/src/state/schema.ts +171 -3
  182. package/src/state/viewer.ts +356 -0
  183. package/src/viewer/cli.ts +49 -17
  184. package/src/workflows/composition.ts +36 -2
  185. package/src/workflows/definition.ts +32 -0
  186. package/src/workflows/engine.ts +157 -54
  187. package/src/workflows/errors.ts +11 -2
  188. package/src/workflows/human-decision.ts +49 -0
  189. package/src/workflows/index.ts +2 -0
  190. package/src/workflows/schema.ts +19 -6
  191. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  192. package/src/workflows/store.ts +1316 -108
  193. package/src/workflows/tool-input.ts +3 -69
  194. package/src/workflows/types.ts +32 -4
  195. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  196. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  197. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  198. package/dist/extension/controller-host.d.ts +0 -48
  199. package/dist/extension/controller-host.js +0 -110
  200. package/dist/extension/controller-host.js.map +0 -1
  201. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  202. package/dist/extension/deferred-turn-coordinator.js +0 -143
  203. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  204. package/dist/extension/deferred-turn.d.ts +0 -44
  205. package/dist/extension/deferred-turn.js +0 -110
  206. package/dist/extension/deferred-turn.js.map +0 -1
  207. package/dist/extension/executor.d.ts +0 -86
  208. package/dist/extension/executor.js +0 -311
  209. package/dist/extension/executor.js.map +0 -1
  210. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  211. package/dist/extension/follow-up-coordinator.js +0 -131
  212. package/dist/extension/follow-up-coordinator.js.map +0 -1
  213. package/dist/extension/recorder.d.ts +0 -84
  214. package/dist/extension/recorder.js +0 -528
  215. package/dist/extension/recorder.js.map +0 -1
  216. package/dist/extension/restart-policy.d.ts +0 -38
  217. package/dist/extension/restart-policy.js +0 -116
  218. package/dist/extension/restart-policy.js.map +0 -1
  219. package/dist/extension/session-events.d.ts +0 -133
  220. package/dist/extension/session-events.js +0 -61
  221. package/dist/extension/session-events.js.map +0 -1
  222. package/dist/extension/terminal-decision.d.ts +0 -51
  223. package/dist/extension/terminal-decision.js +0 -110
  224. package/dist/extension/terminal-decision.js.map +0 -1
  225. package/dist/viewer/session-reducer.js.map +0 -1
  226. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  227. package/src/extension/controller-host.ts +0 -167
  228. package/src/extension/deferred-turn-coordinator.ts +0 -171
  229. package/src/extension/deferred-turn.ts +0 -166
  230. package/src/extension/executor.ts +0 -411
  231. package/src/extension/follow-up-coordinator.ts +0 -151
  232. package/src/extension/recorder.ts +0 -655
  233. package/src/extension/restart-policy.ts +0 -163
  234. package/src/extension/session-events.ts +0 -121
  235. package/src/extension/terminal-decision.ts +0 -172
@@ -0,0 +1,533 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { once } from "node:events";
4
+ import { fileURLToPath } from "node:url";
5
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
6
+ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
7
+ import { compositionMetadata } from "../workflows/composition.js";
8
+ import { WorkflowEngine } from "../workflows/engine.js";
9
+ import {
10
+ ClaimLostError,
11
+ RunParkedError,
12
+ WorkflowSourceChangedError,
13
+ errorMessage,
14
+ } from "../workflows/errors.js";
15
+ import { hashWorkflowSource, resolveWorkflowSource } from "../workflows/loader.js";
16
+ import type {
17
+ AgentStepContract,
18
+ AgentStepExecutor,
19
+ AgentStepRequest,
20
+ AgentStepSubmission,
21
+ ResolvedHumanDecision,
22
+ WorkflowDefinition,
23
+ WorkflowMountedSource,
24
+ WorkflowRunState,
25
+ WorkflowSource,
26
+ } from "../workflows/types.js";
27
+ import { RpcStepExecutor } from "./rpc-executor.js";
28
+ import type { WorkerLaunchEnvelope } from "./state.js";
29
+ import {
30
+ encodeWorkerLine,
31
+ parseWorkerResponse,
32
+ type WorkerMessage,
33
+ type WorkerResponse,
34
+ } from "./worker-protocol.js";
35
+ import { HostBackedWorkflowStore, type WorkerStoreTransport } from "./worker-store.js";
36
+
37
+ const STARTUP_ENV = "PI_WORKFLOWS_WORKER_LAUNCH";
38
+ const PRESENTATION_TIMEOUT_MS = 30_000;
39
+
40
+ type WorkerBootstrap = {
41
+ initialized: boolean;
42
+ input: JsonValue;
43
+ launchOptions: JsonValue;
44
+ parentRunId: string | null;
45
+ originSessionId: string | null;
46
+ stateDirectory: string;
47
+ piArgs?: string[];
48
+ resumeInteractionAttemptId?: string;
49
+ candidateInteraction?: {
50
+ requestId: string;
51
+ attemptId: string;
52
+ nodeId: string;
53
+ submissionId: string;
54
+ payload: JsonValue;
55
+ };
56
+ };
57
+
58
+ class StdioWorkerTransport implements WorkerStoreTransport {
59
+ private readonly pending = new Map<
60
+ string,
61
+ { resolve: (response: WorkerResponse) => void; reject: (error: Error) => void }
62
+ >();
63
+ private buffered = Buffer.alloc(0);
64
+
65
+ constructor(private readonly launch: WorkerLaunchEnvelope) {
66
+ process.stdin.on("data", (chunk: Buffer) => this.onData(chunk));
67
+ process.stdin.on("error", (error) => this.failAll(error));
68
+ process.stdin.resume();
69
+ }
70
+
71
+ async request(options: Parameters<WorkerStoreTransport["request"]>[0]) {
72
+ const message: WorkerMessage = {
73
+ schema: "pi-workflows.worker-message.v1",
74
+ launchSchema: this.launch.schema,
75
+ messageId: options.messageId,
76
+ kind: options.kind,
77
+ operation: options.operation,
78
+ runId: this.launch.runId,
79
+ generation: this.launch.generation,
80
+ workerEpoch: this.launch.workerEpoch,
81
+ expectedRevision: options.expectedRevision,
82
+ ...(options.attemptId === undefined ? {} : { attemptId: options.attemptId }),
83
+ payload: options.payload,
84
+ };
85
+ const response = await this.send(message);
86
+ if (response.outcome === "claimLost") {
87
+ throw new ClaimLostError(this.launch.runId, "ownerChanged");
88
+ }
89
+ if (response.outcome === "rejected") {
90
+ throw new Error(response.error ?? "Workflow worker message was rejected");
91
+ }
92
+ return {
93
+ ...(response.result === undefined ? {} : { result: response.result }),
94
+ ...(response.revision === undefined ? {} : { revision: response.revision }),
95
+ };
96
+ }
97
+
98
+ async control(
99
+ operation: "worker.ready" | "worker.exiting",
100
+ payload: JsonValue,
101
+ ): Promise<WorkerResponse> {
102
+ return await this.send({
103
+ schema: "pi-workflows.worker-message.v1",
104
+ launchSchema: this.launch.schema,
105
+ messageId: randomUUID(),
106
+ kind: operation,
107
+ operation,
108
+ runId: this.launch.runId,
109
+ generation: this.launch.generation,
110
+ workerEpoch: this.launch.workerEpoch,
111
+ expectedRevision: 0,
112
+ payload,
113
+ });
114
+ }
115
+
116
+ close(): void {
117
+ process.stdin.pause();
118
+ process.stdin.removeAllListeners("data");
119
+ process.stdin.removeAllListeners("error");
120
+ this.failAll(new Error("Workflow worker transport closed"));
121
+ }
122
+
123
+ private async send(message: WorkerMessage): Promise<WorkerResponse> {
124
+ const response = new Promise<WorkerResponse>((resolve, reject) => {
125
+ this.pending.set(message.messageId, { resolve, reject });
126
+ });
127
+ if (!process.stdout.write(encodeWorkerLine(message))) await once(process.stdout, "drain");
128
+ return await response;
129
+ }
130
+
131
+ private onData(chunk: Buffer): void {
132
+ this.buffered = Buffer.concat([this.buffered, chunk]);
133
+ if (this.buffered.byteLength > 1024 * 1024 && !this.buffered.includes(0x0a)) {
134
+ this.failAll(new Error("Worker response exceeds 1 MiB"));
135
+ return;
136
+ }
137
+ for (;;) {
138
+ const newline = this.buffered.indexOf(0x0a);
139
+ if (newline < 0) return;
140
+ const frame = this.buffered.subarray(0, newline);
141
+ this.buffered = this.buffered.subarray(newline + 1);
142
+ if (frame.byteLength === 0) continue;
143
+ try {
144
+ const response = parseWorkerResponse(frame);
145
+ const pending = this.pending.get(response.messageId);
146
+ if (pending === undefined) throw new Error("Worker response has no pending request");
147
+ this.pending.delete(response.messageId);
148
+ pending.resolve(response);
149
+ } catch (error) {
150
+ this.failAll(error instanceof Error ? error : new Error(String(error)));
151
+ }
152
+ }
153
+ }
154
+
155
+ private failAll(error: Error): void {
156
+ for (const pending of this.pending.values()) pending.reject(error);
157
+ this.pending.clear();
158
+ }
159
+ }
160
+
161
+ class InteractiveExecutor implements AgentStepExecutor {
162
+ readonly assistantMessageMode = "visible" as const;
163
+ readonly preservesDeadlineWhileParked = true;
164
+
165
+ constructor(
166
+ private readonly store: HostBackedWorkflowStore,
167
+ private readonly candidate:
168
+ | {
169
+ requestId: string;
170
+ submissionId: string;
171
+ attemptId: string;
172
+ nodeId: string;
173
+ payload: JsonValue;
174
+ }
175
+ | undefined,
176
+ ) {}
177
+
178
+ async runAgentStep(request: AgentStepRequest): Promise<AgentStepSubmission> {
179
+ if (
180
+ this.candidate?.nodeId === request.contract.nodeId &&
181
+ this.candidate.attemptId === request.contract.attemptId
182
+ ) {
183
+ const submission = interactionSubmission(this.candidate.payload);
184
+ let value: AgentStepSubmission;
185
+ if (request.contract.completion === "assistant") {
186
+ const accepted = validateAcceptedAssistantSubmission(submission, request.contract);
187
+ if (!accepted.ok) return await this.rejectCandidate(accepted.error);
188
+ value = accepted.value;
189
+ } else {
190
+ const accepted = await request.accept(submission.output);
191
+ if (!accepted.ok) return await this.rejectCandidate(accepted.error);
192
+ value = { ...submission, output: accepted.value };
193
+ }
194
+ await this.store.acceptInteraction({
195
+ requestId: this.candidate.requestId,
196
+ submissionId: this.candidate.submissionId,
197
+ attemptId: this.candidate.attemptId,
198
+ value: value as unknown as JsonValue,
199
+ });
200
+ return value;
201
+ }
202
+ await this.store.requestInteraction({
203
+ attemptId: request.contract.attemptId,
204
+ kind: request.contract.completion === "assistant" ? "assistant" : "agent",
205
+ contract: {
206
+ contract: request.contract,
207
+ prompt: request.prompt,
208
+ ...(request.presentation === undefined ? {} : { presentation: request.presentation }),
209
+ } as JsonValue,
210
+ });
211
+ throw new RunParkedError();
212
+ }
213
+
214
+ private async rejectCandidate(error: string): Promise<never> {
215
+ if (this.candidate === undefined) throw new RunParkedError();
216
+ await this.store.rejectInteraction({
217
+ requestId: this.candidate.requestId,
218
+ submissionId: this.candidate.submissionId,
219
+ attemptId: this.candidate.attemptId,
220
+ error,
221
+ });
222
+ throw new RunParkedError();
223
+ }
224
+ }
225
+
226
+ function interactionSubmission(payload: JsonValue): AgentStepSubmission {
227
+ if (
228
+ typeof payload === "object" &&
229
+ payload !== null &&
230
+ !Array.isArray(payload) &&
231
+ Object.hasOwn(payload, "output")
232
+ ) {
233
+ return payload as unknown as AgentStepSubmission;
234
+ }
235
+ return { output: payload };
236
+ }
237
+
238
+ export function validateAcceptedAssistantSubmission(
239
+ submission: AgentStepSubmission,
240
+ contract: AgentStepContract,
241
+ ): { ok: true; value: AgentStepSubmission } | { ok: false; error: string } {
242
+ if (contract.completion !== "assistant") {
243
+ return { ok: false, error: "The interaction is not an assistant response" };
244
+ }
245
+ if (typeof submission.output !== "string" || submission.output.trim().length === 0) {
246
+ return { ok: false, error: "Assistant response has no visible text" };
247
+ }
248
+ if (contract.maxOutputChars !== undefined && submission.output.length > contract.maxOutputChars) {
249
+ return {
250
+ ok: false,
251
+ error: `Assistant response has ${submission.output.length} characters, above the configured limit of ${contract.maxOutputChars}`,
252
+ };
253
+ }
254
+ const receipt = submission.assistantMessage;
255
+ const conversation = submission.conversation;
256
+ const digest = createHash("sha256").update(submission.output).digest("hex");
257
+ if (
258
+ receipt === undefined ||
259
+ receipt.sha256 !== digest ||
260
+ typeof receipt.entryId !== "string" ||
261
+ receipt.entryId.length === 0 ||
262
+ receipt.recovered !== true ||
263
+ receipt.maxChars !== contract.maxOutputChars ||
264
+ conversation === undefined ||
265
+ typeof conversation.firstEntryId !== "string" ||
266
+ conversation.firstEntryId.length === 0 ||
267
+ conversation.lastEntryId !== receipt.entryId
268
+ ) {
269
+ return { ok: false, error: "Assistant response receipt is invalid" };
270
+ }
271
+ return {
272
+ ok: true,
273
+ value: {
274
+ output: submission.output,
275
+ assistantMessage: receipt,
276
+ conversation,
277
+ },
278
+ };
279
+ }
280
+
281
+ export async function runWorkflowWorker(): Promise<number> {
282
+ const launch = readLaunchEnvelope();
283
+ const transport = new StdioWorkerTransport(launch);
284
+ try {
285
+ const ready = await transport.control("worker.ready", {});
286
+ if (ready.outcome !== "accepted" || ready.result === undefined) {
287
+ throw new Error(ready.error ?? "Workflow host rejected worker startup");
288
+ }
289
+ const bootstrap = ready.result as unknown as WorkerBootstrap;
290
+ const workflowSource = parseWorkerWorkflowSources(launch.workflowSource);
291
+ let workflow: WorkflowDefinition;
292
+ try {
293
+ workflow = await resolveVerifiedWorkflow(launch.runId, workflowSource);
294
+ } catch (error) {
295
+ const response = await transport.control("worker.exiting", {
296
+ status: "workflowLoadFailed",
297
+ error: errorMessage(error),
298
+ });
299
+ if (response.outcome !== "accepted") {
300
+ throw new Error(response.error ?? "Workflow host rejected the load-failure report");
301
+ }
302
+ return 1;
303
+ }
304
+ const store = new HostBackedWorkflowStore(launch.runId, transport, ready.revision ?? 0);
305
+ let executor: AgentStepExecutor;
306
+ let closeExecutor: (() => Promise<void>) | undefined;
307
+ if (bootstrap.originSessionId !== null) {
308
+ executor = new InteractiveExecutor(store, bootstrap.candidateInteraction);
309
+ } else {
310
+ const rpc = new RpcStepExecutor({
311
+ cwd: launch.projectPath,
312
+ processGroup: "own",
313
+ abortGraceMs: 1_000,
314
+ registry: {
315
+ register: async (pid) => {
316
+ await transport.request({
317
+ messageId: randomUUID(),
318
+ operation: "process.register",
319
+ kind: "worker.progress",
320
+ expectedRevision: 0,
321
+ payload: { pid },
322
+ });
323
+ },
324
+ unregister: async (pid) => {
325
+ await transport.request({
326
+ messageId: randomUUID(),
327
+ operation: "process.unregister",
328
+ kind: "worker.progress",
329
+ expectedRevision: 0,
330
+ payload: { pid },
331
+ });
332
+ },
333
+ },
334
+ ...(bootstrap.piArgs === undefined ? {} : { piArgs: bootstrap.piArgs }),
335
+ });
336
+ const closeRpc = async () => await rpc.close();
337
+ const onShutdown = () => {
338
+ void closeRpc().catch((error: unknown) => {
339
+ process.stderr.write(`Headless pi shutdown failed: ${errorMessage(error)}\n`);
340
+ });
341
+ };
342
+ process.on("SIGTERM", onShutdown);
343
+ process.on("SIGINT", onShutdown);
344
+ executor = rpc;
345
+ closeExecutor = async () => {
346
+ process.removeListener("SIGTERM", onShutdown);
347
+ process.removeListener("SIGINT", onShutdown);
348
+ await closeRpc();
349
+ };
350
+ }
351
+ const engine = new WorkflowEngine({
352
+ store,
353
+ executor,
354
+ notificationSink: {
355
+ notify: async (request) => await store.requestNotification(request),
356
+ },
357
+ onRunFinishing: async (_runId, state) => {
358
+ if (state.status !== "completed" || workflow.presentationPrompt === undefined) return;
359
+ const instructions = await resolvePresentationInstructions(workflow, state);
360
+ await store.requestPresentation(instructions);
361
+ },
362
+ });
363
+ const launchOptions =
364
+ typeof bootstrap.launchOptions === "object" &&
365
+ bootstrap.launchOptions !== null &&
366
+ !Array.isArray(bootstrap.launchOptions)
367
+ ? (bootstrap.launchOptions as Record<string, unknown>)
368
+ : {};
369
+ try {
370
+ const result = bootstrap.initialized
371
+ ? await engine.resumeRun(workflow, launch.runId, {
372
+ workflowSource: workflowSource.root,
373
+ ...(bootstrap.resumeInteractionAttemptId === undefined
374
+ ? {}
375
+ : { resumeInteractionAttemptId: bootstrap.resumeInteractionAttemptId }),
376
+ })
377
+ : bootstrap.parentRunId === null
378
+ ? await engine.run(workflow, bootstrap.input, {
379
+ runId: launch.runId,
380
+ workflowSource: workflowSource.root,
381
+ })
382
+ : await engine.continueRun(workflow, bootstrap.parentRunId, bootstrap.input, {
383
+ runId: launch.runId,
384
+ workflowSource: workflowSource.root,
385
+ ...(launchOptions.humanDecision === undefined
386
+ ? {}
387
+ : { humanDecision: launchOptions.humanDecision as ResolvedHumanDecision }),
388
+ });
389
+ await transport.control("worker.exiting", {
390
+ status: result.state.status,
391
+ traceSeq: result.state.traceSeq,
392
+ });
393
+ return 0;
394
+ } finally {
395
+ await closeExecutor?.();
396
+ }
397
+ } finally {
398
+ transport.close();
399
+ }
400
+ }
401
+
402
+ async function resolvePresentationInstructions(
403
+ workflow: WorkflowDefinition,
404
+ state: WorkflowRunState,
405
+ ): Promise<string> {
406
+ const fallback = "Summarize the completed workflow result for the user in a normal response.";
407
+ if (typeof workflow.presentationPrompt === "string") {
408
+ return workflow.presentationPrompt.trim() || fallback;
409
+ }
410
+ if (workflow.presentationPrompt === undefined) return fallback;
411
+ const controller = new AbortController();
412
+ const timer = setTimeout(
413
+ () => controller.abort(new Error("Workflow presentation prompt timed out")),
414
+ PRESENTATION_TIMEOUT_MS,
415
+ );
416
+ timer.unref?.();
417
+ const snapshot = structuredClone(state);
418
+ try {
419
+ const instructions = await Promise.race([
420
+ workflow.presentationPrompt({
421
+ state: snapshot,
422
+ finalOutput: snapshot.finalOutput,
423
+ signal: controller.signal,
424
+ }),
425
+ new Promise<never>((_resolve, reject) => {
426
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason), {
427
+ once: true,
428
+ });
429
+ }),
430
+ ]);
431
+ return instructions?.trim() || fallback;
432
+ } catch {
433
+ return fallback;
434
+ } finally {
435
+ clearTimeout(timer);
436
+ }
437
+ }
438
+
439
+ type WorkerWorkflowSources = {
440
+ root: WorkflowSource;
441
+ mounted: WorkflowMountedSource[];
442
+ };
443
+
444
+ function parseWorkerWorkflowSources(value: JsonValue): WorkerWorkflowSources {
445
+ if (!isRecord(value) || !isWorkflowSource(value.root) || !Array.isArray(value.mounted)) {
446
+ throw new Error("Workflow worker source identity is invalid");
447
+ }
448
+ const mounted = value.mounted;
449
+ if (!mounted.every(isMountedWorkflowSource)) {
450
+ throw new Error("Workflow worker mounted source identity is invalid");
451
+ }
452
+ return { root: value.root, mounted };
453
+ }
454
+
455
+ async function resolveVerifiedWorkflow(
456
+ runId: string,
457
+ sources: WorkerWorkflowSources,
458
+ ): Promise<WorkflowDefinition> {
459
+ for (const source of [sources.root, ...sources.mounted.map((mounted) => mounted.source)]) {
460
+ if (source.kind === "file") {
461
+ if ((await hashWorkflowSource(source.path)) !== source.hash) {
462
+ throw new WorkflowSourceChangedError(runId);
463
+ }
464
+ } else {
465
+ builtinWorkflowCatalog.resolve(source, runId);
466
+ }
467
+ }
468
+ const workflow = await resolveWorkflowSource(sources.root, builtinWorkflowCatalog, runId);
469
+ const observed = compositionMetadata(workflow)?.sources ?? [];
470
+ if (
471
+ canonicalJson(sortMountedSources(observed)) !==
472
+ canonicalJson(sortMountedSources(sources.mounted))
473
+ ) {
474
+ throw new WorkflowSourceChangedError(runId);
475
+ }
476
+ return workflow;
477
+ }
478
+
479
+ function sortMountedSources(sources: WorkflowMountedSource[]): WorkflowMountedSource[] {
480
+ return [...sources].sort((left, right) =>
481
+ left.mountPath.join("/").localeCompare(right.mountPath.join("/")),
482
+ );
483
+ }
484
+
485
+ function isWorkflowSource(value: unknown): value is WorkflowSource {
486
+ return (
487
+ isRecord(value) &&
488
+ ((value.kind === "builtin" &&
489
+ typeof value.id === "string" &&
490
+ typeof value.revision === "string") ||
491
+ (value.kind === "file" && typeof value.path === "string" && typeof value.hash === "string"))
492
+ );
493
+ }
494
+
495
+ function isMountedWorkflowSource(value: unknown): value is WorkflowMountedSource {
496
+ return (
497
+ isRecord(value) &&
498
+ Array.isArray(value.mountPath) &&
499
+ value.mountPath.every((part) => typeof part === "string") &&
500
+ typeof value.workflowName === "string" &&
501
+ isWorkflowSource(value.source)
502
+ );
503
+ }
504
+
505
+ function isRecord(value: unknown): value is Record<string, unknown> {
506
+ return typeof value === "object" && value !== null && !Array.isArray(value);
507
+ }
508
+
509
+ function readLaunchEnvelope(): WorkerLaunchEnvelope {
510
+ const encoded = process.env[STARTUP_ENV];
511
+ if (encoded === undefined) throw new Error("Workflow worker launch envelope is missing");
512
+ const value = parseJson(Buffer.from(encoded, "base64url").toString("utf8"));
513
+ if (
514
+ typeof value !== "object" ||
515
+ value === null ||
516
+ Array.isArray(value) ||
517
+ (value as { schema?: unknown }).schema !== "pi-workflows.worker-launch.v1"
518
+ ) {
519
+ throw new Error("Workflow worker launch envelope is invalid");
520
+ }
521
+ return value as unknown as WorkerLaunchEnvelope;
522
+ }
523
+
524
+ async function main(): Promise<void> {
525
+ try {
526
+ process.exitCode = await runWorkflowWorker();
527
+ } catch (error) {
528
+ process.stderr.write(`${errorMessage(error)}\n`);
529
+ process.exitCode = 1;
530
+ }
531
+ }
532
+
533
+ if (process.argv[1] === fileURLToPath(import.meta.url)) void main();
@@ -0,0 +1,165 @@
1
+ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
2
+ import { MAX_PROTOCOL_MESSAGE_BYTES } from "./protocol.js";
3
+
4
+ export const WORKER_MESSAGE_SCHEMA = "pi-workflows.worker-message.v1" as const;
5
+ export const WORKER_RESPONSE_SCHEMA = "pi-workflows.worker-response.v1" as const;
6
+
7
+ export const WORKER_MESSAGE_KINDS = [
8
+ "worker.ready",
9
+ "node.started",
10
+ "node.update",
11
+ "node.finished",
12
+ "node.failed",
13
+ "run.parked",
14
+ "run.finished",
15
+ "interaction.requested",
16
+ "interaction.accepted",
17
+ "interaction.rejected",
18
+ "notification.requested",
19
+ "presentation.requested",
20
+ "effect.reserve",
21
+ "effect.settle",
22
+ "worker.progress",
23
+ "worker.exiting",
24
+ ] as const;
25
+
26
+ export type WorkerMessageKind = (typeof WORKER_MESSAGE_KINDS)[number];
27
+ export type WorkerStoreOperation =
28
+ | "store.initializeRun"
29
+ | "store.prepareRunResume"
30
+ | "store.readRun"
31
+ | "store.writeSnapshot"
32
+ | "store.publishUpdate"
33
+ | "store.findSettingsScope"
34
+ | "store.ensureSettingsScope"
35
+ | "store.getSettingsScopeAtChange"
36
+ | "store.createHumanDecisionRequest"
37
+ | "store.readResolvedHumanDecision"
38
+ | "store.reserveEffect"
39
+ | "store.settleEffect"
40
+ | "process.register"
41
+ | "process.unregister"
42
+ | "interaction.request"
43
+ | "interaction.accept"
44
+ | "interaction.reject"
45
+ | "notification.request"
46
+ | "presentation.request";
47
+
48
+ export type WorkerMessage = {
49
+ schema: typeof WORKER_MESSAGE_SCHEMA;
50
+ launchSchema: "pi-workflows.worker-launch.v1";
51
+ messageId: string;
52
+ kind: WorkerMessageKind;
53
+ operation: WorkerStoreOperation | "worker.ready" | "worker.exiting";
54
+ runId: string;
55
+ generation: number;
56
+ workerEpoch: string;
57
+ expectedRevision: number;
58
+ attemptId?: string;
59
+ payload: JsonValue;
60
+ };
61
+
62
+ export type WorkerResponse = {
63
+ schema: typeof WORKER_RESPONSE_SCHEMA;
64
+ messageId: string;
65
+ outcome: "accepted" | "adopted" | "rejected" | "claimLost";
66
+ revision?: number;
67
+ result?: JsonValue;
68
+ error?: string;
69
+ };
70
+
71
+ export function workerKindForOperation(
72
+ operation: WorkerStoreOperation,
73
+ payload: Record<string, unknown>,
74
+ ): WorkerMessageKind {
75
+ if (operation === "store.publishUpdate") return "node.update";
76
+ if (operation === "store.reserveEffect") return "effect.reserve";
77
+ if (operation === "store.settleEffect") return "effect.settle";
78
+ if (operation === "store.createHumanDecisionRequest" || operation === "interaction.request") {
79
+ return "interaction.requested";
80
+ }
81
+ if (operation === "interaction.accept") return "interaction.accepted";
82
+ if (operation === "interaction.reject") return "interaction.rejected";
83
+ if (operation === "notification.request") return "notification.requested";
84
+ if (operation === "presentation.request") return "presentation.requested";
85
+ if (operation === "store.writeSnapshot") {
86
+ const event = payload.event;
87
+ const eventType = isRecord(event) && typeof event.type === "string" ? event.type : "";
88
+ if (eventType === "node_started") return "node.started";
89
+ if (eventType === "node_finished") return "node.finished";
90
+ if (eventType === "node_failed" || eventType === "node_timed_out") return "node.failed";
91
+ if (eventType === "run_waiting" || eventType.includes("park")) return "run.parked";
92
+ if (eventType.startsWith("run_") && eventType !== "run_started") return "run.finished";
93
+ }
94
+ return "worker.progress";
95
+ }
96
+
97
+ export function encodeWorkerLine(message: WorkerMessage | WorkerResponse): Buffer {
98
+ const encoded = Buffer.from(`${canonicalJson(message)}\n`, "utf8");
99
+ if (encoded.byteLength > MAX_PROTOCOL_MESSAGE_BYTES) {
100
+ throw new Error("Worker protocol message exceeds 1 MiB");
101
+ }
102
+ return encoded;
103
+ }
104
+
105
+ export function parseWorkerMessage(line: Buffer | string): WorkerMessage {
106
+ const value = parseWorkerValue(line);
107
+ if (
108
+ value.schema !== WORKER_MESSAGE_SCHEMA ||
109
+ value.launchSchema !== "pi-workflows.worker-launch.v1"
110
+ ) {
111
+ throw new Error("Invalid worker message schema");
112
+ }
113
+ requireText(value.messageId, "messageId");
114
+ requireText(value.runId, "runId");
115
+ requireText(value.workerEpoch, "workerEpoch");
116
+ if (!WORKER_MESSAGE_KINDS.includes(value.kind as WorkerMessageKind)) {
117
+ throw new Error("Invalid worker message kind");
118
+ }
119
+ if (!Number.isSafeInteger(value.generation) || (value.generation as number) <= 0) {
120
+ throw new Error("Worker generation must be positive");
121
+ }
122
+ if (!Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0) {
123
+ throw new Error("Worker expectedRevision must be non-negative");
124
+ }
125
+ requireText(value.operation, "operation");
126
+ if (value.attemptId !== undefined) requireText(value.attemptId, "attemptId");
127
+ if (!Object.hasOwn(value, "payload")) throw new Error("Worker payload is required");
128
+ return value as WorkerMessage;
129
+ }
130
+
131
+ export function parseWorkerResponse(line: Buffer | string): WorkerResponse {
132
+ const value = parseWorkerValue(line);
133
+ if (value.schema !== WORKER_RESPONSE_SCHEMA) throw new Error("Invalid worker response schema");
134
+ requireText(value.messageId, "messageId");
135
+ if (!["accepted", "adopted", "rejected", "claimLost"].includes(value.outcome as string)) {
136
+ throw new Error("Invalid worker response outcome");
137
+ }
138
+ if (value.error !== undefined && typeof value.error !== "string") {
139
+ throw new Error("Worker response error must be text");
140
+ }
141
+ return value as WorkerResponse;
142
+ }
143
+
144
+ function parseWorkerValue(line: Buffer | string): Record<string, unknown> {
145
+ const buffer = Buffer.isBuffer(line) ? line : Buffer.from(line, "utf8");
146
+ if (buffer.byteLength + 1 > MAX_PROTOCOL_MESSAGE_BYTES) {
147
+ throw new Error("Worker protocol message exceeds 1 MiB");
148
+ }
149
+ const text = buffer.toString("utf8");
150
+ const value = parseJson(text);
151
+ if (!isRecord(value) || canonicalJson(value) !== text) {
152
+ throw new Error("Worker protocol message must be a canonical JSON object");
153
+ }
154
+ return value;
155
+ }
156
+
157
+ function requireText(value: unknown, field: string): asserts value is string {
158
+ if (typeof value !== "string" || value.length === 0 || value.length > 256) {
159
+ throw new Error(`Worker ${field} must be nonempty bounded text`);
160
+ }
161
+ }
162
+
163
+ function isRecord(value: unknown): value is Record<string, unknown> {
164
+ return typeof value === "object" && value !== null && !Array.isArray(value);
165
+ }