@osolmaz/pi-workflows 0.13.2 → 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 (130) hide show
  1. package/README.md +5 -3
  2. package/dist/builtins/autodoc.workflow.d.ts +5 -5
  3. package/dist/builtins/autoimplement.workflow.d.ts +65 -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 +5 -5
  7. package/dist/builtins/catalog.js +1 -1
  8. package/dist/builtins/change-verification.workflow.d.ts +1 -1
  9. package/dist/builtins/plain-summary.workflow.d.ts +1 -1
  10. package/dist/builtins/plan-approval.workflow.d.ts +1 -1
  11. package/dist/builtins/plan-change.workflow.d.ts +23 -23
  12. package/dist/builtins/sanity-check.workflow.d.ts +3 -3
  13. package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
  14. package/dist/controllers/index.d.ts +2 -2
  15. package/dist/controllers/index.js.map +1 -1
  16. package/dist/controllers/sqlite.js +85 -23
  17. package/dist/controllers/sqlite.js.map +1 -1
  18. package/dist/controllers/types.d.ts +34 -0
  19. package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
  20. package/dist/controllers/workflow-engine-scheduler.js +83 -1
  21. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  22. package/dist/controllers/workflows.d.ts +8 -1
  23. package/dist/controllers/workflows.js +36 -0
  24. package/dist/controllers/workflows.js.map +1 -1
  25. package/dist/extension/controller-host.d.ts +2 -1
  26. package/dist/extension/controller-host.js +1 -1
  27. package/dist/extension/controller-host.js.map +1 -1
  28. package/dist/extension/follow-up-coordinator.d.ts +27 -0
  29. package/dist/extension/follow-up-coordinator.js +131 -0
  30. package/dist/extension/follow-up-coordinator.js.map +1 -0
  31. package/dist/extension/index.d.ts +9 -0
  32. package/dist/extension/index.js +460 -75
  33. package/dist/extension/index.js.map +1 -1
  34. package/dist/extension/session-events.d.ts +2 -3
  35. package/dist/extension/session-events.js +11 -10
  36. package/dist/extension/session-events.js.map +1 -1
  37. package/dist/extension/widget.js +9 -0
  38. package/dist/extension/widget.js.map +1 -1
  39. package/dist/state/database.d.ts +2 -1
  40. package/dist/state/database.js +11 -13
  41. package/dist/state/database.js.map +1 -1
  42. package/dist/state/json.js +6 -1
  43. package/dist/state/json.js.map +1 -1
  44. package/dist/state/mutation.d.ts +1 -1
  45. package/dist/state/mutation.js.map +1 -1
  46. package/dist/state/prune.d.ts +18 -0
  47. package/dist/state/prune.js +373 -0
  48. package/dist/state/prune.js.map +1 -0
  49. package/dist/state/schema.d.ts +1 -1
  50. package/dist/state/schema.js +116 -18
  51. package/dist/state/schema.js.map +1 -1
  52. package/dist/viewer/cli.d.ts +3 -1
  53. package/dist/viewer/cli.js +55 -4
  54. package/dist/viewer/cli.js.map +1 -1
  55. package/dist/viewer/render.js +19 -1
  56. package/dist/viewer/render.js.map +1 -1
  57. package/dist/workflows/composition.d.ts +2 -0
  58. package/dist/workflows/composition.js +15 -0
  59. package/dist/workflows/composition.js.map +1 -1
  60. package/dist/workflows/definition.d.ts +6 -3
  61. package/dist/workflows/definition.js +3 -0
  62. package/dist/workflows/definition.js.map +1 -1
  63. package/dist/workflows/engine.d.ts +6 -1
  64. package/dist/workflows/engine.js +301 -35
  65. package/dist/workflows/engine.js.map +1 -1
  66. package/dist/workflows/human-decision.d.ts +1 -0
  67. package/dist/workflows/human-decision.js +25 -9
  68. package/dist/workflows/human-decision.js.map +1 -1
  69. package/dist/workflows/index.d.ts +3 -1
  70. package/dist/workflows/index.js +2 -0
  71. package/dist/workflows/index.js.map +1 -1
  72. package/dist/workflows/json-patch.d.ts +47 -0
  73. package/dist/workflows/json-patch.js +298 -0
  74. package/dist/workflows/json-patch.js.map +1 -0
  75. package/dist/workflows/schema.js +29 -1
  76. package/dist/workflows/schema.js.map +1 -1
  77. package/dist/workflows/settings.d.ts +128 -0
  78. package/dist/workflows/settings.js +199 -0
  79. package/dist/workflows/settings.js.map +1 -0
  80. package/dist/workflows/store.d.ts +75 -2
  81. package/dist/workflows/store.js +1349 -89
  82. package/dist/workflows/store.js.map +1 -1
  83. package/dist/workflows/tool-input.d.ts +22 -0
  84. package/dist/workflows/tool-input.js +43 -0
  85. package/dist/workflows/tool-input.js.map +1 -1
  86. package/dist/workflows/types.d.ts +40 -5
  87. package/docs/2026-08-25-workflow-follow-ups.md +132 -0
  88. package/docs/2026-08-25-workflow-settings.md +169 -0
  89. package/docs/DEFERRED_TURNS.md +6 -0
  90. package/docs/DESIGN_PHILOSOPHY.md +2 -0
  91. package/docs/SQLITE_STATE.md +31 -18
  92. package/docs/WORKFLOW_COMPOSITION.md +15 -0
  93. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
  94. package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
  95. package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
  96. package/docs/tui-viewer.md +7 -6
  97. package/docs/workflows.md +22 -1
  98. package/examples/workflows/live-settings.workflow.ts +93 -0
  99. package/herdr-plugin.toml +1 -1
  100. package/package.json +1 -1
  101. package/src/builtins/autoimplement.workflow.ts +56 -8
  102. package/src/builtins/catalog.ts +1 -1
  103. package/src/controllers/index.ts +6 -0
  104. package/src/controllers/sqlite.ts +129 -35
  105. package/src/controllers/types.ts +40 -0
  106. package/src/controllers/workflow-engine-scheduler.ts +103 -1
  107. package/src/controllers/workflows.ts +68 -0
  108. package/src/extension/controller-host.ts +6 -1
  109. package/src/extension/follow-up-coordinator.ts +151 -0
  110. package/src/extension/index.ts +538 -88
  111. package/src/extension/session-events.ts +15 -13
  112. package/src/extension/widget.ts +8 -0
  113. package/src/state/database.ts +12 -12
  114. package/src/state/json.ts +6 -1
  115. package/src/state/mutation.ts +4 -1
  116. package/src/state/prune.ts +502 -0
  117. package/src/state/schema.ts +116 -18
  118. package/src/viewer/cli.ts +52 -5
  119. package/src/viewer/render.ts +43 -1
  120. package/src/workflows/composition.ts +19 -0
  121. package/src/workflows/definition.ts +19 -5
  122. package/src/workflows/engine.ts +363 -35
  123. package/src/workflows/human-decision.ts +41 -11
  124. package/src/workflows/index.ts +43 -0
  125. package/src/workflows/json-patch.ts +375 -0
  126. package/src/workflows/schema.ts +31 -1
  127. package/src/workflows/settings.ts +430 -0
  128. package/src/workflows/store.ts +1977 -184
  129. package/src/workflows/tool-input.ts +58 -0
  130. package/src/workflows/types.ts +43 -4
