@osolmaz/pi-workflows 0.13.2 → 0.13.4

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 (134) 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 +77 -73
  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 +8 -8
  7. package/dist/builtins/autoplan.workflow.js +44 -35
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +2 -2
  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/plan-approval.workflow.d.ts +1 -1
  13. package/dist/builtins/plan-change.workflow.d.ts +29 -29
  14. package/dist/builtins/sanity-check.workflow.d.ts +3 -3
  15. package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
  16. package/dist/controllers/index.d.ts +2 -2
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.js +85 -23
  19. package/dist/controllers/sqlite.js.map +1 -1
  20. package/dist/controllers/types.d.ts +34 -0
  21. package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
  22. package/dist/controllers/workflow-engine-scheduler.js +83 -1
  23. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  24. package/dist/controllers/workflows.d.ts +8 -1
  25. package/dist/controllers/workflows.js +36 -0
  26. package/dist/controllers/workflows.js.map +1 -1
  27. package/dist/extension/controller-host.d.ts +2 -1
  28. package/dist/extension/controller-host.js +1 -1
  29. package/dist/extension/controller-host.js.map +1 -1
  30. package/dist/extension/follow-up-coordinator.d.ts +27 -0
  31. package/dist/extension/follow-up-coordinator.js +131 -0
  32. package/dist/extension/follow-up-coordinator.js.map +1 -0
  33. package/dist/extension/index.d.ts +9 -0
  34. package/dist/extension/index.js +460 -75
  35. package/dist/extension/index.js.map +1 -1
  36. package/dist/extension/session-events.d.ts +2 -3
  37. package/dist/extension/session-events.js +11 -10
  38. package/dist/extension/session-events.js.map +1 -1
  39. package/dist/extension/widget.js +9 -0
  40. package/dist/extension/widget.js.map +1 -1
  41. package/dist/state/database.d.ts +2 -1
  42. package/dist/state/database.js +11 -13
  43. package/dist/state/database.js.map +1 -1
  44. package/dist/state/json.js +6 -1
  45. package/dist/state/json.js.map +1 -1
  46. package/dist/state/mutation.d.ts +1 -1
  47. package/dist/state/mutation.js.map +1 -1
  48. package/dist/state/prune.d.ts +18 -0
  49. package/dist/state/prune.js +373 -0
  50. package/dist/state/prune.js.map +1 -0
  51. package/dist/state/schema.d.ts +1 -1
  52. package/dist/state/schema.js +116 -18
  53. package/dist/state/schema.js.map +1 -1
  54. package/dist/viewer/cli.d.ts +3 -1
  55. package/dist/viewer/cli.js +55 -4
  56. package/dist/viewer/cli.js.map +1 -1
  57. package/dist/viewer/render.js +19 -1
  58. package/dist/viewer/render.js.map +1 -1
  59. package/dist/workflows/composition.d.ts +2 -0
  60. package/dist/workflows/composition.js +15 -0
  61. package/dist/workflows/composition.js.map +1 -1
  62. package/dist/workflows/definition.d.ts +6 -3
  63. package/dist/workflows/definition.js +3 -0
  64. package/dist/workflows/definition.js.map +1 -1
  65. package/dist/workflows/engine.d.ts +6 -1
  66. package/dist/workflows/engine.js +301 -35
  67. package/dist/workflows/engine.js.map +1 -1
  68. package/dist/workflows/human-decision.d.ts +1 -0
  69. package/dist/workflows/human-decision.js +25 -9
  70. package/dist/workflows/human-decision.js.map +1 -1
  71. package/dist/workflows/index.d.ts +3 -1
  72. package/dist/workflows/index.js +2 -0
  73. package/dist/workflows/index.js.map +1 -1
  74. package/dist/workflows/json-patch.d.ts +47 -0
  75. package/dist/workflows/json-patch.js +298 -0
  76. package/dist/workflows/json-patch.js.map +1 -0
  77. package/dist/workflows/schema.js +29 -1
  78. package/dist/workflows/schema.js.map +1 -1
  79. package/dist/workflows/settings.d.ts +128 -0
  80. package/dist/workflows/settings.js +199 -0
  81. package/dist/workflows/settings.js.map +1 -0
  82. package/dist/workflows/store.d.ts +75 -2
  83. package/dist/workflows/store.js +1349 -89
  84. package/dist/workflows/store.js.map +1 -1
  85. package/dist/workflows/tool-input.d.ts +22 -0
  86. package/dist/workflows/tool-input.js +43 -0
  87. package/dist/workflows/tool-input.js.map +1 -1
  88. package/dist/workflows/types.d.ts +40 -5
  89. package/docs/2026-08-25-workflow-follow-ups.md +132 -0
  90. package/docs/2026-08-25-workflow-settings.md +169 -0
  91. package/docs/DEFERRED_TURNS.md +6 -0
  92. package/docs/DESIGN_PHILOSOPHY.md +2 -0
  93. package/docs/SQLITE_STATE.md +31 -18
  94. package/docs/WORKFLOW_COMPOSITION.md +16 -1
  95. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
  96. package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
  97. package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
  98. package/docs/tui-viewer.md +7 -6
  99. package/docs/workflows.md +22 -1
  100. package/examples/workflows/live-settings.workflow.ts +93 -0
  101. package/herdr-plugin.toml +1 -1
  102. package/package.json +1 -1
  103. package/skills/autoplan/SKILL.md +6 -6
  104. package/src/builtins/autoimplement.workflow.ts +56 -8
  105. package/src/builtins/autoplan.workflow.ts +45 -37
  106. package/src/builtins/catalog.ts +2 -2
  107. package/src/controllers/index.ts +6 -0
  108. package/src/controllers/sqlite.ts +129 -35
  109. package/src/controllers/types.ts +40 -0
  110. package/src/controllers/workflow-engine-scheduler.ts +103 -1
  111. package/src/controllers/workflows.ts +68 -0
  112. package/src/extension/controller-host.ts +6 -1
  113. package/src/extension/follow-up-coordinator.ts +151 -0
  114. package/src/extension/index.ts +538 -88
  115. package/src/extension/session-events.ts +15 -13
  116. package/src/extension/widget.ts +8 -0
  117. package/src/state/database.ts +12 -12
  118. package/src/state/json.ts +6 -1
  119. package/src/state/mutation.ts +4 -1
  120. package/src/state/prune.ts +502 -0
  121. package/src/state/schema.ts +116 -18
  122. package/src/viewer/cli.ts +52 -5
  123. package/src/viewer/render.ts +43 -1
  124. package/src/workflows/composition.ts +19 -0
  125. package/src/workflows/definition.ts +19 -5
  126. package/src/workflows/engine.ts +363 -35
  127. package/src/workflows/human-decision.ts +41 -11
  128. package/src/workflows/index.ts +43 -0
  129. package/src/workflows/json-patch.ts +375 -0
  130. package/src/workflows/schema.ts +31 -1
  131. package/src/workflows/settings.ts +430 -0
  132. package/src/workflows/store.ts +1977 -184
  133. package/src/workflows/tool-input.ts +58 -0
  134. package/src/workflows/types.ts +43 -4
