@osolmaz/pi-workflows 0.15.3 → 0.16.1

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 (207) hide show
  1. package/README.md +24 -8
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/activity.d.ts +2 -0
  15. package/dist/client/activity.js +6 -0
  16. package/dist/client/activity.js.map +1 -0
  17. package/dist/client/client.d.ts +102 -0
  18. package/dist/client/client.js +737 -0
  19. package/dist/client/client.js.map +1 -0
  20. package/dist/client/index.d.ts +3 -0
  21. package/dist/client/index.js +3 -0
  22. package/dist/client/index.js.map +1 -0
  23. package/dist/client/materialize.d.ts +7 -0
  24. package/dist/client/materialize.js +177 -0
  25. package/dist/client/materialize.js.map +1 -0
  26. package/dist/client/protocol.d.ts +60 -0
  27. package/dist/client/protocol.js +275 -0
  28. package/dist/client/protocol.js.map +1 -0
  29. package/dist/client/resolver.d.ts +23 -0
  30. package/dist/client/resolver.js +2 -0
  31. package/dist/client/resolver.js.map +1 -0
  32. package/dist/client/view.d.ts +144 -0
  33. package/dist/client/view.js +3 -0
  34. package/dist/client/view.js.map +1 -0
  35. package/dist/controllers/index.d.ts +1 -1
  36. package/dist/controllers/index.js.map +1 -1
  37. package/dist/controllers/sqlite.d.ts +47 -157
  38. package/dist/controllers/sqlite.js +230 -406
  39. package/dist/controllers/sqlite.js.map +1 -1
  40. package/dist/extension/index.d.ts +23 -1
  41. package/dist/extension/index.js +734 -555
  42. package/dist/extension/index.js.map +1 -1
  43. package/dist/extension/recorder.d.ts +95 -0
  44. package/dist/extension/recorder.js +530 -0
  45. package/dist/extension/recorder.js.map +1 -0
  46. package/dist/extension/remote-recorder-store.d.ts +25 -0
  47. package/dist/extension/remote-recorder-store.js +81 -0
  48. package/dist/extension/remote-recorder-store.js.map +1 -0
  49. package/dist/extension/session-events.d.ts +134 -0
  50. package/dist/extension/session-events.js +60 -0
  51. package/dist/extension/session-events.js.map +1 -0
  52. package/dist/extension/session-view.d.ts +9 -2
  53. package/dist/extension/session-view.js +117 -51
  54. package/dist/extension/session-view.js.map +1 -1
  55. package/dist/extension/step-message.d.ts +6 -4
  56. package/dist/extension/step-message.js +12 -6
  57. package/dist/extension/step-message.js.map +1 -1
  58. package/dist/extension/widget.d.ts +3 -2
  59. package/dist/extension/widget.js +41 -24
  60. package/dist/extension/widget.js.map +1 -1
  61. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  62. package/dist/extension/workflow-message-coordinator.js +294 -0
  63. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  64. package/dist/host/channel-effects.d.ts +72 -0
  65. package/dist/host/channel-effects.js +271 -0
  66. package/dist/host/channel-effects.js.map +1 -0
  67. package/dist/host/channel-supervisor.d.ts +21 -0
  68. package/dist/host/channel-supervisor.js +54 -0
  69. package/dist/host/channel-supervisor.js.map +1 -0
  70. package/dist/host/child-worker-supervisor.js +1 -1
  71. package/dist/host/child-worker-supervisor.js.map +1 -1
  72. package/dist/host/resolver-entry.d.ts +2 -23
  73. package/dist/host/resolver-entry.js +1 -1
  74. package/dist/host/resolver-entry.js.map +1 -1
  75. package/dist/host/runner.d.ts +54 -8
  76. package/dist/host/runner.js +1997 -305
  77. package/dist/host/runner.js.map +1 -1
  78. package/dist/host/state.d.ts +25 -19
  79. package/dist/host/state.js +303 -89
  80. package/dist/host/state.js.map +1 -1
  81. package/dist/host/view.d.ts +83 -0
  82. package/dist/host/view.js +975 -0
  83. package/dist/host/view.js.map +1 -0
  84. package/dist/host/worker-protocol.js +1 -1
  85. package/dist/host/worker-protocol.js.map +1 -1
  86. package/dist/state/database.d.ts +1 -0
  87. package/dist/state/database.js +15 -0
  88. package/dist/state/database.js.map +1 -1
  89. package/dist/state/prune.d.ts +3 -1
  90. package/dist/state/prune.js +9 -33
  91. package/dist/state/prune.js.map +1 -1
  92. package/dist/state/schema.js +88 -98
  93. package/dist/state/schema.js.map +1 -1
  94. package/dist/state/workflow-messages.d.ts +96 -0
  95. package/dist/state/workflow-messages.js +335 -0
  96. package/dist/state/workflow-messages.js.map +1 -0
  97. package/dist/viewer/backup.d.ts +2 -0
  98. package/dist/viewer/backup.js +28 -0
  99. package/dist/viewer/backup.js.map +1 -0
  100. package/dist/viewer/cli.d.ts +4 -0
  101. package/dist/viewer/cli.js +150 -170
  102. package/dist/viewer/cli.js.map +1 -1
  103. package/dist/viewer/render.js +1 -1
  104. package/dist/viewer/render.js.map +1 -1
  105. package/dist/viewer/tui.d.ts +5 -7
  106. package/dist/viewer/tui.js +245 -108
  107. package/dist/viewer/tui.js.map +1 -1
  108. package/dist/workflows/human-decision.d.ts +2 -0
  109. package/dist/workflows/human-decision.js +8 -2
  110. package/dist/workflows/human-decision.js.map +1 -1
  111. package/dist/workflows/index.d.ts +1 -1
  112. package/dist/workflows/index.js.map +1 -1
  113. package/dist/workflows/settings.d.ts +1 -8
  114. package/dist/workflows/settings.js.map +1 -1
  115. package/dist/workflows/store.d.ts +67 -20
  116. package/dist/workflows/store.js +451 -378
  117. package/dist/workflows/store.js.map +1 -1
  118. package/dist/workflows/tool-input.d.ts +21 -0
  119. package/dist/workflows/tool-input.js +23 -1
  120. package/dist/workflows/tool-input.js.map +1 -1
  121. package/dist/workflows/types.d.ts +5 -5
  122. package/dist/workflows/workflow-message-content.d.ts +38 -0
  123. package/dist/workflows/workflow-message-content.js +157 -0
  124. package/dist/workflows/workflow-message-content.js.map +1 -0
  125. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  126. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  127. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  128. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  129. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  130. package/docs/2026-09-01-unified-workflow-client-plan.md +385 -0
  131. package/docs/2026-09-02-installed-live-e2e-plan.md +239 -0
  132. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  133. package/docs/DEFERRED_TURNS.md +66 -294
  134. package/docs/HUMAN_DECISIONS.md +29 -30
  135. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  136. package/docs/SQLITE_STATE.md +31 -25
  137. package/docs/WORKFLOW_HOST.md +155 -77
  138. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  139. package/docs/development.md +2 -1
  140. package/docs/live-replay-protocol.md +74 -132
  141. package/docs/tui-viewer.md +12 -14
  142. package/docs/workflows.md +78 -28
  143. package/herdr-plugin.toml +1 -1
  144. package/package.json +9 -3
  145. package/plugins/herdr/viewer.mjs +14 -5
  146. package/protocol/client.v1.schema.json +137 -0
  147. package/protocol/fixtures/client-v1.json +23 -0
  148. package/src/channels/adapter-entry.ts +220 -0
  149. package/src/channels/config.ts +296 -0
  150. package/src/channels/protocol.ts +333 -0
  151. package/src/channels/telegram.ts +335 -0
  152. package/src/client/activity.ts +6 -0
  153. package/src/client/client.ts +939 -0
  154. package/src/client/index.ts +25 -0
  155. package/src/client/materialize.ts +228 -0
  156. package/src/client/protocol.ts +333 -0
  157. package/src/client/resolver.ts +26 -0
  158. package/src/client/view.ts +163 -0
  159. package/src/controllers/index.ts +0 -1
  160. package/src/controllers/sqlite.ts +339 -808
  161. package/src/extension/index.ts +853 -665
  162. package/src/extension/recorder.ts +687 -0
  163. package/src/extension/remote-recorder-store.ts +126 -0
  164. package/src/extension/session-events.ts +119 -0
  165. package/src/extension/session-view.ts +157 -51
  166. package/src/extension/step-message.ts +19 -9
  167. package/src/extension/widget.ts +53 -25
  168. package/src/extension/workflow-message-coordinator.ts +334 -0
  169. package/src/host/channel-effects.ts +465 -0
  170. package/src/host/channel-supervisor.ts +73 -0
  171. package/src/host/child-worker-supervisor.ts +1 -1
  172. package/src/host/resolver-entry.ts +11 -26
  173. package/src/host/runner.ts +2639 -450
  174. package/src/host/state.ts +404 -137
  175. package/src/host/view.ts +1212 -0
  176. package/src/host/worker-protocol.ts +1 -1
  177. package/src/state/database.ts +13 -0
  178. package/src/state/prune.ts +14 -42
  179. package/src/state/schema.ts +88 -98
  180. package/src/state/workflow-messages.ts +548 -0
  181. package/src/viewer/backup.ts +29 -0
  182. package/src/viewer/cli.ts +171 -185
  183. package/src/viewer/render.ts +1 -5
  184. package/src/viewer/tui.ts +261 -124
  185. package/src/workflows/human-decision.ts +18 -2
  186. package/src/workflows/index.ts +0 -1
  187. package/src/workflows/settings.ts +1 -20
  188. package/src/workflows/store.ts +650 -511
  189. package/src/workflows/tool-input.ts +36 -1
  190. package/src/workflows/types.ts +5 -5
  191. package/src/workflows/workflow-message-content.ts +197 -0
  192. package/dist/extension/decision-channels.d.ts +0 -134
  193. package/dist/extension/decision-channels.js +0 -1307
  194. package/dist/extension/decision-channels.js.map +0 -1
  195. package/dist/host/client.d.ts +0 -48
  196. package/dist/host/client.js +0 -216
  197. package/dist/host/client.js.map +0 -1
  198. package/dist/host/protocol.d.ts +0 -38
  199. package/dist/host/protocol.js +0 -156
  200. package/dist/host/protocol.js.map +0 -1
  201. package/dist/viewer/watch.d.ts +0 -6
  202. package/dist/viewer/watch.js +0 -46
  203. package/dist/viewer/watch.js.map +0 -1
  204. package/src/extension/decision-channels.ts +0 -1826
  205. package/src/host/client.ts +0 -293
  206. package/src/host/protocol.ts +0 -196
  207. package/src/viewer/watch.ts +0 -51