@@ -1,20 +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,
10
37
  WorkflowNodeResult,
11
38
  WorkflowNodeSnapshot,
12
39
  WorkflowRunState,
40
+ WorkflowSource,
41
+ WorkflowMountedSource,
13
42
  WorkflowStepRecord,
14
43
  WorkflowSessionBinding,
15
44
  WorkflowSessionCapture,
16
45
  WorkflowSessionEntryRecord,
17
46
  WorkflowSessionEventRecord,
47
+ WorkflowSettingsSnapshot,
18
48
  WorkflowTraceEvent,
19
49
  WorkflowTraceEventDraft,
20
50
  WorkflowUpdateInput,
@@ -29,6 +59,19 @@ export const SESSION_EVENT_SCHEMA = "pi-workflows.session-event.v1" as const;
29
59
  export const SESSION_CAPTURE_SCHEMA = "pi-workflows.session-capture.v1" as const;
30
60
  export const SESSION_EVENT_MAX_BYTES = 1024 * 1024;
31
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
+
32
75
  export function workflowStateDatabasePath(homeDir?: string): string {
33
76
  return workflowStatePath(homeDir);
34
77
  }
@@ -63,29 +106,27 @@ type RunRow = {
63
106
  definitionHash: Buffer;
64
107
  definitionDigest: Buffer;
65
108
  workflowRef: string;
66
- sourceRef: string;
67
- workflowSourceHash: Buffer;
68
- workflowSourcesHash: Buffer | null;
69
109
  parentRunId: string | null;
70
110
  title: string | null;
71
111
  status: string;
72
112
  paused: number;
73
113
  statusDetail: string | null;
74
114
  inputHash: Buffer;
75
- humanDecisionHash: Buffer | null;
76
115
  finalOutputHash: Buffer | null;
77
116
  errorHash: Buffer | null;
78
- carriedStepCount: number;
79
- currentNode: string | null;
80
- currentAttemptId: string | null;
81
- currentNodeStartedAt: number | null;
82
- waitingOn: string | null;
83
117
  createdAt: number;
84
118
  updatedAt: number;
85
119
  finishedAt: number | null;
86
120
  };
87
121
 
88
- type StoredStepMetadata = Pick<WorkflowStepRecord, "action" | "assistantMessage" | "conversation">;
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">;
89
130
 
