@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,6 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { canonicalJson } from "../state/json.js";
4
+ import { StaleResourceError } from "../state/mutation.js";
4
5
  import {
5
6
  compileWorkflowDefinition,
6
7
  compositionMetadata,
@@ -23,6 +24,11 @@ import {
23
24
  validateHumanDecisionResponse,
24
25
  } from "./human-decision.js";
25
26
  import { extractJsonValue } from "./json.js";
27
+ import {
28
+ parseWorkflowSettingsValue,
29
+ resolveInitialWorkflowSettings,
30
+ type WorkflowSettingsScopeRecord,
31
+ } from "./settings.js";
26
32
  import { runShellAction, shellResultFromError } from "./shell.js";
27
33
  import {
28
34
  RUN_STATE_SCHEMA,
@@ -68,6 +74,7 @@ const TITLE_TIMEOUT_MS = 30_000;
68
74
  const TIMEOUT_RESOLUTION_TIMEOUT_MS = 30_000;
69
75
  // Covers the shell SIGTERM → SIGKILL escalation (1s) plus stdio close.
70
76
  const ABORT_CLEANUP_GRACE_MS = 2_000;
77
+ const MAX_SETTINGS_ROUTE_RETRIES = 16;
71
78
 
72
79
  type NodeExecution = {
73
80
  output: unknown;
@@ -89,6 +96,7 @@ type NodeExecutionMeta = {
89
96
  type NodeAttempt = {
90
97
  result: WorkflowNodeResult;
91
98
  execution: NodeExecution | null;
99
+ settings?: WorkflowSettingsScopeRecord;
92
100
  error?: unknown;
93
101
  };
94
102
 
@@ -96,6 +104,7 @@ type ResumedNodeAttempt = {
96
104
  nodeId: string;
97
105
  attemptId: string;
98
106
  startedAt: string;
107
+ settings?: WorkflowSettingsScopeRecord;
99
108
  };
100
109
 
101
110
  /**
@@ -120,6 +129,7 @@ export class WorkflowEngine {
120
129
  state: WorkflowRunState;
121
130
  nodeId: string;
122
131
  attemptId: string;
132
+ settingsScopeId?: string;
123
133
  signal: AbortSignal;
124
134
  }
125
135
  | undefined;
@@ -128,6 +138,7 @@ export class WorkflowEngine {
128
138
  private cancelled = false;
129
139
  private parked = false;
130
140
  private paused = false;
141
+ private presentationRequired = false;
131
142
  private wakePause: (() => void) | null = null;
132
143
 
133
144
  constructor(options: WorkflowEngineOptions) {
@@ -237,6 +248,10 @@ export class WorkflowEngine {
237
248
  return this.paused;
238
249
  }
239
250
 
251
+ get activeSettingsScopeId(): string | undefined {
252
+ return this.activeAttempt?.settingsScopeId;
253
+ }
254
+
240
255
  async run(
241
256
  workflow: WorkflowDefinition,
242
257
  input: unknown,
@@ -244,6 +259,7 @@ export class WorkflowEngine {
244
259
  ): Promise<WorkflowRunResult> {
245
260
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
246
261
  validateWorkflowDefinition(workflow);
262
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
247
263
  // Fail before any run row exists so bad input cannot leave partial state.
248
264
  const suppliedInput = input === undefined ? null : input;
249
265
  const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
@@ -261,7 +277,18 @@ export class WorkflowEngine {
261
277
  options.workflowSource,
262
278
  options.runId,
263
279
  );
264
- 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 });
265
292
  await this.persist(runId, state, {
266
293
  scope: "run",
267
294
  type: "run_started",
@@ -300,6 +327,7 @@ export class WorkflowEngine {
300
327
  ): Promise<WorkflowRunResult> {
301
328
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
302
329
  validateWorkflowDefinition(workflow);
330
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
303
331
  // Reset before any await: a park or cancel landing during preparation
304
332
  // must survive, or a host drain would hang while the run executes.
305
333
  this.cancelled = false;
@@ -315,18 +343,22 @@ export class WorkflowEngine {
315
343
  const point = this.resumePointFor(workflow, state, "wait");
316
344
  const interruptedNode =
317
345
  state.currentNode !== undefined ? workflow.nodes[state.currentNode] : undefined;
318
- const resumedAttempt: ResumedNodeAttempt | undefined =
346
+ let resumedAttempt: ResumedNodeAttempt | undefined;
347
+ if (
319
348
  state.currentNode !== undefined &&
320
349
  state.currentAttemptId !== undefined &&
321
350
  state.currentNodeStartedAt !== undefined &&
322
351
  interruptedNode?.nodeType === "agent" &&
323
352
  assistantMessageConfig(interruptedNode) !== undefined
324
- ? {
325
- nodeId: state.currentNode,
326
- attemptId: state.currentAttemptId,
327
- startedAt: state.currentNodeStartedAt,
328
- }
329
- : 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
+ }
330
362
  // A resumed run starts unpaused. Submitted and non-agent nodes discard
331
363
  // stale in-flight markers and start a new attempt. Assistant-message
332
364
  // nodes keep their attempt id so the origin session can adopt an already
@@ -336,6 +368,9 @@ export class WorkflowEngine {
336
368
  delete state.currentNode;
337
369
  delete state.currentAttemptId;
338
370
  delete state.currentNodeStartedAt;
371
+ delete state.currentSettingsScopeId;
372
+ delete state.currentSettingsChangeNumber;
373
+ delete state.currentSettingsHash;
339
374
  delete state.statusDetail;
340
375
  }
341
376
  await this.persist(runId, state, {
@@ -408,6 +443,7 @@ export class WorkflowEngine {
408
443
  ): Promise<WorkflowRunResult> {
409
444
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
410
445
  validateWorkflowDefinition(workflow);
446
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
411
447
  this.cancelled = false;
412
448
  this.paused = false;
413
449
  this.parked = false;
@@ -687,6 +723,7 @@ export class WorkflowEngine {
687
723
  let currentNodeId: string | null = startNodeId;
688
724
  let executedSteps = executedStepsBase;
689
725
  let lastOutput: unknown = initialLastOutput;
726
+ const settingsRouteRetries = new Map<string, number>();
690
727
 
691
728
  while (currentNodeId !== null) {
692
729
  await this.holdWhilePaused(state, runId);
@@ -723,28 +760,84 @@ export class WorkflowEngine {
723
760
  // as in-flight, and resume reruns it with a fresh attempt.
724
761
  throw new RunParkedError();
725
762
  }
763
+ const beforeRecord = structuredClone(state);
726
764
  this.recordAttempt(workflow, state, attempt);
727
- // The durable step row owns the output. The trace keeps the terminal fact
728
- // and compact execution metadata without copying the output value.
729
- await this.persist(runId, state, {
730
- scope: "node",
731
- type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
732
- nodeId: attempt.result.nodeId,
733
- attemptId: attempt.result.attemptId,
734
- payload: {
735
- outcome: attempt.result.outcome,
736
- durationMs: attempt.result.durationMs,
737
- ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
738
- ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
739
- ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
740
- ...(attempt.execution?.assistantMessage !== undefined
741
- ? { assistantMessage: attempt.execution.assistantMessage }
742
- : {}),
743
- ...(attempt.execution?.conversation !== undefined
744
- ? { conversation: attempt.execution.conversation }
745
- : {}),
746
- },
747
- });
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);
748
841
 
749
842
  if (attempt.result.outcome !== "ok") {
750
843
  currentNodeId = this.routeAfterFailure(workflow, state, attempt);
@@ -887,11 +980,21 @@ export class WorkflowEngine {
887
980
  ...(attempt.execution?.conversation !== undefined
888
981
  ? { conversation: attempt.execution.conversation }
889
982
  : {}),
983
+ ...(attempt.settings !== undefined
984
+ ? {
985
+ settingsScopeId: attempt.settings.scopeId,
986
+ settingsChangeNumber: attempt.settings.changeNumber,
987
+ settingsHash: attempt.settings.settingsHash,
988
+ }
989
+ : {}),
890
990
  };
891
991
  state.steps.push(step);
892
992
  delete state.currentNode;
893
993
  delete state.currentAttemptId;
894
994
  delete state.currentNodeStartedAt;
995
+ delete state.currentSettingsScopeId;
996
+ delete state.currentSettingsChangeNumber;
997
+ delete state.currentSettingsHash;
895
998
  delete state.statusDetail;
896
999
  }
897
1000
 
@@ -905,9 +1008,20 @@ export class WorkflowEngine {
905
1008
  ): Promise<NodeAttempt> {
906
1009
  const attemptId = resumedAttempt?.attemptId ?? randomUUID();
907
1010
  const startedAt = resumedAttempt?.startedAt ?? new Date().toISOString();
1011
+ const settings =
1012
+ resumedAttempt?.settings ?? (await this.captureSettingsBinding(workflow, state, nodeId));
908
1013
  state.currentNode = nodeId;
909
1014
  state.currentAttemptId = attemptId;
910
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
+ }
911
1025
  if (node.statusDetail !== undefined) {
912
1026
  state.statusDetail = node.statusDetail;
913
1027
  }
@@ -917,7 +1031,16 @@ export class WorkflowEngine {
917
1031
  type: "node_started",
918
1032
  nodeId,
919
1033
  attemptId,
920
- 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
+ },
921
1044
  });
922
1045
  }
923
1046
 
@@ -931,10 +1054,12 @@ export class WorkflowEngine {
931
1054
  attemptId,
932
1055
  node,
933
1056
  meta,
1057
+ settings,
934
1058
  );
935
1059
  return {
936
1060
  result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
937
1061
  execution,
1062
+ ...(settings !== undefined ? { settings } : {}),
938
1063
  };
939
1064
  } catch (error) {
940
1065
  const outcome = this.outcomeForError(error);
@@ -950,6 +1075,7 @@ export class WorkflowEngine {
950
1075
  promptText: meta.promptText,
951
1076
  ...(meta.action !== undefined ? { action: meta.action } : {}),
952
1077
  },
1078
+ ...(settings !== undefined ? { settings } : {}),
953
1079
  error,
954
1080
  };
955
1081
  }
@@ -994,9 +1120,10 @@ export class WorkflowEngine {
994
1120
  attemptId: string,
995
1121
  node: WorkflowNodeDefinition,
996
1122
  meta: NodeExecutionMeta,
1123
+ settings?: WorkflowSettingsScopeRecord,
997
1124
  ): Promise<NodeExecution> {
998
1125
  const abort = new AbortController();
999
- const context = this.createNodeContext(state, abort.signal);
1126
+ const context = this.createNodeContext(state, abort.signal, settings);
1000
1127
  let timer: NodeJS.Timeout | undefined;
1001
1128
  let dispatchSettled: Promise<void> | undefined;
1002
1129
  this.activeAbort = abort;
@@ -1019,7 +1146,14 @@ export class WorkflowEngine {
1019
1146
  abort.abort(new TimeoutError(timeoutMs));
1020
1147
  }, timeoutMs);
1021
1148
  }
1022
- 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
+ };
1023
1157
  const dispatched = this.dispatchNode(
1024
1158
  workflow,
1025
1159
  state,
@@ -1162,12 +1296,89 @@ export class WorkflowEngine {
1162
1296
  }
1163
1297
  }
1164
1298
 
1165
- 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 {
1166
1370
  return {
1167
1371
  input: state.input,
1168
1372
  outputs: state.outputs,
1169
1373
  results: state.results,
1170
1374
  state,
1375
+ ...(settings !== undefined
1376
+ ? {
1377
+ settings: deepFreezeJson(structuredClone(settings.settings)),
1378
+ settingsScopeId: settings.scopeId,
1379
+ settingsChangeNumber: settings.changeNumber,
1380
+ }
1381
+ : {}),
1171
1382
  signal,
1172
1383
  };
1173
1384
  }
@@ -1202,7 +1413,8 @@ export class WorkflowEngine {
1202
1413
  }
1203
1414
  }
1204
1415
 
1205
- const basePrompt = await node.prompt(context);
1416
+ const authoredPrompt = await node.prompt(context);
1417
+ const basePrompt = appendLiveControlInstructions(authoredPrompt, workflow, nodeId, context);
1206
1418
  if (signal.aborted) {
1207
1419
  // The node timed out or the run was cancelled while the async prompt
1208
1420
  // builder ran; a late continuation must not write into a run that
@@ -1352,6 +1564,9 @@ export class WorkflowEngine {
1352
1564
  delete state.currentNode;
1353
1565
  delete state.currentAttemptId;
1354
1566
  delete state.currentNodeStartedAt;
1567
+ delete state.currentSettingsScopeId;
1568
+ delete state.currentSettingsChangeNumber;
1569
+ delete state.currentSettingsHash;
1355
1570
  await this.persist(runId, state, {
1356
1571
  scope: "run",
1357
1572
  type: `run_${status}`,
@@ -1360,6 +1575,7 @@ export class WorkflowEngine {
1360
1575
  ...(fields.error !== undefined ? { error: fields.error } : {}),
1361
1576
  ...(fields.waitingOn !== undefined ? { waitingOn: fields.waitingOn } : {}),
1362
1577
  ...(fields.finalOutput !== undefined ? { finalOutput: fields.finalOutput } : {}),
1578
+ presentationRequired: status === "completed" && this.presentationRequired,
1363
1579
  },
1364
1580
  });
1365
1581
  }
@@ -1478,6 +1694,58 @@ function abortRejection(signal: AbortSignal): Promise<never> {
1478
1694
  });
1479
1695
  }
1480
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
+
1481
1749
  function countExecutableSteps(workflow: WorkflowDefinition, steps: WorkflowStepRecord[]): number {
1482
1750
  const metadata = compositionMetadata(workflow);
1483
1751
  if (metadata === undefined) return steps.length;
@@ -1547,7 +1815,7 @@ function workflowSourceMismatch(
1547
1815
  );
1548
1816
  }
1549
1817
 
1550
- function workflowIdentityMismatch(
1818
+ export function workflowIdentityMismatch(
1551
1819
  state: WorkflowRunState,
1552
1820
  workflow: WorkflowDefinition,
1553
1821
  source: WorkflowSource | undefined,
@@ -1608,6 +1876,66 @@ function assistantMessageConfig(node: AgentNodeDefinition): AssistantMessageOutp
1608
1876
  return assistantMessageOutput(node.expectedOutput);
1609
1877
  }
1610
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
+
1611
1939
  /**
1612
1940
  * The step contract appended to every agent-node prompt. This is the
1613
1941
  * documented standard for how the model completes a workflow step.