@osolmaz/pi-workflows 0.13.4 → 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 (237) hide show
  1. package/README.md +146 -160
  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 +1 -2
  16. package/dist/controllers/index.js +0 -1
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.d.ts +85 -31
  19. package/dist/controllers/sqlite.js +541 -126
  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 -2928
  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/prune.js +36 -10
  94. package/dist/state/prune.js.map +1 -1
  95. package/dist/state/schema.d.ts +1 -1
  96. package/dist/state/schema.js +171 -3
  97. package/dist/state/schema.js.map +1 -1
  98. package/dist/state/viewer.d.ts +46 -0
  99. package/dist/state/viewer.js +249 -0
  100. package/dist/state/viewer.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +1 -1
  102. package/dist/viewer/cli.js +47 -18
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/workflows/composition.js +25 -2
  105. package/dist/workflows/composition.js.map +1 -1
  106. package/dist/workflows/definition.d.ts +3 -1
  107. package/dist/workflows/definition.js +25 -0
  108. package/dist/workflows/definition.js.map +1 -1
  109. package/dist/workflows/engine.d.ts +1 -0
  110. package/dist/workflows/engine.js +157 -42
  111. package/dist/workflows/engine.js.map +1 -1
  112. package/dist/workflows/errors.d.ts +3 -1
  113. package/dist/workflows/errors.js +4 -7
  114. package/dist/workflows/errors.js.map +1 -1
  115. package/dist/workflows/human-decision.d.ts +3 -0
  116. package/dist/workflows/human-decision.js +31 -0
  117. package/dist/workflows/human-decision.js.map +1 -1
  118. package/dist/workflows/index.d.ts +1 -1
  119. package/dist/workflows/index.js +1 -1
  120. package/dist/workflows/index.js.map +1 -1
  121. package/dist/workflows/schema.js +17 -6
  122. package/dist/workflows/schema.js.map +1 -1
  123. package/dist/{viewer → workflows}/session-reducer.d.ts +3 -1
  124. package/dist/{viewer → workflows}/session-reducer.js +4 -0
  125. package/dist/workflows/session-reducer.js.map +1 -0
  126. package/dist/workflows/store.d.ts +87 -1
  127. package/dist/workflows/store.js +892 -93
  128. package/dist/workflows/store.js.map +1 -1
  129. package/dist/workflows/tool-input.d.ts +0 -22
  130. package/dist/workflows/tool-input.js +1 -44
  131. package/dist/workflows/tool-input.js.map +1 -1
  132. package/dist/workflows/types.d.ts +36 -4
  133. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  134. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +353 -0
  135. package/docs/CONTROLLERS.md +11 -11
  136. package/docs/DEFERRED_TURNS.md +61 -27
  137. package/docs/HUMAN_DECISIONS.md +12 -4
  138. package/docs/SQLITE_STATE.md +42 -8
  139. package/docs/WORKFLOW_HOST.md +452 -0
  140. package/docs/development.md +46 -30
  141. package/docs/live-replay-protocol.md +129 -100
  142. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  143. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +399 -0
  144. package/docs/plans/2026-08-28-piw-incremental-viewer-plan.md +293 -0
  145. package/docs/plans/piw-viewer-experience-implementation-plan.md +11 -1
  146. package/docs/plans/tui-viewer-implementation-plan.md +5 -0
  147. package/docs/tui-viewer.md +18 -3
  148. package/docs/workflows.md +164 -156
  149. package/examples/workflows/command-batch.workflow.ts +2 -0
  150. package/examples/workflows/shell.workflow.ts +2 -1
  151. package/herdr-plugin.toml +1 -1
  152. package/package.json +1 -1
  153. package/skills/autodoc/SKILL.md +1 -1
  154. package/skills/autoimplement/SKILL.md +1 -1
  155. package/skills/autoplan/SKILL.md +1 -1
  156. package/skills/pi-workflows/SKILL.md +2 -0
  157. package/src/builtins/autoimplement.workflow.ts +3 -0
  158. package/src/builtins/change-verification.workflow.ts +7 -2
  159. package/src/builtins/metadata.ts +9 -0
  160. package/src/builtins/monitor.workflow.ts +4 -0
  161. package/src/builtins/sanity-check.workflow.ts +4 -0
  162. package/src/builtins/workspace-preparation.workflow.ts +3 -1
  163. package/src/controllers/index.ts +3 -5
  164. package/src/controllers/sqlite.ts +929 -225
  165. package/src/controllers/store.ts +1 -0
  166. package/src/extension/controller-command.ts +45 -0
  167. package/src/extension/index.ts +948 -3506
  168. package/src/extension/message-card.ts +61 -0
  169. package/src/extension/step-message.ts +58 -63
  170. package/src/host/child-worker-supervisor.ts +183 -0
  171. package/src/host/client.ts +293 -0
  172. package/src/host/controller-worker-entry.ts +311 -0
  173. package/src/host/controller-worker-protocol.ts +104 -0
  174. package/src/host/controller-worker-supervisor.ts +79 -0
  175. package/src/host/host-entry.ts +23 -0
  176. package/src/host/processes.ts +171 -54
  177. package/src/host/protocol.ts +196 -0
  178. package/src/host/resolver-entry.ts +241 -0
  179. package/src/host/rpc-executor.ts +76 -34
  180. package/src/host/runner.ts +2813 -422
  181. package/src/host/state.ts +1160 -0
  182. package/src/host/worker-entry.ts +533 -0
  183. package/src/host/worker-protocol.ts +165 -0
  184. package/src/host/worker-store.ts +229 -0
  185. package/src/host/worker-supervisor.ts +74 -0
  186. package/src/render/canvas.ts +44 -10
  187. package/src/render/graph-render.ts +145 -90
  188. package/src/state/database.ts +2 -1
  189. package/src/state/index.ts +14 -0
  190. package/src/state/prune.ts +35 -9
  191. package/src/state/schema.ts +171 -3
  192. package/src/state/viewer.ts +356 -0
  193. package/src/viewer/cli.ts +49 -17
  194. package/src/workflows/composition.ts +36 -2
  195. package/src/workflows/definition.ts +32 -0
  196. package/src/workflows/engine.ts +157 -54
  197. package/src/workflows/errors.ts +11 -2
  198. package/src/workflows/human-decision.ts +49 -0
  199. package/src/workflows/index.ts +2 -0
  200. package/src/workflows/schema.ts +19 -6
  201. package/src/{viewer → workflows}/session-reducer.ts +11 -1
  202. package/src/workflows/store.ts +1316 -108
  203. package/src/workflows/tool-input.ts +2 -60
  204. package/src/workflows/types.ts +32 -4
  205. package/dist/controllers/workflow-engine-scheduler.d.ts +0 -29
  206. package/dist/controllers/workflow-engine-scheduler.js +0 -175
  207. package/dist/controllers/workflow-engine-scheduler.js.map +0 -1
  208. package/dist/extension/controller-host.d.ts +0 -48
  209. package/dist/extension/controller-host.js +0 -110
  210. package/dist/extension/controller-host.js.map +0 -1
  211. package/dist/extension/deferred-turn-coordinator.d.ts +0 -32
  212. package/dist/extension/deferred-turn-coordinator.js +0 -143
  213. package/dist/extension/deferred-turn-coordinator.js.map +0 -1
  214. package/dist/extension/deferred-turn.d.ts +0 -44
  215. package/dist/extension/deferred-turn.js +0 -110
  216. package/dist/extension/deferred-turn.js.map +0 -1
  217. package/dist/extension/executor.d.ts +0 -86
  218. package/dist/extension/executor.js +0 -311
  219. package/dist/extension/executor.js.map +0 -1
  220. package/dist/extension/follow-up-coordinator.d.ts +0 -27
  221. package/dist/extension/follow-up-coordinator.js +0 -131
  222. package/dist/extension/follow-up-coordinator.js.map +0 -1
  223. package/dist/extension/recorder.d.ts +0 -84
  224. package/dist/extension/recorder.js +0 -528
  225. package/dist/extension/recorder.js.map +0 -1
  226. package/dist/extension/session-events.d.ts +0 -133
  227. package/dist/extension/session-events.js +0 -61
  228. package/dist/extension/session-events.js.map +0 -1
  229. package/dist/viewer/session-reducer.js.map +0 -1
  230. package/src/controllers/workflow-engine-scheduler.ts +0 -246
  231. package/src/extension/controller-host.ts +0 -167
  232. package/src/extension/deferred-turn-coordinator.ts +0 -171
  233. package/src/extension/deferred-turn.ts +0 -166
  234. package/src/extension/executor.ts +0 -411
  235. package/src/extension/follow-up-coordinator.ts +0 -151
  236. package/src/extension/recorder.ts +0 -655
  237. package/src/extension/session-events.ts +0 -121
