@osolmaz/pi-workflows 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +9 -7
  2. package/dist/builtins/autodoc.workflow.d.ts +5 -5
  3. package/dist/builtins/autoimplement.workflow.d.ts +69 -61
  4. package/dist/builtins/autoimplement.workflow.js +47 -8
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +11 -5
  7. package/dist/builtins/autoplan.workflow.js +68 -32
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +4 -4
  10. package/dist/builtins/change-verification.workflow.d.ts +1 -1
  11. package/dist/builtins/plain-summary.workflow.d.ts +1 -1
  12. package/dist/builtins/plain-summary.workflow.js +35 -24
  13. package/dist/builtins/plain-summary.workflow.js.map +1 -1
  14. package/dist/builtins/plan-approval.workflow.d.ts +1 -1
  15. package/dist/builtins/plan-change.workflow.d.ts +25 -23
  16. package/dist/builtins/sanity-check.workflow.d.ts +3 -3
  17. package/dist/builtins/sanity-check.workflow.js +19 -22
  18. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  19. package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
  20. package/dist/controllers/index.d.ts +2 -2
  21. package/dist/controllers/index.js.map +1 -1
  22. package/dist/controllers/sqlite.js +88 -61
  23. package/dist/controllers/sqlite.js.map +1 -1
  24. package/dist/controllers/types.d.ts +34 -0
  25. package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
  26. package/dist/controllers/workflow-engine-scheduler.js +83 -1
  27. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  28. package/dist/controllers/workflows.d.ts +8 -1
  29. package/dist/controllers/workflows.js +36 -0
  30. package/dist/controllers/workflows.js.map +1 -1
  31. package/dist/extension/controller-host.d.ts +2 -1
  32. package/dist/extension/controller-host.js +1 -1
  33. package/dist/extension/controller-host.js.map +1 -1
  34. package/dist/extension/decision-channels.js +45 -7
  35. package/dist/extension/decision-channels.js.map +1 -1
  36. package/dist/extension/follow-up-coordinator.d.ts +27 -0
  37. package/dist/extension/follow-up-coordinator.js +131 -0
  38. package/dist/extension/follow-up-coordinator.js.map +1 -0
  39. package/dist/extension/index.d.ts +9 -0
  40. package/dist/extension/index.js +462 -76
  41. package/dist/extension/index.js.map +1 -1
  42. package/dist/extension/recorder.d.ts +1 -1
  43. package/dist/extension/recorder.js +8 -8
  44. package/dist/extension/recorder.js.map +1 -1
  45. package/dist/extension/session-events.d.ts +2 -3
  46. package/dist/extension/session-events.js +11 -10
  47. package/dist/extension/session-events.js.map +1 -1
  48. package/dist/extension/widget.js +9 -0
  49. package/dist/extension/widget.js.map +1 -1
  50. package/dist/state/database.d.ts +2 -1
  51. package/dist/state/database.js +11 -13
  52. package/dist/state/database.js.map +1 -1
  53. package/dist/state/json.js +6 -1
  54. package/dist/state/json.js.map +1 -1
  55. package/dist/state/mutation.d.ts +1 -1
  56. package/dist/state/mutation.js.map +1 -1
  57. package/dist/state/prune.d.ts +18 -0
  58. package/dist/state/prune.js +373 -0
  59. package/dist/state/prune.js.map +1 -0
  60. package/dist/state/schema.d.ts +1 -1
  61. package/dist/state/schema.js +130 -13
  62. package/dist/state/schema.js.map +1 -1
  63. package/dist/viewer/cli.d.ts +3 -1
  64. package/dist/viewer/cli.js +55 -4
  65. package/dist/viewer/cli.js.map +1 -1
  66. package/dist/viewer/render.js +19 -1
  67. package/dist/viewer/render.js.map +1 -1
  68. package/dist/viewer/session-reducer.d.ts +0 -1
  69. package/dist/viewer/session-reducer.js +2 -24
  70. package/dist/viewer/session-reducer.js.map +1 -1
  71. package/dist/workflows/composition.d.ts +2 -0
  72. package/dist/workflows/composition.js +15 -0
  73. package/dist/workflows/composition.js.map +1 -1
  74. package/dist/workflows/definition.d.ts +6 -3
  75. package/dist/workflows/definition.js +3 -0
  76. package/dist/workflows/definition.js.map +1 -1
  77. package/dist/workflows/engine.d.ts +6 -1
  78. package/dist/workflows/engine.js +303 -36
  79. package/dist/workflows/engine.js.map +1 -1
  80. package/dist/workflows/human-decision.d.ts +1 -0
  81. package/dist/workflows/human-decision.js +25 -9
  82. package/dist/workflows/human-decision.js.map +1 -1
  83. package/dist/workflows/index.d.ts +3 -1
  84. package/dist/workflows/index.js +2 -0
  85. package/dist/workflows/index.js.map +1 -1
  86. package/dist/workflows/json-patch.d.ts +47 -0
  87. package/dist/workflows/json-patch.js +298 -0
  88. package/dist/workflows/json-patch.js.map +1 -0
  89. package/dist/workflows/schema.js +29 -1
  90. package/dist/workflows/schema.js.map +1 -1
  91. package/dist/workflows/settings.d.ts +128 -0
  92. package/dist/workflows/settings.js +199 -0
  93. package/dist/workflows/settings.js.map +1 -0
  94. package/dist/workflows/store.d.ts +81 -3
  95. package/dist/workflows/store.js +1563 -50
  96. package/dist/workflows/store.js.map +1 -1
  97. package/dist/workflows/tool-input.d.ts +22 -0
  98. package/dist/workflows/tool-input.js +43 -0
  99. package/dist/workflows/tool-input.js.map +1 -1
  100. package/dist/workflows/types.d.ts +41 -6
  101. package/docs/2026-08-25-workflow-follow-ups.md +132 -0
  102. package/docs/2026-08-25-workflow-settings.md +169 -0
  103. package/docs/DEFERRED_TURNS.md +6 -0
  104. package/docs/DESIGN_PHILOSOPHY.md +2 -0
  105. package/docs/SQLITE_STATE.md +36 -17
  106. package/docs/WORKFLOW_COMPOSITION.md +15 -0
  107. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
  108. package/docs/plans/2026-08-25-autoplan-user-intent-capture-plan.md +107 -0
  109. package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
  110. package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
  111. package/docs/session-event-journal.md +2 -3
  112. package/docs/tui-viewer.md +7 -6
  113. package/docs/workflows.md +58 -10
  114. package/examples/workflows/live-settings.workflow.ts +93 -0
  115. package/herdr-plugin.toml +1 -1
  116. package/package.json +1 -1
  117. package/src/builtins/autoimplement.workflow.ts +56 -8
  118. package/src/builtins/autoplan.workflow.ts +80 -32
  119. package/src/builtins/catalog.ts +4 -4
  120. package/src/builtins/plain-summary.workflow.ts +38 -40
  121. package/src/builtins/sanity-check.workflow.ts +19 -22
  122. package/src/controllers/index.ts +6 -0
  123. package/src/controllers/sqlite.ts +132 -72
  124. package/src/controllers/types.ts +40 -0
  125. package/src/controllers/workflow-engine-scheduler.ts +103 -1
  126. package/src/controllers/workflows.ts +68 -0
  127. package/src/extension/controller-host.ts +6 -1
  128. package/src/extension/decision-channels.ts +47 -7
  129. package/src/extension/follow-up-coordinator.ts +151 -0
  130. package/src/extension/index.ts +540 -89
  131. package/src/extension/recorder.ts +10 -8
  132. package/src/extension/session-events.ts +15 -13
  133. package/src/extension/widget.ts +8 -0
  134. package/src/state/database.ts +12 -12
  135. package/src/state/json.ts +6 -1
  136. package/src/state/mutation.ts +4 -1
  137. package/src/state/prune.ts +502 -0
  138. package/src/state/schema.ts +130 -13
  139. package/src/viewer/cli.ts +52 -5
  140. package/src/viewer/render.ts +43 -1
  141. package/src/viewer/session-reducer.ts +2 -29
  142. package/src/workflows/composition.ts +19 -0
  143. package/src/workflows/definition.ts +19 -5
  144. package/src/workflows/engine.ts +365 -36
  145. package/src/workflows/human-decision.ts +41 -11
  146. package/src/workflows/index.ts +43 -0
  147. package/src/workflows/json-patch.ts +375 -0
  148. package/src/workflows/schema.ts +31 -1
  149. package/src/workflows/settings.ts +430 -0
  150. package/src/workflows/store.ts +2268 -121
  151. package/src/workflows/tool-input.ts +58 -0
  152. package/src/workflows/types.ts +43 -5
