@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
@@ -651,7 +651,7 @@ export class HumanDecisionStore {
651
651
  return this.state.transaction(() => {
652
652
  const existing = this.readContinuationRow(decisionId);
653
653
  if (existing !== undefined) {
654
- const stored = this.state.readJson(existing.recordHash) as HumanDecisionContinuationRecord;
654
+ const stored = this.continuationRecord(decisionId, existing);
655
655
  if (canonicalJson(stored) !== canonicalJson(value)) {
656
656
  throw new Error("Immutable human decision continuation conflicts");
657
657
  }
@@ -661,23 +661,20 @@ export class HumanDecisionStore {
661
661
  if (resolution?.outcome !== "accepted") {
662
662
  throw new Error("A continuation requires an accepted human decision");
663
663
  }
664
- const recordHash = this.state.putJson(value, Date.parse(value.createdAt));
665
664
  this.state.connection
666
665
  .prepare(
667
666
  `INSERT INTO continuations(
668
- decision_id, parent_run_id, continuation_run_id, response_hash, created_at
669
- ) VALUES (?, ?, ?, ?, ?)`,
667
+ decision_id, parent_run_id, continuation_run_id, created_at
668
+ ) VALUES (?, ?, ?, ?)`,
670
669
  )
671
- .run(decisionId, value.parentRunId, value.runId, recordHash, Date.parse(value.createdAt));
670
+ .run(decisionId, value.parentRunId, value.runId, Date.parse(value.createdAt));
672
671
  return "created";
673
672
  });
674
673
  }
675
674
 
676
675
  async readContinuation(decisionId: string): Promise<HumanDecisionContinuationRecord | null> {
677
676
  const row = this.readContinuationRow(decisionId);
678
- return row === undefined
679
- ? null
680
- : (this.state.readJson(row.recordHash) as HumanDecisionContinuationRecord);
677
+ return row === undefined ? null : this.continuationRecord(decisionId, row);
681
678
  }
682
679
 
683
680
  markEffectApplied(
@@ -1145,12 +1142,37 @@ export class HumanDecisionStore {
1145
1142
  .run(outcome, resultHash, decisionId, attemptId);
1146
1143
  }
1147
1144
 
1148
- private readContinuationRow(decisionId: string): { recordHash: Buffer } | undefined {
1145
+ private readContinuationRow(decisionId: string): ContinuationRow | undefined {
1149
1146
  const row = this.state.connection
1150
- .prepare("SELECT response_hash AS recordHash FROM continuations WHERE decision_id = ?")
1147
+ .prepare(
1148
+ `SELECT c.parent_run_id AS parentRunId, c.continuation_run_id AS continuationRunId,
1149
+ c.created_at AS createdAt, d.request_hash AS requestHash,
1150
+ r.response_hash AS resolutionHash
1151
+ FROM continuations c
1152
+ JOIN human_decisions d ON d.decision_id = c.decision_id
1153
+ JOIN human_decision_resolutions r ON r.decision_id = c.decision_id
1154
+ WHERE c.decision_id = ? AND r.outcome = 'accepted'`,
1155
+ )
1151
1156
  .get(decisionId);
1152
1157
  return isContinuationRow(row) ? row : undefined;
1153
1158
  }
1159
+
1160
+ private continuationRecord(
1161
+ decisionId: string,
1162
+ row: ContinuationRow,
1163
+ ): HumanDecisionContinuationRecord {
1164
+ const request = this.state.readJson(row.requestHash) as HumanDecisionRequest;
1165
+ const decision = this.state.readJson(row.resolutionHash) as ResolvedHumanDecision;
1166
+ return {
1167
+ schema: "pi-workflows.human-decision-continuation.v1",
1168
+ decisionId,
1169
+ requestDigest: request.requestDigest,
1170
+ provenance: decision.provenance,
1171
+ parentRunId: row.parentRunId,
1172
+ runId: row.continuationRunId,
1173
+ createdAt: new Date(row.createdAt).toISOString(),
1174
+ };
1175
+ }
1154
1176
  }
1155
1177
 
1156
1178
  function digestBuffer(value: string): Buffer {
@@ -1215,7 +1237,15 @@ function isContentHashRow(value: unknown): value is { contentHash: Buffer } {
1215
1237
  return isRecordValue(value);
1216
1238
  }
1217
1239
 
1218
- function isContinuationRow(value: unknown): value is { recordHash: Buffer } {
1240
+ type ContinuationRow = {
1241
+ parentRunId: string;
1242
+ continuationRunId: string;
1243
+ createdAt: number;
1244
+ requestHash: Buffer;
1245
+ resolutionHash: Buffer;
1246
+ };
1247
+
1248
+ function isContinuationRow(value: unknown): value is ContinuationRow {
1219
1249
  return isRecordValue(value);
1220
1250
  }
1221
1251
 
@@ -63,6 +63,47 @@ export {
63
63
  parseStrictJsonValue,
64
64
  type JsonParseMode,
65
65
  } from "./json.js";
66
+ export {
67
+ MAX_JSON_PATCH_BYTES,
68
+ MAX_JSON_PATCH_OPERATIONS,
69
+ MAX_JSON_POINTER_BYTES,
70
+ MAX_WORKFLOW_SETTINGS_BYTES,
71
+ applyJsonPatch,
72
+ encodeJsonPointer,
73
+ jsonPointerTarget,
74
+ parseJsonPointer,
75
+ validateJsonPatch,
76
+ type JsonPatch,
77
+ type JsonPatchOperation,
78
+ } from "./json-patch.js";
79
+ export {
80
+ allowSettingsPath,
81
+ applyWorkflowSettingsPatch,
82
+ parseWorkflowSettingsValue,
83
+ resolveInitialWorkflowSettings,
84
+ settingsRoute,
85
+ settingsRuleForPath,
86
+ workflowSettings,
87
+ workflowSettingsScopeId,
88
+ type AppliedWorkflowSettings,
89
+ type InitialWorkflowSettingsScope,
90
+ type WorkflowFollowUpQueueRecord,
91
+ type WorkflowFollowUpRecord,
92
+ type WorkflowFollowUpState,
93
+ type WorkflowPresentationState,
94
+ type WorkflowQueueFollowUpRequest,
95
+ type WorkflowRemoveFollowUpRequest,
96
+ type WorkflowSettingsChangeContext,
97
+ type WorkflowSettingsChangeRecord,
98
+ type WorkflowSettingsChangeRequest,
99
+ type WorkflowSettingsChangeResult,
100
+ type WorkflowSettingsScopeRecord,
101
+ type WorkflowSettingsDefinition,
102
+ type WorkflowSettingsOptions,
103
+ type WorkflowSettingsPathPermissions,
104
+ type WorkflowSettingsPathRule,
105
+ type WorkflowSettingsPermission,
106
+ } from "./settings.js";
66
107
  export { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
67
108
  export {
68
109
  discoverWorkflows,
@@ -191,6 +232,7 @@ export type {
191
232
  WorkflowIncludeDefinition,
192
233
  WorkflowIncludedResult,
193
234
  WorkflowInputOf,
235
+ WorkflowSettingsOf,
194
236
  WorkflowMountedSource,
195
237
  WorkflowMountSnapshot,
196
238
  WorkflowValueParser,
@@ -213,6 +255,7 @@ export type {
213
255
  WorkflowSource,
214
256
  WorkflowSessionBinding,
215
257
  WorkflowSessionEntryRecord,
258
+ WorkflowSettingsSnapshot,
216
259
  WorkflowStepRecord,
217
260
  WorkflowTraceEvent,
218
261
  WorkflowTraceEventDraft,
@@ -0,0 +1,375 @@
1
+ import { canonicalJson, type JsonValue } from "../state/json.js";
2
+
3
+ export const MAX_JSON_PATCH_OPERATIONS = 256;
4
+ export const MAX_JSON_PATCH_BYTES = 64 * 1024;
5
+ export const MAX_JSON_POINTER_BYTES = 1024;
6
+ export const MAX_WORKFLOW_SETTINGS_BYTES = 256 * 1024;
7
+
8
+ export type JsonPatchAddOperation = {
9
+ op: "add";
10
+ path: string;
11
+ value: JsonValue;
12
+ };
13
+
14
+ export type JsonPatchRemoveOperation = {
15
+ op: "remove";
16
+ path: string;
17
+ };
18
+
19
+ export type JsonPatchReplaceOperation = {
20
+ op: "replace";
21
+ path: string;
22
+ value: JsonValue;
23
+ };
24
+
25
+ export type JsonPatchMoveOperation = {
26
+ op: "move";
27
+ path: string;
28
+ from: string;
29
+ };
30
+
31
+ export type JsonPatchCopyOperation = {
32
+ op: "copy";
33
+ path: string;
34
+ from: string;
35
+ };
36
+
37
+ export type JsonPatchTestOperation = {
38
+ op: "test";
39
+ path: string;
40
+ value: JsonValue;
41
+ };
42
+
43
+ export type JsonPatchOperation =
44
+ | JsonPatchAddOperation
45
+ | JsonPatchRemoveOperation
46
+ | JsonPatchReplaceOperation
47
+ | JsonPatchMoveOperation
48
+ | JsonPatchCopyOperation
49
+ | JsonPatchTestOperation;
50
+
51
+ export type JsonPatch = JsonPatchOperation[];
52
+
53
+ export type JsonPointerTarget = {
54
+ exists: boolean;
55
+ value?: JsonValue;
56
+ };
57
+
58
+ export function validateJsonPatch(value: unknown): JsonPatch {
59
+ if (!Array.isArray(value)) {
60
+ throw new Error("JSON Patch must be an array");
61
+ }
62
+ if (value.length > MAX_JSON_PATCH_OPERATIONS) {
63
+ throw new Error(`JSON Patch cannot contain more than ${MAX_JSON_PATCH_OPERATIONS} operations`);
64
+ }
65
+ const patch = value.map((operation, index) => validateOperation(operation, index));
66
+ const bytes = Buffer.byteLength(canonicalJson(patch), "utf8");
67
+ if (bytes > MAX_JSON_PATCH_BYTES) {
68
+ throw new Error(`JSON Patch cannot exceed ${MAX_JSON_PATCH_BYTES} bytes`);
69
+ }
70
+ return patch;
71
+ }
72
+
73
+ export function parseJsonPointer(pointer: string): string[] {
74
+ if (typeof pointer !== "string") {
75
+ throw new Error("JSON Pointer must be a string");
76
+ }
77
+ if (Buffer.byteLength(pointer, "utf8") > MAX_JSON_POINTER_BYTES) {
78
+ throw new Error(`JSON Pointer cannot exceed ${MAX_JSON_POINTER_BYTES} bytes`);
79
+ }
80
+ if (pointer === "") return [];
81
+ if (!pointer.startsWith("/")) {
82
+ throw new Error(`Invalid JSON Pointer: ${JSON.stringify(pointer)}`);
83
+ }
84
+ return pointer
85
+ .slice(1)
86
+ .split("/")
87
+ .map((segment) => {
88
+ if (/~(?![01])/u.test(segment)) {
89
+ throw new Error(`Invalid JSON Pointer escape in ${JSON.stringify(pointer)}`);
90
+ }
91
+ return segment.replaceAll("~1", "/").replaceAll("~0", "~");
92
+ });
93
+ }
94
+
95
+ export function encodeJsonPointer(segments: readonly string[]): string {
96
+ if (segments.length === 0) return "";
97
+ return `/${segments.map((segment) => segment.replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`;
98
+ }
99
+
100
+ export function jsonPointerTarget(document: JsonValue, pointer: string): JsonPointerTarget {
101
+ const segments = parseJsonPointer(pointer);
102
+ if (segments.length === 0) return { exists: true, value: document };
103
+ let current: JsonValue = document;
104
+ for (const segment of segments) {
105
+ if (Array.isArray(current)) {
106
+ const index = parseArrayIndex(segment, current.length, false);
107
+ if (index >= current.length) return { exists: false };
108
+ current = current[index] as JsonValue;
109
+ continue;
110
+ }
111
+ if (isJsonObject(current)) {
112
+ if (!Object.hasOwn(current, segment)) return { exists: false };
113
+ current = current[segment] as JsonValue;
114
+ continue;
115
+ }
116
+ return { exists: false };
117
+ }
118
+ return { exists: true, value: current };
119
+ }
120
+
121
+ export function applyJsonPatch(document: JsonValue, value: unknown): JsonValue {
122
+ const patch = validateJsonPatch(value);
123
+ let current = cloneJson(document);
124
+ for (const operation of patch) {
125
+ current = applyJsonPatchOperation(current, operation);
126
+ }
127
+ const bytes = Buffer.byteLength(canonicalJson(current), "utf8");
128
+ if (bytes > MAX_WORKFLOW_SETTINGS_BYTES) {
129
+ throw new Error(`JSON Patch result cannot exceed ${MAX_WORKFLOW_SETTINGS_BYTES} bytes`);
130
+ }
131
+ return current;
132
+ }
133
+
134
+ export function applyJsonPatchOperation(
135
+ document: JsonValue,
136
+ operation: JsonPatchOperation,
137
+ ): JsonValue {
138
+ const path = parseJsonPointer(operation.path);
139
+ switch (operation.op) {
140
+ case "add":
141
+ return addValue(document, path, cloneJson(operation.value));
142
+ case "remove":
143
+ return removeValue(document, path).document;
144
+ case "replace":
145
+ if (path.length === 0) return cloneJson(operation.value);
146
+ requireTarget(document, path, operation.path);
147
+ return addValue(document, path, cloneJson(operation.value), true);
148
+ case "copy": {
149
+ const source = requireTarget(document, parseJsonPointer(operation.from), operation.from);
150
+ return addValue(document, path, cloneJson(source.value));
151
+ }
152
+ case "move": {
153
+ const from = parseJsonPointer(operation.from);
154
+ if (sameSegments(from, path)) return document;
155
+ if (from.length === 0) {
156
+ throw new Error("JSON Patch move cannot move the document root into a child path");
157
+ }
158
+ if (isPrefix(from, path)) {
159
+ throw new Error("JSON Patch move destination cannot be inside its source");
160
+ }
161
+ const removed = removeValue(document, from);
162
+ return addValue(removed.document, path, removed.value);
163
+ }
164
+ case "test": {
165
+ const target = requireTarget(document, path, operation.path);
166
+ if (canonicalJson(target.value) !== canonicalJson(operation.value)) {
167
+ throw new Error(`JSON Patch test failed at ${JSON.stringify(operation.path)}`);
168
+ }
169
+ return document;
170
+ }
171
+ }
172
+ }
173
+
174
+ export function cloneJson<T extends JsonValue>(value: T): T {
175
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
176
+ if (typeof value === "number") {
177
+ if (!Number.isFinite(value)) throw new Error("JSON numbers must be finite");
178
+ return (Object.is(value, -0) ? 0 : value) as T;
179
+ }
180
+ if (Array.isArray(value)) {
181
+ return value.map((item) => cloneJson(item)) as T;
182
+ }
183
+ if (!isJsonObject(value)) {
184
+ throw new Error("Workflow settings must contain only JSON values");
185
+ }
186
+ const output: Record<string, JsonValue> = {};
187
+ for (const key of Object.keys(value)) {
188
+ defineJsonProperty(output, key, cloneJson(value[key] as JsonValue));
189
+ }
190
+ return output as T;
191
+ }
192
+
193
+ function validateOperation(value: unknown, index: number): JsonPatchOperation {
194
+ if (!isRecord(value) || typeof value.op !== "string" || typeof value.path !== "string") {
195
+ throw new Error(`JSON Patch operation ${index + 1} must contain string op and path fields`);
196
+ }
197
+ parseJsonPointer(value.path);
198
+ switch (value.op) {
199
+ case "add":
200
+ case "replace":
201
+ case "test":
202
+ assertExactFields(value, ["op", "path", "value"], index);
203
+ if (!Object.hasOwn(value, "value")) {
204
+ throw new Error(`JSON Patch ${value.op} operation ${index + 1} requires value`);
205
+ }
206
+ return { op: value.op, path: value.path, value: cloneJsonValue(value.value, index) };
207
+ case "remove":
208
+ assertExactFields(value, ["op", "path"], index);
209
+ return { op: "remove", path: value.path };
210
+ case "copy":
211
+ case "move":
212
+ assertExactFields(value, ["op", "path", "from"], index);
213
+ if (typeof value.from !== "string") {
214
+ throw new Error(`JSON Patch ${value.op} operation ${index + 1} requires string from`);
215
+ }
216
+ parseJsonPointer(value.from);
217
+ return { op: value.op, path: value.path, from: value.from };
218
+ default:
219
+ throw new Error(`Unknown JSON Patch operation ${JSON.stringify(value.op)}`);
220
+ }
221
+ }
222
+
223
+ function cloneJsonValue(value: unknown, index: number): JsonValue {
224
+ try {
225
+ return cloneJson(value as JsonValue);
226
+ } catch (error) {
227
+ throw new Error(`JSON Patch operation ${index + 1} value is not valid JSON`, { cause: error });
228
+ }
229
+ }
230
+
231
+ function assertExactFields(
232
+ value: Record<string, unknown>,
233
+ allowed: readonly string[],
234
+ index: number,
235
+ ): void {
236
+ const allowedSet = new Set(allowed);
237
+ const unknown = Object.keys(value).find((key) => !allowedSet.has(key));
238
+ if (unknown !== undefined) {
239
+ throw new Error(
240
+ `JSON Patch operation ${index + 1} has unknown field ${JSON.stringify(unknown)}`,
241
+ );
242
+ }
243
+ }
244
+
245
+ function addValue(
246
+ document: JsonValue,
247
+ path: readonly string[],
248
+ value: JsonValue,
249
+ replaceOnly = false,
250
+ ): JsonValue {
251
+ if (path.length === 0) return value;
252
+ const { parent, key } = resolveParent(document, path);
253
+ if (Array.isArray(parent)) {
254
+ if (replaceOnly) {
255
+ const index = parseArrayIndex(key, parent.length, false);
256
+ if (index >= parent.length) throw missingPath(encodeJsonPointer(path));
257
+ parent[index] = value;
258
+ return document;
259
+ }
260
+ const index = parseArrayIndex(key, parent.length, true);
261
+ if (index > parent.length) throw missingPath(encodeJsonPointer(path));
262
+ parent.splice(index, 0, value);
263
+ return document;
264
+ }
265
+ if (!isJsonObject(parent)) throw missingPath(encodeJsonPointer(path));
266
+ if (replaceOnly && !Object.hasOwn(parent, key)) throw missingPath(encodeJsonPointer(path));
267
+ defineJsonProperty(parent, key, value);
268
+ return document;
269
+ }
270
+
271
+ function removeValue(
272
+ document: JsonValue,
273
+ path: readonly string[],
274
+ ): { document: JsonValue; value: JsonValue } {
275
+ if (path.length === 0) {
276
+ throw new Error("JSON Patch remove cannot remove the document root");
277
+ }
278
+ const { parent, key } = resolveParent(document, path);
279
+ if (Array.isArray(parent)) {
280
+ const index = parseArrayIndex(key, parent.length, false);
281
+ if (index >= parent.length) throw missingPath(encodeJsonPointer(path));
282
+ const [value] = parent.splice(index, 1);
283
+ return { document, value: value as JsonValue };
284
+ }
285
+ if (!isJsonObject(parent) || !Object.hasOwn(parent, key)) {
286
+ throw missingPath(encodeJsonPointer(path));
287
+ }
288
+ const value = parent[key] as JsonValue;
289
+ delete parent[key];
290
+ return { document, value };
291
+ }
292
+
293
+ function requireTarget(
294
+ document: JsonValue,
295
+ path: readonly string[],
296
+ pointer: string,
297
+ ): { value: JsonValue } {
298
+ if (path.length === 0) return { value: document };
299
+ const { parent, key } = resolveParent(document, path);
300
+ if (Array.isArray(parent)) {
301
+ const index = parseArrayIndex(key, parent.length, false);
302
+ if (index >= parent.length) throw missingPath(pointer);
303
+ return { value: parent[index] as JsonValue };
304
+ }
305
+ if (!isJsonObject(parent) || !Object.hasOwn(parent, key)) throw missingPath(pointer);
306
+ return { value: parent[key] as JsonValue };
307
+ }
308
+
309
+ function resolveParent(
310
+ document: JsonValue,
311
+ path: readonly string[],
312
+ ): { parent: JsonValue; key: string } {
313
+ let current = document;
314
+ for (const segment of path.slice(0, -1)) {
315
+ if (Array.isArray(current)) {
316
+ const index = parseArrayIndex(segment, current.length, false);
317
+ if (index >= current.length) throw missingPath(encodeJsonPointer(path));
318
+ current = current[index] as JsonValue;
319
+ continue;
320
+ }
321
+ if (isJsonObject(current) && Object.hasOwn(current, segment)) {
322
+ current = current[segment] as JsonValue;
323
+ continue;
324
+ }
325
+ throw missingPath(encodeJsonPointer(path));
326
+ }
327
+ return { parent: current, key: path.at(-1) as string };
328
+ }
329
+
330
+ function parseArrayIndex(segment: string, length: number, allowEnd: boolean): number {
331
+ if (allowEnd && segment === "-") return length;
332
+ if (!/^(0|[1-9][0-9]*)$/u.test(segment)) {
333
+ throw new Error(`Invalid JSON Patch array index: ${JSON.stringify(segment)}`);
334
+ }
335
+ const index = Number(segment);
336
+ if (!Number.isSafeInteger(index) || index > length || (!allowEnd && index === length)) {
337
+ return length + 1;
338
+ }
339
+ return index;
340
+ }
341
+
342
+ function isPrefix(prefix: readonly string[], value: readonly string[]): boolean {
343
+ return prefix.length < value.length && prefix.every((segment, index) => value[index] === segment);
344
+ }
345
+
346
+ function sameSegments(left: readonly string[], right: readonly string[]): boolean {
347
+ return left.length === right.length && left.every((segment, index) => right[index] === segment);
348
+ }
349
+
350
+ function missingPath(path: string): Error {
351
+ return new Error(`JSON Patch path does not exist: ${JSON.stringify(path)}`);
352
+ }
353
+
354
+ function defineJsonProperty(
355
+ target: Record<string, JsonValue>,
356
+ key: string,
357
+ value: JsonValue,
358
+ ): void {
359
+ Object.defineProperty(target, key, {
360
+ value,
361
+ enumerable: true,
362
+ configurable: true,
363
+ writable: true,
364
+ });
365
+ }
366
+
367
+ function isJsonObject(value: JsonValue): value is { [key: string]: JsonValue } {
368
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
369
+ const prototype = Object.getPrototypeOf(value) as object | null;
370
+ return prototype === Object.prototype || prototype === null;
371
+ }
372
+
373
+ function isRecord(value: unknown): value is Record<string, unknown> {
374
+ return typeof value === "object" && value !== null && !Array.isArray(value);
375
+ }
@@ -79,6 +79,9 @@ export function assertValidComputeNode(node: ComputeNodeDefinition, nodeId = "co
79
79
  if (typeof node.run !== "function") {
80
80
  fail(`node ${nodeId} requires a run function`);
81
81
  }
82
+ if (node.settingsRoute !== undefined && node.settingsRoute !== true) {
83
+ fail(`node ${nodeId} settingsRoute must be true when provided`);
84
+ }
82
85
  assertCommonNodeFields(node, nodeId);
83
86
  }
84
87
 
@@ -250,7 +253,17 @@ function assertValidEdgeShape(edge: WorkflowEdge, index: number): void {
250
253
  * Names claimed by `/workflow` subcommands; a workflow with one of these
251
254
  * names could never be started because the keyword wins the argument slot.
252
255
  */
253
- const RESERVED_WORKFLOW_NAMES = new Set(["answer", "cancel", "list", "pause", "resume", "status"]);
256
+ const RESERVED_WORKFLOW_NAMES = new Set([
257
+ "answer",
258
+ "cancel",
259
+ "change-settings",
260
+ "list",
261
+ "pause",
262
+ "queue-follow-up",
263
+ "remove-follow-up",
264
+ "resume",
265
+ "status",
266
+ ]);
254
267
 
255
268
  export function assertValidWorkflowDefinitionShape(
256
269
  definition: WorkflowDefinition,
@@ -274,6 +287,22 @@ export function assertValidWorkflowDefinitionShape(
274
287
  fail("workflow contractId must be a stable identifier");
275
288
  }
276
289
  assertOptionalFunction(definition.input, "workflow input");
290
+ if (definition.settings !== undefined) {
291
+ assertRecord(definition.settings, "workflow settings");
292
+ if (
293
+ typeof definition.settings.initial !== "function" &&
294
+ definition.settings.initial === undefined
295
+ ) {
296
+ fail("workflow settings requires initial");
297
+ }
298
+ if (typeof definition.settings.parse !== "function") {
299
+ fail("workflow settings requires a parse function");
300
+ }
301
+ if (!Array.isArray(definition.settings.paths)) {
302
+ fail("workflow settings paths must be an array");
303
+ }
304
+ assertOptionalFunction(definition.settings.validateChange, "workflow settings validateChange");
305
+ }
277
306
  if (
278
307
  definition.title !== undefined &&
279
308
  typeof definition.title !== "string" &&
@@ -339,6 +368,7 @@ export function assertValidWorkflowDefinitionShape(
339
368
  fail(`include ${mountName} requires a workflow definition or reference`);
340
369
  }
341
370
  assertOptionalFunction(include.input, `include ${mountName} input`);
371
+ assertOptionalFunction(include.settings, `include ${mountName} settings`);
342
372
  if (
343
373
  include.contract !== undefined &&
344
374
  (include.contract === null || typeof include.contract !== "object")