@osolmaz/pi-workflows 0.13.1 → 0.13.3

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 (152) hide show
  1. package/README.md +9 -7
  2. package/dist/builtins/autodoc.workflow.d.ts +5 -5
  3. package/dist/builtins/autoimplement.workflow.d.ts +69 -61
  4. package/dist/builtins/autoimplement.workflow.js +47 -8
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +11 -5
  7. package/dist/builtins/autoplan.workflow.js +68 -32
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +4 -4
  10. package/dist/builtins/change-verification.workflow.d.ts +1 -1
  11. package/dist/builtins/plain-summary.workflow.d.ts +1 -1
  12. package/dist/builtins/plain-summary.workflow.js +35 -24
  13. package/dist/builtins/plain-summary.workflow.js.map +1 -1
  14. package/dist/builtins/plan-approval.workflow.d.ts +1 -1
  15. package/dist/builtins/plan-change.workflow.d.ts +25 -23
  16. package/dist/builtins/sanity-check.workflow.d.ts +3 -3
  17. package/dist/builtins/sanity-check.workflow.js +19 -22
  18. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  19. package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
  20. package/dist/controllers/index.d.ts +2 -2
  21. package/dist/controllers/index.js.map +1 -1
  22. package/dist/controllers/sqlite.js +88 -61
  23. package/dist/controllers/sqlite.js.map +1 -1
  24. package/dist/controllers/types.d.ts +34 -0
  25. package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
  26. package/dist/controllers/workflow-engine-scheduler.js +83 -1
  27. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  28. package/dist/controllers/workflows.d.ts +8 -1
  29. package/dist/controllers/workflows.js +36 -0
  30. package/dist/controllers/workflows.js.map +1 -1
  31. package/dist/extension/controller-host.d.ts +2 -1
  32. package/dist/extension/controller-host.js +1 -1
  33. package/dist/extension/controller-host.js.map +1 -1
  34. package/dist/extension/decision-channels.js +45 -7
  35. package/dist/extension/decision-channels.js.map +1 -1
  36. package/dist/extension/follow-up-coordinator.d.ts +27 -0
  37. package/dist/extension/follow-up-coordinator.js +131 -0
  38. package/dist/extension/follow-up-coordinator.js.map +1 -0
  39. package/dist/extension/index.d.ts +9 -0
  40. package/dist/extension/index.js +462 -76
  41. package/dist/extension/index.js.map +1 -1
  42. package/dist/extension/recorder.d.ts +1 -1
  43. package/dist/extension/recorder.js +8 -8
  44. package/dist/extension/recorder.js.map +1 -1
  45. package/dist/extension/session-events.d.ts +2 -3
  46. package/dist/extension/session-events.js +11 -10
  47. package/dist/extension/session-events.js.map +1 -1
  48. package/dist/extension/widget.js +9 -0
  49. package/dist/extension/widget.js.map +1 -1
  50. package/dist/state/database.d.ts +2 -1
  51. package/dist/state/database.js +11 -13
  52. package/dist/state/database.js.map +1 -1
  53. package/dist/state/json.js +6 -1
  54. package/dist/state/json.js.map +1 -1
  55. package/dist/state/mutation.d.ts +1 -1
  56. package/dist/state/mutation.js.map +1 -1
  57. package/dist/state/prune.d.ts +18 -0
  58. package/dist/state/prune.js +373 -0
  59. package/dist/state/prune.js.map +1 -0
  60. package/dist/state/schema.d.ts +1 -1
  61. package/dist/state/schema.js +130 -13
  62. package/dist/state/schema.js.map +1 -1
  63. package/dist/viewer/cli.d.ts +3 -1
  64. package/dist/viewer/cli.js +55 -4
  65. package/dist/viewer/cli.js.map +1 -1
  66. package/dist/viewer/render.js +19 -1
  67. package/dist/viewer/render.js.map +1 -1
  68. package/dist/viewer/session-reducer.d.ts +0 -1
  69. package/dist/viewer/session-reducer.js +2 -24
  70. package/dist/viewer/session-reducer.js.map +1 -1
  71. package/dist/workflows/composition.d.ts +2 -0
  72. package/dist/workflows/composition.js +15 -0
  73. package/dist/workflows/composition.js.map +1 -1
  74. package/dist/workflows/definition.d.ts +6 -3
  75. package/dist/workflows/definition.js +3 -0
  76. package/dist/workflows/definition.js.map +1 -1
  77. package/dist/workflows/engine.d.ts +6 -1
  78. package/dist/workflows/engine.js +303 -36
  79. package/dist/workflows/engine.js.map +1 -1
  80. package/dist/workflows/human-decision.d.ts +1 -0
  81. package/dist/workflows/human-decision.js +25 -9
  82. package/dist/workflows/human-decision.js.map +1 -1
  83. package/dist/workflows/index.d.ts +3 -1
  84. package/dist/workflows/index.js +2 -0
  85. package/dist/workflows/index.js.map +1 -1
  86. package/dist/workflows/json-patch.d.ts +47 -0
  87. package/dist/workflows/json-patch.js +298 -0
  88. package/dist/workflows/json-patch.js.map +1 -0
  89. package/dist/workflows/schema.js +29 -1
  90. package/dist/workflows/schema.js.map +1 -1
  91. package/dist/workflows/settings.d.ts +128 -0
  92. package/dist/workflows/settings.js +199 -0
  93. package/dist/workflows/settings.js.map +1 -0
  94. package/dist/workflows/store.d.ts +81 -3
  95. package/dist/workflows/store.js +1563 -50
  96. package/dist/workflows/store.js.map +1 -1
  97. package/dist/workflows/tool-input.d.ts +22 -0
  98. package/dist/workflows/tool-input.js +43 -0
  99. package/dist/workflows/tool-input.js.map +1 -1
  100. package/dist/workflows/types.d.ts +41 -6
  101. package/docs/2026-08-25-workflow-follow-ups.md +132 -0
  102. package/docs/2026-08-25-workflow-settings.md +169 -0
  103. package/docs/DEFERRED_TURNS.md +6 -0
  104. package/docs/DESIGN_PHILOSOPHY.md +2 -0
  105. package/docs/SQLITE_STATE.md +36 -17
  106. package/docs/WORKFLOW_COMPOSITION.md +15 -0
  107. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
  108. package/docs/plans/2026-08-25-autoplan-user-intent-capture-plan.md +107 -0
  109. package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
  110. package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
  111. package/docs/session-event-journal.md +2 -3
  112. package/docs/tui-viewer.md +7 -6
  113. package/docs/workflows.md +58 -10
  114. package/examples/workflows/live-settings.workflow.ts +93 -0
  115. package/herdr-plugin.toml +1 -1
  116. package/package.json +1 -1
  117. package/src/builtins/autoimplement.workflow.ts +56 -8
  118. package/src/builtins/autoplan.workflow.ts +80 -32
  119. package/src/builtins/catalog.ts +4 -4
  120. package/src/builtins/plain-summary.workflow.ts +38 -40
  121. package/src/builtins/sanity-check.workflow.ts +19 -22
  122. package/src/controllers/index.ts +6 -0
  123. package/src/controllers/sqlite.ts +132 -72
  124. package/src/controllers/types.ts +40 -0
  125. package/src/controllers/workflow-engine-scheduler.ts +103 -1
  126. package/src/controllers/workflows.ts +68 -0
  127. package/src/extension/controller-host.ts +6 -1
  128. package/src/extension/decision-channels.ts +47 -7
  129. package/src/extension/follow-up-coordinator.ts +151 -0
  130. package/src/extension/index.ts +540 -89
  131. package/src/extension/recorder.ts +10 -8
  132. package/src/extension/session-events.ts +15 -13
  133. package/src/extension/widget.ts +8 -0
  134. package/src/state/database.ts +12 -12
  135. package/src/state/json.ts +6 -1
  136. package/src/state/mutation.ts +4 -1
  137. package/src/state/prune.ts +502 -0
  138. package/src/state/schema.ts +130 -13
  139. package/src/viewer/cli.ts +52 -5
  140. package/src/viewer/render.ts +43 -1
  141. package/src/viewer/session-reducer.ts +2 -29
  142. package/src/workflows/composition.ts +19 -0
  143. package/src/workflows/definition.ts +19 -5
  144. package/src/workflows/engine.ts +365 -36
  145. package/src/workflows/human-decision.ts +41 -11
  146. package/src/workflows/index.ts +43 -0
  147. package/src/workflows/json-patch.ts +375 -0
  148. package/src/workflows/schema.ts +31 -1
  149. package/src/workflows/settings.ts +430 -0
  150. package/src/workflows/store.ts +2268 -121
  151. package/src/workflows/tool-input.ts +58 -0
  152. package/src/workflows/types.ts +43 -5
@@ -1,18 +1,50 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { StateDatabase, workflowStatePath } from "../state/database.js";
3
- import { canonicalJson } from "../state/json.js";
4
- import { resourceIdFor, tokenHash, type MutationActor, type OwnerType } from "../state/mutation.js";
3
+ import { canonicalJson, type JsonValue } from "../state/json.js";
4
+ import {
5
+ StateMutationStore,
6
+ StaleResourceError,
7
+ resourceIdFor,
8
+ tokenHash,
9
+ type ActorType,
10
+ type LeaseClaim,
11
+ type MutationActor,
12
+ type OwnerType,
13
+ } from "../state/mutation.js";
5
14
  import { compositionMetadata } from "./composition.js";