@@ -1,11 +1,14 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import { CLIENT_PROTOCOL_SCHEMA, clientRequestFingerprint, } from "../client/protocol.js";
2
3
  import { StateDatabase } from "../state/database.js";
3
4
  import { canonicalJson } from "../state/json.js";
4
5
  import { tokenHash } from "../state/mutation.js";
5
- import { requestFingerprint } from "./protocol.js";
6
+ import { WorkflowMessageStore, workflowMessageIdFor, } from "../state/workflow-messages.js";
7
+ import { decisionWorkflowMessageContent, stepWorkflowMessageContent, } from "../workflows/workflow-message-content.js";
6
8
  export class HostStateStore {
7
9
  state;
8
10
  ownsState;
11
+ workflowMessages;
9
12
  constructor(databasePath, options = {}) {
10
13
  this.ownsState = options.state === undefined;
11
14
  this.state =
@@ -14,6 +17,7 @@ export class HostStateStore {
14
17
  filePath: databasePath,
15
18
  mode: options.readOnly === true ? "read-only" : "read-write",
16
19
  });
20
+ this.workflowMessages = new WorkflowMessageStore(this.state);
17
21
  }
18
22
  close() {
19
23
  if (this.ownsState)
@@ -86,39 +90,56 @@ export class HostStateStore {
86
90
  }
87
91
  readCommand(request) {
88
92
  const row = this.state.connection
89
- .prepare(`SELECT request_fingerprint AS requestFingerprint, outcome, accepted_revision AS revision,
93
+ .prepare(`SELECT request_fingerprint AS clientRequestFingerprint, outcome, accepted_revision AS revision,
90
94
  receipt_hash AS receiptHash, error_hash AS errorHash
91
95
  FROM host_commands WHERE request_id = ?`)
92
96
  .get(request.requestId);
93
97
  if (!isCommandRow(row))
94
98
  return undefined;
95
- if (!row.requestFingerprint.equals(requestFingerprint(request))) {
99
+ if (!row.clientRequestFingerprint.equals(clientRequestFingerprint(request))) {
96
100
  return conflictResponse(request.requestId, "Request ID was reused with another payload");
97
101
  }
98
102
  return this.commandResponse(request.requestId, row);
99
103
  }
100
- executeCommand(request, hostEpoch, operation) {
104
+ adoptCommand(request) {
101
105
  const existing = this.readCommand(request);
102
106
  if (existing !== undefined) {
103
- return existing.outcome === "conflict" ? existing : { ...existing, outcome: "adopted" };
107
+ return existing.outcome === "accepted" || existing.outcome === "adopted"
108
+ ? { ...existing, outcome: "adopted" }
109
+ : existing;
110
+ }
111
+ const idempotent = this.state.connection
112
+ .prepare(`SELECT request_id AS requestId, request_fingerprint AS clientRequestFingerprint,
113
+ outcome, accepted_revision AS revision, receipt_hash AS receiptHash,
114
+ error_hash AS errorHash
115
+ FROM host_commands WHERE client_id = ? AND idempotency_key = ?`)
116
+ .get(request.clientId, request.idempotencyKey);
117
+ if (!isIdempotentCommandRow(idempotent))
118
+ return undefined;
119
+ if (!idempotent.clientRequestFingerprint.equals(clientRequestFingerprint(request))) {
120
+ return conflictResponse(request.requestId, "Idempotency key was reused with another payload");
104
121
  }
122
+ const adopted = this.commandResponse(idempotent.requestId, idempotent);
123
+ return {
124
+ ...adopted,
125
+ requestId: request.requestId,
126
+ ...(adopted.outcome === "accepted" || adopted.outcome === "adopted"
127
+ ? { outcome: "adopted" }
128
+ : {}),
129
+ };
130
+ }
131
+ executeCommand(request, hostEpoch, operation) {
132
+ const existing = this.adoptCommand(request);
133
+ if (existing !== undefined)
134
+ return existing;
105
135
  return this.state.transaction(() => {
106
- const idempotent = this.state.connection
107
- .prepare(`SELECT request_id AS requestId, request_fingerprint AS requestFingerprint,
108
- outcome, accepted_revision AS revision, receipt_hash AS receiptHash,
109
- error_hash AS errorHash
110
- FROM host_commands WHERE client_id = ? AND idempotency_key = ?`)
111
- .get(request.clientId, request.idempotencyKey);
112
- if (isIdempotentCommandRow(idempotent)) {
113
- if (!idempotent.requestFingerprint.equals(requestFingerprint(request))) {
114
- return conflictResponse(request.requestId, "Idempotency key was reused with another payload");
115
- }
116
- const adopted = this.commandResponse(idempotent.requestId, idempotent);
117
- return { ...adopted, requestId: request.requestId, outcome: "adopted" };
118
- }
136
+ const idempotent = this.adoptCommand(request);
137
+ if (idempotent !== undefined)
138
+ return idempotent;
119
139
  const result = operation();
120
140
  const response = {
121
- schema: "pi-workflows.host-response.v1",
141
+ schema: CLIENT_PROTOCOL_SCHEMA,
142
+ type: "response",
122
143
  requestId: request.requestId,
123
144
  ...result,
124
145
  };
@@ -131,7 +152,7 @@ export class HostStateStore {
131
152
  run_id, accepted_revision, outcome, receipt_hash, error_hash,
132
153
  host_epoch, created_at, completed_at
133
154
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
134
- .run(request.requestId, request.clientId, request.operation, request.idempotencyKey, requestFingerprint(request), request.runId ?? null, response.revision ?? null, response.outcome, receiptHash, errorHash, hostEpoch, now, now);
155
+ .run(request.requestId, request.clientId, request.operation, request.idempotencyKey, clientRequestFingerprint(request), request.runId ?? null, response.revision ?? null, response.outcome, receiptHash, errorHash, hostEpoch, now, now);
135
156
  return response;
136
157
  });
137
158
  }
@@ -179,13 +200,13 @@ export class HostStateStore {
179
200
  }
180
201
  readWorkerMessage(message) {
181
202
  const row = this.state.connection
182
- .prepare(`SELECT request_fingerprint AS requestFingerprint, outcome,
203
+ .prepare(`SELECT request_fingerprint AS clientRequestFingerprint, outcome,
183
204
  accepted_revision AS revision, result_hash AS resultHash, error_hash AS errorHash
184
205
  FROM worker_messages WHERE worker_epoch = ? AND message_id = ?`)
185
206
  .get(message.workerEpoch, message.messageId);
186
207
  if (!isWorkerMessageRow(row))
187
208
  return undefined;
188
- if (!row.requestFingerprint.equals(workerMessageFingerprint(message))) {
209
+ if (!row.clientRequestFingerprint.equals(workerMessageFingerprint(message))) {
189
210
  return {
190
211
  schema: "pi-workflows.worker-response.v1",
191
212
  messageId: message.messageId,
@@ -225,14 +246,16 @@ export class HostStateStore {
225
246
  if (existing.status === "settled" && existing.consumedAt === null) {
226
247
  this.state.connection
227
248
  .prepare(`UPDATE interactive_requests
228
- SET status = 'pending', presenter_id = NULL,
229
- presentation_claim_expires_at = NULL, presentation_session_entry_id = NULL,
230
- accepted_submission_id = NULL, settled_at = NULL, consumed_at = NULL,
231
- revision = revision + 1, updated_at = ?
249
+ SET status = 'pending', accepted_submission_id = NULL,
250
+ settled_at = NULL, consumed_at = NULL, revision = revision + 1, updated_at = ?
232
251
  WHERE request_id = ? AND status = 'settled' AND consumed_at IS NULL`)
233
252
  .run(now, options.requestId);
234
- return this.requireInteractiveRequest(options.requestId);
253
+ const reopened = this.requireInteractiveRequest(options.requestId);
254
+ this.ensureInteractionMessage(reopened, "resumed", now);
255
+ return reopened;
235
256
  }
257
+ if (existing.status === "pending")
258
+ this.ensureInteractionMessage(existing, "initial", now);
236
259
  return existing;
237
260
  }
238
261
  const contractHash = this.state.putJson(options.contract, now);
@@ -242,9 +265,172 @@ export class HostStateStore {
242
265
  revision, status, created_at, updated_at
243
266
  ) VALUES (?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`)
244
267
  .run(options.requestId, options.runId, options.attemptId, options.targetSessionId, options.kind, contractHash, now, now);
245
- return this.requireInteractiveRequest(options.requestId);
268
+ const created = this.requireInteractiveRequest(options.requestId);
269
+ this.ensureInteractionMessage(created, "initial", now);
270
+ return created;
246
271
  });
247
272
  }
273
+ ensureInteractionMessage(request, reason, now = Date.now()) {
274
+ const kind = request.kind === "decision" ? "decision" : "step";
275
+ const idempotencyKey = `${request.revision}:${reason}`;
276
+ const workflowMessageId = workflowMessageIdFor(kind, request.requestId, idempotencyKey);
277
+ const content = kind === "decision"
278
+ ? decisionWorkflowMessageContent({
279
+ workflowMessageId,
280
+ requestId: request.requestId,
281
+ runId: request.runId,
282
+ contract: request.contract,
283
+ })
284
+ : stepWorkflowMessageContent({
285
+ workflowMessageId,
286
+ requestId: request.requestId,
287
+ contract: request.contract,
288
+ reason,
289
+ });
290
+ return this.workflowMessages.create({
291
+ workflowMessageId,
292
+ runId: request.runId,
293
+ targetSessionId: request.targetSessionId,
294
+ kind,
295
+ sourceId: request.requestId,
296
+ idempotencyKey,
297
+ content,
298
+ now,
299
+ });
300
+ }
301
+ resumePendingInteraction(runId, now = Date.now()) {
302
+ return this.state.transaction(() => {
303
+ const row = this.state.connection
304
+ .prepare(`SELECT request_id AS requestId FROM interactive_requests
305
+ WHERE run_id = ? AND status = 'pending' ORDER BY created_at LIMIT 1`)
306
+ .get(runId);
307
+ if (!isRequestIdRow(row))
308
+ throw new Error("Pending workflow interaction is missing");
309
+ const current = this.requireInteractiveRequest(row.requestId);
310
+ if (current.kind === "decision")
311
+ return current;
312
+ this.workflowMessages.cancelPendingForSource(current.requestId, "step", now);
313
+ const changed = this.state.connection
314
+ .prepare(`UPDATE interactive_requests SET revision = revision + 1, updated_at = ?
315
+ WHERE request_id = ? AND status = 'pending'`)
316
+ .run(now, row.requestId);
317
+ if (changed.changes !== 1)
318
+ throw new Error("Pending workflow interaction resume is stale");
319
+ const request = this.requireInteractiveRequest(row.requestId);
320
+ this.ensureInteractionMessage(request, "resumed", now);
321
+ return request;
322
+ });
323
+ }
324
+ beginInteractionModelTurn(requestId, now = Date.now()) {
325
+ const row = this.state.connection
326
+ .prepare(`SELECT i.attempt_id AS attemptId, i.created_at AS createdAt,
327
+ a.started_at AS startedAt, a.deadline_at AS deadlineAt,
328
+ (SELECT MAX(t.ended_at)
329
+ FROM workflow_messages m
330
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
331
+ WHERE m.source_id = i.request_id AND m.kind = 'step'
332
+ AND t.state = 'ended') AS previousTurnEndedAt
333
+ FROM interactive_requests i
334
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
335
+ WHERE i.request_id = ? AND i.status = 'pending'`)
336
+ .get(requestId);
337
+ if (!isInteractionTimeoutRow(row)) {
338
+ throw new Error("Pending workflow interaction timeout is missing");
339
+ }
340
+ this.shiftInteractionTimeout(row, Math.max(0, now - (row.previousTurnEndedAt ?? row.startedAt ?? row.createdAt)), now);
341
+ }
342
+ interactionPauseStartedAt(runId) {
343
+ const row = this.state.connection
344
+ .prepare(`SELECT r.updated_at AS pausedAt
345
+ FROM runs r
346
+ JOIN interactive_requests i ON i.run_id = r.run_id AND i.status = 'pending'
347
+ WHERE r.run_id = ? AND r.paused = 1 LIMIT 1`)
348
+ .get(runId);
349
+ return isPausedAtRow(row) ? row.pausedAt : undefined;
350
+ }
351
+ resumeInteractionModelTurn(runId, pausedAt, now = Date.now()) {
352
+ if (pausedAt === undefined)
353
+ return;
354
+ const row = this.state.connection
355
+ .prepare(`SELECT i.attempt_id AS attemptId, i.created_at AS createdAt,
356
+ a.started_at AS startedAt, a.deadline_at AS deadlineAt,
357
+ t.ended_at AS previousTurnEndedAt
358
+ FROM interactive_requests i
359
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
360
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
361
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
362
+ WHERE i.run_id = ? AND i.status = 'pending' AND t.started_at <= ?
363
+ AND (t.ended_at IS NULL OR t.ended_at > ?)
364
+ ORDER BY t.started_at DESC LIMIT 1`)
365
+ .get(runId, pausedAt, pausedAt);
366
+ if (!isInteractionTimeoutRow(row))
367
+ return;
368
+ const modelTurnStoppedAt = Math.min(now, row.previousTurnEndedAt ?? now);
369
+ this.shiftInteractionTimeout(row, Math.max(0, modelTurnStoppedAt - pausedAt), now);
370
+ }
371
+ markSessionModelTurnsInactive(targetSessionId, now = Date.now()) {
372
+ this.state.connection
373
+ .prepare(`UPDATE node_attempts SET status = 'interrupted', updated_at = ?
374
+ WHERE status <> 'interrupted' AND attempt_id IN (
375
+ SELECT DISTINCT i.attempt_id
376
+ FROM interactive_requests i
377
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
378
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
379
+ AND t.state = 'started'
380
+ WHERE i.target_session_id = ? AND i.status = 'pending'
381
+ )`)
382
+ .run(now, targetSessionId);
383
+ }
384
+ recoverInactiveModelTurns(previousHeartbeatAt, now = Date.now()) {
385
+ const rows = this.state.connection
386
+ .prepare(`SELECT i.attempt_id AS attemptId, a.status, a.started_at AS startedAt,
387
+ a.deadline_at AS deadlineAt, a.updated_at AS updatedAt,
388
+ MAX(t.started_at) AS turnStartedAt
389
+ FROM interactive_requests i
390
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
391
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
392
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
393
+ AND t.state = 'started'
394
+ WHERE i.status = 'pending'
395
+ GROUP BY i.attempt_id`)
396
+ .all()
397
+ .filter(isInactiveInteractionTimeoutRow);
398
+ for (const row of rows) {
399
+ const inactiveAt = row.status === "interrupted"
400
+ ? row.updatedAt
401
+ : previousHeartbeatAt === undefined
402
+ ? row.updatedAt
403
+ : Math.max(previousHeartbeatAt, row.turnStartedAt);
404
+ this.shiftInteractionTimeout(row, Math.max(0, now - inactiveAt), now);
405
+ this.state.connection
406
+ .prepare(`UPDATE node_attempts SET status = 'interrupted', updated_at = ?
407
+ WHERE attempt_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`)
408
+ .run(now, row.attemptId);
409
+ }
410
+ }
411
+ resumeSessionModelTurns(targetSessionId, now = Date.now()) {
412
+ const rows = this.state.connection
413
+ .prepare(`SELECT i.attempt_id AS attemptId, a.status, a.started_at AS startedAt,
414
+ a.deadline_at AS deadlineAt, a.updated_at AS updatedAt,
415
+ MAX(t.started_at) AS turnStartedAt
416
+ FROM interactive_requests i
417
+ JOIN node_attempts a ON a.attempt_id = i.attempt_id
418
+ JOIN workflow_messages m ON m.source_id = i.request_id AND m.kind = 'step'
419
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
420
+ AND t.state = 'started'
421
+ WHERE i.target_session_id = ? AND i.status = 'pending'
422
+ AND a.status = 'interrupted'
423
+ GROUP BY i.attempt_id`)
424
+ .all(targetSessionId)
425
+ .filter(isInactiveInteractionTimeoutRow);
426
+ for (const row of rows) {
427
+ this.shiftInteractionTimeout(row, Math.max(0, now - row.updatedAt), now);
428
+ this.state.connection
429
+ .prepare(`UPDATE node_attempts SET status = 'waiting', updated_at = ?
430
+ WHERE attempt_id = ? AND status = 'interrupted'`)
431
+ .run(now, row.attemptId);
432
+ }
433
+ }
248
434
  getInteraction(requestId) {
249
435
  return this.interactiveRequest(requestId);
250
436
  }
@@ -275,7 +461,7 @@ export class HostStateStore {
275
461
  FROM interactive_requests i
276
462
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
277
463
  JOIN interactive_submissions s ON s.request_id = i.request_id
278
- WHERE i.run_id = ? AND i.status IN ('pending', 'presenting')
464
+ WHERE i.run_id = ? AND i.status = 'pending'
279
465
  AND s.outcome = 'validating'
280
466
  ORDER BY s.submitted_at DESC LIMIT 1`)
281
467
  .get(runId);
@@ -295,26 +481,32 @@ export class HostStateStore {
295
481
  FROM interactive_requests i
296
482
  JOIN interactive_submissions s ON s.request_id = i.request_id
297
483
  JOIN run_queue q ON q.run_id = i.run_id
298
- WHERE i.status IN ('pending', 'presenting') AND s.outcome = 'validating'
484
+ WHERE i.status = 'pending' AND s.outcome = 'validating'
299
485
  AND q.status NOT IN ('done', 'failed', 'cancelled')
300
486
  GROUP BY i.run_id
301
487
  ORDER BY MIN(s.submitted_at), i.run_id`)
302
488
  .all()
303
489
  .flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
304
490
  }
305
- expiredInteractionRunIds(now = Date.now()) {
491
+ expiredInteractionRuns(now = Date.now()) {
306
492
  return this.state.connection
307
- .prepare(`SELECT i.run_id AS runId
493
+ .prepare(`SELECT i.run_id AS runId, i.target_session_id AS targetSessionId
308
494
  FROM interactive_requests i
309
495
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
496
+ JOIN runs r ON r.run_id = i.run_id
310
497
  JOIN run_queue q ON q.run_id = i.run_id
311
- WHERE i.status IN ('pending', 'presenting')
498
+ WHERE i.status = 'pending' AND r.paused = 0
312
499
  AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
313
500
  AND q.status NOT IN ('done', 'failed', 'cancelled')
314
- GROUP BY i.run_id
501
+ AND EXISTS (
502
+ SELECT 1 FROM workflow_messages m
503
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
504
+ WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
505
+ )
506
+ GROUP BY i.run_id, i.target_session_id
315
507
  ORDER BY MIN(a.deadline_at), i.run_id`)
316
508
  .all(now)
317
- .flatMap((row) => (isRunIdRow(row) ? [row.runId] : []));
509
+ .filter(isExpiredInteractionRunRow);
318
510
  }
319
511
  timedOutInteraction(runId) {
320
512
  const row = this.state.connection
@@ -323,8 +515,13 @@ export class HostStateStore {
323
515
  JOIN node_attempts a ON a.attempt_id = i.attempt_id
324
516
  JOIN runs r ON r.run_id = i.run_id
325
517
  WHERE i.run_id = ? AND i.status = 'cancelled' AND r.status = 'running'
326
- AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
327
- AND a.status IN ('pending', 'running', 'waiting', 'interrupted')
518
+ AND r.paused = 0 AND a.deadline_at IS NOT NULL AND a.deadline_at <= ?
519
+ AND a.status IN ('pending', 'running', 'waiting', 'interrupted', 'timed_out')
520
+ AND EXISTS (
521
+ SELECT 1 FROM workflow_messages m
522
+ JOIN workflow_turns t ON t.workflow_message_id = m.workflow_message_id
523
+ WHERE m.source_id = i.request_id AND m.kind = 'step' AND t.state = 'started'
524
+ )
328
525
  ORDER BY a.deadline_at, i.request_id LIMIT 1`)
329
526
  .get(runId, Date.now());
330
527
  return isTimedOutInteractionRow(row) ? row : undefined;
@@ -367,13 +564,14 @@ export class HostStateStore {
367
564
  if (options.accepted) {
368
565
  const changed = this.state.connection
369
566
  .prepare(`UPDATE interactive_requests
370
- SET status = 'settled', presenter_id = NULL,
371
- presentation_claim_expires_at = NULL, accepted_submission_id = ?,
567
+ SET status = 'settled', accepted_submission_id = ?,
372
568
  revision = revision + 1, updated_at = ?, settled_at = ?
373
- WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`)
569
+ WHERE request_id = ? AND revision = ? AND status = 'pending'`)
374
570
  .run(options.submissionId, now, now, options.requestId, request.revision);
375
571
  if (changed.changes !== 1)
376
572
  throw new Error("Interactive request validation is stale");
573
+ this.workflowMessages.cancelPendingForSource(options.requestId, "step", now);
574
+ this.workflowMessages.cancelPendingForSource(options.requestId, "decision", now);
377
575
  }
378
576
  const settled = this.interactionSubmission(options.requestId, options.submissionId);
379
577
  if (settled === undefined)
@@ -392,40 +590,18 @@ export class HostStateStore {
392
590
  listPendingInteractions(sessionId) {
393
591
  return this.state.connection
394
592
  .prepare(`SELECT request_id AS requestId FROM interactive_requests
395
- WHERE target_session_id = ? AND status IN ('pending', 'presenting')
593
+ WHERE target_session_id = ? AND status = 'pending'
396
594
  ORDER BY created_at, request_id`)
397
595
  .all(sessionId)
398
596
  .flatMap((row) => isRequestIdRow(row) ? [this.requireInteractiveRequest(row.requestId)] : []);
399
597
  }
400
- claimInteractionPresentation(options) {
401
- requireLeaseMs(options.leaseMs);
402
- const now = Date.now();
403
- const changed = this.state.connection
404
- .prepare(`UPDATE interactive_requests
405
- SET status = 'presenting', presenter_id = ?, presentation_claim_expires_at = ?,
406
- revision = revision + 1, updated_at = ?
407
- WHERE request_id = ? AND revision = ? AND presentation_session_entry_id IS NULL
408
- AND (
409
- status = 'pending'
410
- OR (status = 'presenting' AND presentation_claim_expires_at <= ?)
411
- )`)
412
- .run(options.presenterId, now + options.leaseMs, now, options.requestId, options.expectedRevision, now);
413
- if (changed.changes !== 1)
414
- throw new Error("Interactive request presentation claim conflict");
415
- return this.requireInteractiveRequest(options.requestId);
416
- }
417
- markInteractionPresented(options) {
418
- const now = Date.now();
419
- const changed = this.state.connection
420
- .prepare(`UPDATE interactive_requests
421
- SET status = 'presenting', presenter_id = NULL, presentation_claim_expires_at = NULL,
422
- presentation_session_entry_id = ?, revision = revision + 1, updated_at = ?
423
- WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')
424
- AND (presentation_session_entry_id IS NULL OR presentation_session_entry_id = ?)`)
425
- .run(options.sessionEntryId, now, options.requestId, options.expectedRevision, options.sessionEntryId);
426
- if (changed.changes !== 1)
427
- throw new Error("Interactive request presentation conflict");
428
- return this.requireInteractiveRequest(options.requestId);
598
+ listPendingDecisionInteractions() {
599
+ return this.state.connection
600
+ .prepare(`SELECT request_id AS requestId FROM interactive_requests
601
+ WHERE kind = 'decision' AND status = 'pending'
602
+ ORDER BY created_at, request_id`)
603
+ .all()
604
+ .flatMap((row) => isRequestIdRow(row) ? [this.requireInteractiveRequest(row.requestId)] : []);
429
605
  }
430
606
  submitInteraction(options) {
431
607
  return this.recordInteractionSubmission({
@@ -456,6 +632,7 @@ export class HostStateStore {
456
632
  }
457
633
  return {
458
634
  interaction: this.requireInteractiveRequest(options.requestId),
635
+ submissionId: existing.submissionId,
459
636
  outcome: "adopted",
460
637
  receipt: existing.receiptHash === null
461
638
  ? { requestId: options.requestId, submissionId: existing.submissionId }
@@ -472,8 +649,7 @@ export class HostStateStore {
472
649
  }
473
650
  }
474
651
  const request = this.requireInteractiveRequest(options.requestId);
475
- if (request.revision !== options.expectedRevision ||
476
- !["pending", "presenting"].includes(request.status)) {
652
+ if (request.revision !== options.expectedRevision || request.status !== "pending") {
477
653
  throw new Error("Interactive request revision conflict");
478
654
  }
479
655
  const savedPayloadHash = this.state.putJson(options.payload, now);
@@ -487,14 +663,16 @@ export class HostStateStore {
487
663
  if (options.settle) {
488
664
  this.state.connection
489
665
  .prepare(`UPDATE interactive_requests
490
- SET status = 'settled', presenter_id = NULL, presentation_claim_expires_at = NULL,
491
- accepted_submission_id = ?, revision = revision + 1,
666
+ SET status = 'settled', accepted_submission_id = ?, revision = revision + 1,
492
667
  updated_at = ?, settled_at = ?
493
- WHERE request_id = ? AND revision = ? AND status IN ('pending', 'presenting')`)
668
+ WHERE request_id = ? AND revision = ? AND status = 'pending'`)
494
669
  .run(options.submissionId, now, now, options.requestId, options.expectedRevision);
670
+ this.workflowMessages.cancelPendingForSource(options.requestId, "step", now);
671
+ this.workflowMessages.cancelPendingForSource(options.requestId, "decision", now);
495
672
  }
496
673
  return {
497
674
  interaction: this.requireInteractiveRequest(options.requestId),
675
+ submissionId: options.submissionId,
498
676
  outcome: "accepted",
499
677
  receipt: options.receipt ?? {
500
678
  requestId: options.requestId,
@@ -520,7 +698,8 @@ export class HostStateStore {
520
698
  ? undefined
521
699
  : this.state.readBlob(row.errorHash)?.content.toString("utf8");
522
700
  return {
523
- schema: "pi-workflows.host-response.v1",
701
+ schema: CLIENT_PROTOCOL_SCHEMA,
702
+ type: "response",
524
703
  requestId,
525
704
  outcome: row.outcome,
526
705
  ...(row.revision === null ? {} : { revision: row.revision }),
@@ -532,9 +711,7 @@ export class HostStateStore {
532
711
  const row = this.state.connection
533
712
  .prepare(`SELECT request_id AS requestId, run_id AS runId, attempt_id AS attemptId,
534
713
  target_session_id AS targetSessionId, kind, contract_hash AS contractHash,
535
- revision, status, presenter_id AS presenterId,
536
- presentation_claim_expires_at AS presentationClaimExpiresAt,
537
- presentation_session_entry_id AS presentationSessionEntryId,
714
+ revision, status, unproductive_turn_ends AS unproductiveTurnEnds,
538
715
  accepted_submission_id AS acceptedSubmissionId, created_at AS createdAt,
539
716
  updated_at AS updatedAt, settled_at AS settledAt, consumed_at AS consumedAt
540
717
  FROM interactive_requests WHERE request_id = ?`)
@@ -550,9 +727,7 @@ export class HostStateStore {
550
727
  contract: this.state.readJson(row.contractHash),
551
728
  revision: row.revision,
552
729
  status: row.status,
553
- presenterId: row.presenterId,
554
- presentationClaimExpiresAt: iso(row.presentationClaimExpiresAt),
555
- presentationSessionEntryId: row.presentationSessionEntryId,
730
+ unproductiveTurnEnds: row.unproductiveTurnEnds,
556
731
  acceptedSubmissionId: row.acceptedSubmissionId,
557
732
  createdAt: new Date(row.createdAt).toISOString(),
558
733
  updatedAt: new Date(row.updatedAt).toISOString(),
@@ -560,6 +735,18 @@ export class HostStateStore {
560
735
  consumedAt: iso(row.consumedAt),
561
736
  };
562
737
  }
738
+ shiftInteractionTimeout(row, idleMs, now) {
739
+ if (idleMs === 0 || row.deadlineAt === null)
740
+ return;
741
+ const changed = this.state.connection
742
+ .prepare(`UPDATE node_attempts
743
+ SET started_at = CASE WHEN started_at IS NULL THEN NULL ELSE started_at + ? END,
744
+ deadline_at = deadline_at + ?, updated_at = ?
745
+ WHERE attempt_id = ? AND deadline_at = ?`)
746
+ .run(idleMs, idleMs, now, row.attemptId, row.deadlineAt);
747
+ if (changed.changes !== 1)
748
+ throw new Error("Workflow interaction timeout changed concurrently");
749
+ }
563
750
  requireInteractiveRequest(requestId) {
564
751
  const request = this.interactiveRequest(requestId);
565
752
  if (request === undefined)
@@ -568,7 +755,13 @@ export class HostStateStore {
568
755
  }
569
756
  }
570
757
  function conflictResponse(requestId, error) {
571
- return { schema: "pi-workflows.host-response.v1", requestId, outcome: "conflict", error };
758
+ return {
759
+ schema: CLIENT_PROTOCOL_SCHEMA,
760
+ type: "response",
761
+ requestId,
762
+ outcome: "conflict",
763
+ error,
764
+ };
572
765
  }
573
766
  function requireLeaseMs(value) {
574
767
  if (!Number.isSafeInteger(value) || value <= 0)
@@ -590,7 +783,7 @@ function isHostRow(value) {
590
783
  }
591
784
  function isCommandRow(value) {
592
785
  return (isRecord(value) &&
593
- Buffer.isBuffer(value.requestFingerprint) &&
786
+ Buffer.isBuffer(value.clientRequestFingerprint) &&
594
787
  typeof value.outcome === "string" &&
595
788
  nullableNumber(value.revision) &&
596
789
  (value.receiptHash === null || Buffer.isBuffer(value.receiptHash)) &&
@@ -609,7 +802,7 @@ function isRunIdRow(value) {
609
802
  }
610
803
  function isWorkerMessageRow(value) {
611
804
  return (isRecord(value) &&
612
- Buffer.isBuffer(value.requestFingerprint) &&
805
+ Buffer.isBuffer(value.clientRequestFingerprint) &&
613
806
  ["accepted", "adopted", "rejected", "claimLost"].includes(value.outcome) &&
614
807
  nullableNumber(value.revision) &&
615
808
  (value.resultHash === null || Buffer.isBuffer(value.resultHash)) &&
@@ -629,6 +822,29 @@ function isTimedOutInteractionRow(value) {
629
822
  typeof value.attemptId === "string" &&
630
823
  typeof value.nodeId === "string");
631
824
  }
825
+ function isInteractionTimeoutRow(value) {
826
+ return (isRecord(value) &&
827
+ typeof value.attemptId === "string" &&
828
+ typeof value.createdAt === "number" &&
829
+ nullableNumber(value.startedAt) &&
830
+ nullableNumber(value.deadlineAt) &&
831
+ nullableNumber(value.previousTurnEndedAt));
832
+ }
833
+ function isInactiveInteractionTimeoutRow(value) {
834
+ return (isRecord(value) &&
835
+ typeof value.attemptId === "string" &&
836
+ ["pending", "running", "waiting", "interrupted"].includes(value.status) &&
837
+ nullableNumber(value.startedAt) &&
838
+ nullableNumber(value.deadlineAt) &&
839
+ typeof value.updatedAt === "number" &&
840
+ typeof value.turnStartedAt === "number");
841
+ }
842
+ function isExpiredInteractionRunRow(value) {
843
+ return (isRecord(value) && typeof value.runId === "string" && typeof value.targetSessionId === "string");
844
+ }
845
+ function isPausedAtRow(value) {
846
+ return isRecord(value) && typeof value.pausedAt === "number";
847
+ }
632
848
  function isSubmissionRow(value) {
633
849
  return (isRecord(value) &&
634
850
  typeof value.submissionId === "string" &&
@@ -658,10 +874,8 @@ function isInteractiveRequestRow(value) {
658
874
  ["agent", "assistant", "decision"].includes(value.kind) &&
659
875
  Buffer.isBuffer(value.contractHash) &&
660
876
  typeof value.revision === "number" &&
661
- ["pending", "presenting", "settled", "cancelled"].includes(value.status) &&
662
- nullableString(value.presenterId) &&
663
- nullableNumber(value.presentationClaimExpiresAt) &&
664
- nullableString(value.presentationSessionEntryId) &&
877
+ ["pending", "settled", "cancelled"].includes(value.status) &&
878
+ typeof value.unproductiveTurnEnds === "number" &&
665
879
  nullableString(value.acceptedSubmissionId) &&
666
880
  typeof value.createdAt === "number" &&
667
881
  typeof value.updatedAt === "number" &&