@@ -1,5 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
+ import { canonicalJson } from "../state/json.js";
4
+ import { StaleResourceError } from "../state/mutation.js";
3
5
  import {
4
6
  compileWorkflowDefinition,
5
7
  compositionMetadata,
@@ -22,6 +24,11 @@ import {
22
24
  validateHumanDecisionResponse,
23
25
  } from "./human-decision.js";
24
26
  import { extractJsonValue } from "./json.js";
27
+ import {
28
+ parseWorkflowSettingsValue,
29
+ resolveInitialWorkflowSettings,
30
+ type WorkflowSettingsScopeRecord,
31
+ } from "./settings.js";
25
32
  import { runShellAction, shellResultFromError } from "./shell.js";
26
33
  import {
27
34
  RUN_STATE_SCHEMA,
@@ -67,6 +74,7 @@ const TITLE_TIMEOUT_MS = 30_000;
67
74
  const TIMEOUT_RESOLUTION_TIMEOUT_MS = 30_000;
68
75
  // Covers the shell SIGTERM → SIGKILL escalation (1s) plus stdio close.
69
76
  const ABORT_CLEANUP_GRACE_MS = 2_000;
77
+ const MAX_SETTINGS_ROUTE_RETRIES = 16;
70
78
 
71
79
  type NodeExecution = {
72
80
  output: unknown;
@@ -88,6 +96,7 @@ type NodeExecutionMeta = {
88
96
  type NodeAttempt = {
89
97
  result: WorkflowNodeResult;
90
98
  execution: NodeExecution | null;
99
+ settings?: WorkflowSettingsScopeRecord;
91
100
  error?: unknown;
92
101
  };
93
102
 
@@ -95,6 +104,7 @@ type ResumedNodeAttempt = {
95
104
  nodeId: string;
96
105
  attemptId: string;
97
106
  startedAt: string;
107
+ settings?: WorkflowSettingsScopeRecord;
98
108
  };
99
109
 
100
110
  /**
@@ -119,6 +129,7 @@ export class WorkflowEngine {
119
129
  state: WorkflowRunState;
120
130
  nodeId: string;
121
131
  attemptId: string;
132
+ settingsScopeId?: string;
122
133
  signal: AbortSignal;
123
134
  }
124
135
  | undefined;
@@ -127,6 +138,7 @@ export class WorkflowEngine {
127
138
  private cancelled = false;
128
139
  private parked = false;
129
140
  private paused = false;
141
+ private presentationRequired = false;
130
142
  private wakePause: (() => void) | null = null;
131
143
 
132
144
  constructor(options: WorkflowEngineOptions) {
@@ -236,6 +248,10 @@ export class WorkflowEngine {
236
248
  return this.paused;
237
249
  }
238
250
 
251
+ get activeSettingsScopeId(): string | undefined {
252
+ return this.activeAttempt?.settingsScopeId;
253
+ }
254
+
239
255
  async run(
240
256
  workflow: WorkflowDefinition,
241
257
  input: unknown,
@@ -243,6 +259,7 @@ export class WorkflowEngine {
243
259
  ): Promise<WorkflowRunResult> {
244
260
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
245
261
  validateWorkflowDefinition(workflow);
262
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
246
263
  // Fail before any run row exists so bad input cannot leave partial state.
247
264
  const suppliedInput = input === undefined ? null : input;
248
265
  const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
@@ -260,7 +277,18 @@ export class WorkflowEngine {
260
277
  options.workflowSource,
261
278
  options.runId,
262
279
  );
263
- const runId = await this.store.initializeRun(workflow, state);
280
+ const initialSettings =
281
+ workflow.settings === undefined
282
+ ? []
283
+ : [
284
+ {
285
+ mountPath: "",
286
+ invocation: 1,
287
+ settings: (await resolveInitialWorkflowSettings(workflow.settings, normalizedInput))
288
+ .json,
289
+ },
290
+ ];
291
+ const runId = await this.store.initializeRun(workflow, state, { initialSettings });
264
292
  await this.persist(runId, state, {
265
293
  scope: "run",
266
294
  type: "run_started",
@@ -299,6 +327,7 @@ export class WorkflowEngine {
299
327
  ): Promise<WorkflowRunResult> {
300
328
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
301
329
  validateWorkflowDefinition(workflow);
330
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
302
331
  // Reset before any await: a park or cancel landing during preparation
303
332
  // must survive, or a host drain would hang while the run executes.
304
333
  this.cancelled = false;
@@ -314,18 +343,22 @@ export class WorkflowEngine {
314
343
  const point = this.resumePointFor(workflow, state, "wait");
315
344
  const interruptedNode =
316
345
  state.currentNode !== undefined ? workflow.nodes[state.currentNode] : undefined;
317
- const resumedAttempt: ResumedNodeAttempt | undefined =
346
+ let resumedAttempt: ResumedNodeAttempt | undefined;
347
+ if (
318
348
  state.currentNode !== undefined &&
319
349
  state.currentAttemptId !== undefined &&
320
350
  state.currentNodeStartedAt !== undefined &&
321
351
  interruptedNode?.nodeType === "agent" &&
322
352
  assistantMessageConfig(interruptedNode) !== undefined
323
- ? {
324
- nodeId: state.currentNode,
325
- attemptId: state.currentAttemptId,
326
- startedAt: state.currentNodeStartedAt,
327
- }
328
- : undefined;
353
+ ) {
354
+ const resumedSettings = this.persistedSettingsBinding(state);
355
+ resumedAttempt = {
356
+ nodeId: state.currentNode,
357
+ attemptId: state.currentAttemptId,
358
+ startedAt: state.currentNodeStartedAt,
359
+ ...(resumedSettings !== undefined ? { settings: resumedSettings } : {}),
360
+ };
361
+ }
329
362
  // A resumed run starts unpaused. Submitted and non-agent nodes discard
330
363
  // stale in-flight markers and start a new attempt. Assistant-message
331
364
  // nodes keep their attempt id so the origin session can adopt an already
@@ -335,6 +368,9 @@ export class WorkflowEngine {
335
368
  delete state.currentNode;
336
369
  delete state.currentAttemptId;
337
370
  delete state.currentNodeStartedAt;
371
+ delete state.currentSettingsScopeId;
372
+ delete state.currentSettingsChangeNumber;
373
+ delete state.currentSettingsHash;
338
374
  delete state.statusDetail;
339
375
  }
340
376
  await this.persist(runId, state, {
@@ -407,6 +443,7 @@ export class WorkflowEngine {
407
443
  ): Promise<WorkflowRunResult> {
408
444
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
409
445
  validateWorkflowDefinition(workflow);
446
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
410
447
  this.cancelled = false;
411
448
  this.paused = false;
412
449
  this.parked = false;
@@ -686,6 +723,7 @@ export class WorkflowEngine {
686
723
  let currentNodeId: string | null = startNodeId;
687
724
  let executedSteps = executedStepsBase;
688
725
  let lastOutput: unknown = initialLastOutput;
726
+ const settingsRouteRetries = new Map<string, number>();
689
727
 
690
728
  while (currentNodeId !== null) {
691
729
  await this.holdWhilePaused(state, runId);
@@ -722,28 +760,84 @@ export class WorkflowEngine {
722
760
  // as in-flight, and resume reruns it with a fresh attempt.
723
761
  throw new RunParkedError();
724
762
  }
763
+ const beforeRecord = structuredClone(state);
725
764
  this.recordAttempt(workflow, state, attempt);
726
- // The terminal node event carries the output, receipt, and conversation
727
- // linkage so the trace alone is sufficient to reconstruct the run.
728
- await this.persist(runId, state, {
729
- scope: "node",
730
- type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
731
- nodeId: attempt.result.nodeId,
732
- attemptId: attempt.result.attemptId,
733
- payload: {
734
- outcome: attempt.result.outcome,
735
- durationMs: attempt.result.durationMs,
736
- ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
737
- ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
738
- ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
739
- ...(attempt.execution?.assistantMessage !== undefined
740
- ? { assistantMessage: attempt.execution.assistantMessage }
741
- : {}),
742
- ...(attempt.execution?.conversation !== undefined
743
- ? { conversation: attempt.execution.conversation }
744
- : {}),
745
- },
746
- });
765
+ // The durable step row owns the output. The trace keeps the terminal fact,
766
+ // settings binding, and compact execution metadata.
767
+ try {
768
+ await this.persist(runId, state, {
769
+ scope: "node",
770
+ type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
771
+ nodeId: attempt.result.nodeId,
772
+ attemptId: attempt.result.attemptId,
773
+ payload: {
774
+ outcome: attempt.result.outcome,
775
+ durationMs: attempt.result.durationMs,
776
+ ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
777
+ ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
778
+ ...(attempt.execution?.action !== undefined
779
+ ? { action: attempt.execution.action }
780
+ : {}),
781
+ ...(attempt.execution?.assistantMessage !== undefined
782
+ ? { assistantMessage: attempt.execution.assistantMessage }
783
+ : {}),
784
+ ...(attempt.execution?.conversation !== undefined
785
+ ? { conversation: attempt.execution.conversation }
786
+ : {}),
787
+ ...(attempt.settings !== undefined
788
+ ? {
789
+ settingsScopeId: attempt.settings.scopeId,
790
+ settingsChangeNumber: attempt.settings.changeNumber,
791
+ settingsHash: attempt.settings.settingsHash,
792
+ }
793
+ : {}),
794
+ },
795
+ });
796
+ } catch (error) {
797
+ if (
798
+ !(error instanceof StaleResourceError) ||
799
+ node.nodeType !== "compute" ||
800
+ node.settingsRoute !== true ||
801
+ attempt.settings === undefined
802
+ ) {
803
+ throw error;
804
+ }
805
+ restoreRunState(state, beforeRecord);
806
+ const retryCount = (settingsRouteRetries.get(currentNodeId) ?? 0) + 1;
807
+ if (retryCount > MAX_SETTINGS_ROUTE_RETRIES) {
808
+ throw new Error(
809
+ `Settings route ${currentNodeId} changed more than ${MAX_SETTINGS_ROUTE_RETRIES} times before settlement`,
810
+ );
811
+ }
812
+ settingsRouteRetries.set(currentNodeId, retryCount);
813
+ const latest = await this.captureSettingsBinding(workflow, state, currentNodeId);
814
+ if (latest === undefined) {
815
+ throw new Error(`Settings route ${currentNodeId} lost its settings scope`);
816
+ }
817
+ state.currentSettingsScopeId = latest.scopeId;
818
+ state.currentSettingsChangeNumber = latest.changeNumber;
819
+ state.currentSettingsHash = latest.settingsHash;
820
+ await this.persist(runId, state, {
821
+ scope: "run",
822
+ type: "settings_route_retried",
823
+ nodeId: currentNodeId,
824
+ attemptId: attempt.result.attemptId,
825
+ payload: {
826
+ scopeId: latest.scopeId,
827
+ previousChangeNumber: attempt.settings.changeNumber,
828
+ changeNumber: latest.changeNumber,
829
+ },
830
+ });
831
+ resumedAttempt = {
832
+ nodeId: currentNodeId,
833
+ attemptId: attempt.result.attemptId,
834
+ startedAt: attempt.result.startedAt,
835
+ settings: latest,
836
+ };
837
+ executedSteps -= 1;
838
+ continue;
839
+ }
840
+ settingsRouteRetries.delete(currentNodeId);
747
841
 
748
842
  if (attempt.result.outcome !== "ok") {
749
843
  currentNodeId = this.routeAfterFailure(workflow, state, attempt);
@@ -886,11 +980,21 @@ export class WorkflowEngine {
886
980
  ...(attempt.execution?.conversation !== undefined
887
981
  ? { conversation: attempt.execution.conversation }
888
982
  : {}),
983
+ ...(attempt.settings !== undefined
984
+ ? {
985
+ settingsScopeId: attempt.settings.scopeId,
986
+ settingsChangeNumber: attempt.settings.changeNumber,
987
+ settingsHash: attempt.settings.settingsHash,
988
+ }
989
+ : {}),
889
990
  };
890
991
  state.steps.push(step);
891
992
  delete state.currentNode;
892
993
  delete state.currentAttemptId;
893
994
  delete state.currentNodeStartedAt;
995
+ delete state.currentSettingsScopeId;
996
+ delete state.currentSettingsChangeNumber;
997
+ delete state.currentSettingsHash;
894
998
  delete state.statusDetail;
895
999
  }
896
1000
 
@@ -904,9 +1008,20 @@ export class WorkflowEngine {
904
1008
  ): Promise<NodeAttempt> {
905
1009
  const attemptId = resumedAttempt?.attemptId ?? randomUUID();
906
1010
  const startedAt = resumedAttempt?.startedAt ?? new Date().toISOString();
1011
+ const settings =
1012
+ resumedAttempt?.settings ?? (await this.captureSettingsBinding(workflow, state, nodeId));
907
1013
  state.currentNode = nodeId;
908
1014
  state.currentAttemptId = attemptId;
909
1015
  state.currentNodeStartedAt = startedAt;
1016
+ if (settings !== undefined) {
1017
+ state.currentSettingsScopeId = settings.scopeId;
1018
+ state.currentSettingsChangeNumber = settings.changeNumber;
1019
+ state.currentSettingsHash = settings.settingsHash;
1020
+ } else {
1021
+ delete state.currentSettingsScopeId;
1022
+ delete state.currentSettingsChangeNumber;
1023
+ delete state.currentSettingsHash;
1024
+ }
910
1025
  if (node.statusDetail !== undefined) {
911
1026
  state.statusDetail = node.statusDetail;
912
1027
  }
@@ -916,7 +1031,16 @@ export class WorkflowEngine {
916
1031
  type: "node_started",
917
1032
  nodeId,
918
1033
  attemptId,
919
- payload: { nodeType: node.nodeType },
1034
+ payload: {
1035
+ nodeType: node.nodeType,
1036
+ ...(settings !== undefined
1037
+ ? {
1038
+ settingsScopeId: settings.scopeId,
1039
+ settingsChangeNumber: settings.changeNumber,
1040
+ settingsHash: settings.settingsHash,
1041
+ }
1042
+ : {}),
1043
+ },
920
1044
  });
921
1045
  }
922
1046
 
@@ -930,10 +1054,12 @@ export class WorkflowEngine {
930
1054
  attemptId,
931
1055
  node,
932
1056
  meta,
1057
+ settings,
933
1058
  );
934
1059
  return {
935
1060
  result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
936
1061
  execution,
1062
+ ...(settings !== undefined ? { settings } : {}),
937
1063
  };
938
1064
  } catch (error) {
939
1065
  const outcome = this.outcomeForError(error);
@@ -949,6 +1075,7 @@ export class WorkflowEngine {
949
1075
  promptText: meta.promptText,
950
1076
  ...(meta.action !== undefined ? { action: meta.action } : {}),
951
1077
  },
1078
+ ...(settings !== undefined ? { settings } : {}),
952
1079
  error,
953
1080
  };
954
1081
  }
@@ -993,9 +1120,10 @@ export class WorkflowEngine {
993
1120
  attemptId: string,
994
1121
  node: WorkflowNodeDefinition,
995
1122
  meta: NodeExecutionMeta,
1123
+ settings?: WorkflowSettingsScopeRecord,
996
1124
  ): Promise<NodeExecution> {
997
1125
  const abort = new AbortController();
998
- const context = this.createNodeContext(state, abort.signal);
1126
+ const context = this.createNodeContext(state, abort.signal, settings);
999
1127
  let timer: NodeJS.Timeout | undefined;
1000
1128
  let dispatchSettled: Promise<void> | undefined;
1001
1129
  this.activeAbort = abort;
@@ -1018,7 +1146,14 @@ export class WorkflowEngine {
1018
1146
  abort.abort(new TimeoutError(timeoutMs));
1019
1147
  }, timeoutMs);
1020
1148
  }
1021
- this.activeAttempt = { runId, state, nodeId, attemptId, signal: abort.signal };
1149
+ this.activeAttempt = {
1150
+ runId,
1151
+ state,
1152
+ nodeId,
1153
+ attemptId,
1154
+ ...(settings !== undefined ? { settingsScopeId: settings.scopeId } : {}),
1155
+ signal: abort.signal,
1156
+ };
1022
1157
  const dispatched = this.dispatchNode(
1023
1158
  workflow,
1024
1159
  state,
@@ -1161,12 +1296,89 @@ export class WorkflowEngine {
1161
1296
  }
1162
1297
  }
1163
1298
 
1164
- private createNodeContext(state: WorkflowRunState, signal: AbortSignal): WorkflowNodeContext {
1299
+ private async captureSettingsBinding(
1300
+ workflow: WorkflowDefinition,
1301
+ state: WorkflowRunState,
1302
+ nodeId: string,
1303
+ ): Promise<WorkflowSettingsScopeRecord | undefined> {
1304
+ const metadata = compositionMetadata(workflow);
1305
+ let mountPath = "";
1306
+ let definition = workflow.settings;
1307
+ if (metadata !== undefined) {
1308
+ const entry = metadata.entries[nodeId];
1309
+ const exit = metadata.exits[nodeId];
1310
+ const scope =
1311
+ entry !== undefined
1312
+ ? metadata.scopes[entry.parentPath]
1313
+ : exit !== undefined
1314
+ ? metadata.scopes[exit.mountPath]
1315
+ : Object.values(metadata.scopes).find((candidate) =>
1316
+ Object.values(candidate.authoredNodes).includes(nodeId),
1317
+ );
1318
+ if (scope === undefined) {
1319
+ throw new Error(`Workflow settings scope is missing for node ${nodeId}`);
1320
+ }
1321
+ mountPath = scope.path;
1322
+ definition = scope.settings;
1323
+ }
1324
+ if (definition === undefined) return undefined;
1325
+ const invocation = mountPath === "" ? 1 : currentMountInvocation(state, mountPath);
1326
+ const existing = this.store.findSettingsScope(state.runId, mountPath, invocation);
1327
+ if (existing !== undefined) return existing;
1328
+ const input = mountPath === "" ? state.input : currentMountInput(state, mountPath);
1329
+ const mappedSettings =
1330
+ mountPath === "" ? { present: false as const } : currentMountSettings(state, mountPath);
1331
+ const initial = mappedSettings.present
1332
+ ? await parseWorkflowSettingsValue(definition, mappedSettings.value)
1333
+ : await resolveInitialWorkflowSettings(definition, input);
1334
+ return this.store.ensureSettingsScope({
1335
+ runId: state.runId,
1336
+ mountPath,
1337
+ invocation,
1338
+ settings: initial.json,
1339
+ });
1340
+ }
1341
+
1342
+ private persistedSettingsBinding(
1343
+ state: WorkflowRunState,
1344
+ ): WorkflowSettingsScopeRecord | undefined {
1345
+ if (state.currentSettingsScopeId === undefined) return undefined;
1346
+ if (
1347
+ state.currentSettingsChangeNumber === undefined ||
1348
+ state.currentSettingsHash === undefined
1349
+ ) {
1350
+ throw new Error("Saved workflow settings binding is incomplete");
1351
+ }
1352
+ const scope = this.store.getSettingsScopeAtChange(
1353
+ state.currentSettingsScopeId,
1354
+ state.currentSettingsChangeNumber,
1355
+ );
1356
+ if (scope === undefined) {
1357
+ throw new Error(`Saved workflow settings scope is missing: ${state.currentSettingsScopeId}`);
1358
+ }
1359
+ if (scope.settingsHash !== state.currentSettingsHash) {
1360
+ throw new Error("Saved workflow settings binding does not match its saved change");
1361
+ }
1362
+ return scope;
1363
+ }
1364
+
1365
+ private createNodeContext(
1366
+ state: WorkflowRunState,
1367
+ signal: AbortSignal,
1368
+ settings?: WorkflowSettingsScopeRecord,
1369
+ ): WorkflowNodeContext {
1165
1370
  return {
1166
1371
  input: state.input,
1167
1372
  outputs: state.outputs,
1168
1373
  results: state.results,
1169
1374
  state,
1375
+ ...(settings !== undefined
1376
+ ? {
1377
+ settings: deepFreezeJson(structuredClone(settings.settings)),
1378
+ settingsScopeId: settings.scopeId,
1379
+ settingsChangeNumber: settings.changeNumber,
1380
+ }
1381
+ : {}),
1170
1382
  signal,
1171
1383
  };
1172
1384
  }
@@ -1201,7 +1413,8 @@ export class WorkflowEngine {
1201
1413
  }
1202
1414
  }
1203
1415
 
1204
- const basePrompt = await node.prompt(context);
1416
+ const authoredPrompt = await node.prompt(context);
1417
+ const basePrompt = appendLiveControlInstructions(authoredPrompt, workflow, nodeId, context);
1205
1418
  if (signal.aborted) {
1206
1419
  // The node timed out or the run was cancelled while the async prompt
1207
1420
  // builder ran; a late continuation must not write into a run that
@@ -1351,6 +1564,9 @@ export class WorkflowEngine {
1351
1564
  delete state.currentNode;
1352
1565
  delete state.currentAttemptId;
1353
1566
  delete state.currentNodeStartedAt;
1567
+ delete state.currentSettingsScopeId;
1568
+ delete state.currentSettingsChangeNumber;
1569
+ delete state.currentSettingsHash;
1354
1570
  await this.persist(runId, state, {
1355
1571
  scope: "run",
1356
1572
  type: `run_${status}`,
@@ -1359,6 +1575,7 @@ export class WorkflowEngine {
1359
1575
  ...(fields.error !== undefined ? { error: fields.error } : {}),
1360
1576
  ...(fields.waitingOn !== undefined ? { waitingOn: fields.waitingOn } : {}),
1361
1577
  ...(fields.finalOutput !== undefined ? { finalOutput: fields.finalOutput } : {}),
1578
+ presentationRequired: status === "completed" && this.presentationRequired,
1362
1579
  },
1363
1580
  });
1364
1581
  }
@@ -1477,6 +1694,58 @@ function abortRejection(signal: AbortSignal): Promise<never> {
1477
1694
  });
1478
1695
  }
1479
1696
 
1697
+ function restoreRunState(target: WorkflowRunState, saved: WorkflowRunState): void {
1698
+ for (const key of Object.keys(target) as Array<keyof WorkflowRunState>) {
1699
+ delete target[key];
1700
+ }
1701
+ Object.assign(target, saved);
1702
+ }
1703
+
1704
+ function currentMountInvocation(state: WorkflowRunState, mountPath: string): number {
1705
+ for (let index = state.steps.length - 1; index >= 0; index -= 1) {
1706
+ const step = state.steps[index];
1707
+ if (step?.nodeId !== mountPath || step.outcome !== "ok") continue;
1708
+ const output = step.output as { invocation?: unknown };
1709
+ if (typeof output.invocation === "number" && Number.isInteger(output.invocation)) {
1710
+ return output.invocation;
1711
+ }
1712
+ }
1713
+ throw new Error(`Workflow include entry is missing for settings scope ${mountPath}`);
1714
+ }
1715
+
1716
+ function currentMountInput(state: WorkflowRunState, mountPath: string): unknown {
1717
+ for (let index = state.steps.length - 1; index >= 0; index -= 1) {
1718
+ const step = state.steps[index];
1719
+ if (step?.nodeId !== mountPath || step.outcome !== "ok") continue;
1720
+ const output = step.output as { input?: unknown };
1721
+ return output.input ?? null;
1722
+ }
1723
+ throw new Error(`Workflow include input is missing for settings scope ${mountPath}`);
1724
+ }
1725
+
1726
+ function currentMountSettings(
1727
+ state: WorkflowRunState,
1728
+ mountPath: string,
1729
+ ): { present: true; value: unknown } | { present: false } {
1730
+ for (let index = state.steps.length - 1; index >= 0; index -= 1) {
1731
+ const step = state.steps[index];
1732
+ if (step?.nodeId !== mountPath || step.outcome !== "ok") continue;
1733
+ const output = step.output as { settings?: unknown };
1734
+ return Object.hasOwn(output, "settings")
1735
+ ? { present: true, value: output.settings }
1736
+ : { present: false };
1737
+ }
1738
+ throw new Error(`Workflow include entry is missing for settings scope ${mountPath}`);
1739
+ }
1740
+
1741
+ function deepFreezeJson<T>(value: T): T {
1742
+ if (value !== null && typeof value === "object") {
1743
+ Object.freeze(value);
1744
+ for (const item of Object.values(value)) deepFreezeJson(item);
1745
+ }
1746
+ return value;
1747
+ }
1748
+
1480
1749
  function countExecutableSteps(workflow: WorkflowDefinition, steps: WorkflowStepRecord[]): number {
1481
1750
  const metadata = compositionMetadata(workflow);
1482
1751
  if (metadata === undefined) return steps.length;
@@ -1546,7 +1815,7 @@ function workflowSourceMismatch(
1546
1815
  );
1547
1816
  }
1548
1817
 
1549
- function workflowIdentityMismatch(
1818
+ export function workflowIdentityMismatch(
1550
1819
  state: WorkflowRunState,
1551
1820
  workflow: WorkflowDefinition,
1552
1821
  source: WorkflowSource | undefined,
@@ -1561,7 +1830,7 @@ function workflowIdentityMismatch(
1561
1830
 
1562
1831
  function definitionDigest(workflow: WorkflowDefinition): string {
1563
1832
  return `sha256:${createHash("sha256")
1564
- .update(JSON.stringify(createDefinitionSnapshot(workflow)))
1833
+ .update(canonicalJson(createDefinitionSnapshot(workflow)))
1565
1834
  .digest("hex")}`;
1566
1835
  }
1567
1836
 
@@ -1607,6 +1876,66 @@ function assistantMessageConfig(node: AgentNodeDefinition): AssistantMessageOutp
1607
1876
  return assistantMessageOutput(node.expectedOutput);
1608
1877
  }
1609
1878
 
1879
+ function appendLiveControlInstructions(
1880
+ prompt: string,
1881
+ workflow: WorkflowDefinition,
1882
+ nodeId: string,
1883
+ context: WorkflowNodeContext,
1884
+ ): string {
1885
+ const lines = [prompt.trimEnd(), "", "Workflow changes during this run:"];
1886
+ if (
1887
+ context.settingsScopeId !== undefined &&
1888
+ context.settingsChangeNumber !== undefined &&
1889
+ context.settings !== undefined
1890
+ ) {
1891
+ const definition = settingsDefinitionForNode(workflow, nodeId);
1892
+ const value = canonicalJson(context.settings);
1893
+ const summary =
1894
+ value.length <= 8_192 ? value : `${value.slice(0, 8_192)}… [settings truncated]`;
1895
+ const allowed = (definition?.paths ?? [])
1896
+ .flatMap((rule) =>
1897
+ Object.entries(rule.permissions)
1898
+ .filter(([, actors]) => actors?.includes("session") === true)
1899
+ .map(([permission]) => `${permission} ${rule.path || "/"}`),
1900
+ )
1901
+ .join(", ");
1902
+ lines.push(
1903
+ `- Active settings scope: ${context.settingsScopeId}`,
1904
+ `- Current settings change number: ${context.settingsChangeNumber}`,
1905
+ ...(definition?.description !== undefined
1906
+ ? [`- Settings purpose: ${definition.description}`]
1907
+ : []),
1908
+ `- Current settings: ${summary}`,
1909
+ `- Changes allowed from this model step: ${allowed || "none"}`,
1910
+ "- Treat the current settings as authoritative for future behavior in this workflow scope.",
1911
+ "- To change future workflow settings, use the workflow tool with action change-settings, this scopeId, an RFC 6902 patch, and optionally expectedChangeNumber.",
1912
+ );
1913
+ } else {
1914
+ lines.push("- This workflow scope has no editable settings.");
1915
+ }
1916
+ lines.push(
1917
+ "- To save normal user work for after successful completion, use workflow action queue-follow-up once for each prompt.",
1918
+ "- To remove an unsent prompt that this model queued, use workflow action remove-follow-up with its followUpId.",
1919
+ "- Setting changes affect only future node attempts and future settings routes. They do not change this running attempt or completed work.",
1920
+ );
1921
+ return lines.join("\n");
1922
+ }
1923
+
1924
+ function settingsDefinitionForNode(
1925
+ workflow: WorkflowDefinition,
1926
+ nodeId: string,
1927
+ ): WorkflowDefinition["settings"] {
1928
+ const metadata = compositionMetadata(workflow);
1929
+ if (metadata === undefined) return workflow.settings;
1930
+ const entry = metadata.entries[nodeId];
1931
+ if (entry !== undefined) return metadata.scopes[entry.parentPath]?.settings;
1932
+ const exit = metadata.exits[nodeId];
1933
+ if (exit !== undefined) return metadata.scopes[exit.mountPath]?.settings;
1934
+ return Object.values(metadata.scopes).find((scope) =>
1935
+ Object.values(scope.authoredNodes).includes(nodeId),
1936
+ )?.settings;
1937
+ }
1938
+
1610
1939
  /**
1611
1940
  * The step contract appended to every agent-node prompt. This is the
1612
1941
  * documented standard for how the model completes a workflow step.