@@ -1,11 +1,13 @@
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 { compileWorkflowDefinition, compositionMetadata, isCompiledWorkflow, } from "./composition.js";
5
6
  import { CancelledError, errorMessage, isAbortLikeError, isClaimLostError, isRunParkedError, RunParkedError, TimeoutError, WorkflowSourceChangedError, } from "./errors.js";
6
7
  import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
7
8
  import { HumanDecisionStore, createHumanDecisionRequest, validateHumanDecisionResponse, } from "./human-decision.js";
8
9
  import { extractJsonValue } from "./json.js";
10
+ import { parseWorkflowSettingsValue, resolveInitialWorkflowSettings, } from "./settings.js";
9
11
  import { runShellAction, shellResultFromError } from "./shell.js";
10
12
  import { RUN_STATE_SCHEMA, WorkflowRunStore, createDefinitionSnapshot, createRunId, } from "./store.js";
11
13
  import { UpdateRateLimiter, updateReceipt, validateWorkflowUpdate } from "./updates.js";
@@ -15,6 +17,7 @@ const TITLE_TIMEOUT_MS = 30_000;
15
17
  const TIMEOUT_RESOLUTION_TIMEOUT_MS = 30_000;
16
18
  // Covers the shell SIGTERM → SIGKILL escalation (1s) plus stdio close.
17
19
  const ABORT_CLEANUP_GRACE_MS = 2_000;