@@ -0,0 +1,1160 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { StateDatabase } from "../state/database.js";
3
+ import { canonicalJson, type JsonValue } from "../state/json.js";
4
+ import { tokenHash } from "../state/mutation.js";
5
+ import type { HostRequest, HostResponse } from "./protocol.js";
6
+ import { requestFingerprint } from "./protocol.js";
7
+ import type { WorkerMessage, WorkerResponse } from "./worker-protocol.js";
8
+
9
+ export type HostClaim = {
10
+ hostId: string;
11
+ token: string;
12
+ epoch: number;
13
+ pid: number;
14
+ processStartIdentity: string;
15
+ expiresAt: number;
16
+ };
17
+
18
+ export type HostStatusRecord = {
19
+ epoch: number;
20
+ hostId: string | null;
21
+ pid: number | null;
22
+ processStartIdentity: string | null;
23
+ startedAt: string | null;
24
+ heartbeatAt: string | null;
25
+ expiresAt: string | null;
26
+ live: boolean;
27
+ };
28
+
29
+ export type WorkerLaunchEnvelope = {
30
+ schema: "pi-workflows.worker-launch.v1";
31
+ runId: string;
32
+ generation: number;
33
+ workerEpoch: string;
34
+ projectPath: string;
35
+ workflowSource: JsonValue;
36
+ definitionDigest: string;
37
+ inputHash: string;
38
+ protocolVersion: 1;
39
+ };
40
+
41
+ export type WorkerOutcome =
42
+ | "exited"
43
+ | "cancelled"
44
+ | "timedOut"
45
+ | "crashed"
46
+ | "claimLost"
47
+ | "orphaned";
48
+
49
+ export type InteractiveSubmissionRecord = {
50
+ requestId: string;
51
+ submissionId: string;
52
+ idempotencyKey: string;
53
+ outcome: "validating" | "accepted" | "rejected" | "adopted";
54
+ payload: JsonValue;
55
+ receipt: JsonValue | null;
56
+ submittedAt: string;
57
+ };
58
+
59
+ export type InteractiveRequestRecord = {
60
+ requestId: string;
61
+ runId: string;
62
+ attemptId: string;
63
+ targetSessionId: string;
64
+ kind: "agent" | "assistant" | "decision";
65
+ contract: JsonValue;
66
+ revision: number;
67
+ status: "pending" | "presenting" | "settled" | "cancelled";
68
+ presentationSessionEntryId: string | null;
69
+ acceptedSubmissionId: string | null;
70
+ createdAt: string;
71
+ updatedAt: string;
72
+ settledAt: string | null;
73
+ consumedAt: string | null;
74
+ };
75
+
76
+ export class HostStateStore {
77
+ readonly state: StateDatabase;
78
+ private readonly ownsState: boolean;
79
+
80
+ constructor(databasePath: string, options: { state?: StateDatabase; readOnly?: boolean } = {}) {
81
+ this.ownsState = options.state === undefined;
82
+ this.state =
83
+ options.state ??
84
+ new StateDatabase({
85
+ filePath: databasePath,
86
+ mode: options.readOnly === true ? "read-only" : "read-write",
87
+ });
88
+ }
89
+
90
+ close(): void {
91
+ if (this.ownsState) this.state.close();
92
+ }
93
+
94
+ acquireHost(options: {
95
+ hostId: string;
96
+ pid: number;
97
+ processStartIdentity: string;
98
+ leaseMs: number;
99
+ now?: number;
100
+ }): HostClaim {
101
+ const now = options.now ?? Date.now();
102
+ requireLeaseMs(options.leaseMs);
103
+ return this.state.transaction(() => {
104
+ const current = this.hostRow();
105
+ if (current.hostId !== null && current.expiresAt !== null && current.expiresAt > now) {
106
+ throw new Error(`A live Pi Workflows host already owns epoch ${current.epoch}`);
107
+ }
108
+ const token = randomBytes(32).toString("base64url");
109
+ const epoch = current.epoch + 1;
110
+ const expiresAt = now + options.leaseMs;
111
+ const changed = this.state.connection
112
+ .prepare(
113
+ `UPDATE workflow_host_state
114
+ SET epoch = ?, host_id = ?, token_hash = ?, pid = ?, process_start_identity = ?,
115
+ started_at = ?, heartbeat_at = ?, expires_at = ?
116
+ WHERE id = 1 AND epoch = ? AND (host_id IS NULL OR expires_at IS NULL OR expires_at <= ?)`,
117
+ )
118
+ .run(
119
+ epoch,
120
+ options.hostId,
121
+ tokenHash(token),
122
+ options.pid,
123
+ options.processStartIdentity,
124
+ now,
125
+ now,
126
+ expiresAt,
127
+ current.epoch,
128
+ now,
129
+ );
130
+ if (changed.changes !== 1) throw new Error("Pi Workflows host claim changed during startup");
131
+ return {
132
+ hostId: options.hostId,
133
+ token,
134
+ epoch,
135
+ pid: options.pid,
136
+ processStartIdentity: options.processStartIdentity,
137
+ expiresAt,
138
+ };
139
+ });
140
+ }
141
+
142
+ renewHost(claim: HostClaim, leaseMs: number, now: number = Date.now()): HostClaim {
143
+ requireLeaseMs(leaseMs);
144
+ return this.state.transaction(() => {
145
+ const expiresAt = now + leaseMs;
146
+ const changed = this.state.connection
147
+ .prepare(
148
+ `UPDATE workflow_host_state SET heartbeat_at = ?, expires_at = ?
149
+ WHERE id = 1 AND epoch = ? AND host_id = ? AND token_hash = ?
150
+ AND pid = ? AND process_start_identity = ? AND expires_at > ?`,
151
+ )
152
+ .run(
153
+ now,
154
+ expiresAt,
155
+ claim.epoch,
156
+ claim.hostId,
157
+ tokenHash(claim.token),
158
+ claim.pid,
159
+ claim.processStartIdentity,
160
+ now,
161
+ );
162
+ if (changed.changes !== 1) throw new Error("Pi Workflows host claim lost");
163
+ return { ...claim, expiresAt };
164
+ });
165
+ }
166
+
167
+ releaseHost(claim: HostClaim, _now: number = Date.now()): boolean {
168
+ return this.state.transaction(
169
+ () =>
170
+ this.state.connection
171
+ .prepare(
172
+ `UPDATE workflow_host_state
173
+ SET host_id = NULL, token_hash = NULL, pid = NULL, process_start_identity = NULL,
174
+ started_at = NULL, heartbeat_at = NULL, expires_at = NULL
175
+ WHERE id = 1 AND epoch = ? AND host_id = ? AND token_hash = ?
176
+ AND pid = ? AND process_start_identity = ?`,
177
+ )
178
+ .run(
179
+ claim.epoch,
180
+ claim.hostId,
181
+ tokenHash(claim.token),
182
+ claim.pid,
183
+ claim.processStartIdentity,
184
+ ).changes === 1,
185
+ );
186
+ }
187
+
188
+ hostStatus(now: number = Date.now()): HostStatusRecord {
189
+ const row = this.hostRow();
190
+ return {
191
+ epoch: row.epoch,
192
+ hostId: row.hostId,
193
+ pid: row.pid,
194
+ processStartIdentity: row.processStartIdentity,
195
+ startedAt: iso(row.startedAt),
196
+ heartbeatAt: iso(row.heartbeatAt),
197
+ expiresAt: iso(row.expiresAt),
198
+ live: row.hostId !== null && row.expiresAt !== null && row.expiresAt > now,
199
+ };
200
+ }
201
+
202
+ readCommand(request: HostRequest): HostResponse | undefined {
203
+ const row = this.state.connection
204
+ .prepare(
205
+ `SELECT request_fingerprint AS requestFingerprint, outcome, accepted_revision AS revision,
206
+ receipt_hash AS receiptHash, error_hash AS errorHash
207
+ FROM host_commands WHERE request_id = ?`,
208
+ )
209
+ .get(request.requestId);
210
+ if (!isCommandRow(row)) return undefined;
211
+ if (!row.requestFingerprint.equals(requestFingerprint(request))) {
212
+ return conflictResponse(request.requestId, "Request ID was reused with another payload");
213
+ }
214
+ return this.commandResponse(request.requestId, row);
215
+ }
216
+
217
+ executeCommand(
218
+ request: HostRequest,
219
+ hostEpoch: number,
220
+ operation: () => Omit<HostResponse, "schema" | "requestId">,
221
+ ): HostResponse {
222
+ const existing = this.readCommand(request);
223
+ if (existing !== undefined) {
224
+ return existing.outcome === "conflict" ? existing : { ...existing, outcome: "adopted" };
225
+ }
226
+ return this.state.transaction(() => {
227
+ const idempotent = this.state.connection
228
+ .prepare(
229
+ `SELECT request_id AS requestId, request_fingerprint AS requestFingerprint,
230
+ outcome, accepted_revision AS revision, receipt_hash AS receiptHash,
231
+ error_hash AS errorHash
232
+ FROM host_commands WHERE client_id = ? AND idempotency_key = ?`,
233
+ )
234
+ .get(request.clientId, request.idempotencyKey);
235
+ if (isIdempotentCommandRow(idempotent)) {
236
+ if (!idempotent.requestFingerprint.equals(requestFingerprint(request))) {
237
+ return conflictResponse(
238
+ request.requestId,
239
+ "Idempotency key was reused with another payload",
240
+ );
241
+ }
242
+ const adopted = this.commandResponse(idempotent.requestId, idempotent);
243
+ return { ...adopted, requestId: request.requestId, outcome: "adopted" };
244
+ }
245
+
246
+ const result = operation();
247
+ const response: HostResponse = {
248
+ schema: "pi-workflows.host-response.v1",
249
+ requestId: request.requestId,
250
+ ...result,
251
+ };
252
+ const now = Date.now();
253
+ const receiptHash =
254
+ response.receipt === undefined ? null : this.state.putJson(response.receipt, now);
255
+ const errorHash =
256
+ response.error === undefined ? null : this.state.putText(response.error, now);
257
+ this.state.connection
258
+ .prepare(
259
+ `INSERT INTO host_commands(
260
+ request_id, client_id, operation, idempotency_key, request_fingerprint,
261
+ run_id, accepted_revision, outcome, receipt_hash, error_hash,
262
+ host_epoch, created_at, completed_at
263
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
264
+ )
265
+ .run(
266
+ request.requestId,
267
+ request.clientId,
268
+ request.operation,
269
+ request.idempotencyKey,
270
+ requestFingerprint(request),
271
+ request.runId ?? null,
272
+ response.revision ?? null,
273
+ response.outcome,
274
+ receiptHash,
275
+ errorHash,
276
+ hostEpoch,
277
+ now,
278
+ now,
279
+ );
280
+ return response;
281
+ });
282
+ }
283
+
284
+ recordWorkerStart(envelope: WorkerLaunchEnvelope, hostEpoch: number): void {
285
+ const now = Date.now();
286
+ this.state.transaction(() => {
287
+ const launchHash = this.state.putJson(envelope, now);
288
+ this.state.connection
289
+ .prepare(
290
+ `INSERT INTO run_workers(
291
+ worker_epoch, run_id, generation, host_epoch, launch_envelope_hash,
292
+ status, started_at
293
+ ) VALUES (?, ?, ?, ?, ?, 'starting', ?)`,
294
+ )
295
+ .run(envelope.workerEpoch, envelope.runId, envelope.generation, hostEpoch, launchHash, now);
296
+ });
297
+ }
298
+
299
+ attachWorkerProcess(workerEpoch: string, pid: number, processStartIdentity: string): void {
300
+ const changed = this.state.connection
301
+ .prepare(
302
+ `UPDATE run_workers SET pid = ?, process_start_identity = ?
303
+ WHERE worker_epoch = ? AND status = 'starting' AND pid IS NULL`,
304
+ )
305
+ .run(pid, processStartIdentity, workerEpoch);
306
+ if (changed.changes !== 1) throw new Error(`Worker epoch is not starting: ${workerEpoch}`);
307
+ }
308
+
309
+ markWorkerReady(workerEpoch: string): void {
310
+ const now = Date.now();
311
+ const changed = this.state.connection
312
+ .prepare(
313
+ `UPDATE run_workers SET status = 'running', ready_at = ?
314
+ WHERE worker_epoch = ? AND status = 'starting'`,
315
+ )
316
+ .run(now, workerEpoch);
317
+ if (changed.changes !== 1) throw new Error(`Worker epoch cannot become ready: ${workerEpoch}`);
318
+ }
319
+
320
+ finishWorker(options: {
321
+ workerEpoch: string;
322
+ outcome: WorkerOutcome;
323
+ exitCode?: number | null;
324
+ signal?: string | null;
325
+ diagnostic?: string;
326
+ }): void {
327
+ const now = Date.now();
328
+ this.state.transaction(() => {
329
+ const diagnosticHash =
330
+ options.diagnostic === undefined ? null : this.state.putText(options.diagnostic, now);
331
+ const changed = this.state.connection
332
+ .prepare(
333
+ `UPDATE run_workers
334
+ SET status = ?, finished_at = ?, exit_code = ?, signal = ?, diagnostic_hash = ?
335
+ WHERE worker_epoch = ? AND status IN ('starting', 'ready', 'running')`,
336
+ )
337
+ .run(
338
+ options.outcome,
339
+ now,
340
+ options.exitCode ?? null,
341
+ options.signal ?? null,
342
+ diagnosticHash,
343
+ options.workerEpoch,
344
+ );
345
+ if (changed.changes !== 1)
346
+ throw new Error(`Worker epoch is not active: ${options.workerEpoch}`);
347
+ });
348
+ }
349
+
350
+ readWorkerMessage(message: WorkerMessage): WorkerResponse | undefined {
351
+ const row = this.state.connection
352
+ .prepare(
353
+ `SELECT request_fingerprint AS requestFingerprint, outcome,
354
+ accepted_revision AS revision, result_hash AS resultHash, error_hash AS errorHash
355
+ FROM worker_messages WHERE worker_epoch = ? AND message_id = ?`,
356
+ )
357
+ .get(message.workerEpoch, message.messageId);
358
+ if (!isWorkerMessageRow(row)) return undefined;
359
+ if (!row.requestFingerprint.equals(workerMessageFingerprint(message))) {
360
+ return {
361
+ schema: "pi-workflows.worker-response.v1",
362
+ messageId: message.messageId,
363
+ outcome: "rejected",
364
+ error: "Worker message ID was reused with another payload",
365
+ };
366
+ }
367
+ return workerMessageResponse(this.state, message.messageId, row);
368
+ }
369
+
370
+ recordWorkerMessage(message: WorkerMessage, response: WorkerResponse): WorkerResponse {
371
+ return this.state.transaction(() => {
372
+ const existing = this.readWorkerMessage(message);
373
+ if (existing !== undefined) return existing;
374
+ const now = Date.now();
375
+ const resultHash =
376
+ response.result === undefined ? null : this.state.putJson(response.result, now);
377
+ const errorHash =
378
+ response.error === undefined ? null : this.state.putText(response.error, now);
379
+ this.state.connection
380
+ .prepare(
381
+ `INSERT INTO worker_messages(
382
+ worker_epoch, message_id, request_fingerprint, outcome, accepted_revision,
383
+ result_hash, error_hash, completed_at
384
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
385
+ )
386
+ .run(
387
+ message.workerEpoch,
388
+ message.messageId,
389
+ workerMessageFingerprint(message),
390
+ response.outcome,
391
+ response.revision ?? null,
392
+ resultHash,
393
+ errorHash,
394
+ now,
395
+ );
396
+ return response;
397
+ });
398
+ }
399
+
400
+ createInteractiveRequest(options: {
401
+ requestId: string;
402
+ runId: string;
403
+ attemptId: string;
404
+ targetSessionId: string;
405
+ kind: InteractiveRequestRecord["kind"];
406
+ contract: JsonValue;
407
+ }): InteractiveRequestRecord {
408
+ const now = Date.now();
409
+ return this.state.transaction(() => {
410
+ const existing = this.interactiveRequest(options.requestId);
411
+ if (existing !== undefined) {
412
+ if (
413
+ existing.runId !== options.runId ||
414
+ existing.attemptId !== options.attemptId ||
415
+ canonicalJson(existing.contract) !== canonicalJson(options.contract)
416
+ ) {
417
+ throw new Error(`Interactive request conflicts: ${options.requestId}`);
418
+ }
419
+ if (existing.status === "settled" && existing.consumedAt === null) {
420
+ this.state.connection
421
+ .prepare(
422
+ `UPDATE interactive_requests
423
+ SET status = 'pending', presenter_id = NULL,
424
+ presentation_claim_expires_at = NULL, presentation_session_entry_id = NULL,
425
+ accepted_submission_id = NULL, settled_at = NULL, consumed_at = NULL,
426
+ revision = revision + 1, updated_at = ?
427
+ WHERE request_id = ? AND status = 'settled' AND consumed_at IS NULL`,
428
+ )
429
+ .run(now, options.requestId);
430
+ return this.requireInteractiveRequest(options.requestId);
431
+ }
432
+ return existing;
433
+ }
434
+ const contractHash = this.state.putJson(options.contract, now);
435
+ this.state.connection
436
+ .prepare(
437
+ `INSERT INTO interactive_requests(
438
+ request_id, run_id, attempt_id, target_session_id, kind, contract_hash,
439
+ revision, status, created_at, updated_at
440
+ ) VALUES (?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`,
441
+ )
442
+ .run(
443
+ options.requestId,
444
+ options.runId,
445
+ options.attemptId,
446
+ options.targetSessionId,
447
+ options.kind,
448
+ contractHash,
449
+ now,
450
+ now,
451
+ );
452
+ return this.requireInteractiveRequest(options.requestId);
453
+ });
454
+ }
455
+
456
+ getInteraction(requestId: string): InteractiveRequestRecord | undefined {
457
+ return this.interactiveRequest(requestId);
458
+ }
459
+
460
+ acceptedInteraction(runId: string):
461
+ | {
462
+ requestId: string;
463
+ attemptId: string;
464
+ nodeId: string;
465
+ submissionId: string;
466
+ payload: JsonValue;
467
+ }
468
+ | undefined {
469
+ const row = this.state.connection
470
+ .prepare(
471
+ `SELECT i.request_id AS requestId, i.attempt_id AS attemptId, a.node_id AS nodeId,
472
+ s.submission_id AS submissionId, s.payload_hash AS payloadHash
473
+ FROM interactive_requests i
474
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
475
+ JOIN interactive_submissions s ON s.submission_id = i.accepted_submission_id
476
+ WHERE i.run_id = ? AND i.status = 'settled' AND i.consumed_at IS NULL
477
+ ORDER BY i.settled_at DESC LIMIT 1`,
478
+ )
479
+ .get(runId);
480
+ if (!isAcceptedInteractionRow(row)) return undefined;
481
+ return {
482
+ requestId: row.requestId,
483
+ attemptId: row.attemptId,
484
+ nodeId: row.nodeId,
485
+ submissionId: row.submissionId,
486
+ payload: this.state.readJson(row.payloadHash),
487
+ };
488
+ }
489
+
490
+ validatingInteraction(runId: string):
491
+ | {
492
+ requestId: string;
493
+ attemptId: string;
494
+ nodeId: string;
495
+ submissionId: string;
496
+ payload: JsonValue;
497
+ }
498
+ | undefined {
499
+ const row = this.state.connection
500
+ .prepare(
501
+ `SELECT i.request_id AS requestId, i.attempt_id AS attemptId, a.node_id AS nodeId,
502
+ s.submission_id AS submissionId, s.payload_hash AS payloadHash
503
+ FROM interactive_requests i
504
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
505
+ JOIN interactive_submissions s ON s.request_id = i.request_id
506
+ WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
507
+ AND s.outcome = 'validating'
508
+ ORDER BY s.submitted_at DESC LIMIT 1`,
509
+ )
510
+ .get(runId);
511
+ if (!isAcceptedInteractionRow(row)) return undefined;
512
+ return {
513
+ requestId: row.requestId,
514
+ attemptId: row.attemptId,
515
+ nodeId: row.nodeId,
516
+ submissionId: row.submissionId,
517
+ payload: this.state.readJson(row.payloadHash),
518
+ };
519
+ }
520
+
521
+ validatingInteractionRunIds(): string[] {
522
+ return this.state.connection
523
+ .prepare(
524
+ `SELECT i.run_id AS runId
525
+ FROM interactive_requests i
526
+ JOIN interactive_submissions s ON s.request_id = i.request_id
527
+ JOIN run_queue q ON q.run_id = i.run_id
528
+ WHERE i.status IN ('pending', 'presenting') AND s.outcome = 'validating'
529
+ AND q.status NOT IN ('done', 'failed', 'cancelled')
530
+ GROUP BY i.run_id
531
+ ORDER BY MIN(s.submitted_at), i.run_id`,
532
+ )
533
+ .all()
534
+ .flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
535
+ }
536
+
537
+ expiredInteractionRunIds(now: number = Date.now()): string[] {
538
+ return this.state.connection
539
+ .prepare(
540
+ `SELECT i.run_id AS runId
541
+ FROM interactive_requests i
542
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
543
+ JOIN run_queue q ON q.run_id = i.run_id
544
+ WHERE i.status IN ('pending', 'presenting')
545
+ AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
546
+ AND q.status NOT IN ('done', 'failed', 'cancelled')
547
+ GROUP BY i.run_id
548
+ ORDER BY MIN(a.deadline_at), i.run_id`,
549
+ )
550
+ .all(now)
551
+ .flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
552
+ }
553
+
554
+ timedOutInteraction(runId: string): TimedOutInteractionRow | undefined {
555
+ const row = this.state.connection
556
+ .prepare(
557
+ `SELECT i.request_id AS requestId, i.attempt_id AS attemptId, a.node_id AS nodeId
558
+ FROM interactive_requests i
559
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
560
+ JOIN runs r ON r.run_id = i.run_id
561
+ WHERE i.run_id = ? AND i.status = 'cancelled' AND r.status = 'running'
562
+ AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
563
+ AND a.status IN ('pending', 'running', 'waiting', 'interrupted')
564
+ ORDER BY a.deadline_at, i.request_id LIMIT 1`,
565
+ )
566
+ .get(runId, Date.now());
567
+ return isTimedOutInteractionRow(row) ? row : undefined;
568
+ }
569
+
570
+ interactionSubmission(
571
+ requestId: string,
572
+ submissionId: string,
573
+ ): InteractiveSubmissionRecord | undefined {
574
+ const row = this.state.connection
575
+ .prepare(
576
+ `SELECT request_id AS requestId, submission_id AS submissionId,
577
+ idempotency_key AS idempotencyKey, outcome, payload_hash AS payloadHash,
578
+ receipt_hash AS receiptHash, submitted_at AS submittedAt
579
+ FROM interactive_submissions WHERE request_id = ? AND submission_id = ?`,
580
+ )
581
+ .get(requestId, submissionId);
582
+ if (!isSubmissionDetailRow(row)) return undefined;
583
+ return {
584
+ requestId: row.requestId,
585
+ submissionId: row.submissionId,
586
+ idempotencyKey: row.idempotencyKey,
587
+ outcome: row.outcome,
588
+ payload: this.state.readJson(row.payloadHash),
589
+ receipt: row.receiptHash === null ? null : this.state.readJson(row.receiptHash),
590
+ submittedAt: new Date(row.submittedAt).toISOString(),
591
+ };
592
+ }
593
+
594
+ finishInteractionValidation(options: {
595
+ requestId: string;
596
+ submissionId: string;
597
+ accepted: boolean;
598
+ receipt: JsonValue;
599
+ }): InteractiveSubmissionRecord {
600
+ const now = Date.now();
601
+ return this.state.transaction(() => {
602
+ const submission = this.interactionSubmission(options.requestId, options.submissionId);
603
+ const expectedOutcome = options.accepted ? "accepted" : "rejected";
604
+ if (submission?.outcome === expectedOutcome) return submission;
605
+ if (submission === undefined || submission.outcome !== "validating") {
606
+ throw new Error("Interactive submission is not awaiting validation");
607
+ }
608
+ const request = this.requireInteractiveRequest(options.requestId);
609
+ const receiptHash = this.state.putJson(options.receipt, now);
610
+ this.state.connection
611
+ .prepare(
612
+ `UPDATE interactive_submissions SET outcome = ?, receipt_hash = ?
613
+ WHERE request_id = ? AND submission_id = ? AND outcome = 'validating'`,
614
+ )
615
+ .run(
616
+ options.accepted ? "accepted" : "rejected",
617
+ receiptHash,
618
+ options.requestId,
619
+ options.submissionId,
620
+ );
621
+ if (options.accepted) {
622
+ const changed = this.state.connection
623
+ .prepare(
624
+ `UPDATE interactive_requests
625
+ SET status = 'settled', presenter_id = NULL,
626
+ presentation_claim_expires_at = NULL, accepted_submission_id = ?,
627
+ revision = revision + 1, updated_at = ?, settled_at = ?
628
+ WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`,
629
+ )
630
+ .run(options.submissionId, now, now, options.requestId, request.revision);
631
+ if (changed.changes !== 1) throw new Error("Interactive request validation is stale");
632
+ }
633
+ const settled = this.interactionSubmission(options.requestId, options.submissionId);
634
+ if (settled === undefined) throw new Error("Interactive submission result is missing");
635
+ return settled;
636
+ });
637
+ }
638
+
639
+ consumeAcceptedInteraction(runId: string, attemptId: string, now: number = Date.now()): boolean {
640
+ const changed = this.state.connection
641
+ .prepare(
642
+ `UPDATE interactive_requests SET consumed_at = ?, updated_at = ?
643
+ WHERE run_id = ? AND attempt_id = ? AND status = 'settled'
644
+ AND accepted_submission_id IS NOT NULL AND consumed_at IS NULL`,
645
+ )
646
+ .run(now, now, runId, attemptId);
647
+ return changed.changes === 1;
648
+ }
649
+
650
+ listPendingInteractions(sessionId: string): InteractiveRequestRecord[] {
651
+ return this.state.connection
652
+ .prepare(
653
+ `SELECT request_id AS requestId FROM interactive_requests
654
+ WHERE target_session_id = ? AND status IN ('pending', 'presenting')
655
+ ORDER BY created_at, request_id`,
656
+ )
657
+ .all(sessionId)
658
+ .flatMap((row) =>
659
+ isRequestIdRow(row) ? [this.requireInteractiveRequest(row.requestId)] : [],
660
+ );
661
+ }
662
+
663
+ claimInteractionPresentation(options: {
664
+ requestId: string;
665
+ expectedRevision: number;
666
+ presenterId: string;
667
+ leaseMs: number;
668
+ }): InteractiveRequestRecord {
669
+ requireLeaseMs(options.leaseMs);
670
+ const now = Date.now();
671
+ const changed = this.state.connection
672
+ .prepare(
673
+ `UPDATE interactive_requests
674
+ SET status = 'presenting', presenter_id = ?, presentation_claim_expires_at = ?,
675
+ revision = revision + 1, updated_at = ?
676
+ WHERE request_id = ? AND revision = ? AND presentation_session_entry_id IS NULL
677
+ AND (
678
+ status = 'pending'
679
+ OR (status = 'presenting' AND (presenter_id = ? OR presentation_claim_expires_at <= ?))
680
+ )`,
681
+ )
682
+ .run(
683
+ options.presenterId,
684
+ now + options.leaseMs,
685
+ now,
686
+ options.requestId,
687
+ options.expectedRevision,
688
+ options.presenterId,
689
+ now,
690
+ );
691
+ if (changed.changes !== 1) throw new Error("Interactive request presentation claim conflict");
692
+ return this.requireInteractiveRequest(options.requestId);
693
+ }
694
+
695
+ markInteractionPresented(options: {
696
+ requestId: string;
697
+ expectedRevision: number;
698
+ sessionEntryId: string;
699
+ }): InteractiveRequestRecord {
700
+ const now = Date.now();
701
+ const changed = this.state.connection
702
+ .prepare(
703
+ `UPDATE interactive_requests
704
+ SET status = 'presenting', presenter_id = NULL, presentation_claim_expires_at = NULL,
705
+ presentation_session_entry_id = ?, revision = revision + 1, updated_at = ?
706
+ WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')
707
+ AND (presentation_session_entry_id IS NULL OR presentation_session_entry_id = ?)`,
708
+ )
709
+ .run(
710
+ options.sessionEntryId,
711
+ now,
712
+ options.requestId,
713
+ options.expectedRevision,
714
+ options.sessionEntryId,
715
+ );
716
+ if (changed.changes !== 1) throw new Error("Interactive request presentation conflict");
717
+ return this.requireInteractiveRequest(options.requestId);
718
+ }
719
+
720
+ submitInteraction(options: {
721
+ requestId: string;
722
+ submissionId: string;
723
+ idempotencyKey: string;
724
+ expectedRevision: number;
725
+ payload: JsonValue;
726
+ accepted: boolean;
727
+ receipt?: JsonValue;
728
+ }): {
729
+ interaction: InteractiveRequestRecord;
730
+ outcome: "accepted" | "adopted";
731
+ receipt: JsonValue;
732
+ } {
733
+ return this.recordInteractionSubmission({
734
+ ...options,
735
+ outcome: options.accepted ? "accepted" : "rejected",
736
+ settle: options.accepted,
737
+ });
738
+ }
739
+
740
+ beginInteractionValidation(options: {
741
+ requestId: string;
742
+ submissionId: string;
743
+ idempotencyKey: string;
744
+ expectedRevision: number;
745
+ payload: JsonValue;
746
+ receipt?: JsonValue;
747
+ }): {
748
+ interaction: InteractiveRequestRecord;
749
+ outcome: "accepted" | "adopted";
750
+ receipt: JsonValue;
751
+ } {
752
+ return this.recordInteractionSubmission({
753
+ ...options,
754
+ outcome: "validating",
755
+ settle: false,
756
+ });
757
+ }
758
+
759
+ private recordInteractionSubmission(options: {
760
+ requestId: string;
761
+ submissionId: string;
762
+ idempotencyKey: string;
763
+ expectedRevision: number;
764
+ payload: JsonValue;
765
+ outcome: InteractiveSubmissionRecord["outcome"];
766
+ settle: boolean;
767
+ receipt?: JsonValue;
768
+ }): {
769
+ interaction: InteractiveRequestRecord;
770
+ outcome: "accepted" | "adopted";
771
+ receipt: JsonValue;
772
+ } {
773
+ const now = Date.now();
774
+ return this.state.transaction(() => {
775
+ const existing = this.state.connection
776
+ .prepare(
777
+ `SELECT submission_id AS submissionId, payload_hash AS payloadHash,
778
+ outcome, receipt_hash AS receiptHash
779
+ FROM interactive_submissions WHERE request_id = ? AND idempotency_key = ?`,
780
+ )
781
+ .get(options.requestId, options.idempotencyKey);
782
+ const payloadHash = createHash("sha256").update(canonicalJson(options.payload)).digest();
783
+ if (isSubmissionRow(existing)) {
784
+ if (!existing.payloadHash.equals(payloadHash)) {
785
+ throw new Error("Interactive submission idempotency key conflicts");
786
+ }
787
+ return {
788
+ interaction: this.requireInteractiveRequest(options.requestId),
789
+ outcome: "adopted",
790
+ receipt:
791
+ existing.receiptHash === null
792
+ ? { requestId: options.requestId, submissionId: existing.submissionId }
793
+ : this.state.readJson(existing.receiptHash),
794
+ };
795
+ }
796
+ if (options.outcome === "validating") {
797
+ const active = this.state.connection
798
+ .prepare(
799
+ `SELECT submission_id AS submissionId FROM interactive_submissions
800
+ WHERE request_id = ? AND outcome = 'validating' LIMIT 1`,
801
+ )
802
+ .get(options.requestId);
803
+ if (isSubmissionIdRow(active)) {
804
+ throw new Error("Interactive submission validation is already active");
805
+ }
806
+ }
807
+ const request = this.requireInteractiveRequest(options.requestId);
808
+ if (
809
+ request.revision !== options.expectedRevision ||
810
+ !["pending", "presenting"].includes(request.status)
811
+ ) {
812
+ throw new Error("Interactive request revision conflict");
813
+ }
814
+ const savedPayloadHash = this.state.putJson(options.payload, now);
815
+ const receiptHash =
816
+ options.receipt === undefined ? null : this.state.putJson(options.receipt, now);
817
+ this.state.connection
818
+ .prepare(
819
+ `INSERT INTO interactive_submissions(
820
+ submission_id, request_id, idempotency_key, request_revision,
821
+ payload_hash, outcome, receipt_hash, submitted_at
822
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
823
+ )
824
+ .run(
825
+ options.submissionId,
826
+ options.requestId,
827
+ options.idempotencyKey,
828
+ options.expectedRevision,
829
+ savedPayloadHash,
830
+ options.outcome,
831
+ receiptHash,
832
+ now,
833
+ );
834
+ if (options.settle) {
835
+ this.state.connection
836
+ .prepare(
837
+ `UPDATE interactive_requests
838
+ SET status = 'settled', presenter_id = NULL, presentation_claim_expires_at = NULL,
839
+ accepted_submission_id = ?, revision = revision + 1,
840
+ updated_at = ?, settled_at = ?
841
+ WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`,
842
+ )
843
+ .run(options.submissionId, now, now, options.requestId, options.expectedRevision);
844
+ }
845
+ return {
846
+ interaction: this.requireInteractiveRequest(options.requestId),
847
+ outcome: "accepted",
848
+ receipt: options.receipt ?? {
849
+ requestId: options.requestId,
850
+ submissionId: options.submissionId,
851
+ },
852
+ };
853
+ });
854
+ }
855
+
856
+ private hostRow(): HostRow {
857
+ const row = this.state.connection
858
+ .prepare(
859
+ `SELECT epoch, host_id AS hostId, token_hash AS tokenHash, pid,
860
+ process_start_identity AS processStartIdentity, started_at AS startedAt,
861
+ heartbeat_at AS heartbeatAt, expires_at AS expiresAt
862
+ FROM workflow_host_state WHERE id = 1`,
863
+ )
864
+ .get();
865
+ if (!isHostRow(row)) throw new Error("Pi Workflows host state is missing");
866
+ return row;
867
+ }
868
+
869
+ private commandResponse(requestId: string, row: CommandRow): HostResponse {
870
+ const receipt = row.receiptHash === null ? undefined : this.state.readJson(row.receiptHash);
871
+ const error =
872
+ row.errorHash === null
873
+ ? undefined
874
+ : this.state.readBlob(row.errorHash)?.content.toString("utf8");
875
+ return {
876
+ schema: "pi-workflows.host-response.v1",
877
+ requestId,
878
+ outcome: row.outcome,
879
+ ...(row.revision === null ? {} : { revision: row.revision }),
880
+ ...(receipt === undefined ? {} : { receipt }),
881
+ ...(error === undefined ? {} : { error }),
882
+ };
883
+ }
884
+
885
+ private interactiveRequest(requestId: string): InteractiveRequestRecord | undefined {
886
+ const row = this.state.connection
887
+ .prepare(
888
+ `SELECT request_id AS requestId, run_id AS runId, attempt_id AS attemptId,
889
+ target_session_id AS targetSessionId, kind, contract_hash AS contractHash,
890
+ revision, status, presentation_session_entry_id AS presentationSessionEntryId,
891
+ accepted_submission_id AS acceptedSubmissionId, created_at AS createdAt,
892
+ updated_at AS updatedAt, settled_at AS settledAt, consumed_at AS consumedAt
893
+ FROM interactive_requests WHERE request_id = ?`,
894
+ )
895
+ .get(requestId);
896
+ if (!isInteractiveRequestRow(row)) return undefined;
897
+ return {
898
+ requestId: row.requestId,
899
+ runId: row.runId,
900
+ attemptId: row.attemptId,
901
+ targetSessionId: row.targetSessionId,
902
+ kind: row.kind,
903
+ contract: this.state.readJson(row.contractHash),
904
+ revision: row.revision,
905
+ status: row.status,
906
+ presentationSessionEntryId: row.presentationSessionEntryId,
907
+ acceptedSubmissionId: row.acceptedSubmissionId,
908
+ createdAt: new Date(row.createdAt).toISOString(),
909
+ updatedAt: new Date(row.updatedAt).toISOString(),
910
+ settledAt: iso(row.settledAt),
911
+ consumedAt: iso(row.consumedAt),
912
+ };
913
+ }
914
+
915
+ private requireInteractiveRequest(requestId: string): InteractiveRequestRecord {
916
+ const request = this.interactiveRequest(requestId);
917
+ if (request === undefined) throw new Error(`Interactive request not found: ${requestId}`);
918
+ return request;
919
+ }
920
+ }
921
+
922
+ function conflictResponse(requestId: string, error: string): HostResponse {
923
+ return { schema: "pi-workflows.host-response.v1", requestId, outcome: "conflict", error };
924
+ }
925
+
926
+ function requireLeaseMs(value: number): void {
927
+ if (!Number.isSafeInteger(value) || value <= 0)
928
+ throw new Error("Host lease duration must be positive");
929
+ }
930
+
931
+ function iso(value: number | null): string | null {
932
+ return value === null ? null : new Date(value).toISOString();
933
+ }
934
+
935
+ type HostRow = {
936
+ epoch: number;
937
+ hostId: string | null;
938
+ tokenHash: Buffer | null;
939
+ pid: number | null;
940
+ processStartIdentity: string | null;
941
+ startedAt: number | null;
942
+ heartbeatAt: number | null;
943
+ expiresAt: number | null;
944
+ };
945
+
946
+ type CommandRow = {
947
+ requestFingerprint: Buffer;
948
+ outcome: HostResponse["outcome"];
949
+ revision: number | null;
950
+ receiptHash: Buffer | null;
951
+ errorHash: Buffer | null;
952
+ };
953
+
954
+ type IdempotentCommandRow = CommandRow & { requestId: string };
955
+ type RequestIdRow = { requestId: string };
956
+ type RunIdRow = { runId: string };
957
+ type SubmissionRow = {
958
+ submissionId: string;
959
+ payloadHash: Buffer;
960
+ outcome: string;
961
+ receiptHash: Buffer | null;
962
+ };
963
+ type SubmissionIdRow = { submissionId: string };
964
+ type SubmissionDetailRow = {
965
+ requestId: string;
966
+ submissionId: string;
967
+ idempotencyKey: string;
968
+ outcome: InteractiveSubmissionRecord["outcome"];
969
+ payloadHash: Buffer;
970
+ receiptHash: Buffer | null;
971
+ submittedAt: number;
972
+ };
973
+ type WorkerMessageRow = {
974
+ requestFingerprint: Buffer;
975
+ outcome: WorkerResponse["outcome"];
976
+ revision: number | null;
977
+ resultHash: Buffer | null;
978
+ errorHash: Buffer | null;
979
+ };
980
+ type AcceptedInteractionRow = {
981
+ requestId: string;
982
+ attemptId: string;
983
+ nodeId: string;
984
+ submissionId: string;
985
+ payloadHash: Buffer;
986
+ };
987
+ type TimedOutInteractionRow = {
988
+ requestId: string;
989
+ attemptId: string;
990
+ nodeId: string;
991
+ };
992
+ type InteractiveRequestRow = {
993
+ requestId: string;
994
+ runId: string;
995
+ attemptId: string;
996
+ targetSessionId: string;
997
+ kind: InteractiveRequestRecord["kind"];
998
+ contractHash: Buffer;
999
+ revision: number;
1000
+ status: InteractiveRequestRecord["status"];
1001
+ presentationSessionEntryId: string | null;
1002
+ acceptedSubmissionId: string | null;
1003
+ createdAt: number;
1004
+ updatedAt: number;
1005
+ settledAt: number | null;
1006
+ consumedAt: number | null;
1007
+ };
1008
+
1009
+ function isHostRow(value: unknown): value is HostRow {
1010
+ return (
1011
+ isRecord(value) &&
1012
+ typeof value.epoch === "number" &&
1013
+ nullableString(value.hostId) &&
1014
+ (value.tokenHash === null || Buffer.isBuffer(value.tokenHash)) &&
1015
+ (value.pid === null || typeof value.pid === "number") &&
1016
+ nullableString(value.processStartIdentity) &&
1017
+ nullableNumber(value.startedAt) &&
1018
+ nullableNumber(value.heartbeatAt) &&
1019
+ nullableNumber(value.expiresAt)
1020
+ );
1021
+ }
1022
+
1023
+ function isCommandRow(value: unknown): value is CommandRow {
1024
+ return (
1025
+ isRecord(value) &&
1026
+ Buffer.isBuffer(value.requestFingerprint) &&
1027
+ typeof value.outcome === "string" &&
1028
+ nullableNumber(value.revision) &&
1029
+ (value.receiptHash === null || Buffer.isBuffer(value.receiptHash)) &&
1030
+ (value.errorHash === null || Buffer.isBuffer(value.errorHash))
1031
+ );
1032
+ }
1033
+
1034
+ function isIdempotentCommandRow(value: unknown): value is IdempotentCommandRow {
1035
+ return (
1036
+ isRecord(value) &&
1037
+ isCommandRow(value) &&
1038
+ typeof (value as Record<string, unknown>).requestId === "string"
1039
+ );
1040
+ }
1041
+
1042
+ function isRequestIdRow(value: unknown): value is RequestIdRow {
1043
+ return isRecord(value) && typeof value.requestId === "string";
1044
+ }
1045
+
1046
+ function isRunIdRow(value: unknown): value is RunIdRow {
1047
+ return isRecord(value) && typeof value.runId === "string";
1048
+ }
1049
+
1050
+ function isWorkerMessageRow(value: unknown): value is WorkerMessageRow {
1051
+ return (
1052
+ isRecord(value) &&
1053
+ Buffer.isBuffer(value.requestFingerprint) &&
1054
+ ["accepted", "adopted", "rejected", "claimLost"].includes(value.outcome as string) &&
1055
+ nullableNumber(value.revision) &&
1056
+ (value.resultHash === null || Buffer.isBuffer(value.resultHash)) &&
1057
+ (value.errorHash === null || Buffer.isBuffer(value.errorHash))
1058
+ );
1059
+ }
1060
+
1061
+ function isAcceptedInteractionRow(value: unknown): value is AcceptedInteractionRow {
1062
+ return (
1063
+ isRecord(value) &&
1064
+ typeof value.requestId === "string" &&
1065
+ typeof value.attemptId === "string" &&
1066
+ typeof value.nodeId === "string" &&
1067
+ typeof value.submissionId === "string" &&
1068
+ Buffer.isBuffer(value.payloadHash)
1069
+ );
1070
+ }
1071
+
1072
+ function isTimedOutInteractionRow(value: unknown): value is TimedOutInteractionRow {
1073
+ return (
1074
+ isRecord(value) &&
1075
+ typeof value.requestId === "string" &&
1076
+ typeof value.attemptId === "string" &&
1077
+ typeof value.nodeId === "string"
1078
+ );
1079
+ }
1080
+
1081
+ function isSubmissionRow(value: unknown): value is SubmissionRow {
1082
+ return (
1083
+ isRecord(value) &&
1084
+ typeof value.submissionId === "string" &&
1085
+ Buffer.isBuffer(value.payloadHash) &&
1086
+ typeof value.outcome === "string" &&
1087
+ (value.receiptHash === null || Buffer.isBuffer(value.receiptHash))
1088
+ );
1089
+ }
1090
+
1091
+ function isSubmissionIdRow(value: unknown): value is SubmissionIdRow {
1092
+ return isRecord(value) && typeof value.submissionId === "string";
1093
+ }
1094
+
1095
+ function isSubmissionDetailRow(value: unknown): value is SubmissionDetailRow {
1096
+ return (
1097
+ isRecord(value) &&
1098
+ typeof value.requestId === "string" &&
1099
+ typeof value.submissionId === "string" &&
1100
+ typeof value.idempotencyKey === "string" &&
1101
+ ["validating", "accepted", "rejected", "adopted"].includes(value.outcome as string) &&
1102
+ Buffer.isBuffer(value.payloadHash) &&
1103
+ (value.receiptHash === null || Buffer.isBuffer(value.receiptHash)) &&
1104
+ typeof value.submittedAt === "number"
1105
+ );
1106
+ }
1107
+
1108
+ function isInteractiveRequestRow(value: unknown): value is InteractiveRequestRow {
1109
+ return (
1110
+ isRecord(value) &&
1111
+ typeof value.requestId === "string" &&
1112
+ typeof value.runId === "string" &&
1113
+ typeof value.attemptId === "string" &&
1114
+ typeof value.targetSessionId === "string" &&
1115
+ ["agent", "assistant", "decision"].includes(value.kind as string) &&
1116
+ Buffer.isBuffer(value.contractHash) &&
1117
+ typeof value.revision === "number" &&
1118
+ ["pending", "presenting", "settled", "cancelled"].includes(value.status as string) &&
1119
+ nullableString(value.presentationSessionEntryId) &&
1120
+ nullableString(value.acceptedSubmissionId) &&
1121
+ typeof value.createdAt === "number" &&
1122
+ typeof value.updatedAt === "number" &&
1123
+ nullableNumber(value.settledAt) &&
1124
+ nullableNumber(value.consumedAt)
1125
+ );
1126
+ }
1127
+
1128
+ function workerMessageFingerprint(message: WorkerMessage): Buffer {
1129
+ return createHash("sha256").update(canonicalJson(message)).digest();
1130
+ }
1131
+
1132
+ function workerMessageResponse(
1133
+ state: StateDatabase,
1134
+ messageId: string,
1135
+ row: WorkerMessageRow,
1136
+ ): WorkerResponse {
1137
+ const result = row.resultHash === null ? undefined : state.readJson(row.resultHash);
1138
+ const error =
1139
+ row.errorHash === null ? undefined : state.readBlob(row.errorHash)?.content.toString("utf8");
1140
+ return {
1141
+ schema: "pi-workflows.worker-response.v1",
1142
+ messageId,
1143
+ outcome: row.outcome,
1144
+ ...(row.revision === null ? {} : { revision: row.revision }),
1145
+ ...(result === undefined ? {} : { result }),
1146
+ ...(error === undefined ? {} : { error }),
1147
+ };
1148
+ }
1149
+
1150
+ function nullableString(value: unknown): value is string | null {
1151
+ return value === null || typeof value === "string";
1152
+ }
1153
+
1154
+ function nullableNumber(value: unknown): value is number | null {
1155
+ return value === null || typeof value === "number";
1156
+ }
1157
+
1158
+ function isRecord(value: unknown): value is Record<string, unknown> {
1159
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1160
+ }