90
131
  type StepRow = {
91
132
  stepIndex: number;
@@ -96,8 +137,15 @@ type StepRow = {
96
137
  promptHash: Buffer | null;
97
138
  outputHash: Buffer | null;
98
139
  outputOverrideHash: Buffer | null;
99
- stepMetadataHash: Buffer | null;
140
+ receiptHash: Buffer | null;
141
+ promptEntryHash: Buffer | null;
142
+ responseEntryHash: Buffer | null;
143
+ firstEntryId: string | null;
144
+ lastEntryId: string | null;
100
145
  errorHash: Buffer | null;
146
+ settingsScopeId: string | null;
147
+ settingsChangeNumber: number | null;
148
+ settingsHash: Buffer | null;
101
149
  startedAt: number;
102
150
  finishedAt: number;
103
151
  };
@@ -120,6 +168,79 @@ type EventRow = {
120
168
  recordedAt: number;
121
169
  };
122
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
+
123
244
  type SegmentRow = {
124
245
  segmentId: string;
125
246
  runId: string;
@@ -178,12 +299,18 @@ export type LoadedWorkflowRun = {
178
299
  sessionCapture: WorkflowSessionCapture | null;
179
300
  sessionIntegrity: SessionCaptureIntegrity;
180
301
  sessionSegments: SessionCaptureSegment[];
302
+ settingsScopes?: WorkflowSettingsScopeRecord[];
303
+ followUpQueue?: WorkflowFollowUpQueueRecord | null;
181
304
  };
182
305
 
183
306
  export type ReadWorkflowRunOptions = {
184
307
  includeTrace?: boolean;
185
308
  };
186
309
 
310
+ export type InitializeWorkflowRunOptions = {
311
+ initialSettings?: InitialWorkflowSettingsScope[];
312
+ };
313
+
187
314
  export class WorkflowRunStore {
188
315
  readonly databasePath: string;
189
316
  readonly state: StateDatabase;
@@ -192,6 +319,7 @@ export class WorkflowRunStore {
192
319
  | undefined;
193
320
  private readonly contexts = new Map<string, RunContext>();
194
321
  private readonly ownsState: boolean;
322
+ private readonly mutations: StateMutationStore;
195
323
 
196
324
  constructor(databasePath: string = workflowStatePath(), options: WorkflowRunStoreOptions = {}) {
197
325
  this.authorityProvider = options.authorityProvider;
@@ -204,13 +332,18 @@ export class WorkflowRunStore {
204
332
  checkLegacyState: databasePath === workflowStatePath(),
205
333
  });
206
334
  this.databasePath = this.state.filePath;
335
+ this.mutations = new StateMutationStore(this.state);
207
336
  }
208
337
 
209
338
  close(): void {
210
339
  if (this.ownsState) this.state.close();
211
340
  }
212
341
 
213
- async initializeRun(workflow: WorkflowDefinition, state: WorkflowRunState): Promise<string> {
342
+ async initializeRun(
343
+ workflow: WorkflowDefinition,
344
+ state: WorkflowRunState,
345
+ options: InitializeWorkflowRunOptions = {},
346
+ ): Promise<string> {
214
347
  assertValidRunId(state.runId);
215
348
  if (!this.contexts.has(state.runId)) {
216
349
  const reserved = this.readRunRow(state.runId);
@@ -250,7 +383,9 @@ export class WorkflowRunStore {
250
383
  payload: { workflowName: workflow.name },
251
384
  });
252
385
  this.persistRunState(reserved, state, revision, now);
386
+ this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
253
387
  this.syncNodeAttempts(state, snapshot, now);
388
+ this.syncContinuationIdentity(state);
254
389
  context.revision = revision;
255
390
  return;
256
391
  }
@@ -276,14 +411,7 @@ export class WorkflowRunStore {
276
411
  state.traceSeq = 1;
277
412
  state.updatedAt = at;
278
413
  const inputHash = this.state.putJson(state.input, now);
279
- const workflowSourceHash = this.state.putJson(state.workflowSource ?? source, now);
280
414
  const launchOptionsHash = this.state.putJson({}, now);
281
- const workflowSourcesHash =
282
- state.workflowSources === undefined
283
- ? null
284
- : this.state.putJson(state.workflowSources, now);
285
- const humanDecisionHash =
286
- state.humanDecision === undefined ? null : this.state.putJson(state.humanDecision, now);
287
415
  const finalOutputHash =
288
416
  state.finalOutput === undefined ? null : this.state.putJson(state.finalOutput, now);
289
417
  const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
@@ -291,13 +419,10 @@ export class WorkflowRunStore {
291
419
  .prepare(
292
420
  `INSERT INTO runs(
293
421
  run_id, resource_id, project_id, parent_run_id, definition_digest,
294
- workflow_ref, workflow_source_hash, launch_options_hash,
295
- source_type, source_ref, source_revision, title, status, paused,
296
- status_detail, input_hash, workflow_sources_hash, human_decision_hash,
297
- final_output_hash, error_hash, carried_step_count, current_node,
298
- current_attempt_id, current_node_started_at, waiting_on,
422
+ workflow_ref, launch_options_hash, title, status, paused,
423
+ status_detail, input_hash, final_output_hash, error_hash,
299
424
  created_at, updated_at, finished_at
300
- ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
425
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
301
426
  )
302
427
  .run(
303
428
  state.runId,
@@ -305,36 +430,26 @@ export class WorkflowRunStore {
305
430
  state.parentRunId ?? null,
306
431
  definitionDigest,
307
432
  state.workflowName,
308
- workflowSourceHash,
309
433
  launchOptionsHash,
310
- source.type,
311
- source.ref,
312
- source.revision,
313
434
  state.runTitle ?? null,
314
435
  state.status,
315
436
  state.paused === true ? 1 : 0,
316
437
  state.statusDetail ?? null,
317
438
  inputHash,
318
- workflowSourcesHash,
319
- humanDecisionHash,
320
439
  finalOutputHash,
321
440
  errorHash,
322
- state.carriedStepCount ?? 0,
323
- state.currentNode ?? null,
324
- state.currentAttemptId ?? null,
325
- state.currentNodeStartedAt === undefined
326
- ? null
327
- : Date.parse(state.currentNodeStartedAt),
328
- state.waitingOn ?? null,
329
441
  Date.parse(state.startedAt),
330
442
  now,
331
443
  state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
332
444
  );
445
+ this.insertRunSources(state.runId, source, state.workflowSources ?? []);
446
+ this.syncContinuationIdentity(state);
333
447
  insertRunEvent(this.state, resourceId, 1, at, {
334
448
  scope: "run",
335
449
  type: "run_created",
336
450
  payload: { workflowName: workflow.name },
337
451
  });
452
+ this.initializeRunSettingsAndFollowUps(state, options.initialSettings ?? [], now);
338
453
  this.syncNodeAttempts(state, snapshot, now);
339
454
  });
340
455
  this.contexts.set(state.runId, { revision: acceptedRevision, lock: Promise.resolve() });
@@ -342,12 +457,603 @@ export class WorkflowRunStore {
342
457
  });
343
458
  }
344
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
+
345
1050
  async prepareRunResume(runId: string): Promise<LoadedWorkflowRun> {
346
1051
  const loaded = this.readRun(runId, { includeTrace: true });
347
1052
  if (loaded === null) throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
348
1053
  if (loaded.state.status !== "running") {
349
1054
  throw new Error(`Cannot resume workflow run ${runId} with status ${loaded.state.status}`);
350
1055
  }
1056
+ this.verifySettingsProjections(runId);
351
1057
  const revision = this.resourceRevision(runId);
352
1058
  this.contexts.set(runId, { revision, lock: Promise.resolve() });
353
1059
  this.finalizeRecordingCaptures(runId, "Workflow host stopped before the run finished");
@@ -361,10 +1067,10 @@ export class WorkflowRunStore {
361
1067
  this.state.connection
362
1068
  .prepare(
363
1069
  `UPDATE node_attempts
364
- SET status = 'cancelled', finished_at = ?, updated_at = ?
1070
+ SET status = 'interrupted', updated_at = ?
365
1071
  WHERE run_id = ? AND status IN ('pending', 'running', 'waiting')`,
366
1072
  )
367
- .run(now, now, runId);
1073
+ .run(now, runId);
368
1074
  insertRunEvent(this.state, row.resourceId, nextRevision, at, {
369
1075
  scope: "run",
370
1076
  type: "run_resume_prepared",
@@ -393,6 +1099,9 @@ export class WorkflowRunStore {
393
1099
  delete loaded.state.currentNode;
394
1100
  delete loaded.state.currentAttemptId;
395
1101
  delete loaded.state.currentNodeStartedAt;
1102
+ delete loaded.state.currentSettingsScopeId;
1103
+ delete loaded.state.currentSettingsChangeNumber;
1104
+ delete loaded.state.currentSettingsHash;
396
1105
  delete loaded.state.statusDetail;
397
1106
  delete loaded.state.paused;
398
1107
  await this.writeSnapshot(runId, loaded.state, {
@@ -502,12 +1211,14 @@ export class WorkflowRunStore {
502
1211
  const revision = context.revision + 1;
503
1212
  const now = Date.now();
504
1213
  const at = new Date(now).toISOString();
1214
+ const snapshot = this.readDefinition(run.definitionHash);
1215
+ this.assertSettingsRouteCurrent(event, snapshot);
505
1216
  const traceEvent: WorkflowTraceEvent = { seq: revision, at, runId, ...event };
506
1217
  state.traceSeq = revision;
507
1218
  state.updatedAt = at;
508
1219
  insertRunEvent(this.state, run.resourceId, revision, at, traceEvent);
509
1220
  this.persistRunState(run, state, revision, now);
510
- const snapshot = this.readDefinition(run.definitionHash);
1221
+ this.transitionFollowUpsForRunState(state, event, now);
511
1222
  this.syncNodeAttempts(state, snapshot, now);
512
1223
  context.revision = revision;
513
1224
  accepted = traceEvent;
@@ -593,6 +1304,13 @@ export class WorkflowRunStore {
593
1304
  ON CONFLICT(run_id) DO NOTHING`,
594
1305
  )
595
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);
596
1314
  const revision = context.revision + 1;
597
1315
  const at = new Date(now).toISOString();
598
1316
  insertRunEvent(this.state, run.resourceId, revision, at, {
@@ -645,7 +1363,7 @@ export class WorkflowRunStore {
645
1363
  "session.entry_appended",
646
1364
  "session",
647
1365
  segment.sessionId,
648
- entryHash,
1366
+ null,
649
1367
  now,
650
1368
  );
651
1369
  return sequence;
@@ -776,6 +1494,8 @@ export class WorkflowRunStore {
776
1494
  sessionCapture: flat.capture,
777
1495
  sessionIntegrity: flat.integrity,
778
1496
  sessionSegments: segments.filter((segment) => segment.attemptId !== ""),
1497
+ settingsScopes: this.settingsScopesForRunView(runId),
1498
+ followUpQueue: this.readFollowUpQueue(runId) ?? null,
779
1499
  };
780
1500
  }
781
1501
 
@@ -894,10 +1614,6 @@ export class WorkflowRunStore {
894
1614
  now: number,
895
1615
  ): void {
896
1616
  this.bumpResource(run.resourceId, expectedRevision - 1, now);
897
- const workflowSourcesHash =
898
- state.workflowSources === undefined ? null : this.state.putJson(state.workflowSources, now);
899
- const humanDecisionHash =
900
- state.humanDecision === undefined ? null : this.state.putJson(state.humanDecision, now);
901
1617
  const finalOutputHash =
902
1618
  state.finalOutput === undefined ? null : this.state.putJson(state.finalOutput, now);
903
1619
  const errorHash = state.error === undefined ? null : this.state.putText(state.error, now);
@@ -905,9 +1621,7 @@ export class WorkflowRunStore {
905
1621
  .prepare(
906
1622
  `UPDATE runs
907
1623
  SET title = ?, status = ?, paused = ?, status_detail = ?,
908
- workflow_sources_hash = ?, human_decision_hash = ?, final_output_hash = ?,
909
- error_hash = ?, carried_step_count = ?, current_node = ?, current_attempt_id = ?,
910
- current_node_started_at = ?, waiting_on = ?, updated_at = ?, finished_at = ?
1624
+ final_output_hash = ?, error_hash = ?, updated_at = ?, finished_at = ?
911
1625
  WHERE run_id = ?`,
912
1626
  )
913
1627
  .run(
@@ -915,15 +1629,8 @@ export class WorkflowRunStore {
915
1629
  state.status,
916
1630
  state.paused === true ? 1 : 0,
917
1631
  state.statusDetail ?? null,
918
- workflowSourcesHash,
919
- humanDecisionHash,
920
1632
  finalOutputHash,
921
1633
  errorHash,
922
- state.carriedStepCount ?? 0,
923
- state.currentNode ?? null,
924
- state.currentAttemptId ?? null,
925
- state.currentNodeStartedAt === undefined ? null : Date.parse(state.currentNodeStartedAt),
926
- state.waitingOn ?? null,
927
1634
  now,
928
1635
  state.finishedAt === undefined ? null : Date.parse(state.finishedAt),
929
1636
  state.runId,
@@ -1001,104 +1708,533 @@ export class WorkflowRunStore {
1001
1708
  );
1002
1709
  }
1003
1710
 
1004
- private syncNodeAttempts(
1711
+ private initializeRunSettingsAndFollowUps(
1005
1712
  state: WorkflowRunState,
1006
- snapshot: WorkflowDefinitionSnapshot,
1713
+ initialSettings: InitialWorkflowSettingsScope[],
1007
1714
  now: number,
1008
1715
  ): void {
1009
- for (const step of state.steps.slice(state.carriedStepCount ?? 0)) {
1010
- const metadata = stepMetadata(step);
1011
- const stepMetadataHash =
1012
- Object.keys(metadata).length === 0 ? null : this.state.putJson(metadata, now);
1013
- const outputHash = step.output === undefined ? null : this.state.putJson(step.output, now);
1014
- const errorHash = step.error === undefined ? null : this.state.putText(step.error, now);
1015
- const promptHash = step.prompt === null ? null : this.state.putJson(step.prompt, now);
1016
- const existing = this.state.connection
1017
- .prepare("SELECT attempt_id AS attemptId FROM node_attempts WHERE attempt_id = ?")
1018
- .get(step.attemptId);
1019
- if (existing === undefined) {
1020
- 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) {
1021
1720
  this.state.connection
1022
1721
  .prepare(
1023
- `INSERT INTO node_attempts(
1024
- attempt_id, run_id, node_id, attempt_number, node_type, status,
1025
- prompt_hash, output_hash, step_metadata_hash, error_hash,
1026
- started_at, finished_at, created_at, updated_at
1027
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1722
+ `UPDATE workflow_follow_up_queues
1723
+ SET origin_session_id = COALESCE(origin_session_id, ?), updated_at = ?
1724
+ WHERE run_id = ?`,
1028
1725
  )
1029
- .run(
1030
- step.attemptId,
1031
- state.runId,
1032
- step.nodeId,
1033
- attemptNumber,
1034
- step.nodeType,
1035
- outcomeStatus(step.outcome),
1036
- promptHash,
1037
- outputHash,
1038
- stepMetadataHash,
1039
- errorHash,
1040
- Date.parse(step.startedAt),
1041
- Date.parse(step.finishedAt),
1042
- Date.parse(step.startedAt),
1043
- now,
1044
- );
1045
- } else {
1726
+ .run(parentQueue.originSessionId, now, state.runId);
1046
1727
  this.state.connection
1047
1728
  .prepare(
1048
- `UPDATE node_attempts
1049
- SET status = ?, prompt_hash = ?, output_hash = ?, step_metadata_hash = ?,
1050
- error_hash = ?, finished_at = ?, updated_at = ?
1051
- WHERE attempt_id = ?`,
1729
+ `UPDATE workflow_follow_ups
1730
+ SET run_id = ?, queue_resource_id = ?, updated_at = ?
1731
+ WHERE run_id = ? AND status = 'queued'`,
1052
1732
  )
1053
- .run(
1054
- outcomeStatus(step.outcome),
1055
- promptHash,
1056
- outputHash,
1057
- stepMetadataHash,
1058
- errorHash,
1059
- Date.parse(step.finishedAt),
1060
- now,
1061
- step.attemptId,
1062
- );
1733
+ .run(state.runId, childQueueResourceId, now, state.parentRunId);
1063
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);
1064
1740
  }
1065
- if (state.currentAttemptId !== undefined && state.currentNode !== undefined) {
1066
- this.ensureAttempt(state, state.currentNode, state.currentAttemptId, now, snapshot);
1741
+ const existing = this.state.connection
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);
1751
+ this.state.connection
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
+ );
1067
1771
  }
1068
- this.syncRunSteps(state, now);
1069
1772
  }
1070
1773
 
1071
- private syncRunSteps(state: WorkflowRunState, now: number): void {
1072
- const existingRows = this.state.connection
1774
+ private ensureFollowUpQueue(runId: string, now: number): string {
1775
+ const resourceId = this.mutations.ensureResource("follow_up_queue", runId, now);
1776
+ this.state.connection
1073
1777
  .prepare(
1074
- `SELECT step_index AS stepIndex, attempt_id AS attemptId
1075
- FROM run_steps WHERE run_id = ? ORDER BY step_index`,
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`,
1076
1782
  )
1077
- .all(state.runId)
1078
- .filter(isRunStepIdentityRow);
1079
- if (existingRows.length > state.steps.length) {
1080
- throw new Error(`Workflow run steps cannot shrink: ${state.runId}`);
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;
1081
1796
  }
1082
- for (const existing of existingRows) {
1083
- if (state.steps[existing.stepIndex]?.attemptId !== existing.attemptId) {
1084
- throw new Error(`Workflow run step history changed at index ${existing.stepIndex}`);
1085
- }
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}`);
1086
1807
  }
1087
- for (let stepIndex = existingRows.length; stepIndex < state.steps.length; stepIndex += 1) {
1088
- const step = state.steps[stepIndex];
1089
- /* istanbul ignore if -- array index follows a checked bound */
1090
- if (step === undefined) throw new Error("Workflow run step became unavailable");
1091
- const attempt = this.state.connection
1092
- .prepare("SELECT output_hash AS outputHash FROM node_attempts WHERE attempt_id = ?")
1093
- .get(step.attemptId);
1094
- if (!isAttemptOutputRow(attempt)) {
1095
- throw new Error(`Workflow node attempt is missing: ${step.attemptId}`);
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");
1096
1823
  }
1097
- const carried = stepIndex < (state.carriedStepCount ?? 0);
1098
- const outputOverrideHash =
1099
- carried &&
1100
- (attempt.outputHash === null ||
1101
- canonicalJson(this.state.readJson(attempt.outputHash)) !== canonicalJson(step.output))
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)
1102
2238
  ? this.state.putJson(step.output, now)
1103
2239
  : null;
1104
2240
  this.state.connection
@@ -1110,6 +2246,100 @@ export class WorkflowRunStore {
1110
2246
  }
1111
2247
  }
1112
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
+
1113
2343
  private ensureAttempt(
1114
2344
  state: WorkflowRunState,
1115
2345
  nodeId: string,
@@ -1123,19 +2353,41 @@ export class WorkflowRunStore {
1123
2353
  const status = state.status === "waiting" ? "waiting" : "running";
1124
2354
  if (existing !== undefined) {
1125
2355
  this.state.connection
1126
- .prepare("UPDATE node_attempts SET status = ?, updated_at = ? WHERE attempt_id = ?")
1127
- .run(status, now, attemptId);
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
+ );
1128
2372
  return;
1129
2373
  }
1130
2374
  const definition =
1131
2375
  snapshot ?? this.readDefinition(this.requireRunRow(state.runId).definitionHash);
1132
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);
1133
2384
  this.state.connection
1134
2385
  .prepare(
1135
2386
  `INSERT INTO node_attempts(
1136
2387
  attempt_id, run_id, node_id, attempt_number, node_type, status,
2388
+ settings_scope_id, settings_change_number, settings_hash,
1137
2389
  started_at, created_at, updated_at
1138
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2390
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1139
2391
  )
1140
2392
  .run(
1141
2393
  attemptId,
@@ -1144,6 +2396,11 @@ export class WorkflowRunStore {
1144
2396
  this.nextAttemptNumber(state.runId, nodeId),
1145
2397
  nodeType,
1146
2398
  status,
2399
+ state.currentSettingsScopeId ?? null,
2400
+ state.currentSettingsChangeNumber ?? null,
2401
+ state.currentSettingsHash === undefined
2402
+ ? null
2403
+ : Buffer.from(state.currentSettingsHash, "hex"),
1147
2404
  state.currentNodeStartedAt === undefined ? now : Date.parse(state.currentNodeStartedAt),
1148
2405
  now,
1149
2406
  now,
@@ -1174,20 +2431,59 @@ export class WorkflowRunStore {
1174
2431
  return row.updateSeq;
1175
2432
  }
1176
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
+
1177
2478
  private readRunRow(runId: string): RunRow | undefined {
1178
2479
  const row = this.state.connection
1179
2480
  .prepare(
1180
2481
  `SELECT r.run_id AS runId, r.resource_id AS resourceId,
1181
2482
  d.definition_hash AS definitionHash, r.definition_digest AS definitionDigest,
1182
- d.workflow_name AS workflowRef, r.source_ref AS sourceRef,
1183
- r.workflow_source_hash AS workflowSourceHash,
1184
- r.workflow_sources_hash AS workflowSourcesHash, r.parent_run_id AS parentRunId,
2483
+ d.workflow_name AS workflowRef, r.parent_run_id AS parentRunId,
1185
2484
  r.title, r.status, r.paused, r.status_detail AS statusDetail,
1186
- r.input_hash AS inputHash, r.human_decision_hash AS humanDecisionHash,
1187
- r.final_output_hash AS finalOutputHash, r.error_hash AS errorHash,
1188
- r.carried_step_count AS carriedStepCount, r.current_node AS currentNode,
1189
- r.current_attempt_id AS currentAttemptId,
1190
- r.current_node_started_at AS currentNodeStartedAt, r.waiting_on AS waitingOn,
2485
+ r.input_hash AS inputHash, r.final_output_hash AS finalOutputHash,
2486
+ r.error_hash AS errorHash,
1191
2487
  r.created_at AS createdAt, r.updated_at AS updatedAt, r.finished_at AS finishedAt
1192
2488
  FROM runs r
1193
2489
  JOIN workflow_definitions d ON d.definition_digest = r.definition_digest
@@ -1205,6 +2501,10 @@ export class WorkflowRunStore {
1205
2501
 
1206
2502
  private readRunState(row: RunRow, snapshot: WorkflowDefinitionSnapshot): WorkflowRunState {
1207
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);
1208
2508
  const outputs: Record<string, unknown> = {};
1209
2509
  const results: Record<string, WorkflowNodeResult> = {};
1210
2510
  for (const step of steps) {
@@ -1229,15 +2529,11 @@ export class WorkflowRunStore {
1229
2529
  runId: row.runId,
1230
2530
  workflowName: row.workflowRef,
1231
2531
  ...(row.parentRunId === null ? {} : { parentRunId: row.parentRunId }),
1232
- ...(row.carriedStepCount === 0 ? {} : { carriedStepCount: row.carriedStepCount }),
2532
+ ...(carriedStepCount === 0 ? {} : { carriedStepCount }),
1233
2533
  ...(row.title === null ? {} : { runTitle: row.title }),
1234
- ...(row.sourceRef.startsWith("inline:")
1235
- ? {}
1236
- : { workflowSource: this.readJsonAs(row.workflowSourceHash) }),
1237
- ...(row.workflowSourcesHash === null
1238
- ? {}
1239
- : { workflowSources: this.readJsonAs(row.workflowSourcesHash) }),
1240
- ...(row.workflowSourcesHash !== null || snapshot.composition?.mounts.length
2534
+ ...(sources.root === undefined ? {} : { workflowSource: sources.root }),
2535
+ ...(sources.mounted.length === 0 ? {} : { workflowSources: sources.mounted }),
2536
+ ...(sources.mounted.length !== 0 || snapshot.composition?.mounts.length
1241
2537
  ? { definitionDigest: `sha256:${row.definitionDigest.toString("hex")}` }
1242
2538
  : {}),
1243
2539
  startedAt: new Date(row.createdAt).toISOString(),
@@ -1249,17 +2545,22 @@ export class WorkflowRunStore {
1249
2545
  results,
1250
2546
  steps,
1251
2547
  ...this.readUpdates(row.runId),
1252
- ...(row.currentNode === null ? {} : { currentNode: row.currentNode }),
1253
- ...(row.currentAttemptId === null ? {} : { currentAttemptId: row.currentAttemptId }),
1254
- ...(row.currentNodeStartedAt === null
2548
+ ...(activeAttempt === undefined ? {} : { currentNode: activeAttempt.nodeId }),
2549
+ ...(activeAttempt === undefined ? {} : { currentAttemptId: activeAttempt.attemptId }),
2550
+ ...(activeAttempt?.startedAt === null || activeAttempt === undefined
1255
2551
  ? {}
1256
- : { currentNodeStartedAt: new Date(row.currentNodeStartedAt).toISOString() }),
2552
+ : { currentNodeStartedAt: new Date(activeAttempt.startedAt).toISOString() }),
2553
+ ...savedCurrentSettingsBinding(
2554
+ activeAttempt?.settingsScopeId ?? null,
2555
+ activeAttempt?.settingsChangeNumber ?? null,
2556
+ activeAttempt?.settingsHash ?? null,
2557
+ ),
1257
2558
  ...(row.statusDetail === null ? {} : { statusDetail: row.statusDetail }),
1258
- ...(row.humanDecisionHash === null
1259
- ? {}
1260
- : { humanDecision: this.readJsonAs(row.humanDecisionHash) }),
2559
+ ...(humanDecision === undefined ? {} : { humanDecision }),
1261
2560
  ...(row.paused === 0 ? {} : { paused: true }),
1262
- ...(row.waitingOn === null ? {} : { waitingOn: row.waitingOn }),
2561
+ ...(row.status === "waiting" && steps.at(-1) !== undefined
2562
+ ? { waitingOn: steps.at(-1)?.nodeId as string }
2563
+ : {}),
1263
2564
  ...(row.finalOutputHash === null
1264
2565
  ? {}
1265
2566
  : { finalOutput: this.state.readJson(row.finalOutputHash) }),
@@ -1271,12 +2572,33 @@ export class WorkflowRunStore {
1271
2572
  const rows = this.state.connection
1272
2573
  .prepare(
1273
2574
  `SELECT s.step_index AS stepIndex, a.attempt_id AS attemptId, a.node_id AS nodeId,
1274
- a.node_type AS nodeType, a.status, a.prompt_hash AS promptHash,
1275
- a.output_hash AS outputHash, s.output_override_hash AS outputOverrideHash,
1276
- a.step_metadata_hash AS stepMetadataHash, a.error_hash AS errorHash,
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,
1277
2585
  a.started_at AS startedAt, a.finished_at AS finishedAt
1278
2586
  FROM run_steps s
1279
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'
1280
2602
  WHERE s.run_id = ? ORDER BY s.step_index`,
1281
2603
  )
1282
2604
  .all(runId)
@@ -1284,14 +2606,29 @@ export class WorkflowRunStore {
1284
2606
  return rows.map((row, index) => {
1285
2607
  if (row.stepIndex !== index)
1286
2608
  throw new Error(`Workflow run step sequence has a gap: ${runId}`);
1287
- const metadata =
1288
- row.stepMetadataHash === null
1289
- ? {}
1290
- : this.readJsonAs<StoredStepMetadata>(row.stepMetadataHash);
1291
- const prompt = row.promptHash === null ? null : this.readJsonAs<string>(row.promptHash);
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);
1292
2617
  const outputHash = row.outputOverrideHash ?? row.outputHash;
1293
- const output = outputHash === null ? null : this.state.readJson(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
+ );
1294
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 };
1295
2632
  return {
1296
2633
  attemptId: row.attemptId,
1297
2634
  nodeId: row.nodeId,
@@ -1302,11 +2639,127 @@ export class WorkflowRunStore {
1302
2639
  prompt,
1303
2640
  output,
1304
2641
  ...(error === undefined ? {} : { error }),
1305
- ...metadata,
2642
+ ...savedStepSettingsBinding(
2643
+ row.settingsScopeId,
2644
+ row.settingsChangeNumber,
2645
+ row.settingsHash,
2646
+ ),
2647
+ ...receipt,
2648
+ ...(conversation === undefined ? {} : { conversation }),
1306
2649
  };
1307
2650
  });
1308
2651
  }
1309
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
+
1310
2763
  private readUpdates(runId: string): Pick<WorkflowRunState, "updates"> {
1311
2764
  const rows = this.state.connection
1312
2765
  .prepare(
@@ -1613,10 +3066,22 @@ function compactTracePayload(
1613
3066
  eventType: string,
1614
3067
  payload: Record<string, unknown>,
1615
3068
  ): Record<string, unknown> {
1616
- if (
1617
- (eventType === "node_finished" || eventType === "include_exited") &&
1618
- Object.hasOwn(payload, "output")
1619
- ) {
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")) {
1620
3085
  const { output: _output, ...rest } = payload;
1621
3086
  return { ...rest, outputStored: true };
1622
3087
  }
@@ -1675,6 +3140,22 @@ function sourceForState(
1675
3140
  };
1676
3141
  }
1677
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
+
1678
3159
  function segmentIdFor(runId: string, attemptId?: string): string {
1679
3160
  return `segment-${createHash("sha256")
1680
3161
  .update(`${runId}\0${attemptId ?? ""}`)
@@ -1714,14 +3195,45 @@ function outcomeForStatus(status: string): WorkflowStepRecord["outcome"] {
1714
3195
  }
1715
3196
  }
1716
3197
 
1717
- function stepMetadata(step: WorkflowStepRecord): StoredStepMetadata {
3198
+ function attemptReceipt(step: WorkflowStepRecord): StoredAttemptReceipt {
1718
3199
  return {
1719
3200
  ...(step.action === undefined ? {} : { action: step.action }),
1720
3201
  ...(step.assistantMessage === undefined ? {} : { assistantMessage: step.assistantMessage }),
1721
- ...(step.conversation === undefined ? {} : { conversation: step.conversation }),
1722
3202
  };
1723
3203
  }
1724
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
+
1725
3237
  function resultForStep(step: WorkflowStepRecord): WorkflowNodeResult {
1726
3238
  return {
1727
3239
  attemptId: step.attemptId,
@@ -1797,6 +3309,178 @@ function validateSessionCapture(capture: WorkflowSessionCapture): void {
1797
3309
  }
1798
3310
  }
1799
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
+
1800
3484
  function assertValidRunId(runId: string): void {
1801
3485
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
1802
3486
  throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
@@ -1811,6 +3495,41 @@ function isRunRow(value: unknown): value is RunRow {
1811
3495
  return isRecord(value);
1812
3496
  }
1813
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
+
1814
3533
  function isRunIdRow(value: unknown): value is { runId: string } {
1815
3534
  return isRecord(value);
1816
3535
  }
@@ -1822,11 +3541,55 @@ function isRunStepIdentityRow(value: unknown): value is {
1822
3541
  return isRecord(value);
1823
3542
  }
1824
3543
 
1825
- function isAttemptOutputRow(value: unknown): value is { outputHash: Buffer | null } {
1826
- return isRecord(value) && (value.outputHash === null || Buffer.isBuffer(value.outputHash));
3544
+ function isStepRow(value: unknown): value is StepRow {
3545
+ return isRecord(value);
1827
3546
  }
1828
3547
 
1829
- function isStepRow(value: unknown): value is StepRow {
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
+ } {
1830
3593
  return isRecord(value);
1831
3594
  }
1832
3595
 
@@ -1873,7 +3636,15 @@ function isLeaseAuthorityRow(value: unknown): value is {
1873
3636
  }
1874
3637
 
1875
3638
  export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
1876
- 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
+ }));
1877
3648
  return {
1878
3649
  schema: DEFINITION_SNAPSHOT_SCHEMA,
1879
3650
  name: workflow.name,
@@ -1887,6 +3658,8 @@ export function createDefinitionSnapshot(workflow: WorkflowDefinition): Workflow
1887
3658
  ),
1888
3659
  edges: structuredClone(workflow.edges),
1889
3660
  ...(composition !== undefined ? { composition: structuredClone(composition) } : {}),
3661
+ ...(workflow.settings !== undefined ? { settings: snapshotSettings(workflow.settings) } : {}),
3662
+ ...(settingsScopes.length > 0 ? { settingsScopes } : {}),
1890
3663
  };
1891
3664
  }
1892
3665
 
@@ -1927,6 +3700,9 @@ function snapshotNode(
1927
3700
  if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
1928
3701
  common.expectedOutput = node.expectedOutput;
1929
3702
  }
3703
+ if (node.nodeType === "compute" && node.settingsRoute === true) {
3704
+ common.settingsRoute = true;
3705
+ }
1930
3706
  if (node.nodeType === "notify") common.summary = node.kind ?? "progress";
1931
3707
  if (node.nodeType === "checkpoint" && node.summary !== undefined) common.summary = node.summary;
1932
3708
  if (node.nodeType === "checkpoint" && node.humanDecision !== undefined) {
@@ -1945,3 +3721,20 @@ function snapshotNode(
1945
3721
  if (node.nodeType === "action") common.actionExecution = "exec" in node ? "shell" : "function";
1946
3722
  return common;
1947
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
+ }