20
+ const MAX_SETTINGS_ROUTE_RETRIES = 16;
18
21
  /**
19
22
  * Executes a workflow graph step by step. Agent steps are delegated to the
20
23
  * configured executor; compute/action/checkpoint nodes run inline. Every
@@ -37,6 +40,7 @@ export class WorkflowEngine {
37
40
  cancelled = false;
38
41
  parked = false;
39
42
  paused = false;
43
+ presentationRequired = false;
40
44
  wakePause = null;
41
45
  constructor(options) {
42
46
  this.executor = options.executor;
@@ -123,9 +127,13 @@ export class WorkflowEngine {
123
127
  get pauseRequested() {
124
128
  return this.paused;
125
129
  }
130
+ get activeSettingsScopeId() {
131
+ return this.activeAttempt?.settingsScopeId;
132
+ }
126
133
  async run(workflow, input, options = {}) {
127
134
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
128
135
  validateWorkflowDefinition(workflow);
136
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
129
137
  // Fail before any run row exists so bad input cannot leave partial state.
130
138
  const suppliedInput = input === undefined ? null : input;
131
139
  const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
@@ -137,7 +145,17 @@ export class WorkflowEngine {
137
145
  this.paused = false;
138
146
  this.parked = false;
139
147
  const state = await this.createRunState(workflow, normalizedInput, options.workflowSource, options.runId);
140
- const runId = await this.store.initializeRun(workflow, state);
148
+ const initialSettings = workflow.settings === undefined
149
+ ? []
150
+ : [
151
+ {
152
+ mountPath: "",
153
+ invocation: 1,
154
+ settings: (await resolveInitialWorkflowSettings(workflow.settings, normalizedInput))
155
+ .json,
156
+ },
157
+ ];
158
+ const runId = await this.store.initializeRun(workflow, state, { initialSettings });
141
159
  await this.persist(runId, state, {
142
160
  scope: "run",
143
161
  type: "run_started",
@@ -171,6 +189,7 @@ export class WorkflowEngine {
171
189
  async resumeRun(workflow, runId, options = {}) {
172
190
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
173
191
  validateWorkflowDefinition(workflow);
192
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
174
193
  // Reset before any await: a park or cancel landing during preparation
175
194
  // must survive, or a host drain would hang while the run executes.
176
195
  this.cancelled = false;
@@ -184,17 +203,20 @@ export class WorkflowEngine {
184
203
  }
185
204
  const point = this.resumePointFor(workflow, state, "wait");
186
205
  const interruptedNode = state.currentNode !== undefined ? workflow.nodes[state.currentNode] : undefined;
187
- const resumedAttempt = state.currentNode !== undefined &&
206
+ let resumedAttempt;
207
+ if (state.currentNode !== undefined &&
188
208
  state.currentAttemptId !== undefined &&
189
209
  state.currentNodeStartedAt !== undefined &&
190
210
  interruptedNode?.nodeType === "agent" &&
191
- assistantMessageConfig(interruptedNode) !== undefined
192
- ? {
211
+ assistantMessageConfig(interruptedNode) !== undefined) {
212
+ const resumedSettings = this.persistedSettingsBinding(state);
213
+ resumedAttempt = {
193
214
  nodeId: state.currentNode,
194
215
  attemptId: state.currentAttemptId,
195
216
  startedAt: state.currentNodeStartedAt,
196
- }
197
- : undefined;
217
+ ...(resumedSettings !== undefined ? { settings: resumedSettings } : {}),
218
+ };
219
+ }
198
220
  // A resumed run starts unpaused. Submitted and non-agent nodes discard
199
221
  // stale in-flight markers and start a new attempt. Assistant-message
200
222
  // nodes keep their attempt id so the origin session can adopt an already
@@ -204,6 +226,9 @@ export class WorkflowEngine {
204
226
  delete state.currentNode;
205
227
  delete state.currentAttemptId;
206
228
  delete state.currentNodeStartedAt;
229
+ delete state.currentSettingsScopeId;
230
+ delete state.currentSettingsChangeNumber;
231
+ delete state.currentSettingsHash;
207
232
  delete state.statusDetail;
208
233
  }
209
234
  await this.persist(runId, state, {
@@ -258,6 +283,7 @@ export class WorkflowEngine {
258
283
  async continueRun(workflow, parentRunId, input, options = {}) {
259
284
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
260
285
  validateWorkflowDefinition(workflow);
286
+ this.presentationRequired = workflow.presentationPrompt !== undefined;
261
287
  this.cancelled = false;
262
288
  this.paused = false;
263
289
  this.parked = false;
@@ -481,6 +507,7 @@ export class WorkflowEngine {
481
507
  let currentNodeId = startNodeId;
482
508
  let executedSteps = executedStepsBase;
483
509
  let lastOutput = initialLastOutput;
510
+ const settingsRouteRetries = new Map();
484
511
  while (currentNodeId !== null) {
485
512
  await this.holdWhilePaused(state, runId);
486
513
  const isTransition = composition?.entries[currentNodeId] !== undefined ||
@@ -504,28 +531,81 @@ export class WorkflowEngine {
504
531
  // as in-flight, and resume reruns it with a fresh attempt.
505
532
  throw new RunParkedError();
506
533
  }
534
+ const beforeRecord = structuredClone(state);
507
535
  this.recordAttempt(workflow, state, attempt);
508
- // The durable step row owns the output. The trace keeps the terminal fact
509
- // and compact execution metadata without copying the output value.
510
- await this.persist(runId, state, {
511
- scope: "node",
512
- type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
513
- nodeId: attempt.result.nodeId,
514
- attemptId: attempt.result.attemptId,
515
- payload: {
516
- outcome: attempt.result.outcome,
517
- durationMs: attempt.result.durationMs,
518
- ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
519
- ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
520
- ...(attempt.execution?.action !== undefined ? { action: attempt.execution.action } : {}),
521
- ...(attempt.execution?.assistantMessage !== undefined
522
- ? { assistantMessage: attempt.execution.assistantMessage }
523
- : {}),
524
- ...(attempt.execution?.conversation !== undefined
525
- ? { conversation: attempt.execution.conversation }
526
- : {}),
527
- },
528
- });
536
+ // The durable step row owns the output. The trace keeps the terminal fact,
537
+ // settings binding, and compact execution metadata.
538
+ try {
539
+ await this.persist(runId, state, {
540
+ scope: "node",
541
+ type: attempt.result.outcome === "ok" ? "node_finished" : "node_failed",
542
+ nodeId: attempt.result.nodeId,
543
+ attemptId: attempt.result.attemptId,
544
+ payload: {
545
+ outcome: attempt.result.outcome,
546
+ durationMs: attempt.result.durationMs,
547
+ ...(attempt.result.outcome === "ok" ? { output: attempt.result.output ?? null } : {}),
548
+ ...(attempt.result.error !== undefined ? { error: attempt.result.error } : {}),
549
+ ...(attempt.execution?.action !== undefined
550
+ ? { action: attempt.execution.action }
551
+ : {}),
552
+ ...(attempt.execution?.assistantMessage !== undefined
553
+ ? { assistantMessage: attempt.execution.assistantMessage }
554
+ : {}),
555
+ ...(attempt.execution?.conversation !== undefined
556
+ ? { conversation: attempt.execution.conversation }
557
+ : {}),
558
+ ...(attempt.settings !== undefined
559
+ ? {
560
+ settingsScopeId: attempt.settings.scopeId,
561
+ settingsChangeNumber: attempt.settings.changeNumber,
562
+ settingsHash: attempt.settings.settingsHash,
563
+ }
564
+ : {}),
565
+ },
566
+ });
567
+ }
568
+ catch (error) {
569
+ if (!(error instanceof StaleResourceError) ||
570
+ node.nodeType !== "compute" ||
571
+ node.settingsRoute !== true ||
572
+ attempt.settings === undefined) {
573
+ throw error;
574
+ }
575
+ restoreRunState(state, beforeRecord);
576
+ const retryCount = (settingsRouteRetries.get(currentNodeId) ?? 0) + 1;
577
+ if (retryCount > MAX_SETTINGS_ROUTE_RETRIES) {
578
+ throw new Error(`Settings route ${currentNodeId} changed more than ${MAX_SETTINGS_ROUTE_RETRIES} times before settlement`);
579
+ }
580
+ settingsRouteRetries.set(currentNodeId, retryCount);
581
+ const latest = await this.captureSettingsBinding(workflow, state, currentNodeId);
582
+ if (latest === undefined) {
583
+ throw new Error(`Settings route ${currentNodeId} lost its settings scope`);
584
+ }
585
+ state.currentSettingsScopeId = latest.scopeId;
586
+ state.currentSettingsChangeNumber = latest.changeNumber;
587
+ state.currentSettingsHash = latest.settingsHash;
588
+ await this.persist(runId, state, {
589
+ scope: "run",
590
+ type: "settings_route_retried",
591
+ nodeId: currentNodeId,
592
+ attemptId: attempt.result.attemptId,
593
+ payload: {
594
+ scopeId: latest.scopeId,
595
+ previousChangeNumber: attempt.settings.changeNumber,
596
+ changeNumber: latest.changeNumber,
597
+ },
598
+ });
599
+ resumedAttempt = {
600
+ nodeId: currentNodeId,
601
+ attemptId: attempt.result.attemptId,
602
+ startedAt: attempt.result.startedAt,
603
+ settings: latest,
604
+ };
605
+ executedSteps -= 1;
606
+ continue;
607
+ }
608
+ settingsRouteRetries.delete(currentNodeId);
529
609
  if (attempt.result.outcome !== "ok") {
530
610
  currentNodeId = this.routeAfterFailure(workflow, state, attempt);
531
611
  continue;
@@ -649,19 +729,40 @@ export class WorkflowEngine {
649
729
  ...(attempt.execution?.conversation !== undefined
650
730
  ? { conversation: attempt.execution.conversation }
651
731
  : {}),
732
+ ...(attempt.settings !== undefined
733
+ ? {
734
+ settingsScopeId: attempt.settings.scopeId,
735
+ settingsChangeNumber: attempt.settings.changeNumber,
736
+ settingsHash: attempt.settings.settingsHash,
737
+ }
738
+ : {}),
652
739
  };
653
740
  state.steps.push(step);
654
741
  delete state.currentNode;
655
742
  delete state.currentAttemptId;
656
743
  delete state.currentNodeStartedAt;
744
+ delete state.currentSettingsScopeId;
745
+ delete state.currentSettingsChangeNumber;
746
+ delete state.currentSettingsHash;
657
747
  delete state.statusDetail;
658
748
  }
659
749
  async executeNode(workflow, state, runId, nodeId, node, resumedAttempt) {
660
750
  const attemptId = resumedAttempt?.attemptId ?? randomUUID();
661
751
  const startedAt = resumedAttempt?.startedAt ?? new Date().toISOString();
752
+ const settings = resumedAttempt?.settings ?? (await this.captureSettingsBinding(workflow, state, nodeId));
662
753
  state.currentNode = nodeId;
663
754
  state.currentAttemptId = attemptId;
664
755
  state.currentNodeStartedAt = startedAt;
756
+ if (settings !== undefined) {
757
+ state.currentSettingsScopeId = settings.scopeId;
758
+ state.currentSettingsChangeNumber = settings.changeNumber;
759
+ state.currentSettingsHash = settings.settingsHash;
760
+ }
761
+ else {
762
+ delete state.currentSettingsScopeId;
763
+ delete state.currentSettingsChangeNumber;
764
+ delete state.currentSettingsHash;
765
+ }
665
766
  if (node.statusDetail !== undefined) {
666
767
  state.statusDetail = node.statusDetail;
667
768
  }
@@ -671,15 +772,25 @@ export class WorkflowEngine {
671
772
  type: "node_started",
672
773
  nodeId,
673
774
  attemptId,
674
- payload: { nodeType: node.nodeType },
775
+ payload: {
776
+ nodeType: node.nodeType,
777
+ ...(settings !== undefined
778
+ ? {
779
+ settingsScopeId: settings.scopeId,
780
+ settingsChangeNumber: settings.changeNumber,
781
+ settingsHash: settings.settingsHash,
782
+ }
783
+ : {}),
784
+ },
675
785
  });
676
786
  }
677
787
  const meta = { promptText: null };
678
788
  try {
679
- const execution = await this.runNodeWithTimeout(workflow, state, runId, nodeId, attemptId, node, meta);
789
+ const execution = await this.runNodeWithTimeout(workflow, state, runId, nodeId, attemptId, node, meta, settings);
680
790
  return {
681
791
  result: this.createNodeResult(nodeId, node, attemptId, startedAt, "ok", execution.output),
682
792
  execution,
793
+ ...(settings !== undefined ? { settings } : {}),
683
794
  };
684
795
  }
685
796
  catch (error) {
@@ -696,6 +807,7 @@ export class WorkflowEngine {
696
807
  promptText: meta.promptText,
697
808
  ...(meta.action !== undefined ? { action: meta.action } : {}),
698
809
  },
810
+ ...(settings !== undefined ? { settings } : {}),
699
811
  error,
700
812
  };
701
813
  }
@@ -722,9 +834,9 @@ export class WorkflowEngine {
722
834
  ...(output !== undefined ? { output } : {}),
723
835
  };
724
836
  }
725
- async runNodeWithTimeout(workflow, state, runId, nodeId, attemptId, node, meta) {
837
+ async runNodeWithTimeout(workflow, state, runId, nodeId, attemptId, node, meta, settings) {
726
838
  const abort = new AbortController();
727
- const context = this.createNodeContext(state, abort.signal);
839
+ const context = this.createNodeContext(state, abort.signal, settings);
728
840
  let timer;
729
841
  let dispatchSettled;
730
842
  this.activeAbort = abort;
@@ -746,7 +858,14 @@ export class WorkflowEngine {
746
858
  abort.abort(new TimeoutError(timeoutMs));
747
859
  }, timeoutMs);
748
860
  }
749
- this.activeAttempt = { runId, state, nodeId, attemptId, signal: abort.signal };
861
+ this.activeAttempt = {
862
+ runId,
863
+ state,
864
+ nodeId,
865
+ attemptId,
866
+ ...(settings !== undefined ? { settingsScopeId: settings.scopeId } : {}),
867
+ signal: abort.signal,
868
+ };
750
869
  const dispatched = this.dispatchNode(workflow, state, runId, nodeId, attemptId, node, context, abort.signal, meta);
751
870
  dispatchSettled = dispatched.then(() => undefined, () => undefined);
752
871
  // Race the dispatch against the abort signal so timeouts and cancel
@@ -848,12 +967,71 @@ export class WorkflowEngine {
848
967
  });
849
968
  }
850
969
  }
851
- createNodeContext(state, signal) {
970
+ async captureSettingsBinding(workflow, state, nodeId) {
971
+ const metadata = compositionMetadata(workflow);
972
+ let mountPath = "";
973
+ let definition = workflow.settings;
974
+ if (metadata !== undefined) {
975
+ const entry = metadata.entries[nodeId];
976
+ const exit = metadata.exits[nodeId];
977
+ const scope = entry !== undefined
978
+ ? metadata.scopes[entry.parentPath]
979
+ : exit !== undefined
980
+ ? metadata.scopes[exit.mountPath]
981
+ : Object.values(metadata.scopes).find((candidate) => Object.values(candidate.authoredNodes).includes(nodeId));
982
+ if (scope === undefined) {
983
+ throw new Error(`Workflow settings scope is missing for node ${nodeId}`);
984
+ }
985
+ mountPath = scope.path;
986
+ definition = scope.settings;
987
+ }
988
+ if (definition === undefined)
989
+ return undefined;
990
+ const invocation = mountPath === "" ? 1 : currentMountInvocation(state, mountPath);
991
+ const existing = this.store.findSettingsScope(state.runId, mountPath, invocation);
992
+ if (existing !== undefined)
993
+ return existing;
994
+ const input = mountPath === "" ? state.input : currentMountInput(state, mountPath);
995
+ const mappedSettings = mountPath === "" ? { present: false } : currentMountSettings(state, mountPath);
996
+ const initial = mappedSettings.present
997
+ ? await parseWorkflowSettingsValue(definition, mappedSettings.value)
998
+ : await resolveInitialWorkflowSettings(definition, input);
999
+ return this.store.ensureSettingsScope({
1000
+ runId: state.runId,
1001
+ mountPath,
1002
+ invocation,
1003
+ settings: initial.json,
1004
+ });
1005
+ }
1006
+ persistedSettingsBinding(state) {
1007
+ if (state.currentSettingsScopeId === undefined)
1008
+ return undefined;
1009
+ if (state.currentSettingsChangeNumber === undefined ||
1010
+ state.currentSettingsHash === undefined) {
1011
+ throw new Error("Saved workflow settings binding is incomplete");
1012
+ }
1013
+ const scope = this.store.getSettingsScopeAtChange(state.currentSettingsScopeId, state.currentSettingsChangeNumber);
1014
+ if (scope === undefined) {
1015
+ throw new Error(`Saved workflow settings scope is missing: ${state.currentSettingsScopeId}`);
1016
+ }
1017
+ if (scope.settingsHash !== state.currentSettingsHash) {
1018
+ throw new Error("Saved workflow settings binding does not match its saved change");
1019
+ }
1020
+ return scope;
1021
+ }
1022
+ createNodeContext(state, signal, settings) {
852
1023
  return {
853
1024
  input: state.input,
854
1025
  outputs: state.outputs,
855
1026
  results: state.results,
856
1027
  state,
1028
+ ...(settings !== undefined
1029
+ ? {
1030
+ settings: deepFreezeJson(structuredClone(settings.settings)),
1031
+ settingsScopeId: settings.scopeId,
1032
+ settingsChangeNumber: settings.changeNumber,
1033
+ }
1034
+ : {}),
857
1035
  signal,
858
1036
  };
859
1037
  }
@@ -876,7 +1054,8 @@ export class WorkflowEngine {
876
1054
  throw new Error("Assistant completion requires an origin Pi session");
877
1055
  }
878
1056
  }
879
- const basePrompt = await node.prompt(context);
1057
+ const authoredPrompt = await node.prompt(context);
1058
+ const basePrompt = appendLiveControlInstructions(authoredPrompt, workflow, nodeId, context);
880
1059
  if (signal.aborted) {
881
1060
  // The node timed out or the run was cancelled while the async prompt
882
1061
  // builder ran; a late continuation must not write into a run that
@@ -993,6 +1172,9 @@ export class WorkflowEngine {
993
1172
  delete state.currentNode;
994
1173
  delete state.currentAttemptId;
995
1174
  delete state.currentNodeStartedAt;
1175
+ delete state.currentSettingsScopeId;
1176
+ delete state.currentSettingsChangeNumber;
1177
+ delete state.currentSettingsHash;
996
1178
  await this.persist(runId, state, {
997
1179
  scope: "run",
998
1180
  type: `run_${status}`,
@@ -1001,6 +1183,7 @@ export class WorkflowEngine {
1001
1183
  ...(fields.error !== undefined ? { error: fields.error } : {}),
1002
1184
  ...(fields.waitingOn !== undefined ? { waitingOn: fields.waitingOn } : {}),
1003
1185
  ...(fields.finalOutput !== undefined ? { finalOutput: fields.finalOutput } : {}),
1186
+ presentationRequired: status === "completed" && this.presentationRequired,
1004
1187
  },
1005
1188
  });
1006
1189
  }
@@ -1094,6 +1277,54 @@ function abortRejection(signal) {
1094
1277
  signal.addEventListener("abort", onAbort, { once: true });
1095
1278
  });
1096
1279
  }
1280
+ function restoreRunState(target, saved) {
1281
+ for (const key of Object.keys(target)) {
1282
+ delete target[key];
1283
+ }
1284
+ Object.assign(target, saved);
1285
+ }
1286
+ function currentMountInvocation(state, mountPath) {
1287
+ for (let index = state.steps.length - 1; index >= 0; index -= 1) {
1288
+ const step = state.steps[index];
1289
+ if (step?.nodeId !== mountPath || step.outcome !== "ok")
1290
+ continue;
1291
+ const output = step.output;
1292
+ if (typeof output.invocation === "number" && Number.isInteger(output.invocation)) {
1293
+ return output.invocation;
1294
+ }
1295
+ }
1296
+ throw new Error(`Workflow include entry is missing for settings scope ${mountPath}`);
1297
+ }
1298
+ function currentMountInput(state, mountPath) {
1299
+ for (let index = state.steps.length - 1; index >= 0; index -= 1) {
1300
+ const step = state.steps[index];
1301
+ if (step?.nodeId !== mountPath || step.outcome !== "ok")
1302
+ continue;
1303
+ const output = step.output;
1304
+ return output.input ?? null;
1305
+ }
1306
+ throw new Error(`Workflow include input is missing for settings scope ${mountPath}`);
1307
+ }
1308
+ function currentMountSettings(state, mountPath) {
1309
+ for (let index = state.steps.length - 1; index >= 0; index -= 1) {
1310
+ const step = state.steps[index];
1311
+ if (step?.nodeId !== mountPath || step.outcome !== "ok")
1312
+ continue;
1313
+ const output = step.output;
1314
+ return Object.hasOwn(output, "settings")
1315
+ ? { present: true, value: output.settings }
1316
+ : { present: false };
1317
+ }
1318
+ throw new Error(`Workflow include entry is missing for settings scope ${mountPath}`);
1319
+ }
1320
+ function deepFreezeJson(value) {
1321
+ if (value !== null && typeof value === "object") {
1322
+ Object.freeze(value);
1323
+ for (const item of Object.values(value))
1324
+ deepFreezeJson(item);
1325
+ }
1326
+ return value;
1327
+ }
1097
1328
  function countExecutableSteps(workflow, steps) {
1098
1329
  const metadata = compositionMetadata(workflow);
1099
1330
  if (metadata === undefined)
@@ -1144,7 +1375,7 @@ function workflowSourceMismatch(state, source) {
1144
1375
  return (state.workflowHash !== undefined &&
1145
1376
  (source.kind !== "file" || state.workflowHash !== source.hash));
1146
1377
  }
1147
- function workflowIdentityMismatch(state, workflow, source) {
1378
+ export function workflowIdentityMismatch(state, workflow, source) {
1148
1379
  if (workflowSourceMismatch(state, source))
1149
1380
  return true;
1150
1381
  const metadata = compositionMetadata(workflow);
@@ -1195,6 +1426,41 @@ function assistantMessageOutput(expectedOutput) {
1195
1426
  function assistantMessageConfig(node) {
1196
1427
  return assistantMessageOutput(node.expectedOutput);
1197
1428
  }
1429
+ function appendLiveControlInstructions(prompt, workflow, nodeId, context) {
1430
+ const lines = [prompt.trimEnd(), "", "Workflow changes during this run:"];
1431
+ if (context.settingsScopeId !== undefined &&
1432
+ context.settingsChangeNumber !== undefined &&
1433
+ context.settings !== undefined) {
1434
+ const definition = settingsDefinitionForNode(workflow, nodeId);
1435
+ const value = canonicalJson(context.settings);
1436
+ const summary = value.length <= 8_192 ? value : `${value.slice(0, 8_192)}… [settings truncated]`;
1437
+ const allowed = (definition?.paths ?? [])
1438
+ .flatMap((rule) => Object.entries(rule.permissions)
1439
+ .filter(([, actors]) => actors?.includes("session") === true)
1440
+ .map(([permission]) => `${permission} ${rule.path || "/"}`))
1441
+ .join(", ");
1442
+ lines.push(`- Active settings scope: ${context.settingsScopeId}`, `- Current settings change number: ${context.settingsChangeNumber}`, ...(definition?.description !== undefined
1443
+ ? [`- Settings purpose: ${definition.description}`]
1444
+ : []), `- Current settings: ${summary}`, `- Changes allowed from this model step: ${allowed || "none"}`, "- Treat the current settings as authoritative for future behavior in this workflow scope.", "- To change future workflow settings, use the workflow tool with action change-settings, this scopeId, an RFC 6902 patch, and optionally expectedChangeNumber.");
1445
+ }
1446
+ else {
1447
+ lines.push("- This workflow scope has no editable settings.");
1448
+ }
1449
+ lines.push("- To save normal user work for after successful completion, use workflow action queue-follow-up once for each prompt.", "- To remove an unsent prompt that this model queued, use workflow action remove-follow-up with its followUpId.", "- Setting changes affect only future node attempts and future settings routes. They do not change this running attempt or completed work.");
1450
+ return lines.join("\n");
1451
+ }
1452
+ function settingsDefinitionForNode(workflow, nodeId) {
1453
+ const metadata = compositionMetadata(workflow);
1454
+ if (metadata === undefined)
1455
+ return workflow.settings;
1456
+ const entry = metadata.entries[nodeId];
1457
+ if (entry !== undefined)
1458
+ return metadata.scopes[entry.parentPath]?.settings;
1459
+ const exit = metadata.exits[nodeId];
1460
+ if (exit !== undefined)
1461
+ return metadata.scopes[exit.mountPath]?.settings;
1462
+ return Object.values(metadata.scopes).find((scope) => Object.values(scope.authoredNodes).includes(nodeId))?.settings;
1463
+ }
1198
1464
  /**
1199
1465
  * The step contract appended to every agent-node prompt. This is the
1200
1466
  * documented standard for how the model completes a workflow step.