15
+ import { applyJsonPatch, validateJsonPatch } from "./json-patch.js";
16
+ import {
17
+ applyWorkflowSettingsPatch,
18
+ workflowSettingsScopeId,
19
+ type InitialWorkflowSettingsScope,
20
+ type WorkflowFollowUpQueueRecord,
21
+ type WorkflowFollowUpRecord,
22
+ type WorkflowFollowUpState,
23
+ type WorkflowPresentationState,
24
+ type WorkflowQueueFollowUpRequest,
25
+ type WorkflowRemoveFollowUpRequest,
26
+ type WorkflowSettingsChangeRecord,
27
+ type WorkflowSettingsChangeRequest,
28
+ type WorkflowSettingsChangeResult,
29
+ type WorkflowSettingsDefinition,
30
+ type WorkflowSettingsScopeRecord,
31
+ } from "./settings.js";
6
32
  import type {
7
33
  WorkflowDefinition,
8
34
  WorkflowDefinitionSnapshot,
35
+ HumanDecisionReceipt,
9
36
  WorkflowNodeDefinition,
37
+ WorkflowNodeResult,
10
38
  WorkflowNodeSnapshot,
11
39
  WorkflowRunState,
40
+ WorkflowSource,
41
+ WorkflowMountedSource,
42
+ WorkflowStepRecord,
12
43
  WorkflowSessionBinding,
13
44
  WorkflowSessionCapture,
14
45
  WorkflowSessionEntryRecord,
15
46
  WorkflowSessionEventRecord,
47
+ WorkflowSettingsSnapshot,
16
48
  WorkflowTraceEvent,
17
49
  WorkflowTraceEventDraft,
18
50
  WorkflowUpdateInput,
@@ -27,6 +59,19 @@ export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1" as const;
27
59
  export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1" as const;
28
60
  export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
29
61
 
62
+ const FOLLOW_UP_ROW_SELECT = `
63
+ SELECT f.follow_up_id AS followUpId, f.resource_id AS resourceId,
64
+ f.queue_resource_id AS queueResourceId, f.run_id AS runId,
65
+ f.request_id AS requestId, f.order_number AS orderNumber,
66
+ f.target_session_id AS targetSessionId, f.actor_type AS actorType,
67
+ f.actor_id AS actorId, f.source_type AS sourceType,
68
+ f.prompt_hash AS promptHash, f.status,
69
+ f.session_entry_id AS sessionEntryId, f.reason_hash AS reasonHash,
70
+ f.created_at AS createdAt, f.updated_at AS updatedAt, f.sent_at AS sentAt,
71
+ r.revision
72
+ FROM workflow_follow_ups f
73
+ JOIN resources r ON r.resource_id = f.resource_id`;
74
+
30
75
  export function workflowStateDatabasePath(homeDir?: string): string {
31
76
  return workflowStatePath(homeDir);
32
77
  }
@@ -58,9 +103,62 @@ type RunContext = {
58
103
  type RunRow = {
59
104
  runId: string;
60
105
  resourceId: string;
61
- stateHash: Buffer;
62
106
  definitionHash: Buffer;
107
+ definitionDigest: Buffer;
108
+ workflowRef: string;
109
+ parentRunId: string | null;
110
+ title: string | null;
111
+ status: string;
112
+ paused: number;
113
+ statusDetail: string | null;
114
+ inputHash: Buffer;
115
+ finalOutputHash: Buffer | null;
116
+ errorHash: Buffer | null;
117
+ createdAt: number;
118
+ updatedAt: number;
119
+ finishedAt: number | null;
120
+ };
121
+
122
+ type RunSourceRow = {
123
+ mountPath: string;
124
+ sourceType: "builtin" | "file";
125
+ sourceRef: string;
126
+ sourceRevision: string;
127
+ };
128
+
129
+ type StoredAttemptReceipt = Pick<WorkflowStepRecord, "action" | "assistantMessage">;
130
+
131
+ type StepRow = {
132
+ stepIndex: number;
133
+ attemptId: string;
134
+ nodeId: string;
135
+ nodeType: WorkflowStepRecord["nodeType"];
63
136
  status: string;
137
+ promptHash: Buffer | null;
138
+ outputHash: Buffer | null;
139
+ outputOverrideHash: Buffer | null;
140
+ receiptHash: Buffer | null;
141
+ promptEntryHash: Buffer | null;
142
+ responseEntryHash: Buffer | null;
143
+ firstEntryId: string | null;
144
+ lastEntryId: string | null;
145
+ errorHash: Buffer | null;
146
+ settingsScopeId: string | null;
147
+ settingsChangeNumber: number | null;
148
+ settingsHash: Buffer | null;
149
+ startedAt: number;
150
+ finishedAt: number;
151
+ };
152
+
153
+ type UpdateRow = {
154
+ updateId: string;
155
+ runRevision: number;
156
+ nodeId: string;
157
+ attemptId: string;
158
+ updateType: string;
159
+ updateKey: string;
160
+ dataHash: Buffer;
161
+ recordedAt: number;
64
162
  };
65
163
 
66
164
  type EventRow = {
@@ -70,6 +168,79 @@ type EventRow = {
70
168
  recordedAt: number;
71
169
  };
72
170
 
171
+ type SettingsScopeRow = {
172
+ scopeId: string;
173
+ resourceId: string;
174
+ originRunId: string;
175
+ activeRunId: string;
176
+ mountPath: string;
177
+ invocation: number;
178
+ currentHash: Buffer;
179
+ revision: number;
180
+ createdAt: number;
181
+ updatedAt: number;
182
+ };
183
+
184
+ type SettingsProjectionRow = {
185
+ scopeId: string;
186
+ initialHash: Buffer;
187
+ currentHash: Buffer;
188
+ revision: number;
189
+ };
190
+
191
+ type SettingsProjectionChangeRow = {
192
+ changeNumber: number;
193
+ patchHash: Buffer;
194
+ beforeHash: Buffer;
195
+ afterHash: Buffer;
196
+ };
197
+
198
+ type SettingsChangeRow = {
199
+ changeId: string;
200
+ scopeId: string;
201
+ requestId: string;
202
+ changeNumber: number;
203
+ actorType: string;
204
+ actorId: string | null;
205
+ sourceType: string;
206
+ patchHash: Buffer;
207
+ beforeHash: Buffer;
208
+ afterHash: Buffer;
209
+ acceptedAt: number;
210
+ };
211
+
212
+ type FollowUpQueueRow = {
213
+ runId: string;
214
+ resourceId: string;
215
+ originSessionId: string | null;
216
+ presentationState: string;
217
+ presentationEntryId: string | null;
218
+ presentationAssistantEntryId: string | null;
219
+ presentationReasonHash: Buffer | null;
220
+ revision: number;
221
+ };
222
+
223
+ type FollowUpRow = {
224
+ followUpId: string;
225
+ resourceId: string;
226
+ queueResourceId: string;
227
+ runId: string;
228
+ requestId: string;
229
+ orderNumber: number;
230
+ targetSessionId: string;
231
+ actorType: string;
232
+ actorId: string | null;
233
+ sourceType: string;
234
+ promptHash: Buffer;
235
+ status: string;
236
+ sessionEntryId: string | null;
237
+ reasonHash: Buffer | null;
238
+ createdAt: number;
239
+ updatedAt: number;
240
+ sentAt: number | null;
241
+ revision: number;
242
+ };
243
+
73
244
  type SegmentRow = {
74
245
  segmentId: string;
75
246
  runId: string;
@@ -128,12 +299,18 @@ export type LoadedWorkflowRun = {
128
299
  sessionCapture: WorkflowSessionCapture | null;
129
300
  sessionIntegrity: SessionCaptureIntegrity;
130
301
  sessionSegments: SessionCaptureSegment[];
302
+ settingsScopes?: WorkflowSettingsScopeRecord[];
303
+ followUpQueue?: WorkflowFollowUpQueueRecord | null;
131
304
  };
132
305
 
133
306
  export type ReadWorkflowRunOptions = {
134
307
  includeTrace?: boolean;
135
308
  };
136
309
 
310
+ export type InitializeWorkflowRunOptions = {
311
+ initialSettings?: InitialWorkflowSettingsScope[];
312
+ };
313
+
137
314
  export class WorkflowRunStore {
138
315
  readonly databasePath: string;
139
316
  readonly state: StateDatabase;
@@ -142,6 +319,7 @@ export class WorkflowRunStore {
142
319
  | undefined;
143
320
  private readonly contexts = new Map<string, RunContext>();
144
321
  private readonly ownsState: boolean;
322
+ private readonly mutations: StateMutationStore;
145
323
 
146
324
  constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
147
325
  this.authorityProvider = options.authorityProvider;
@@ -154,13 +332,18 @@ export class WorkflowRunStore {
154
332
  checkLegacyState: databasePath === workflowStatePath(),
155
333
  });
156
334
  this.databasePath = this.state.filePath;
335
+ this.mutations = new StateMutationStore(this.state);
157
336
  }
158
337
 
159
338
  close(): void {
160
339
  if (this.ownsState) this.state.close();
161
340
  }
162
341
 
163
- async initializeRun(workflow: WorkflowDefinition, state: WorkflowRunState): Promise<string> {
342
+ async initializeRun(
343
+ workflow: WorkflowDefinition,
344
+ state: WorkflowRunState,
345
+ options: InitializeWorkflowRunOptions = {},
346
+ ): Promise<string> {
164
347
  assertValidRunId(state.runId);
165
348
  if (!this.contexts.has(state.runId)) {
166
349
  const reserved = this.readRunRow(state.runId);
@@ -200,7 +383,9 @@ export class WorkflowRunStore {
200
383
  payload: { workflowName: workflow.name },
201
384
  });
202
385
  this.persistRunState(reserved, state, revision, now);
386
+ this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
203
387
  this.syncNodeAttempts(state, snapshot, now);
388
+ this.syncContinuationIdentity(state);
204
389
  context.revision = revision;
205
390
  return;
206
391
  }
@@ -226,19 +411,18 @@ export class WorkflowRunStore {
226
411
  state.traceSeq = 1;
227
412
  state.updatedAt = at;
228
413
  const inputHash = this.state.putJson(state.input, now);
229
- const workflowSourceHash = this.state.putJson(state.workflowSource ?? source, now);
230
414
  const launchOptionsHash = this.state.putJson({}, now);
231
- const stateHash = this.state.putJson(state, now);
415
+ const finalOutputHash =
416
+ state.finalOutput === undefined ? null : this.state.putJson(state.finalOutput, now);
232
417
  const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
233
418
  this.state.connection
234
419
  .prepare(
235
420
  `INSERT INTO runs(
236
421
  run_id, resource_id, project_id, parent_run_id, definition_digest,
237
- workflow_ref, workflow_source_hash, launch_options_hash,
238
- source_type, source_ref, source_revision, title, status, paused,
239
- status_detail, input_hash, output_hash, error_hash,
422
+ workflow_ref, launch_options_hash, title, status, paused,
423
+ status_detail, input_hash, final_output_hash, error_hash,
240
424
  created_at, updated_at, finished_at
241
- ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
425
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
242
426
  )
243
427
  .run(
244
428
  state.runId,
@@ -246,27 +430,26 @@ export class WorkflowRunStore {
246
430
  state.parentRunId ?? null,
247
431
  definitionDigest,
248
432
  state.workflowName,
249
- workflowSourceHash,
250
433
  launchOptionsHash,
251
- source.type,
252
- source.ref,
253
- source.revision,
254
434
  state.runTitle ?? null,
255
435
  state.status,
256
436
  state.paused === true ? 1 : 0,
257
437
  state.statusDetail ?? null,
258
438
  inputHash,
259
- stateHash,
439
+ finalOutputHash,
260
440
  errorHash,
261
441
  Date.parse(state.startedAt),
262
442
  now,
263
443
  state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
264
444
  );
445
+ this.insertRunSources(state.runId, source, state.workflowSources ?? []);
446
+ this.syncContinuationIdentity(state);
265
447
  insertRunEvent(this.state, resourceId, 1, at, {
266
448
  scope: "run",
267
449
  type: "run_created",
268
450
  payload: { workflowName: workflow.name },
269
451
  });
452
+ this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
270
453
  this.syncNodeAttempts(state, snapshot, now);
271
454
  });
272
455
  this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
@@ -274,12 +457,603 @@ export class WorkflowRunStore {
274
457
  });
275
458
  }
276
459
 
460
+ ensureSettingsScope(options: {
461
+ runId: string;
462
+ mountPath: string;
463
+ invocation: number;
464
+ settings: JsonValue;
465
+ }): WorkflowSettingsScopeRecord {
466
+ const now = Date.now();
467
+ return this.state.transaction(() => {
468
+ this.requireRunAcceptsSettings(options.runId, false);
469
+ const originRunId = this.logicalOriginRunId(options.runId);
470
+ const scopeId = workflowSettingsScopeId(originRunId, options.mountPath, options.invocation);
471
+ const existing = this.settingsScopeRow(scopeId);
472
+ if (existing !== undefined) {
473
+ if (existing.activeRunId !== options.runId) {
474
+ throw new Error(`Workflow settings scope belongs to another active run: ${scopeId}`);
475
+ }
476
+ return this.settingsScopeRecord(existing);
477
+ }
478
+ const resourceId = this.mutations.ensureResource("settings", scopeId, now);
479
+ const settingsHash = this.state.putJson(options.settings, now);
480
+ this.state.connection
481
+ .prepare(
482
+ `INSERT INTO workflow_settings(
483
+ scope_id, resource_id, origin_run_id, active_run_id, mount_path, invocation,
484
+ initial_hash, current_hash, created_at, updated_at
485
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
486
+ )
487
+ .run(
488
+ scopeId,
489
+ resourceId,
490
+ originRunId,
491
+ options.runId,
492
+ options.mountPath,
493
+ options.invocation,
494
+ settingsHash,
495
+ settingsHash,
496
+ now,
497
+ now,
498
+ );
499
+ return this.settingsScopeRecord(this.requireSettingsScopeRow(scopeId));
500
+ });
501
+ }
502
+
503
+ getSettingsScope(scopeId: string): WorkflowSettingsScopeRecord | undefined {
504
+ const row = this.settingsScopeRow(scopeId);
505
+ return row === undefined ? undefined : this.settingsScopeRecord(row);
506
+ }
507
+
508
+ getSettingsScopeAtChange(
509
+ scopeId: string,
510
+ changeNumber: number,
511
+ ): WorkflowSettingsScopeRecord | undefined {
512
+ if (!Number.isInteger(changeNumber) || changeNumber < 0) {
513
+ throw new Error("Workflow settings change number must be a non-negative integer");
514
+ }
515
+ const row = this.settingsScopeRow(scopeId);
516
+ if (row === undefined) return undefined;
517
+ if (changeNumber > row.revision) {
518
+ throw new Error(`Workflow settings change ${changeNumber} does not exist for ${scopeId}`);
519
+ }
520
+ if (changeNumber === row.revision) return this.settingsScopeRecord(row);
521
+ const saved =
522
+ changeNumber === 0
523
+ ? this.state.connection
524
+ .prepare(
525
+ "SELECT initial_hash AS settingsHash FROM workflow_settings WHERE scope_id = ?",
526
+ )
527
+ .get(scopeId)
528
+ : this.state.connection
529
+ .prepare(
530
+ `SELECT after_hash AS settingsHash FROM workflow_setting_changes
531
+ WHERE scope_id = ? AND change_number = ?`,
532
+ )
533
+ .get(scopeId, changeNumber);
534
+ if (!isRecord(saved) || !Buffer.isBuffer(saved.settingsHash)) {
535
+ throw new Error(`Workflow settings change ${changeNumber} is missing for ${scopeId}`);
536
+ }
537
+ return this.settingsScopeRecord(row, changeNumber, saved.settingsHash);
538
+ }
539
+
540
+ listSettingsScopes(runId: string): WorkflowSettingsScopeRecord[] {
541
+ return this.state.connection
542
+ .prepare(
543
+ `SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
544
+ s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
545
+ s.mount_path AS mountPath, s.invocation,
546
+ s.current_hash AS currentHash, r.revision,
547
+ s.created_at AS createdAt, s.updated_at AS updatedAt
548
+ FROM workflow_settings s
549
+ JOIN resources r ON r.resource_id = s.resource_id
550
+ WHERE s.active_run_id = ?
551
+ ORDER BY s.mount_path, s.invocation`,
552
+ )
553
+ .all(runId)
554
+ .filter(isSettingsScopeRow)
555
+ .map((row) => this.settingsScopeRecord(row));
556
+ }
557
+
558
+ private settingsScopesForRunView(runId: string): WorkflowSettingsScopeRecord[] {
559
+ const originRunId = this.logicalOriginRunId(runId);
560
+ return this.state.connection
561
+ .prepare(
562
+ `SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
563
+ s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
564
+ s.mount_path AS mountPath, s.invocation,
565
+ s.current_hash AS currentHash, r.revision,
566
+ s.created_at AS createdAt, s.updated_at AS updatedAt
567
+ FROM workflow_settings s
568
+ JOIN resources r ON r.resource_id = s.resource_id
569
+ WHERE s.origin_run_id = ? OR s.active_run_id = ?
570
+ ORDER BY s.mount_path, s.invocation`,
571
+ )
572
+ .all(originRunId, runId)
573
+ .filter(isSettingsScopeRow)
574
+ .map((row) => this.settingsScopeRecord(row));
575
+ }
576
+
577
+ findSettingsScope(
578
+ runId: string,
579
+ mountPath: string,
580
+ invocation: number,
581
+ ): WorkflowSettingsScopeRecord | undefined {
582
+ const row = this.state.connection
583
+ .prepare(
584
+ `SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
585
+ s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
586
+ s.mount_path AS mountPath, s.invocation,
587
+ s.current_hash AS currentHash, r.revision,
588
+ s.created_at AS createdAt, s.updated_at AS updatedAt
589
+ FROM workflow_settings s
590
+ JOIN resources r ON r.resource_id = s.resource_id
591
+ WHERE s.active_run_id = ? AND s.mount_path = ? AND s.invocation = ?`,
592
+ )
593
+ .get(runId, mountPath, invocation);
594
+ return isSettingsScopeRow(row) ? this.settingsScopeRecord(row) : undefined;
595
+ }
596
+
597
+ async changeSettings<TSettings, TInput = unknown>(
598
+ definition: WorkflowSettingsDefinition<TSettings, TInput>,
599
+ request: WorkflowSettingsChangeRequest,
600
+ ): Promise<WorkflowSettingsChangeResult> {
601
+ assertRequestId(request.requestId);
602
+ assertSourceType(request.source);
603
+ const patch = validateJsonPatch(request.patch);
604
+ const patchJson = canonicalJson(patch);
605
+ const patchDigest = createHash("sha256").update(patchJson).digest();
606
+ for (let attempt = 0; attempt < 8; attempt += 1) {
607
+ const prior = this.settingsChangeRow(request.scopeId, request.requestId);
608
+ if (prior !== undefined) {
609
+ this.assertSameSettingsChange(prior, request, patchDigest);
610
+ return {
611
+ scope: this.settingsScopeRecord(this.requireSettingsScopeRow(request.scopeId)),
612
+ change: this.settingsChangeRecord(prior),
613
+ adopted: true,
614
+ };
615
+ }
616
+ const scope = this.requireSettingsScopeRow(request.scopeId);
617
+ if (scope.activeRunId !== request.runId) {
618
+ throw new Error(`Workflow settings scope is not active for run ${request.runId}`);
619
+ }
620
+ this.requireRunAcceptsSettings(request.runId, true);
621
+ if (
622
+ request.expectedChangeNumber !== undefined &&
623
+ request.expectedChangeNumber !== scope.revision
624
+ ) {
625
+ throw new StaleResourceError(
626
+ `Workflow settings changed: expected ${request.expectedChangeNumber}, current ${scope.revision}`,
627
+ );
628
+ }
629
+ const before = this.state.readJson(scope.currentHash);
630
+ const applied = await applyWorkflowSettingsPatch(
631
+ definition,
632
+ before,
633
+ patch,
634
+ request.actor,
635
+ request.source,
636
+ );
637
+ const beforeHash = scope.currentHash;
638
+ const afterHash = createHash("sha256").update(canonicalJson(applied.json)).digest();
639
+ try {
640
+ const mutation = this.mutations.mutate(
641
+ {
642
+ resourceId: scope.resourceId,
643
+ operation: "settings.change",
644
+ actor: request.actor,
645
+ expectedRevision: scope.revision,
646
+ },
647
+ "settings.changed",
648
+ ({ database, nextRevision, now }) => {
649
+ this.requireRunAcceptsSettings(request.runId, true);
650
+ const current = this.requireSettingsScopeRow(request.scopeId);
651
+ if (current.activeRunId !== request.runId || !current.currentHash.equals(beforeHash)) {
652
+ throw new StaleResourceError("Workflow settings changed before the patch committed");
653
+ }
654
+ const patchHash = database.putJson(applied.patch, now);
655
+ const savedAfterHash = database.putJson(applied.json, now);
656
+ const changeId = `setting-change-${randomUUID()}`;
657
+ database.connection
658
+ .prepare(
659
+ `INSERT INTO workflow_setting_changes(
660
+ change_id, scope_id, request_id, change_number,
661
+ actor_type, actor_id, source_type, patch_hash,
662
+ before_hash, after_hash, accepted_at
663
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
664
+ )
665
+ .run(
666
+ changeId,
667
+ request.scopeId,
668
+ request.requestId,
669
+ nextRevision,
670
+ request.actor.type,
671
+ request.actor.id ?? null,
672
+ request.source,
673
+ patchHash,
674
+ beforeHash,
675
+ savedAfterHash,
676
+ now,
677
+ );
678
+ database.connection
679
+ .prepare(
680
+ `UPDATE workflow_settings SET current_hash = ?, updated_at = ? WHERE scope_id = ?`,
681
+ )
682
+ .run(savedAfterHash, now, request.scopeId);
683
+ return changeId;
684
+ },
685
+ {
686
+ payload: {
687
+ scopeId: request.scopeId,
688
+ requestId: request.requestId,
689
+ source: request.source,
690
+ beforeHash: beforeHash.toString("hex"),
691
+ afterHash: afterHash.toString("hex"),
692
+ },
693
+ },
694
+ );
695
+ const row = this.settingsChangeRow(request.scopeId, request.requestId);
696
+ if (row === undefined || row.changeId !== mutation.value) {
697
+ throw new Error("Accepted workflow settings change is missing");
698
+ }
699
+ return {
700
+ scope: this.settingsScopeRecord(this.requireSettingsScopeRow(request.scopeId)),
701
+ change: this.settingsChangeRecord(row),
702
+ adopted: false,
703
+ };
704
+ } catch (error) {
705
+ if (!(error instanceof StaleResourceError) || request.expectedChangeNumber !== undefined) {
706
+ throw error;
707
+ }
708
+ }
709
+ }
710
+ throw new StaleResourceError("Workflow settings kept changing; retry with an expected number");
711
+ }
712
+
713
+ queueFollowUp(request: WorkflowQueueFollowUpRequest): {
714
+ followUp: WorkflowFollowUpRecord;
715
+ adopted: boolean;
716
+ } {
717
+ assertRequestId(request.requestId);
718
+ assertSourceType(request.source);
719
+ if (typeof request.prompt !== "string" || request.prompt.trim().length === 0) {
720
+ throw new Error("Workflow follow-up prompt must be a non-empty string");
721
+ }
722
+ if (Buffer.byteLength(request.prompt, "utf8") > 64 * 1024) {
723
+ throw new Error("Workflow follow-up prompt cannot exceed 65536 bytes");
724
+ }
725
+ for (let attempt = 0; attempt < 8; attempt += 1) {
726
+ const prior = this.followUpRowByRequest(request.runId, request.requestId);
727
+ if (prior !== undefined) {
728
+ this.assertSameFollowUp(prior, request);
729
+ return { followUp: this.followUpRecord(prior), adopted: true };
730
+ }
731
+ this.requireRunAcceptsSettings(request.runId, true);
732
+ const queue = this.requireFollowUpQueueRow(request.runId);
733
+ try {
734
+ const result = this.mutations.mutate(
735
+ {
736
+ resourceId: queue.resourceId,
737
+ operation: "follow-up.queue",
738
+ actor: request.actor,
739
+ expectedRevision: queue.revision,
740
+ },
741
+ "follow-up.queued",
742
+ ({ database, now }) => {
743
+ this.requireRunAcceptsSettings(request.runId, true);
744
+ const currentQueue = this.requireFollowUpQueueRow(request.runId);
745
+ if (
746
+ currentQueue.originSessionId !== null &&
747
+ currentQueue.originSessionId !== request.targetSessionId
748
+ ) {
749
+ throw new Error("Workflow follow-ups must target the run's origin Pi session");
750
+ }
751
+ if (currentQueue.originSessionId === null) {
752
+ database.connection
753
+ .prepare(
754
+ `UPDATE workflow_follow_up_queues
755
+ SET origin_session_id = ?, updated_at = ? WHERE run_id = ?`,
756
+ )
757
+ .run(request.targetSessionId, now, request.runId);
758
+ }
759
+ const order = this.nextFollowUpOrder(request.runId);
760
+ const followUpId = followUpIdFor(request.runId, request.requestId);
761
+ const resourceId = this.mutations.ensureResource("follow_up", followUpId, now);
762
+ const promptHash = database.putText(request.prompt, now);
763
+ database.connection
764
+ .prepare(
765
+ `INSERT INTO workflow_follow_ups(
766
+ follow_up_id, resource_id, queue_resource_id, run_id,
767
+ request_id, order_number, target_session_id,
768
+ actor_type, actor_id, source_type, prompt_hash, status,
769
+ created_at, updated_at
770
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)`,
771
+ )
772
+ .run(
773
+ followUpId,
774
+ resourceId,
775
+ queue.resourceId,
776
+ request.runId,
777
+ request.requestId,
778
+ order,
779
+ request.targetSessionId,
780
+ request.actor.type,
781
+ request.actor.id ?? null,
782
+ request.source,
783
+ promptHash,
784
+ now,
785
+ now,
786
+ );
787
+ return followUpId;
788
+ },
789
+ { payload: { runId: request.runId, requestId: request.requestId } },
790
+ );
791
+ return {
792
+ followUp: this.followUpRecord(this.requireFollowUpRow(result.value)),
793
+ adopted: false,
794
+ };
795
+ } catch (error) {
796
+ if (!(error instanceof StaleResourceError)) throw error;
797
+ }
798
+ }
799
+ throw new StaleResourceError("Workflow follow-up queue kept changing; retry the request");
800
+ }
801
+
802
+ removeFollowUp(request: WorkflowRemoveFollowUpRequest): WorkflowFollowUpRecord {
803
+ assertSourceType(request.source);
804
+ const row = this.requireFollowUpRow(request.followUpId);
805
+ if (row.runId !== request.runId) {
806
+ throw new Error(`Workflow follow-up is not part of run ${request.runId}`);
807
+ }
808
+ if (row.status === "removed") return this.followUpRecord(row);
809
+ if (row.status === "sent" || row.status === "cancelled") {
810
+ throw new Error(`Workflow follow-up cannot be removed after it is ${row.status}`);
811
+ }
812
+ this.assertFollowUpRemovalAuthority(row, request.actor, request.source);
813
+ if (this.hasLiveLease(row.resourceId)) {
814
+ throw new Error("Workflow follow-up is being delivered and cannot be removed");
815
+ }
816
+ const reason = "Removed before delivery";
817
+ this.mutations.mutate(
818
+ {
819
+ resourceId: row.resourceId,
820
+ operation: "follow-up.remove",
821
+ actor: request.actor,
822
+ expectedRevision: row.revision,
823
+ },
824
+ "follow-up.removed",
825
+ ({ database, now }) => {
826
+ const reasonHash = database.putText(reason, now);
827
+ const update = database.connection
828
+ .prepare(
829
+ `UPDATE workflow_follow_ups
830
+ SET status = 'removed', reason_hash = ?, updated_at = ?
831
+ WHERE follow_up_id = ? AND status IN ('queued', 'pending_presentation', 'ready')`,
832
+ )
833
+ .run(reasonHash, now, request.followUpId);
834
+ if (update.changes !== 1) {
835
+ throw new StaleResourceError("Workflow follow-up changed before removal");
836
+ }
837
+ },
838
+ { payload: { followUpId: request.followUpId, source: request.source } },
839
+ );
840
+ return this.followUpRecord(this.requireFollowUpRow(request.followUpId));
841
+ }
842
+
843
+ originSessionId(runId: string): string | undefined {
844
+ const row = this.state.connection
845
+ .prepare(
846
+ `SELECT COALESCE(q.origin_session_id, b.origin_session_id) AS originSessionId
847
+ FROM runs r
848
+ LEFT JOIN workflow_follow_up_queues q ON q.run_id = r.run_id
849
+ LEFT JOIN run_bindings b ON b.run_id = r.run_id
850
+ WHERE r.run_id = ?`,
851
+ )
852
+ .get(runId);
853
+ return isRecord(row) && typeof row.originSessionId === "string"
854
+ ? row.originSessionId
855
+ : undefined;
856
+ }
857
+
858
+ readFollowUpQueue(runId: string): WorkflowFollowUpQueueRecord | undefined {
859
+ const queue = this.followUpQueueRow(runId);
860
+ if (queue === undefined) return undefined;
861
+ const followUps = this.state.connection
862
+ .prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? ORDER BY f.order_number`)
863
+ .all(runId)
864
+ .filter(isFollowUpRow)
865
+ .map((row) => this.followUpRecord(row));
866
+ return {
867
+ runId,
868
+ ...(queue.originSessionId !== null ? { originSessionId: queue.originSessionId } : {}),
869
+ presentationState: assertPresentationState(queue.presentationState),
870
+ ...(queue.presentationEntryId !== null
871
+ ? { presentationEntryId: queue.presentationEntryId }
872
+ : {}),
873
+ ...(queue.presentationAssistantEntryId !== null
874
+ ? { presentationAssistantEntryId: queue.presentationAssistantEntryId }
875
+ : {}),
876
+ ...(queue.presentationReasonHash !== null
877
+ ? { presentationReason: this.readText(queue.presentationReasonHash) }
878
+ : {}),
879
+ followUps,
880
+ };
881
+ }
882
+
883
+ listPendingPresentations(targetSessionId: string): string[] {
884
+ return this.state.connection
885
+ .prepare(
886
+ `SELECT run_id AS runId FROM workflow_follow_up_queues
887
+ WHERE origin_session_id = ? AND presentation_state = 'pending'
888
+ ORDER BY created_at`,
889
+ )
890
+ .all(targetSessionId)
891
+ .flatMap((row) => (isRecord(row) && typeof row.runId === "string" ? [row.runId] : []));
892
+ }
893
+
894
+ settleFollowUpPresentation(options: {
895
+ runId: string;
896
+ state: "settled" | "unavailable";
897
+ presentationEntryId?: string;
898
+ assistantEntryId?: string;
899
+ reason?: string;
900
+ }): WorkflowFollowUpQueueRecord {
901
+ const queue = this.requireFollowUpQueueRow(options.runId);
902
+ if (queue.presentationState === options.state) {
903
+ return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
904
+ }
905
+ if (queue.presentationState !== "pending") {
906
+ throw new Error(
907
+ `Workflow presentation is ${queue.presentationState}, not pending for ${options.runId}`,
908
+ );
909
+ }
910
+ if (
911
+ options.state === "settled" &&
912
+ (options.presentationEntryId === undefined || options.assistantEntryId === undefined)
913
+ ) {
914
+ throw new Error("Settled workflow presentation requires both session entry IDs");
915
+ }
916
+ if (options.state === "unavailable" && !options.reason?.trim()) {
917
+ throw new Error("Unavailable workflow presentation requires a reason");
918
+ }
919
+ try {
920
+ this.mutations.mutate(
921
+ {
922
+ resourceId: queue.resourceId,
923
+ operation: "follow-up.presentation",
924
+ actor: {
925
+ type: "session",
926
+ ...(queue.originSessionId !== null ? { id: queue.originSessionId } : {}),
927
+ },
928
+ expectedRevision: queue.revision,
929
+ },
930
+ `follow-up.presentation-${options.state}`,
931
+ ({ database, now }) => {
932
+ const reasonHash =
933
+ options.state === "unavailable"
934
+ ? database.putText(options.reason as string, now)
935
+ : null;
936
+ database.connection
937
+ .prepare(
938
+ `UPDATE workflow_follow_up_queues
939
+ SET presentation_state = ?, presentation_entry_id = ?,
940
+ presentation_assistant_entry_id = ?, presentation_reason_hash = ?,
941
+ presentation_updated_at = ?, updated_at = ?
942
+ WHERE run_id = ? AND presentation_state = 'pending'`,
943
+ )
944
+ .run(
945
+ options.state,
946
+ options.presentationEntryId ?? null,
947
+ options.assistantEntryId ?? null,
948
+ reasonHash,
949
+ now,
950
+ now,
951
+ options.runId,
952
+ );
953
+ database.connection
954
+ .prepare(
955
+ `UPDATE workflow_follow_ups SET status = 'ready', updated_at = ?
956
+ WHERE run_id = ? AND status = 'pending_presentation'`,
957
+ )
958
+ .run(now, options.runId);
959
+ },
960
+ { payload: { runId: options.runId, state: options.state } },
961
+ );
962
+ } catch (error) {
963
+ if (error instanceof StaleResourceError) {
964
+ const current = this.requireFollowUpQueueRow(options.runId);
965
+ if (current.presentationState === options.state) {
966
+ return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
967
+ }
968
+ }
969
+ throw error;
970
+ }
971
+ return this.readFollowUpQueue(options.runId) as WorkflowFollowUpQueueRecord;
972
+ }
973
+
974
+ claimNextFollowUp(
975
+ targetSessionId: string,
976
+ ownerId: string,
977
+ leaseMs = 30_000,
978
+ ): { followUp: WorkflowFollowUpRecord; claim: LeaseClaim } | undefined {
979
+ const rows = this.state.connection
980
+ .prepare(
981
+ `${FOLLOW_UP_ROW_SELECT}
982
+ WHERE f.target_session_id = ? AND f.status = 'ready'
983
+ ORDER BY f.created_at, f.order_number LIMIT 16`,
984
+ )
985
+ .all(targetSessionId)
986
+ .filter(isFollowUpRow);
987
+ for (const row of rows) {
988
+ let claim: LeaseClaim | undefined;
989
+ try {
990
+ claim = this.mutations.claim({
991
+ resourceId: row.resourceId,
992
+ ownerType: "session",
993
+ ownerId,
994
+ expectedRevision: row.revision,
995
+ leaseMs,
996
+ });
997
+ } catch (error) {
998
+ if (error instanceof StaleResourceError) continue;
999
+ throw error;
1000
+ }
1001
+ if (claim === undefined) continue;
1002
+ const current = this.requireFollowUpRow(row.followUpId);
1003
+ if (current.status !== "ready") {
1004
+ this.mutations.release(claim, claim.resourceRevision);
1005
+ continue;
1006
+ }
1007
+ return { followUp: this.followUpRecord(current), claim };
1008
+ }
1009
+ return undefined;
1010
+ }
1011
+
1012
+ markFollowUpSent(
1013
+ followUpId: string,
1014
+ claim: LeaseClaim,
1015
+ sessionEntryId: string,
1016
+ ): WorkflowFollowUpRecord {
1017
+ const row = this.requireFollowUpRow(followUpId);
1018
+ if (row.status === "sent") return this.followUpRecord(row);
1019
+ this.mutations.mutate(
1020
+ {
1021
+ resourceId: row.resourceId,
1022
+ operation: "follow-up.sent",
1023
+ actor: { type: "session", id: claim.ownerId },
1024
+ expectedRevision: claim.resourceRevision,
1025
+ lease: claim,
1026
+ },
1027
+ "follow-up.sent",
1028
+ ({ database, now }) => {
1029
+ const update = database.connection
1030
+ .prepare(
1031
+ `UPDATE workflow_follow_ups
1032
+ SET status = 'sent', session_entry_id = ?, sent_at = ?, updated_at = ?
1033
+ WHERE follow_up_id = ? AND status = 'ready'`,
1034
+ )
1035
+ .run(sessionEntryId, now, now, followUpId);
1036
+ if (update.changes !== 1) {
1037
+ throw new StaleResourceError("Workflow follow-up changed before delivery completed");
1038
+ }
1039
+ },
1040
+ { payload: { followUpId, sessionEntryId } },
1041
+ );
1042
+ return this.followUpRecord(this.requireFollowUpRow(followUpId));
1043
+ }
1044
+
1045
+ releaseFollowUpClaim(claim: LeaseClaim): void {
1046
+ const revision = this.requireResourceRevision(claim.resourceId);
1047
+ this.mutations.release(claim, revision);
1048
+ }
1049
+
277
1050
  async prepareRunResume(runId: string): Promise<LoadedWorkflowRun> {
278
1051
  const loaded = this.readRun(runId, { includeTrace: true });
279
1052
  if (loaded === null) throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
280
1053
  if (loaded.state.status !== "running") {
281
1054
  throw new Error(`Cannot resume workflow run ${runId} with status ${loaded.state.status}`);
282
1055
  }
1056
+ this.verifySettingsProjections(runId);
283
1057
  const revision = this.resourceRevision(runId);
284
1058
  this.contexts.set(runId, { revision, lock: Promise.resolve() });
285
1059
  this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
@@ -293,10 +1067,10 @@ export class WorkflowRunStore {
293
1067
  this.state.connection
294
1068
  .prepare(
295
1069
  `UPDATE node_attempts
296
- SET status = 'cancelled', finished_at = ?, updated_at = ?
1070
+ SET status = 'interrupted', updated_at = ?
297
1071
  WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`,
298
1072
  )
299
- .run(now, now, runId);
1073
+ .run(now, runId);
300
1074
  insertRunEvent(this.state, row.resourceId, nextRevision, at, {
301
1075
  scope: "run",
302
1076
  type: "run_resume_prepared",
@@ -325,6 +1099,9 @@ export class WorkflowRunStore {
325
1099
  delete loaded.state.currentNode;
326
1100
  delete loaded.state.currentAttemptId;
327
1101
  delete loaded.state.currentNodeStartedAt;
1102
+ delete loaded.state.currentSettingsScopeId;
1103
+ delete loaded.state.currentSettingsChangeNumber;
1104
+ delete loaded.state.currentSettingsHash;
328
1105
  delete loaded.state.statusDetail;
329
1106
  delete loaded.state.paused;
330
1107
  await this.writeSnapshot(runId, loaded.state, {
@@ -393,10 +1170,20 @@ export class WorkflowRunStore {
393
1170
  this.state.connection
394
1171
  .prepare(
395
1172
  `INSERT INTO workflow_updates(
396
- update_id, attempt_id, update_seq, update_type, update_key, data_hash, recorded_at
397
- ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
1173
+ update_id, attempt_id, update_seq, run_revision,
1174
+ update_type, update_key, data_hash, recorded_at
1175
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
398
1176
  )
399
- .run(updateId, attemptId, nextUpdateSeq, update.type, update.key, dataHash, Date.now());
1177
+ .run(
1178
+ updateId,
1179
+ attemptId,
1180
+ nextUpdateSeq,
1181
+ revision,
1182
+ update.type,
1183
+ update.key,
1184
+ dataHash,
1185
+ Date.now(),
1186
+ );
400
1187
  insertRunEvent(this.state, run.resourceId, revision, at, event);
401
1188
  this.persistRunState(run, state, revision, Date.now());
402
1189
  context.revision = revision;
@@ -424,12 +1211,14 @@ export class WorkflowRunStore {
424
1211
  const revision = context.revision + 1;
425
1212
  const now = Date.now();
426
1213
  const at = new Date(now).toISOString();
1214
+ const snapshot = this.readDefinition(run.definitionHash);
1215
+ this.assertSettingsRouteCurrent(event, snapshot);
427
1216
  const traceEvent: WorkflowTraceEvent = { seq: revision, at, runId, ...event };
428
1217
  state.traceSeq = revision;
429
1218
  state.updatedAt = at;
430
1219
  insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
431
1220
  this.persistRunState(run, state, revision, now);
432
- const snapshot = this.readDefinition(run.definitionHash);
1221
+ this.transitionFollowUpsForRunState(state, event, now);
433
1222
  this.syncNodeAttempts(state, snapshot, now);
434
1223
  context.revision = revision;
435
1224
  accepted = traceEvent;
@@ -515,6 +1304,13 @@ export class WorkflowRunStore {
515
1304
  ON CONFLICT(run_id) DO NOTHING`,
516
1305
  )
517
1306
  .run(runId, binding.piSessionId, now);
1307
+ this.state.connection
1308
+ .prepare(
1309
+ `UPDATE workflow_follow_up_queues
1310
+ SET origin_session_id = COALESCE(origin_session_id, ?), updated_at = ?
1311
+ WHERE run_id = ?`,
1312
+ )
1313
+ .run(binding.piSessionId, now, runId);
518
1314
  const revision = context.revision + 1;
519
1315
  const at = new Date(now).toISOString();
520
1316
  insertRunEvent(this.state, run.resourceId, revision, at, {
@@ -528,7 +1324,7 @@ export class WorkflowRunStore {
528
1324
  ...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
529
1325
  },
530
1326
  });
531
- const current = this.readState(run.stateHash);
1327
+ const current = this.readRunState(run, this.readDefinition(run.definitionHash));
532
1328
  current.traceSeq = revision;
533
1329
  current.updatedAt = at;
534
1330
  this.persistRunState(run, current, revision, now);
@@ -567,7 +1363,7 @@ export class WorkflowRunStore {
567
1363
  "session.entry_appended",
568
1364
  "session",
569
1365
  segment.sessionId,
570
- entryHash,
1366
+ null,
571
1367
  now,
572
1368
  );
573
1369
  return sequence;
@@ -616,24 +1412,9 @@ export class WorkflowRunStore {
616
1412
  );
617
1413
  expected += 1;
618
1414
  }
619
- const now = Date.now();
620
1415
  this.state.connection
621
1416
  .prepare("UPDATE session_segments SET event_count = ? WHERE segment_id = ?")
622
1417
  .run(expected - 1, current.segmentId);
623
- const resourceId = segmentResourceId(current);
624
- const revision = this.requireResourceRevision(resourceId);
625
- this.bumpResource(resourceId, revision, now);
626
- const payloadHash = this.state.putJson({ count: records.length, lastSeq: expected - 1 }, now);
627
- insertGenericEvent(
628
- this.state,
629
- resourceId,
630
- revision + 1,
631
- "session.events_appended",
632
- "session",
633
- current.sessionId,
634
- payloadHash,
635
- now,
636
- );
637
1418
  });
638
1419
  }
639
1420
 
@@ -698,8 +1479,8 @@ export class WorkflowRunStore {
698
1479
  readRun(runId: string, options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun | null {
699
1480
  const row = this.readRunRow(runId);
700
1481
  if (row === undefined) return null;
701
- const state = this.readState(row.stateHash);
702
1482
  const snapshot = this.readDefinition(row.definitionHash);
1483
+ const state = this.readRunState(row, snapshot);
703
1484
  const segments = this.segmentRows(runId).map((segment) => this.loadSegment(segment, state));
704
1485
  const flat = segments.find((segment) => segment.attemptId === "") ?? emptySegment();
705
1486
  return {
@@ -713,6 +1494,8 @@ export class WorkflowRunStore {
713
1494
  sessionCapture: flat.capture,
714
1495
  sessionIntegrity: flat.integrity,
715
1496
  sessionSegments: segments.filter((segment) => segment.attemptId !== ""),
1497
+ settingsScopes: this.settingsScopesForRunView(runId),
1498
+ followUpQueue: this.readFollowUpQueue(runId) ?? null,
716
1499
  };
717
1500
  }
718
1501
 
@@ -831,13 +1614,14 @@ export class WorkflowRunStore {
831
1614
  now: number,
832
1615
  ): void {
833
1616
  this.bumpResource(run.resourceId, expectedRevision - 1, now);
834
- const stateHash = this.state.putJson(state, now);
1617
+ const finalOutputHash =
1618
+ state.finalOutput === undefined ? null : this.state.putJson(state.finalOutput, now);
835
1619
  const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
836
1620
  const update = this.state.connection
837
1621
  .prepare(
838
1622
  `UPDATE runs
839
- SET title = ?, status = ?, paused = ?, status_detail = ?, output_hash = ?,
840
- error_hash = ?, updated_at = ?, finished_at = ?
1623
+ SET title = ?, status = ?, paused = ?, status_detail = ?,
1624
+ final_output_hash = ?, error_hash = ?, updated_at = ?, finished_at = ?
841
1625
  WHERE run_id = ?`,
842
1626
  )
843
1627
  .run(
@@ -845,7 +1629,7 @@ export class WorkflowRunStore {
845
1629
  state.status,
846
1630
  state.paused === true ? 1 : 0,
847
1631
  state.statusDetail ?? null,
848
- stateHash,
1632
+ finalOutputHash,
849
1633
  errorHash,
850
1634
  now,
851
1635
  state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
@@ -855,7 +1639,6 @@ export class WorkflowRunStore {
855
1639
  if (state.status !== "running") {
856
1640
  this.enqueueRunSettlementEffect(run.resourceId, expectedRevision, state, now);
857
1641
  }
858
- run.stateHash = stateHash;
859
1642
  }
860
1643
 
861
1644
  private enqueueRunSettlementEffect(
@@ -925,96 +1708,686 @@ export class WorkflowRunStore {
925
1708
  );
926
1709
  }
927
1710
 
928
- private syncNodeAttempts(
1711
+ private initializeRunSettingsAndFollowUps(
929
1712
  state: WorkflowRunState,
930
- snapshot: WorkflowDefinitionSnapshot,
1713
+ initialSettings: InitialWorkflowSettingsScope[],
931
1714
  now: number,
932
1715
  ): void {
933
- for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
934
- const resultHash = this.state.putJson(step, now);
935
- const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
936
- const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
937
- const promptHash = step.prompt === null ? null : this.state.putJson(step.prompt, now);
938
- const existing = this.state.connection
939
- .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
940
- .get(step.attemptId);
941
- if (existing === undefined) {
942
- const attemptNumber = this.nextAttemptNumber(state.runId, step.nodeId);
1716
+ const childQueueResourceId = this.ensureFollowUpQueue(state.runId, now);
1717
+ if (state.parentRunId !== undefined) {
1718
+ const parentQueue = this.followUpQueueRow(state.parentRunId);
1719
+ if (parentQueue !== undefined) {
943
1720
  this.state.connection
944
1721
  .prepare(
945
- `INSERT INTO node_attempts(
946
- attempt_id, run_id, node_id, attempt_number, node_type, status,
947
- presentation_hash, output_hash, result_hash, error_hash,
948
- started_at, finished_at, created_at, updated_at
949
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1722
+ `UPDATE workflow_follow_up_queues
1723
+ SET origin_session_id = COALESCE(origin_session_id, ?), updated_at = ?
1724
+ WHERE run_id = ?`,
950
1725
  )
951
- .run(
952
- step.attemptId,
953
- state.runId,
954
- step.nodeId,
955
- attemptNumber,
956
- step.nodeType,
957
- outcomeStatus(step.outcome),
958
- promptHash,
959
- outputHash,
960
- resultHash,
961
- errorHash,
962
- Date.parse(step.startedAt),
963
- Date.parse(step.finishedAt),
964
- Date.parse(step.startedAt),
965
- now,
966
- );
967
- } else {
1726
+ .run(parentQueue.originSessionId, now, state.runId);
968
1727
  this.state.connection
969
1728
  .prepare(
970
- `UPDATE node_attempts
971
- SET status = ?, presentation_hash = ?, output_hash = ?, result_hash = ?,
972
- error_hash = ?, finished_at = ?, updated_at = ?
973
- WHERE attempt_id = ?`,
1729
+ `UPDATE workflow_follow_ups
1730
+ SET run_id = ?, queue_resource_id = ?, updated_at = ?
1731
+ WHERE run_id = ? AND status = 'queued'`,
974
1732
  )
975
- .run(
976
- outcomeStatus(step.outcome),
977
- promptHash,
978
- outputHash,
979
- resultHash,
980
- errorHash,
981
- Date.parse(step.finishedAt),
982
- now,
983
- step.attemptId,
984
- );
1733
+ .run(state.runId, childQueueResourceId, now, state.parentRunId);
985
1734
  }
1735
+ this.state.connection
1736
+ .prepare(
1737
+ `UPDATE workflow_settings SET active_run_id = ?, updated_at = ? WHERE active_run_id = ?`,
1738
+ )
1739
+ .run(state.runId, now, state.parentRunId);
986
1740
  }
987
- if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
988
- this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
989
- }
990
- }
991
-
992
- private ensureAttempt(
993
- state: WorkflowRunState,
994
- nodeId: string,
995
- attemptId: string,
996
- now: number,
997
- snapshot?: WorkflowDefinitionSnapshot,
998
- ): void {
999
1741
  const existing = this.state.connection
1000
- .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
1001
- .get(attemptId);
1002
- const status = state.status === "waiting" ? "waiting" : "running";
1003
- if (existing !== undefined) {
1742
+ .prepare("SELECT COUNT(*) AS count FROM workflow_settings WHERE active_run_id = ?")
1743
+ .get(state.runId);
1744
+ const hasSettings = isCountRow(existing) && existing.count > 0;
1745
+ if (hasSettings) return;
1746
+ for (const scope of initialSettings) {
1747
+ const originRunId = this.logicalOriginRunId(state.runId);
1748
+ const scopeId = workflowSettingsScopeId(originRunId, scope.mountPath, scope.invocation);
1749
+ const resourceId = this.mutations.ensureResource("settings", scopeId, now);
1750
+ const settingsHash = this.state.putJson(scope.settings, now);
1004
1751
  this.state.connection
1005
- .prepare("UPDATE node_attempts SET status = ?, updated_at = ? WHERE attempt_id = ?")
1006
- .run(status, now, attemptId);
1007
- return;
1752
+ .prepare(
1753
+ `INSERT INTO workflow_settings(
1754
+ scope_id, resource_id, origin_run_id, active_run_id, mount_path, invocation,
1755
+ initial_hash, current_hash, created_at, updated_at
1756
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1757
+ ON CONFLICT(scope_id) DO NOTHING`,
1758
+ )
1759
+ .run(
1760
+ scopeId,
1761
+ resourceId,
1762
+ originRunId,
1763
+ state.runId,
1764
+ scope.mountPath,
1765
+ scope.invocation,
1766
+ settingsHash,
1767
+ settingsHash,
1768
+ now,
1769
+ now,
1770
+ );
1771
+ }
1772
+ }
1773
+
1774
+ private ensureFollowUpQueue(runId: string, now: number): string {
1775
+ const resourceId = this.mutations.ensureResource("follow_up_queue", runId, now);
1776
+ this.state.connection
1777
+ .prepare(
1778
+ `INSERT INTO workflow_follow_up_queues(
1779
+ run_id, resource_id, created_at, updated_at
1780
+ ) VALUES (?, ?, ?, ?)
1781
+ ON CONFLICT(run_id) DO NOTHING`,
1782
+ )
1783
+ .run(runId, resourceId, now, now);
1784
+ return resourceId;
1785
+ }
1786
+
1787
+ private logicalOriginRunId(runId: string): string {
1788
+ let current = runId;
1789
+ for (let depth = 0; depth < 100; depth += 1) {
1790
+ const row = this.state.connection
1791
+ .prepare("SELECT parent_run_id AS parentRunId FROM runs WHERE run_id = ?")
1792
+ .get(current);
1793
+ if (!isParentRunRow(row)) return current;
1794
+ if (row.parentRunId === null) return current;
1795
+ current = row.parentRunId;
1796
+ }
1797
+ throw new Error(`Workflow continuation chain is too deep for run ${runId}`);
1798
+ }
1799
+
1800
+ private requireRunAcceptsSettings(runId: string, allowWaiting: boolean): void {
1801
+ const row = this.state.connection
1802
+ .prepare("SELECT status FROM runs WHERE run_id = ?")
1803
+ .get(runId);
1804
+ if (!isStatusRow(row)) throw new Error(`Workflow run is missing: ${runId}`);
1805
+ if (row.status !== "running" && !(allowWaiting && row.status === "waiting")) {
1806
+ throw new Error(`Workflow run ${runId} does not accept changes with status ${row.status}`);
1807
+ }
1808
+ }
1809
+
1810
+ private verifySettingsProjections(runId: string): void {
1811
+ const scopes = this.state.connection
1812
+ .prepare(
1813
+ `SELECT s.scope_id AS scopeId, s.initial_hash AS initialHash,
1814
+ s.current_hash AS currentHash, r.revision
1815
+ FROM workflow_settings s
1816
+ JOIN resources r ON r.resource_id = s.resource_id
1817
+ WHERE s.active_run_id = ?`,
1818
+ )
1819
+ .all(runId);
1820
+ for (const value of scopes) {
1821
+ if (!isSettingsProjectionRow(value)) {
1822
+ throw new Error("Workflow settings projection row is invalid");
1823
+ }
1824
+ let current = this.state.readJson(value.initialHash);
1825
+ let currentHash = value.initialHash;
1826
+ const changes = this.state.connection
1827
+ .prepare(
1828
+ `SELECT change_number AS changeNumber, patch_hash AS patchHash,
1829
+ before_hash AS beforeHash, after_hash AS afterHash
1830
+ FROM workflow_setting_changes WHERE scope_id = ? ORDER BY change_number`,
1831
+ )
1832
+ .all(value.scopeId);
1833
+ let expected = 1;
1834
+ for (const change of changes) {
1835
+ if (!isSettingsProjectionChangeRow(change) || change.changeNumber !== expected) {
1836
+ throw new Error(`Workflow settings changes are not contiguous for ${value.scopeId}`);
1837
+ }
1838
+ if (!change.beforeHash.equals(currentHash)) {
1839
+ throw new Error(`Workflow settings change has a wrong old value for ${value.scopeId}`);
1840
+ }
1841
+ current = applyJsonPatch(current, this.state.readJson(change.patchHash));
1842
+ currentHash = createHash("sha256").update(canonicalJson(current)).digest();
1843
+ if (!currentHash.equals(change.afterHash)) {
1844
+ throw new Error(`Workflow settings change has a wrong new value for ${value.scopeId}`);
1845
+ }
1846
+ expected += 1;
1847
+ }
1848
+ if (value.revision !== expected - 1 || !value.currentHash.equals(currentHash)) {
1849
+ throw new Error(
1850
+ `Workflow settings projection does not match saved changes for ${value.scopeId}`,
1851
+ );
1852
+ }
1853
+ }
1854
+ }
1855
+
1856
+ private settingsScopeRow(scopeId: string): SettingsScopeRow | undefined {
1857
+ const row = this.state.connection
1858
+ .prepare(
1859
+ `SELECT s.scope_id AS scopeId, s.resource_id AS resourceId,
1860
+ s.origin_run_id AS originRunId, s.active_run_id AS activeRunId,
1861
+ s.mount_path AS mountPath, s.invocation,
1862
+ s.current_hash AS currentHash, r.revision,
1863
+ s.created_at AS createdAt, s.updated_at AS updatedAt
1864
+ FROM workflow_settings s
1865
+ JOIN resources r ON r.resource_id = s.resource_id
1866
+ WHERE s.scope_id = ?`,
1867
+ )
1868
+ .get(scopeId);
1869
+ return isSettingsScopeRow(row) ? row : undefined;
1870
+ }
1871
+
1872
+ private requireSettingsScopeRow(scopeId: string): SettingsScopeRow {
1873
+ const row = this.settingsScopeRow(scopeId);
1874
+ if (row === undefined) throw new Error(`Workflow settings scope not found: ${scopeId}`);
1875
+ return row;
1876
+ }
1877
+
1878
+ private settingsScopeRecord(
1879
+ row: SettingsScopeRow,
1880
+ changeNumber = row.revision,
1881
+ settingsHash = row.currentHash,
1882
+ ): WorkflowSettingsScopeRecord {
1883
+ return {
1884
+ scopeId: row.scopeId,
1885
+ originRunId: row.originRunId,
1886
+ activeRunId: row.activeRunId,
1887
+ mountPath: row.mountPath,
1888
+ invocation: row.invocation,
1889
+ changeNumber,
1890
+ settings: this.state.readJson(settingsHash),
1891
+ settingsHash: settingsHash.toString("hex"),
1892
+ createdAt: new Date(row.createdAt).toISOString(),
1893
+ updatedAt: new Date(row.updatedAt).toISOString(),
1894
+ };
1895
+ }
1896
+
1897
+ private settingsChangeRow(scopeId: string, requestId: string): SettingsChangeRow | undefined {
1898
+ const row = this.state.connection
1899
+ .prepare(
1900
+ `SELECT change_id AS changeId, scope_id AS scopeId, request_id AS requestId,
1901
+ change_number AS changeNumber, actor_type AS actorType, actor_id AS actorId,
1902
+ source_type AS sourceType, patch_hash AS patchHash,
1903
+ before_hash AS beforeHash, after_hash AS afterHash,
1904
+ accepted_at AS acceptedAt
1905
+ FROM workflow_setting_changes WHERE scope_id = ? AND request_id = ?`,
1906
+ )
1907
+ .get(scopeId, requestId);
1908
+ return isSettingsChangeRow(row) ? row : undefined;
1909
+ }
1910
+
1911
+ private settingsChangeRecord(row: SettingsChangeRow): WorkflowSettingsChangeRecord {
1912
+ return {
1913
+ changeId: row.changeId,
1914
+ scopeId: row.scopeId,
1915
+ requestId: row.requestId,
1916
+ changeNumber: row.changeNumber,
1917
+ actor: {
1918
+ type: assertActorType(row.actorType),
1919
+ ...(row.actorId !== null ? { id: row.actorId } : {}),
1920
+ },
1921
+ source: row.sourceType,
1922
+ patch: validateJsonPatch(this.state.readJson(row.patchHash)),
1923
+ beforeHash: row.beforeHash.toString("hex"),
1924
+ afterHash: row.afterHash.toString("hex"),
1925
+ acceptedAt: new Date(row.acceptedAt).toISOString(),
1926
+ };
1927
+ }
1928
+
1929
+ private assertSameSettingsChange(
1930
+ row: SettingsChangeRow,
1931
+ request: WorkflowSettingsChangeRequest,
1932
+ patchDigest: Buffer,
1933
+ ): void {
1934
+ if (
1935
+ !row.patchHash.equals(patchDigest) ||
1936
+ row.actorType !== request.actor.type ||
1937
+ row.actorId !== (request.actor.id ?? null) ||
1938
+ row.sourceType !== request.source
1939
+ ) {
1940
+ throw new Error(`Workflow settings request ID was reused with different content`);
1941
+ }
1942
+ }
1943
+
1944
+ private followUpQueueRow(runId: string): FollowUpQueueRow | undefined {
1945
+ const row = this.state.connection
1946
+ .prepare(
1947
+ `SELECT q.run_id AS runId, q.resource_id AS resourceId,
1948
+ q.origin_session_id AS originSessionId,
1949
+ q.presentation_state AS presentationState,
1950
+ q.presentation_entry_id AS presentationEntryId,
1951
+ q.presentation_assistant_entry_id AS presentationAssistantEntryId,
1952
+ q.presentation_reason_hash AS presentationReasonHash,
1953
+ r.revision
1954
+ FROM workflow_follow_up_queues q
1955
+ JOIN resources r ON r.resource_id = q.resource_id
1956
+ WHERE q.run_id = ?`,
1957
+ )
1958
+ .get(runId);
1959
+ return isFollowUpQueueRow(row) ? row : undefined;
1960
+ }
1961
+
1962
+ private requireFollowUpQueueRow(runId: string): FollowUpQueueRow {
1963
+ const row = this.followUpQueueRow(runId);
1964
+ if (row === undefined) throw new Error(`Workflow follow-up queue not found: ${runId}`);
1965
+ return row;
1966
+ }
1967
+
1968
+ private followUpRowByRequest(runId: string, requestId: string): FollowUpRow | undefined {
1969
+ const row = this.state.connection
1970
+ .prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.run_id = ? AND f.request_id = ?`)
1971
+ .get(runId, requestId);
1972
+ return isFollowUpRow(row) ? row : undefined;
1973
+ }
1974
+
1975
+ private followUpRow(followUpId: string): FollowUpRow | undefined {
1976
+ const row = this.state.connection
1977
+ .prepare(`${FOLLOW_UP_ROW_SELECT} WHERE f.follow_up_id = ?`)
1978
+ .get(followUpId);
1979
+ return isFollowUpRow(row) ? row : undefined;
1980
+ }
1981
+
1982
+ private requireFollowUpRow(followUpId: string): FollowUpRow {
1983
+ const row = this.followUpRow(followUpId);
1984
+ if (row === undefined) throw new Error(`Workflow follow-up not found: ${followUpId}`);
1985
+ return row;
1986
+ }
1987
+
1988
+ private followUpRecord(row: FollowUpRow): WorkflowFollowUpRecord {
1989
+ return {
1990
+ followUpId: row.followUpId,
1991
+ runId: row.runId,
1992
+ requestId: row.requestId,
1993
+ order: row.orderNumber,
1994
+ targetSessionId: row.targetSessionId,
1995
+ actor: {
1996
+ type: assertActorType(row.actorType),
1997
+ ...(row.actorId !== null ? { id: row.actorId } : {}),
1998
+ },
1999
+ source: row.sourceType,
2000
+ prompt: this.readText(row.promptHash),
2001
+ state: assertFollowUpState(row.status),
2002
+ ...(row.sessionEntryId !== null ? { sessionEntryId: row.sessionEntryId } : {}),
2003
+ ...(row.reasonHash !== null ? { reason: this.readText(row.reasonHash) } : {}),
2004
+ createdAt: new Date(row.createdAt).toISOString(),
2005
+ updatedAt: new Date(row.updatedAt).toISOString(),
2006
+ ...(row.sentAt !== null ? { sentAt: new Date(row.sentAt).toISOString() } : {}),
2007
+ };
2008
+ }
2009
+
2010
+ private assertSameFollowUp(row: FollowUpRow, request: WorkflowQueueFollowUpRequest): void {
2011
+ const promptHash = createHash("sha256").update(request.prompt).digest();
2012
+ if (
2013
+ !row.promptHash.equals(promptHash) ||
2014
+ row.targetSessionId !== request.targetSessionId ||
2015
+ row.actorType !== request.actor.type ||
2016
+ row.actorId !== (request.actor.id ?? null) ||
2017
+ row.sourceType !== request.source
2018
+ ) {
2019
+ throw new Error("Workflow follow-up request ID was reused with different content");
2020
+ }
2021
+ }
2022
+
2023
+ private assertFollowUpRemovalAuthority(
2024
+ row: FollowUpRow,
2025
+ actor: MutationActor,
2026
+ source: string,
2027
+ ): void {
2028
+ if (actor.type === "human") return;
2029
+ if (
2030
+ (actor.type === "session" || actor.type === "controller") &&
2031
+ row.actorType === actor.type &&
2032
+ row.actorId === (actor.id ?? null) &&
2033
+ row.sourceType === source
2034
+ ) {
2035
+ return;
2036
+ }
2037
+ throw new Error("Workflow follow-up can be removed only by its source or a verified human");
2038
+ }
2039
+
2040
+ private hasLiveLease(resourceId: string): boolean {
2041
+ const row = this.state.connection
2042
+ .prepare("SELECT expires_at AS expiresAt FROM leases WHERE resource_id = ?")
2043
+ .get(resourceId);
2044
+ return isLeaseExpiryRow(row) && row.expiresAt !== null && row.expiresAt > Date.now();
2045
+ }
2046
+
2047
+ private nextFollowUpOrder(runId: string): number {
2048
+ const row = this.state.connection
2049
+ .prepare(
2050
+ `SELECT COALESCE(MAX(order_number), 0) + 1 AS nextOrder
2051
+ FROM workflow_follow_ups WHERE run_id = ?`,
2052
+ )
2053
+ .get(runId);
2054
+ if (!isNextOrderRow(row)) throw new Error("Could not allocate workflow follow-up order");
2055
+ return row.nextOrder;
2056
+ }
2057
+
2058
+ private assertSettingsRouteCurrent(
2059
+ event: WorkflowTraceEventDraft,
2060
+ snapshot: WorkflowDefinitionSnapshot,
2061
+ ): void {
2062
+ if (event.type !== "node_finished" || event.nodeId === undefined) return;
2063
+ if (snapshot.nodes[event.nodeId]?.settingsRoute !== true) return;
2064
+ const scopeId = event.payload.settingsScopeId;
2065
+ const changeNumber = event.payload.settingsChangeNumber;
2066
+ if (typeof scopeId !== "string" || typeof changeNumber !== "number") {
2067
+ throw new Error(`Settings route ${event.nodeId} has no saved settings binding`);
2068
+ }
2069
+ const scope = this.requireSettingsScopeRow(scopeId);
2070
+ if (scope.revision !== changeNumber) {
2071
+ throw new StaleResourceError(
2072
+ `Settings route ${event.nodeId} used change ${changeNumber}, current change is ${scope.revision}`,
2073
+ );
2074
+ }
2075
+ }
2076
+
2077
+ private transitionFollowUpsForRunState(
2078
+ state: WorkflowRunState,
2079
+ event: WorkflowTraceEventDraft,
2080
+ now: number,
2081
+ ): void {
2082
+ if (state.status === "running" || state.status === "waiting") return;
2083
+ const queue = this.followUpQueueRow(state.runId);
2084
+ if (queue === undefined || queue.presentationState !== "none") return;
2085
+ let presentationState: WorkflowPresentationState = "not-needed";
2086
+ let followUpState: WorkflowFollowUpState = "cancelled";
2087
+ let reasonHash: Buffer | null = null;
2088
+ if (state.status === "completed") {
2089
+ const required = event.payload.presentationRequired === true;
2090
+ presentationState = required ? "pending" : "not-needed";
2091
+ followUpState = required ? "pending_presentation" : "ready";
2092
+ } else {
2093
+ reasonHash = this.state.putText(
2094
+ state.error ?? `Workflow ended with status ${state.status}`,
2095
+ now,
2096
+ );
2097
+ }
2098
+ this.state.connection
2099
+ .prepare(
2100
+ `UPDATE workflow_follow_up_queues
2101
+ SET presentation_state = ?, presentation_updated_at = ?, updated_at = ?
2102
+ WHERE run_id = ? AND presentation_state = 'none'`,
2103
+ )
2104
+ .run(presentationState, now, now, state.runId);
2105
+ this.state.connection
2106
+ .prepare(
2107
+ `UPDATE workflow_follow_ups
2108
+ SET status = ?, reason_hash = ?, updated_at = ?
2109
+ WHERE run_id = ? AND status IN ('queued', 'pending_presentation', 'ready')`,
2110
+ )
2111
+ .run(followUpState, reasonHash, now, state.runId);
2112
+ const revision = this.requireResourceRevision(queue.resourceId);
2113
+ this.bumpResource(queue.resourceId, revision, now);
2114
+ const payloadHash = this.state.putJson(
2115
+ { runId: state.runId, state: presentationState, followUps: followUpState },
2116
+ now,
2117
+ );
2118
+ insertGenericEvent(
2119
+ this.state,
2120
+ queue.resourceId,
2121
+ revision + 1,
2122
+ "follow-up.run-finished",
2123
+ "system",
2124
+ null,
2125
+ payloadHash,
2126
+ now,
2127
+ );
2128
+ }
2129
+
2130
+ private syncNodeAttempts(
2131
+ state: WorkflowRunState,
2132
+ snapshot: WorkflowDefinitionSnapshot,
2133
+ now: number,
2134
+ ): void {
2135
+ for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
2136
+ const promptEntryId = this.findAttemptPromptEntry(state.runId, step.attemptId);
2137
+ const promptHash =
2138
+ promptEntryId === undefined && step.prompt !== null
2139
+ ? this.state.putText(step.prompt, now)
2140
+ : null;
2141
+ const receipt = attemptReceipt(step);
2142
+ const receiptHash =
2143
+ Object.keys(receipt).length === 0 ? null : this.state.putJson(receipt, now);
2144
+ const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
2145
+ const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
2146
+ const existing = this.state.connection
2147
+ .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
2148
+ .get(step.attemptId);
2149
+ if (existing === undefined) {
2150
+ const attemptNumber = this.nextAttemptNumber(state.runId, step.nodeId);
2151
+ this.state.connection
2152
+ .prepare(
2153
+ `INSERT INTO node_attempts(
2154
+ attempt_id, run_id, node_id, attempt_number, node_type, status,
2155
+ prompt_hash, output_hash, receipt_hash, error_hash,
2156
+ settings_scope_id, settings_change_number, settings_hash,
2157
+ started_at, finished_at, created_at, updated_at
2158
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2159
+ )
2160
+ .run(
2161
+ step.attemptId,
2162
+ state.runId,
2163
+ step.nodeId,
2164
+ attemptNumber,
2165
+ step.nodeType,
2166
+ outcomeStatus(step.outcome),
2167
+ promptHash,
2168
+ outputHash,
2169
+ receiptHash,
2170
+ errorHash,
2171
+ step.settingsScopeId ?? null,
2172
+ step.settingsChangeNumber ?? null,
2173
+ step.settingsHash === undefined ? null : Buffer.from(step.settingsHash, "hex"),
2174
+ Date.parse(step.startedAt),
2175
+ Date.parse(step.finishedAt),
2176
+ Date.parse(step.startedAt),
2177
+ now,
2178
+ );
2179
+ } else {
2180
+ this.state.connection
2181
+ .prepare(
2182
+ `UPDATE node_attempts
2183
+ SET status = ?, prompt_hash = ?, output_hash = ?, receipt_hash = ?, error_hash = ?,
2184
+ settings_scope_id = ?, settings_change_number = ?,
2185
+ settings_hash = ?, finished_at = ?, updated_at = ?
2186
+ WHERE attempt_id = ?`,
2187
+ )
2188
+ .run(
2189
+ outcomeStatus(step.outcome),
2190
+ promptHash,
2191
+ outputHash,
2192
+ receiptHash,
2193
+ errorHash,
2194
+ step.settingsScopeId ?? null,
2195
+ step.settingsChangeNumber ?? null,
2196
+ step.settingsHash === undefined ? null : Buffer.from(step.settingsHash, "hex"),
2197
+ Date.parse(step.finishedAt),
2198
+ now,
2199
+ step.attemptId,
2200
+ );
2201
+ }
2202
+ if (this.syncAttemptEntries(state.runId, step, promptEntryId)) {
2203
+ this.state.connection
2204
+ .prepare("UPDATE node_attempts SET output_hash = NULL WHERE attempt_id = ?")
2205
+ .run(step.attemptId);
2206
+ }
2207
+ }
2208
+ if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
2209
+ this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
2210
+ }
2211
+ this.syncRunSteps(state, now);
2212
+ }
2213
+
2214
+ private syncRunSteps(state: WorkflowRunState, now: number): void {
2215
+ const existingRows = this.state.connection
2216
+ .prepare(
2217
+ `SELECT step_index AS stepIndex, attempt_id AS attemptId
2218
+ FROM run_steps WHERE run_id = ? ORDER BY step_index`,
2219
+ )
2220
+ .all(state.runId)
2221
+ .filter(isRunStepIdentityRow);
2222
+ if (existingRows.length > state.steps.length) {
2223
+ throw new Error(`Workflow run steps cannot shrink: ${state.runId}`);
2224
+ }
2225
+ for (const existing of existingRows) {
2226
+ if (state.steps[existing.stepIndex]?.attemptId !== existing.attemptId) {
2227
+ throw new Error(`Workflow run step history changed at index ${existing.stepIndex}`);
2228
+ }
2229
+ }
2230
+ for (let stepIndex = existingRows.length; stepIndex < state.steps.length; stepIndex += 1) {
2231
+ const step = state.steps[stepIndex];
2232
+ /* istanbul ignore if -- array index follows a checked bound */
2233
+ if (step === undefined) throw new Error("Workflow run step became unavailable");
2234
+ const attemptOutput = this.readAttemptOutput(step.attemptId);
2235
+ const carried = stepIndex < (state.carriedStepCount ?? 0);
2236
+ const outputOverrideHash =
2237
+ carried && canonicalJson(attemptOutput) !== canonicalJson(step.output)
2238
+ ? this.state.putJson(step.output, now)
2239
+ : null;
2240
+ this.state.connection
2241
+ .prepare(
2242
+ `INSERT INTO run_steps(run_id, step_index, attempt_id, output_override_hash)
2243
+ VALUES (?, ?, ?, ?)`,
2244
+ )
2245
+ .run(state.runId, stepIndex, step.attemptId, outputOverrideHash);
2246
+ }
2247
+ }
2248
+
2249
+ private syncAttemptEntries(
2250
+ runId: string,
2251
+ step: WorkflowStepRecord,
2252
+ promptEntryId: string | undefined,
2253
+ ): boolean {
2254
+ const links: Array<["prompt" | "response" | "first" | "last", string]> = [];
2255
+ if (promptEntryId !== undefined) links.push(["prompt", promptEntryId]);
2256
+ if (step.conversation !== undefined) {
2257
+ links.push(
2258
+ ["first", step.conversation.firstEntryId],
2259
+ ["last", step.conversation.lastEntryId],
2260
+ );
2261
+ }
2262
+ if (step.assistantMessage?.entryId !== undefined) {
2263
+ links.push(["response", step.assistantMessage.entryId]);
2264
+ }
2265
+ let responseLinked = false;
2266
+ for (const [role, entryId] of links) {
2267
+ const row = this.state.connection
2268
+ .prepare(
2269
+ `SELECT e.segment_id AS segmentId, e.entry_hash AS entryHash
2270
+ FROM session_entries e
2271
+ JOIN session_segments s ON s.segment_id = e.segment_id
2272
+ WHERE s.run_id = ? AND e.entry_id = ?
2273
+ ORDER BY e.recorded_at DESC LIMIT 1`,
2274
+ )
2275
+ .get(runId, entryId);
2276
+ if (!isSegmentIdentityRow(row)) continue;
2277
+ if (role === "response") {
2278
+ const output = assistantOutputFromEntry(
2279
+ this.state.readJson(row.entryHash),
2280
+ step.assistantMessage?.maxChars,
2281
+ );
2282
+ if (
2283
+ canonicalJson(output) !== canonicalJson(step.output) ||
2284
+ createHash("sha256").update(output).digest("hex") !== step.assistantMessage?.sha256
2285
+ ) {
2286
+ throw new Error(`Assistant response entry conflicts with attempt ${step.attemptId}`);
2287
+ }
2288
+ }
2289
+ this.state.connection
2290
+ .prepare(
2291
+ `INSERT INTO attempt_entries(attempt_id, role, segment_id, entry_id)
2292
+ VALUES (?, ?, ?, ?)
2293
+ ON CONFLICT(attempt_id, role) DO UPDATE SET
2294
+ segment_id = excluded.segment_id, entry_id = excluded.entry_id`,
2295
+ )
2296
+ .run(step.attemptId, role, row.segmentId, entryId);
2297
+ if (role === "response") responseLinked = true;
2298
+ }
2299
+ return responseLinked;
2300
+ }
2301
+
2302
+ private findAttemptPromptEntry(runId: string, attemptId: string): string | undefined {
2303
+ const row = this.state.connection
2304
+ .prepare(
2305
+ `SELECT e.entry_id AS entryId
2306
+ FROM session_entries e
2307
+ JOIN session_segments s ON s.segment_id = e.segment_id
2308
+ JOIN blobs b ON b.blob_hash = e.entry_hash
2309
+ WHERE s.run_id = ?
2310
+ AND json_extract(CAST(b.content AS TEXT), '$.customType') = 'pi-workflows-agent-step'
2311
+ AND json_extract(CAST(b.content AS TEXT), '$.details.contract.attemptId') = ?
2312
+ ORDER BY e.recorded_at DESC LIMIT 1`,
2313
+ )
2314
+ .get(runId, attemptId);
2315
+ return isEntryIdentityRow(row) ? row.entryId : undefined;
2316
+ }
2317
+
2318
+ private readAttemptOutput(attemptId: string): unknown {
2319
+ const row = this.state.connection
2320
+ .prepare(
2321
+ `SELECT a.output_hash AS outputHash, a.receipt_hash AS receiptHash,
2322
+ e.entry_hash AS responseEntryHash
2323
+ FROM node_attempts a
2324
+ LEFT JOIN attempt_entries l ON l.attempt_id = a.attempt_id AND l.role = 'response'
2325
+ LEFT JOIN session_entries e
2326
+ ON e.segment_id = l.segment_id AND e.entry_id = l.entry_id
2327
+ WHERE a.attempt_id = ?`,
2328
+ )
2329
+ .get(attemptId);
2330
+ if (!isAttemptValueRow(row)) {
2331
+ throw new Error(`Workflow node attempt is missing: ${attemptId}`);
2332
+ }
2333
+ if (row.outputHash !== null) return this.state.readJson(row.outputHash);
2334
+ if (row.responseEntryHash === null) return null;
2335
+ const receipt =
2336
+ row.receiptHash === null ? {} : this.readJsonAs<StoredAttemptReceipt>(row.receiptHash);
2337
+ return assistantOutputFromEntry(
2338
+ this.state.readJson(row.responseEntryHash),
2339
+ receipt.assistantMessage?.maxChars,
2340
+ );
2341
+ }
2342
+
2343
+ private ensureAttempt(
2344
+ state: WorkflowRunState,
2345
+ nodeId: string,
2346
+ attemptId: string,
2347
+ now: number,
2348
+ snapshot?: WorkflowDefinitionSnapshot,
2349
+ ): void {
2350
+ const existing = this.state.connection
2351
+ .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
2352
+ .get(attemptId);
2353
+ const status = state.status === "waiting" ? "waiting" : "running";
2354
+ if (existing !== undefined) {
2355
+ this.state.connection
2356
+ .prepare(
2357
+ `UPDATE node_attempts
2358
+ SET status = ?, settings_scope_id = ?, settings_change_number = ?,
2359
+ settings_hash = ?, updated_at = ?
2360
+ WHERE attempt_id = ?`,
2361
+ )
2362
+ .run(
2363
+ status,
2364
+ state.currentSettingsScopeId ?? null,
2365
+ state.currentSettingsChangeNumber ?? null,
2366
+ state.currentSettingsHash === undefined
2367
+ ? null
2368
+ : Buffer.from(state.currentSettingsHash, "hex"),
2369
+ now,
2370
+ attemptId,
2371
+ );
2372
+ return;
1008
2373
  }
1009
2374
  const definition =
1010
2375
  snapshot ?? this.readDefinition(this.requireRunRow(state.runId).definitionHash);
1011
2376
  const nodeType = definition.nodes[nodeId]?.nodeType ?? "agent";
2377
+ this.state.connection
2378
+ .prepare(
2379
+ `UPDATE node_attempts
2380
+ SET status = 'cancelled', finished_at = COALESCE(finished_at, ?), updated_at = ?
2381
+ WHERE run_id = ? AND status = 'interrupted'`,
2382
+ )
2383
+ .run(now, now, state.runId);
1012
2384
  this.state.connection
1013
2385
  .prepare(
1014
2386
  `INSERT INTO node_attempts(
1015
2387
  attempt_id, run_id, node_id, attempt_number, node_type, status,
2388
+ settings_scope_id, settings_change_number, settings_hash,
1016
2389
  started_at, created_at, updated_at
1017
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2390
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1018
2391
  )
1019
2392
  .run(
1020
2393
  attemptId,
@@ -1023,6 +2396,11 @@ export class WorkflowRunStore {
1023
2396
  this.nextAttemptNumber(state.runId, nodeId),
1024
2397
  nodeType,
1025
2398
  status,
2399
+ state.currentSettingsScopeId ?? null,
2400
+ state.currentSettingsChangeNumber ?? null,
2401
+ state.currentSettingsHash === undefined
2402
+ ? null
2403
+ : Buffer.from(state.currentSettingsHash, "hex"),
1026
2404
  state.currentNodeStartedAt === undefined ? now : Date.parse(state.currentNodeStartedAt),
1027
2405
  now,
1028
2406
  now,
@@ -1053,12 +2431,60 @@ export class WorkflowRunStore {
1053
2431
  return row.updateSeq;
1054
2432
  }
1055
2433
 
2434
+ private insertRunSources(
2435
+ runId: string,
2436
+ root: { type: "builtin" | "file"; ref: string; revision: string },
2437
+ mounted: WorkflowMountedSource[],
2438
+ ): void {
2439
+ const insert = this.state.connection.prepare(
2440
+ `INSERT INTO run_sources(run_id, mount_path, source_type, source_ref, source_revision)
2441
+ VALUES (?, ?, ?, ?, ?)`,
2442
+ );
2443
+ insert.run(runId, "", root.type, root.ref, root.revision);
2444
+ for (const item of mounted) {
2445
+ const source = sourceParts(item.source);
2446
+ insert.run(runId, item.mountPath.join("/"), source.type, source.ref, source.revision);
2447
+ }
2448
+ }
2449
+
2450
+ private syncContinuationIdentity(state: WorkflowRunState): void {
2451
+ if (state.parentRunId === undefined || state.humanDecision === undefined) return;
2452
+ const createdAt = Date.parse(state.humanDecision.acceptedAt);
2453
+ this.state.connection
2454
+ .prepare(
2455
+ `INSERT INTO continuations(
2456
+ decision_id, parent_run_id, continuation_run_id, created_at
2457
+ ) VALUES (?, ?, ?, ?)
2458
+ ON CONFLICT(decision_id) DO NOTHING`,
2459
+ )
2460
+ .run(state.humanDecision.decisionId, state.parentRunId, state.runId, createdAt);
2461
+ const row = this.state.connection
2462
+ .prepare(
2463
+ `SELECT parent_run_id AS parentRunId, continuation_run_id AS continuationRunId,
2464
+ created_at AS createdAt
2465
+ FROM continuations WHERE decision_id = ?`,
2466
+ )
2467
+ .get(state.humanDecision.decisionId);
2468
+ if (
2469
+ !isContinuationIdentityRow(row) ||
2470
+ row.parentRunId !== state.parentRunId ||
2471
+ row.continuationRunId !== state.runId ||
2472
+ row.createdAt !== createdAt
2473
+ ) {
2474
+ throw new Error("Immutable human decision continuation conflicts");
2475
+ }
2476
+ }
2477
+
1056
2478
  private readRunRow(runId: string): RunRow | undefined {
1057
2479
  const row = this.state.connection
1058
2480
  .prepare(
1059
2481
  `SELECT r.run_id AS runId, r.resource_id AS resourceId,
1060
- r.output_hash AS stateHash, d.definition_hash AS definitionHash,
1061
- r.status
2482
+ d.definition_hash AS definitionHash, r.definition_digest AS definitionDigest,
2483
+ d.workflow_name AS workflowRef, r.parent_run_id AS parentRunId,
2484
+ r.title, r.status, r.paused, r.status_detail AS statusDetail,
2485
+ r.input_hash AS inputHash, r.final_output_hash AS finalOutputHash,
2486
+ r.error_hash AS errorHash,
2487
+ r.created_at AS createdAt, r.updated_at AS updatedAt, r.finished_at AS finishedAt
1062
2488
  FROM runs r
1063
2489
  JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
1064
2490
  WHERE r.run_id = ?`,
@@ -1073,12 +2499,308 @@ export class WorkflowRunStore {
1073
2499
  return row;
1074
2500
  }
1075
2501
 
1076
- private readState(hash: Buffer): WorkflowRunState {
1077
- const value = this.state.readJson(hash);
1078
- if (!isRecord(value) || value.schema !== RUN_STATE_SCHEMA) {
1079
- throw new Error("Workflow run state has an incompatible schema");
2502
+ private readRunState(row: RunRow, snapshot: WorkflowDefinitionSnapshot): WorkflowRunState {
2503
+ const steps = this.readSteps(row.runId);
2504
+ const sources = this.readRunSources(row.runId, snapshot);
2505
+ const carriedStepCount = this.carriedStepCount(row.runId);
2506
+ const activeAttempt = row.status === "running" ? this.readActiveAttempt(row.runId) : undefined;
2507
+ const humanDecision = this.readHumanDecisionReceipt(row.runId);
2508
+ const outputs: Record<string, unknown> = {};
2509
+ const results: Record<string, WorkflowNodeResult> = {};
2510
+ for (const step of steps) {
2511
+ const result = resultForStep(step);
2512
+ results[step.nodeId] = result;
2513
+ if (step.outcome === "ok") outputs[step.nodeId] = step.output;
2514
+ else delete outputs[step.nodeId];
2515
+ const node = snapshot.nodes[step.nodeId];
2516
+ const mountPath =
2517
+ node?.includeTransition === "exit" && node.mountPath !== undefined
2518
+ ? node.mountPath.join("/")
2519
+ : undefined;
2520
+ if (mountPath !== undefined && step.outcome === "ok") {
2521
+ outputs[mountPath] = step.output;
2522
+ results[mountPath] = { ...result, nodeId: mountPath };
2523
+ }
1080
2524
  }
1081
- return value as WorkflowRunState;
2525
+ const revision = this.requireResourceRevision(row.resourceId);
2526
+ return {
2527
+ schema: RUN_STATE_SCHEMA,
2528
+ traceSeq: revision,
2529
+ runId: row.runId,
2530
+ workflowName: row.workflowRef,
2531
+ ...(row.parentRunId === null ? {} : { parentRunId: row.parentRunId }),
2532
+ ...(carriedStepCount === 0 ? {} : { carriedStepCount }),
2533
+ ...(row.title === null ? {} : { runTitle: row.title }),
2534
+ ...(sources.root === undefined ? {} : { workflowSource: sources.root }),
2535
+ ...(sources.mounted.length === 0 ? {} : { workflowSources: sources.mounted }),
2536
+ ...(sources.mounted.length !== 0 || snapshot.composition?.mounts.length
2537
+ ? { definitionDigest: `sha256:${row.definitionDigest.toString("hex")}` }
2538
+ : {}),
2539
+ startedAt: new Date(row.createdAt).toISOString(),
2540
+ ...(row.finishedAt === null ? {} : { finishedAt: new Date(row.finishedAt).toISOString() }),
2541
+ updatedAt: new Date(row.updatedAt).toISOString(),
2542
+ status: row.status as WorkflowRunState["status"],
2543
+ input: this.state.readJson(row.inputHash),
2544
+ outputs,
2545
+ results,
2546
+ steps,
2547
+ ...this.readUpdates(row.runId),
2548
+ ...(activeAttempt === undefined ? {} : { currentNode: activeAttempt.nodeId }),
2549
+ ...(activeAttempt === undefined ? {} : { currentAttemptId: activeAttempt.attemptId }),
2550
+ ...(activeAttempt?.startedAt === null || activeAttempt === undefined
2551
+ ? {}
2552
+ : { currentNodeStartedAt: new Date(activeAttempt.startedAt).toISOString() }),
2553
+ ...savedCurrentSettingsBinding(
2554
+ activeAttempt?.settingsScopeId ?? null,
2555
+ activeAttempt?.settingsChangeNumber ?? null,
2556
+ activeAttempt?.settingsHash ?? null,
2557
+ ),
2558
+ ...(row.statusDetail === null ? {} : { statusDetail: row.statusDetail }),
2559
+ ...(humanDecision === undefined ? {} : { humanDecision }),
2560
+ ...(row.paused === 0 ? {} : { paused: true }),
2561
+ ...(row.status === "waiting" && steps.at(-1) !== undefined
2562
+ ? { waitingOn: steps.at(-1)?.nodeId as string }
2563
+ : {}),
2564
+ ...(row.finalOutputHash === null
2565
+ ? {}
2566
+ : { finalOutput: this.state.readJson(row.finalOutputHash) }),
2567
+ ...(row.errorHash === null ? {} : { error: this.readText(row.errorHash) }),
2568
+ };
2569
+ }
2570
+
2571
+ private readSteps(runId: string): WorkflowStepRecord[] {
2572
+ const rows = this.state.connection
2573
+ .prepare(
2574
+ `SELECT s.step_index AS stepIndex, a.attempt_id AS attemptId, a.node_id AS nodeId,
2575
+ a.node_type AS nodeType, a.status,
2576
+ a.prompt_hash AS promptHash, a.output_hash AS outputHash,
2577
+ s.output_override_hash AS outputOverrideHash,
2578
+ a.receipt_hash AS receiptHash, a.error_hash AS errorHash,
2579
+ prompt_entry.entry_hash AS promptEntryHash,
2580
+ response_entry.entry_hash AS responseEntryHash,
2581
+ first_link.entry_id AS firstEntryId, last_link.entry_id AS lastEntryId,
2582
+ a.settings_scope_id AS settingsScopeId,
2583
+ a.settings_change_number AS settingsChangeNumber,
2584
+ a.settings_hash AS settingsHash,
2585
+ a.started_at AS startedAt, a.finished_at AS finishedAt
2586
+ FROM run_steps s
2587
+ JOIN node_attempts a ON a.attempt_id = s.attempt_id
2588
+ LEFT JOIN attempt_entries prompt_link
2589
+ ON prompt_link.attempt_id = a.attempt_id AND prompt_link.role = 'prompt'
2590
+ LEFT JOIN session_entries prompt_entry
2591
+ ON prompt_entry.segment_id = prompt_link.segment_id
2592
+ AND prompt_entry.entry_id = prompt_link.entry_id
2593
+ LEFT JOIN attempt_entries response_link
2594
+ ON response_link.attempt_id = a.attempt_id AND response_link.role = 'response'
2595
+ LEFT JOIN session_entries response_entry
2596
+ ON response_entry.segment_id = response_link.segment_id
2597
+ AND response_entry.entry_id = response_link.entry_id
2598
+ LEFT JOIN attempt_entries first_link
2599
+ ON first_link.attempt_id = a.attempt_id AND first_link.role = 'first'
2600
+ LEFT JOIN attempt_entries last_link
2601
+ ON last_link.attempt_id = a.attempt_id AND last_link.role = 'last'
2602
+ WHERE s.run_id = ? ORDER BY s.step_index`,
2603
+ )
2604
+ .all(runId)
2605
+ .filter(isStepRow);
2606
+ return rows.map((row, index) => {
2607
+ if (row.stepIndex !== index)
2608
+ throw new Error(`Workflow run step sequence has a gap: ${runId}`);
2609
+ const receipt =
2610
+ row.receiptHash === null ? {} : this.readJsonAs<StoredAttemptReceipt>(row.receiptHash);
2611
+ const prompt =
2612
+ row.promptEntryHash !== null
2613
+ ? promptFromEntry(this.state.readJson(row.promptEntryHash))
2614
+ : row.promptHash === null
2615
+ ? null
2616
+ : this.readText(row.promptHash);
2617
+ const outputHash = row.outputOverrideHash ?? row.outputHash;
2618
+ const output =
2619
+ outputHash !== null
2620
+ ? this.state.readJson(outputHash)
2621
+ : row.responseEntryHash === null
2622
+ ? null
2623
+ : assistantOutputFromEntry(
2624
+ this.state.readJson(row.responseEntryHash),
2625
+ receipt.assistantMessage?.maxChars,
2626
+ );
2627
+ const error = row.errorHash === null ? undefined : this.readText(row.errorHash);
2628
+ const conversation =
2629
+ row.firstEntryId === null || row.lastEntryId === null
2630
+ ? undefined
2631
+ : { firstEntryId: row.firstEntryId, lastEntryId: row.lastEntryId };
2632
+ return {
2633
+ attemptId: row.attemptId,
2634
+ nodeId: row.nodeId,
2635
+ nodeType: row.nodeType,
2636
+ outcome: outcomeForStatus(row.status),
2637
+ startedAt: new Date(row.startedAt).toISOString(),
2638
+ finishedAt: new Date(row.finishedAt).toISOString(),
2639
+ prompt,
2640
+ output,
2641
+ ...(error === undefined ? {} : { error }),
2642
+ ...savedStepSettingsBinding(
2643
+ row.settingsScopeId,
2644
+ row.settingsChangeNumber,
2645
+ row.settingsHash,
2646
+ ),
2647
+ ...receipt,
2648
+ ...(conversation === undefined ? {} : { conversation }),
2649
+ };
2650
+ });
2651
+ }
2652
+
2653
+ private readRunSources(
2654
+ runId: string,
2655
+ snapshot: WorkflowDefinitionSnapshot,
2656
+ ): { root?: WorkflowSource; mounted: WorkflowMountedSource[] } {
2657
+ const rows = this.state.connection
2658
+ .prepare(
2659
+ `SELECT mount_path AS mountPath, source_type AS sourceType,
2660
+ source_ref AS sourceRef, source_revision AS sourceRevision
2661
+ FROM run_sources WHERE run_id = ? ORDER BY mount_path`,
2662
+ )
2663
+ .all(runId)
2664
+ .filter(isRunSourceRow);
2665
+ const rootRow = rows.find((row) => row.mountPath === "");
2666
+ const root =
2667
+ rootRow === undefined ||
2668
+ (rootRow.sourceType === "file" && rootRow.sourceRef.startsWith("inline:"))
2669
+ ? undefined
2670
+ : workflowSourceFromRow(rootRow);
2671
+ const mountNames = new Map(
2672
+ (snapshot.composition?.mounts ?? []).map((mount) => [
2673
+ mount.mountPath.join("/"),
2674
+ mount.workflowName,
2675
+ ]),
2676
+ );
2677
+ const mounted = rows
2678
+ .filter((row) => row.mountPath !== "")
2679
+ .map((row) => ({
2680
+ mountPath: row.mountPath.split("/"),
2681
+ workflowName: mountNames.get(row.mountPath) ?? row.mountPath,
2682
+ source: workflowSourceFromRow(row),
2683
+ }));
2684
+ return { ...(root === undefined ? {} : { root }), mounted };
2685
+ }
2686
+
2687
+ private carriedStepCount(runId: string): number {
2688
+ const row = this.state.connection
2689
+ .prepare(
2690
+ `SELECT count(*) AS count
2691
+ FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
2692
+ WHERE s.run_id = ? AND a.run_id <> s.run_id`,
2693
+ )
2694
+ .get(runId);
2695
+ return isCountRow(row) ? row.count : 0;
2696
+ }
2697
+
2698
+ private readActiveAttempt(runId: string):
2699
+ | {
2700
+ attemptId: string;
2701
+ nodeId: string;
2702
+ startedAt: number | null;
2703
+ settingsScopeId: string | null;
2704
+ settingsChangeNumber: number | null;
2705
+ settingsHash: Buffer | null;
2706
+ }
2707
+ | undefined {
2708
+ const row = this.state.connection
2709
+ .prepare(
2710
+ `SELECT attempt_id AS attemptId, node_id AS nodeId, started_at AS startedAt,
2711
+ settings_scope_id AS settingsScopeId,
2712
+ settings_change_number AS settingsChangeNumber,
2713
+ settings_hash AS settingsHash
2714
+ FROM node_attempts
2715
+ WHERE run_id = ? AND status IN ('pending', 'running', 'waiting', 'interrupted')`,
2716
+ )
2717
+ .get(runId);
2718
+ return isActiveAttemptRow(row) ? row : undefined;
2719
+ }
2720
+
2721
+ private readHumanDecisionReceipt(runId: string): HumanDecisionReceipt | undefined {
2722
+ const row = this.state.connection
2723
+ .prepare(
2724
+ `SELECT d.request_hash AS requestHash, r.response_hash AS responseHash
2725
+ FROM continuations c
2726
+ JOIN human_decisions d ON d.decision_id = c.decision_id
2727
+ JOIN human_decision_resolutions r ON r.decision_id = c.decision_id
2728
+ WHERE c.continuation_run_id = ? AND r.outcome = 'accepted'`,
2729
+ )
2730
+ .get(runId);
2731
+ if (!isDecisionReceiptRow(row)) return undefined;
2732
+ const request = this.readJsonAs<Record<string, unknown>>(row.requestHash);
2733
+ const decision = this.readJsonAs<Record<string, unknown>>(row.responseHash);
2734
+ if (
2735
+ typeof request.decisionId !== "string" ||
2736
+ typeof request.requestDigest !== "string" ||
2737
+ typeof request.nodeId !== "string" ||
2738
+ typeof decision.provenance !== "string" ||
2739
+ typeof decision.acceptedAt !== "string" ||
2740
+ typeof decision.answerDigest !== "string" ||
2741
+ typeof decision.subjectDigest !== "string" ||
2742
+ typeof decision.presentationDigest !== "string" ||
2743
+ typeof decision.revision !== "number" ||
2744
+ decision.response === undefined
2745
+ ) {
2746
+ throw new Error(`Human decision receipt is invalid for run ${runId}`);
2747
+ }
2748
+ return {
2749
+ schema: "pi-workflows.human-decision-receipt.v1",
2750
+ decisionId: request.decisionId,
2751
+ requestDigest: request.requestDigest,
2752
+ nodeId: request.nodeId,
2753
+ response: decision.response as HumanDecisionReceipt["response"],
2754
+ provenance: decision.provenance === "timeout" ? "timeout" : "human",
2755
+ acceptedAt: decision.acceptedAt,
2756
+ answerDigest: decision.answerDigest,
2757
+ subjectDigest: decision.subjectDigest,
2758
+ presentationDigest: decision.presentationDigest,
2759
+ revision: decision.revision,
2760
+ };
2761
+ }
2762
+
2763
+ private readUpdates(runId: string): Pick<WorkflowRunState, "updates"> {
2764
+ const rows = this.state.connection
2765
+ .prepare(
2766
+ `SELECT u.update_id AS updateId, u.run_revision AS runRevision,
2767
+ a.node_id AS nodeId, u.attempt_id AS attemptId,
2768
+ u.update_type AS updateType, u.update_key AS updateKey,
2769
+ u.data_hash AS dataHash, u.recorded_at AS recordedAt
2770
+ FROM workflow_updates u
2771
+ JOIN node_attempts a ON a.attempt_id = u.attempt_id
2772
+ WHERE a.run_id = ? ORDER BY u.run_revision`,
2773
+ )
2774
+ .all(runId)
2775
+ .filter(isUpdateRow);
2776
+ if (rows.length === 0) return {};
2777
+ let updates: WorkflowUpdateRecord[] | undefined;
2778
+ for (const row of rows) {
2779
+ updates = updateProjection(updates, {
2780
+ updateId: row.updateId,
2781
+ seq: row.runRevision,
2782
+ at: new Date(row.recordedAt).toISOString(),
2783
+ runId,
2784
+ nodeId: row.nodeId,
2785
+ attemptId: row.attemptId,
2786
+ type: row.updateType,
2787
+ key: row.updateKey,
2788
+ data: this.readJsonAs<Record<string, unknown>>(row.dataHash),
2789
+ });
2790
+ }
2791
+ return updates === undefined ? {} : { updates };
2792
+ }
2793
+
2794
+ private readJsonAs<T>(hash: Buffer): T {
2795
+ return this.state.readJson(hash) as T;
2796
+ }
2797
+
2798
+ private readText(hash: Buffer): string {
2799
+ const blob = this.state.readBlob(hash);
2800
+ if (blob === undefined || blob.mediaType !== "text/plain") {
2801
+ throw new Error("Text blob is missing or has the wrong media type");
2802
+ }
2803
+ return blob.content.toString("utf8");
1082
2804
  }
1083
2805
 
1084
2806
  private readDefinition(hash: Buffer): WorkflowDefinitionSnapshot {
@@ -1318,12 +3040,13 @@ function insertRunEvent(
1318
3040
  at: string,
1319
3041
  event: WorkflowTraceEventDraft | WorkflowTraceEvent,
1320
3042
  ): void {
3043
+ const payload = compactTracePayload(event.type, event.payload);
1321
3044
  const payloadHash = state.putJson(
1322
3045
  {
1323
3046
  scope: event.scope,
1324
3047
  ...(event.nodeId === undefined ? {} : { nodeId: event.nodeId }),
1325
3048
  ...(event.attemptId === undefined ? {} : { attemptId: event.attemptId }),
1326
- payload: event.payload,
3049
+ payload,
1327
3050
  },
1328
3051
  Date.parse(at),
1329
3052
  );
@@ -1339,6 +3062,40 @@ function insertRunEvent(
1339
3062
  );
1340
3063
  }
1341
3064
 
3065
+ function compactTracePayload(
3066
+ eventType: string,
3067
+ payload: Record<string, unknown>,
3068
+ ): Record<string, unknown> {
3069
+ if (eventType === "agent_prompt_sent" && Object.hasOwn(payload, "prompt")) {
3070
+ const { prompt: _prompt, ...rest } = payload;
3071
+ return { ...rest, promptStored: true };
3072
+ }
3073
+ if (eventType === "node_finished" || eventType === "node_failed") {
3074
+ const {
3075
+ output: _output,
3076
+ action: _action,
3077
+ assistantMessage: _assistantMessage,
3078
+ conversation: _conversation,
3079
+ error: _error,
3080
+ ...rest
3081
+ } = payload;
3082
+ return { ...rest, ...(Object.hasOwn(payload, "output") ? { outputStored: true } : {}) };
3083
+ }
3084
+ if (eventType === "include_exited" && Object.hasOwn(payload, "output")) {
3085
+ const { output: _output, ...rest } = payload;
3086
+ return { ...rest, outputStored: true };
3087
+ }
3088
+ if (eventType === "run_started" && Object.hasOwn(payload, "input")) {
3089
+ const { input: _input, ...rest } = payload;
3090
+ return { ...rest, inputStored: true };
3091
+ }
3092
+ if (Object.hasOwn(payload, "finalOutput")) {
3093
+ const { finalOutput: _finalOutput, ...rest } = payload;
3094
+ return { ...rest, finalOutputStored: true };
3095
+ }
3096
+ return payload;
3097
+ }
3098
+
1342
3099
  function insertGenericEvent(
1343
3100
  state: StateDatabase,
1344
3101
  resourceId: string,
@@ -1383,6 +3140,22 @@ function sourceForState(
1383
3140
  };
1384
3141
  }
1385
3142
 
3143
+ function sourceParts(source: WorkflowSource): {
3144
+ type: "builtin" | "file";
3145
+ ref: string;
3146
+ revision: string;
3147
+ } {
3148
+ return source.kind === "builtin"
3149
+ ? { type: "builtin", ref: source.id, revision: source.revision }
3150
+ : { type: "file", ref: source.path, revision: source.hash };
3151
+ }
3152
+
3153
+ function workflowSourceFromRow(row: RunSourceRow): WorkflowSource {
3154
+ return row.sourceType === "builtin"
3155
+ ? { kind: "builtin", id: row.sourceRef, revision: row.sourceRevision }
3156
+ : { kind: "file", path: row.sourceRef, hash: row.sourceRevision };
3157
+ }
3158
+
1386
3159
  function segmentIdFor(runId: string, attemptId?: string): string {
1387
3160
  return `segment-${createHash("sha256")
1388
3161
  .update(`${runId}\0${attemptId ?? ""}`)
@@ -1407,6 +3180,74 @@ function outcomeStatus(outcome: string): string {
1407
3180
  }
1408
3181
  }
1409
3182
 
3183
+ function outcomeForStatus(status: string): WorkflowStepRecord["outcome"] {
3184
+ switch (status) {
3185
+ case "completed":
3186
+ return "ok";
3187
+ case "timed_out":
3188
+ return "timed_out";
3189
+ case "cancelled":
3190
+ return "cancelled";
3191
+ case "failed":
3192
+ return "failed";
3193
+ default:
3194
+ throw new Error(`Workflow step has nonterminal status: ${status}`);
3195
+ }
3196
+ }
3197
+
3198
+ function attemptReceipt(step: WorkflowStepRecord): StoredAttemptReceipt {
3199
+ return {
3200
+ ...(step.action === undefined ? {} : { action: step.action }),
3201
+ ...(step.assistantMessage === undefined ? {} : { assistantMessage: step.assistantMessage }),
3202
+ };
3203
+ }
3204
+
3205
+ function promptFromEntry(value: unknown): string | null {
3206
+ if (!isRecord(value)) return null;
3207
+ const content = value.content;
3208
+ if (typeof content === "string") return content;
3209
+ if (!Array.isArray(content)) return null;
3210
+ return content
3211
+ .filter(isRecord)
3212
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
3213
+ .filter((text) => text.length !== 0)
3214
+ .join("\n");
3215
+ }
3216
+
3217
+ function assistantOutputFromEntry(value: unknown, maxChars?: number): string {
3218
+ if (!isRecord(value) || !isRecord(value.message)) {
3219
+ throw new Error("Assistant response entry is invalid");
3220
+ }
3221
+ const message = value.message;
3222
+ if (message.role !== "assistant" || !Array.isArray(message.content)) {
3223
+ throw new Error("Assistant response entry does not contain an assistant message");
3224
+ }
3225
+ const text = message.content
3226
+ .filter(isRecord)
3227
+ .filter((part) => part.type === "text" && typeof part.text === "string")
3228
+ .map((part) => part.text as string)
3229
+ .join("\n");
3230
+ if (text.trim().length === 0) throw new Error("Assistant response entry has no visible text");
3231
+ if (maxChars !== undefined && text.length > maxChars) {
3232
+ throw new Error(`Assistant response exceeds its stored limit of ${maxChars} characters`);
3233
+ }
3234
+ return text;
3235
+ }
3236
+
3237
+ function resultForStep(step: WorkflowStepRecord): WorkflowNodeResult {
3238
+ return {
3239
+ attemptId: step.attemptId,
3240
+ nodeId: step.nodeId,
3241
+ nodeType: step.nodeType,
3242
+ outcome: step.outcome,
3243
+ startedAt: step.startedAt,
3244
+ finishedAt: step.finishedAt,
3245
+ durationMs: Date.parse(step.finishedAt) - Date.parse(step.startedAt),
3246
+ ...(step.outcome === "ok" ? { output: step.output } : {}),
3247
+ ...(step.error === undefined ? {} : { error: step.error }),
3248
+ };
3249
+ }
3250
+
1410
3251
  function traceScope(value: unknown): WorkflowTraceEvent["scope"] {
1411
3252
  return value === "node" || value === "agent" || value === "action" || value === "session"
1412
3253
  ? value
@@ -1438,6 +3279,12 @@ function validateSessionEventRecord(record: WorkflowSessionEventRecord): void {
1438
3279
  ) {
1439
3280
  throw new Error("Session event is missing required envelope fields");
1440
3281
  }
3282
+ if (
3283
+ record.type === "assistant_event" &&
3284
+ ["text_delta", "thinking_delta", "toolcall_delta"].includes(String(record.payload.type))
3285
+ ) {
3286
+ throw new Error("Incremental assistant events are not durable session facts");
3287
+ }
1441
3288
  }
1442
3289
 
1443
3290
  function validateSessionCapture(capture: WorkflowSessionCapture): void {
@@ -1462,6 +3309,178 @@ function validateSessionCapture(capture: WorkflowSessionCapture): void {
1462
3309
  }
1463
3310
  }
1464
3311
 
3312
+ function followUpIdFor(runId: string, requestId: string): string {
3313
+ const digest = createHash("sha256").update(`${runId}\0${requestId}`).digest("hex").slice(0, 40);
3314
+ return `follow-up-${digest}`;
3315
+ }
3316
+
3317
+ function assertRequestId(requestId: string): void {
3318
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u.test(requestId)) {
3319
+ throw new Error("Workflow request ID must be a stable identifier of at most 512 characters");
3320
+ }
3321
+ }
3322
+
3323
+ function assertSourceType(source: string): void {
3324
+ if (typeof source !== "string" || source.trim().length === 0 || source.length > 128) {
3325
+ throw new Error("Workflow change source must be a non-empty string of at most 128 characters");
3326
+ }
3327
+ }
3328
+
3329
+ function assertActorType(value: string): ActorType {
3330
+ if (
3331
+ value !== "session" &&
3332
+ value !== "host" &&
3333
+ value !== "controller" &&
3334
+ value !== "channel" &&
3335
+ value !== "human" &&
3336
+ value !== "policy" &&
3337
+ value !== "control" &&
3338
+ value !== "system"
3339
+ ) {
3340
+ throw new Error(`Unknown saved actor type: ${value}`);
3341
+ }
3342
+ return value;
3343
+ }
3344
+
3345
+ function assertFollowUpState(value: string): WorkflowFollowUpState {
3346
+ if (
3347
+ value !== "queued" &&
3348
+ value !== "pending_presentation" &&
3349
+ value !== "ready" &&
3350
+ value !== "sent" &&
3351
+ value !== "removed" &&
3352
+ value !== "cancelled"
3353
+ ) {
3354
+ throw new Error(`Unknown workflow follow-up state: ${value}`);
3355
+ }
3356
+ return value;
3357
+ }
3358
+
3359
+ function assertPresentationState(value: string): WorkflowPresentationState {
3360
+ if (
3361
+ value !== "none" &&
3362
+ value !== "not-needed" &&
3363
+ value !== "pending" &&
3364
+ value !== "settled" &&
3365
+ value !== "unavailable"
3366
+ ) {
3367
+ throw new Error(`Unknown workflow presentation state: ${value}`);
3368
+ }
3369
+ return value;
3370
+ }
3371
+
3372
+ function isSettingsScopeRow(value: unknown): value is SettingsScopeRow {
3373
+ return (
3374
+ isRecord(value) &&
3375
+ typeof value.scopeId === "string" &&
3376
+ typeof value.resourceId === "string" &&
3377
+ typeof value.originRunId === "string" &&
3378
+ typeof value.activeRunId === "string" &&
3379
+ typeof value.mountPath === "string" &&
3380
+ typeof value.invocation === "number" &&
3381
+ Buffer.isBuffer(value.currentHash) &&
3382
+ typeof value.revision === "number" &&
3383
+ typeof value.createdAt === "number" &&
3384
+ typeof value.updatedAt === "number"
3385
+ );
3386
+ }
3387
+
3388
+ function isSettingsProjectionRow(value: unknown): value is SettingsProjectionRow {
3389
+ return (
3390
+ isRecord(value) &&
3391
+ typeof value.scopeId === "string" &&
3392
+ Buffer.isBuffer(value.initialHash) &&
3393
+ Buffer.isBuffer(value.currentHash) &&
3394
+ typeof value.revision === "number"
3395
+ );
3396
+ }
3397
+
3398
+ function isSettingsProjectionChangeRow(value: unknown): value is SettingsProjectionChangeRow {
3399
+ return (
3400
+ isRecord(value) &&
3401
+ typeof value.changeNumber === "number" &&
3402
+ Buffer.isBuffer(value.patchHash) &&
3403
+ Buffer.isBuffer(value.beforeHash) &&
3404
+ Buffer.isBuffer(value.afterHash)
3405
+ );
3406
+ }
3407
+
3408
+ function isSettingsChangeRow(value: unknown): value is SettingsChangeRow {
3409
+ return (
3410
+ isRecord(value) &&
3411
+ typeof value.changeId === "string" &&
3412
+ typeof value.scopeId === "string" &&
3413
+ typeof value.requestId === "string" &&
3414
+ typeof value.changeNumber === "number" &&
3415
+ typeof value.actorType === "string" &&
3416
+ (typeof value.actorId === "string" || value.actorId === null) &&
3417
+ typeof value.sourceType === "string" &&
3418
+ Buffer.isBuffer(value.patchHash) &&
3419
+ Buffer.isBuffer(value.beforeHash) &&
3420
+ Buffer.isBuffer(value.afterHash) &&
3421
+ typeof value.acceptedAt === "number"
3422
+ );
3423
+ }
3424
+
3425
+ function isFollowUpQueueRow(value: unknown): value is FollowUpQueueRow {
3426
+ return (
3427
+ isRecord(value) &&
3428
+ typeof value.runId === "string" &&
3429
+ typeof value.resourceId === "string" &&
3430
+ (typeof value.originSessionId === "string" || value.originSessionId === null) &&
3431
+ typeof value.presentationState === "string" &&
3432
+ (typeof value.presentationEntryId === "string" || value.presentationEntryId === null) &&
3433
+ (typeof value.presentationAssistantEntryId === "string" ||
3434
+ value.presentationAssistantEntryId === null) &&
3435
+ (Buffer.isBuffer(value.presentationReasonHash) || value.presentationReasonHash === null) &&
3436
+ typeof value.revision === "number"
3437
+ );
3438
+ }
3439
+
3440
+ function isFollowUpRow(value: unknown): value is FollowUpRow {
3441
+ return (
3442
+ isRecord(value) &&
3443
+ typeof value.followUpId === "string" &&
3444
+ typeof value.resourceId === "string" &&
3445
+ typeof value.queueResourceId === "string" &&
3446
+ typeof value.runId === "string" &&
3447
+ typeof value.requestId === "string" &&
3448
+ typeof value.orderNumber === "number" &&
3449
+ typeof value.targetSessionId === "string" &&
3450
+ typeof value.actorType === "string" &&
3451
+ (typeof value.actorId === "string" || value.actorId === null) &&
3452
+ typeof value.sourceType === "string" &&
3453
+ Buffer.isBuffer(value.promptHash) &&
3454
+ typeof value.status === "string" &&
3455
+ (typeof value.sessionEntryId === "string" || value.sessionEntryId === null) &&
3456
+ (Buffer.isBuffer(value.reasonHash) || value.reasonHash === null) &&
3457
+ typeof value.createdAt === "number" &&
3458
+ typeof value.updatedAt === "number" &&
3459
+ (typeof value.sentAt === "number" || value.sentAt === null) &&
3460
+ typeof value.revision === "number"
3461
+ );
3462
+ }
3463
+
3464
+ function isParentRunRow(value: unknown): value is { parentRunId: string | null } {
3465
+ return isRecord(value) && (typeof value.parentRunId === "string" || value.parentRunId === null);
3466
+ }
3467
+
3468
+ function isStatusRow(value: unknown): value is { status: string } {
3469
+ return isRecord(value) && typeof value.status === "string";
3470
+ }
3471
+
3472
+ function isCountRow(value: unknown): value is { count: number } {
3473
+ return isRecord(value) && typeof value.count === "number";
3474
+ }
3475
+
3476
+ function isLeaseExpiryRow(value: unknown): value is { expiresAt: number | null } {
3477
+ return isRecord(value) && (typeof value.expiresAt === "number" || value.expiresAt === null);
3478
+ }
3479
+
3480
+ function isNextOrderRow(value: unknown): value is { nextOrder: number } {
3481
+ return isRecord(value) && typeof value.nextOrder === "number";
3482
+ }
3483
+
1465
3484
  function assertValidRunId(runId: string): void {
1466
3485
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
1467
3486
  throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
@@ -1476,10 +3495,108 @@ function isRunRow(value: unknown): value is RunRow {
1476
3495
  return isRecord(value);
1477
3496
  }
1478
3497
 
3498
+ function savedCurrentSettingsBinding(
3499
+ scopeId: string | null,
3500
+ changeNumber: number | null,
3501
+ settingsHash: Buffer | null,
3502
+ ): Pick<
3503
+ WorkflowRunState,
3504
+ "currentSettingsScopeId" | "currentSettingsChangeNumber" | "currentSettingsHash"
3505
+ > {
3506
+ if (scopeId === null && changeNumber === null && settingsHash === null) return {};
3507
+ if (scopeId === null || changeNumber === null || settingsHash === null) {
3508
+ throw new Error("Saved current workflow settings binding is incomplete");
3509
+ }
3510
+ return {
3511
+ currentSettingsScopeId: scopeId,
3512
+ currentSettingsChangeNumber: changeNumber,
3513
+ currentSettingsHash: settingsHash.toString("hex"),
3514
+ };
3515
+ }
3516
+
3517
+ function savedStepSettingsBinding(
3518
+ scopeId: string | null,
3519
+ changeNumber: number | null,
3520
+ settingsHash: Buffer | null,
3521
+ ): Pick<WorkflowStepRecord, "settingsScopeId" | "settingsChangeNumber" | "settingsHash"> {
3522
+ if (scopeId === null && changeNumber === null && settingsHash === null) return {};
3523
+ if (scopeId === null || changeNumber === null || settingsHash === null) {
3524
+ throw new Error("Saved workflow step settings binding is incomplete");
3525
+ }
3526
+ return {
3527
+ settingsScopeId: scopeId,
3528
+ settingsChangeNumber: changeNumber,
3529
+ settingsHash: settingsHash.toString("hex"),
3530
+ };
3531
+ }
3532
+
1479
3533
  function isRunIdRow(value: unknown): value is { runId: string } {
1480
3534
  return isRecord(value);
1481
3535
  }
1482
3536
 
3537
+ function isRunStepIdentityRow(value: unknown): value is {
3538
+ stepIndex: number;
3539
+ attemptId: string;
3540
+ } {
3541
+ return isRecord(value);
3542
+ }
3543
+
3544
+ function isStepRow(value: unknown): value is StepRow {
3545
+ return isRecord(value);
3546
+ }
3547
+
3548
+ function isRunSourceRow(value: unknown): value is RunSourceRow {
3549
+ return isRecord(value);
3550
+ }
3551
+
3552
+ function isSegmentIdentityRow(value: unknown): value is { segmentId: string; entryHash: Buffer } {
3553
+ return isRecord(value) && typeof value.segmentId === "string" && Buffer.isBuffer(value.entryHash);
3554
+ }
3555
+
3556
+ function isEntryIdentityRow(value: unknown): value is { entryId: string } {
3557
+ return isRecord(value) && typeof value.entryId === "string";
3558
+ }
3559
+
3560
+ function isAttemptValueRow(value: unknown): value is {
3561
+ outputHash: Buffer | null;
3562
+ receiptHash: Buffer | null;
3563
+ responseEntryHash: Buffer | null;
3564
+ } {
3565
+ return isRecord(value);
3566
+ }
3567
+
3568
+ function isActiveAttemptRow(value: unknown): value is {
3569
+ attemptId: string;
3570
+ nodeId: string;
3571
+ startedAt: number | null;
3572
+ settingsScopeId: string | null;
3573
+ settingsChangeNumber: number | null;
3574
+ settingsHash: Buffer | null;
3575
+ } {
3576
+ return isRecord(value);
3577
+ }
3578
+
3579
+ function isDecisionReceiptRow(value: unknown): value is {
3580
+ requestHash: Buffer;
3581
+ responseHash: Buffer;
3582
+ } {
3583
+ return (
3584
+ isRecord(value) && Buffer.isBuffer(value.requestHash) && Buffer.isBuffer(value.responseHash)
3585
+ );
3586
+ }
3587
+
3588
+ function isContinuationIdentityRow(value: unknown): value is {
3589
+ parentRunId: string;
3590
+ continuationRunId: string;
3591
+ createdAt: number;
3592
+ } {
3593
+ return isRecord(value);
3594
+ }
3595
+
3596
+ function isUpdateRow(value: unknown): value is UpdateRow {
3597
+ return isRecord(value);
3598
+ }
3599
+
1483
3600
  function isRevisionRow(value: unknown): value is { revision: number } {
1484
3601
  return isRecord(value);
1485
3602
  }
@@ -1519,7 +3636,15 @@ function isLeaseAuthorityRow(value: unknown): value is {
1519
3636
  }
1520
3637
 
1521
3638
  export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
1522
- const composition = compositionMetadata(workflow)?.snapshot;
3639
+ const metadata = compositionMetadata(workflow);
3640
+ const composition = metadata?.snapshot;
3641
+ const settingsScopes = Object.values(metadata?.scopes ?? {})
3642
+ .filter((scope) => scope.path !== "" && scope.settings !== undefined)
3643
+ .sort((left, right) => left.path.localeCompare(right.path))
3644
+ .map((scope) => ({
3645
+ mountPath: scope.path.split("/"),
3646
+ settings: snapshotSettings(scope.settings!),
3647
+ }));
1523
3648
  return {
1524
3649
  schema: DEFINITION_SNAPSHOT_SCHEMA,
1525
3650
  name: workflow.name,
@@ -1533,6 +3658,8 @@ export function createDefinitionSnapshot(workflow: WorkflowDefinition): Workflow
1533
3658
  ),
1534
3659
  edges: structuredClone(workflow.edges),
1535
3660
  ...(composition !== undefined ? { composition: structuredClone(composition) } : {}),
3661
+ ...(workflow.settings !== undefined ? { settings: snapshotSettings(workflow.settings) } : {}),
3662
+ ...(settingsScopes.length > 0 ? { settingsScopes } : {}),
1536
3663
  };
1537
3664
  }
1538
3665
 
@@ -1573,6 +3700,9 @@ function snapshotNode(
1573
3700
  if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
1574
3701
  common.expectedOutput = node.expectedOutput;
1575
3702
  }
3703
+ if (node.nodeType === "compute" && node.settingsRoute === true) {
3704
+ common.settingsRoute = true;
3705
+ }
1576
3706
  if (node.nodeType === "notify") common.summary = node.kind ?? "progress";
1577
3707
  if (node.nodeType === "checkpoint" && node.summary !== undefined) common.summary = node.summary;
1578
3708
  if (node.nodeType === "checkpoint" && node.humanDecision !== undefined) {
@@ -1591,3 +3721,20 @@ function snapshotNode(
1591
3721
  if (node.nodeType === "action") common.actionExecution = "exec" in node ? "shell" : "function";
1592
3722
  return common;
1593
3723
  }
3724
+
3725
+ function snapshotSettings(
3726
+ settings: NonNullable<WorkflowDefinition["settings"]>,
3727
+ ): WorkflowSettingsSnapshot {
3728
+ return {
3729
+ ...(settings.description !== undefined ? { description: settings.description } : {}),
3730
+ paths: settings.paths.map((rule) => ({
3731
+ path: rule.path,
3732
+ permissions: Object.fromEntries(
3733
+ Object.entries(rule.permissions).map(([permission, actors]) => [
3734
+ permission,
3735
+ actors === undefined ? [] : [...actors],
3736
+ ]),
3737
+ ),
3738
+ })),
3739
+ };
3740
+ }