@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
@@ -155,9 +155,8 @@ type RunRow = {
155
155
  workflowName: string;
156
156
  workflowRef: string;
157
157
  runStatus: string;
158
- workflowSourceHash: Buffer;
159
- workflowSourcesHash: Buffer | null;
160
158
  definitionDigest: Buffer;
159
+ definitionHash: Buffer;
161
160
  inputHash: Buffer;
162
161
  launchOptionsHash: Buffer;
163
162
  status: WorkflowRunLaunchStatus;
@@ -176,6 +175,12 @@ type RunRow = {
176
175
  ownerId: string | null;
177
176
  claimExpiresAt: number | null;
178
177
  };
178
+ type RunSourceIdentityRow = {
179
+ mountPath: string;
180
+ sourceType: "builtin" | "file";
181
+ sourceRef: string;
182
+ sourceRevision: string;
183
+ };
179
184
 
180
185
  type EffectRow = {
181
186
  effectId: string;
@@ -1049,9 +1054,6 @@ export class SqliteControllerStore implements ControllerStore {
1049
1054
  const resourceId = resourceIdFor("run", options.runId);
1050
1055
  const definitionHash = this.state.putJson(options.definitionSnapshot, now);
1051
1056
  const queuedSource = queuedWorkflowSource(options.workflowSource);
1052
- const sourceHash = this.state.putJson(queuedSource.root, now);
1053
- const mountedSourcesHash =
1054
- queuedSource.mounted.length === 0 ? null : this.state.putJson(queuedSource.mounted, now);
1055
1057
  const inputHash = this.state.putJson(options.input ?? null, now);
1056
1058
  const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
1057
1059
  this.state.connection
@@ -1071,15 +1073,13 @@ export class SqliteControllerStore implements ControllerStore {
1071
1073
  this.state.connection
1072
1074
  .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1073
1075
  .run(resourceId);
1074
- const source = sourceParts(options.workflowSourceRef, options.definitionDigest);
1075
1076
  this.state.connection
1076
1077
  .prepare(
1077
1078
  `INSERT INTO runs(
1078
1079
  run_id, resource_id, project_id, parent_run_id, definition_digest,
1079
- workflow_ref, workflow_source_hash, launch_options_hash,
1080
- source_type, source_ref, source_revision, status, paused,
1081
- input_hash, workflow_sources_hash, created_at, updated_at
1082
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`,
1080
+ workflow_ref, launch_options_hash, status, paused,
1081
+ input_hash, created_at, updated_at
1082
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1083
1083
  )
1084
1084
  .run(
1085
1085
  options.runId,
@@ -1088,16 +1088,12 @@ export class SqliteControllerStore implements ControllerStore {
1088
1088
  options.parentRunId ?? null,
1089
1089
  definitionDigest,
1090
1090
  options.workflowSourceRef,
1091
- sourceHash,
1092
1091
  launchHash,
1093
- source.type,
1094
- source.ref,
1095
- source.revision,
1096
1092
  inputHash,
1097
- mountedSourcesHash,
1098
1093
  now,
1099
1094
  now,
1100
1095
  );
1096
+ insertQueuedRunSources(this.state, options.runId, queuedSource);
1101
1097
  this.state.connection
1102
1098
  .prepare(
1103
1099
  `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
@@ -2238,11 +2234,7 @@ export class SqliteControllerStore implements ControllerStore {
2238
2234
  runId: row.runId,
2239
2235
  workflowName: row.workflowName,
2240
2236
  workflowSourceRef: row.workflowRef,
2241
- workflowSource: {
2242
- root: this.state.readJson(row.workflowSourceHash),
2243
- mounted:
2244
- row.workflowSourcesHash === null ? [] : this.state.readJson(row.workflowSourcesHash),
2245
- },
2237
+ workflowSource: readQueuedRunSources(this.state, row),
2246
2238
  initialized: row.runStatus !== "queued",
2247
2239
  definitionDigest: `sha256:${row.definitionDigest.toString("hex")}`,
2248
2240
  input: this.state.readJson(row.inputHash),
@@ -2469,8 +2461,6 @@ export class SqliteControllerStore implements ControllerStore {
2469
2461
  .prepare(
2470
2462
  `UPDATE runs
2471
2463
  SET status = ?, paused = 0, status_detail = NULL, error_hash = ?,
2472
- current_node = NULL, current_attempt_id = NULL,
2473
- current_node_started_at = NULL, waiting_on = NULL,
2474
2464
  updated_at = ?, finished_at = ?
2475
2465
  WHERE run_id = ?`,
2476
2466
  )
@@ -2776,9 +2766,8 @@ function workflowSelect(clause: string): string {
2776
2766
  function workflowRunSelect(clause: string): string {
2777
2767
  return `SELECT r.run_id AS runId, r.resource_id AS resourceId,
2778
2768
  d.workflow_name AS workflowName, r.workflow_ref AS workflowRef, r.status AS runStatus,
2779
- r.workflow_source_hash AS workflowSourceHash,
2780
- r.workflow_sources_hash AS workflowSourcesHash,
2781
- r.definition_digest AS definitionDigest, r.input_hash AS inputHash,
2769
+ r.definition_digest AS definitionDigest, d.definition_hash AS definitionHash,
2770
+ r.input_hash AS inputHash,
2782
2771
  r.launch_options_hash AS launchOptionsHash,
2783
2772
  q.status, q.available_at AS availableAt, q.affinity_runner_id AS affinityRunnerId,
2784
2773
  q.consecutive_errors AS consecutiveErrors, q.error_code AS errorCode,
@@ -2886,6 +2875,9 @@ function isLeaseRow(value: unknown): value is LeaseRow {
2886
2875
  function isRunRow(value: unknown): value is RunRow {
2887
2876
  return isRecord(value);
2888
2877
  }
2878
+ function isRunSourceIdentityRow(value: unknown): value is RunSourceIdentityRow {
2879
+ return isRecord(value);
2880
+ }
2889
2881
  function isEffectRow(value: unknown): value is EffectRow {
2890
2882
  return isRecord(value);
2891
2883
  }
@@ -2958,24 +2950,126 @@ function digestBuffer(value: string): Buffer {
2958
2950
  if (!/^[a-f0-9]{64}$/i.test(hex)) throw new Error("Expected a SHA-256 digest");
2959
2951
  return Buffer.from(hex, "hex");
2960
2952
  }
2961
- function queuedWorkflowSource(value: unknown): { root: unknown; mounted: unknown[] } {
2962
- if (isRecord(value) && (value.kind === "builtin" || value.kind === "file")) {
2953
+ type QueuedSource = {
2954
+ root:
2955
+ | { kind: "builtin"; id: string; revision: string }
2956
+ | { kind: "file"; path: string; hash: string };
2957
+ mounted: Array<{
2958
+ mountPath: string[];
2959
+ workflowName: string;
2960
+ source:
2961
+ | { kind: "builtin"; id: string; revision: string }
2962
+ | { kind: "file"; path: string; hash: string };
2963
+ }>;
2964
+ };
2965
+
2966
+ function queuedWorkflowSource(value: unknown): QueuedSource {
2967
+ if (isWorkflowSource(value)) {
2963
2968
  return { root: value, mounted: [] };
2964
2969
  }
2965
- if (isRecord(value) && isRecord(value.root) && Array.isArray(value.mounted)) {
2970
+ if (
2971
+ isRecord(value) &&
2972
+ isWorkflowSource(value.root) &&
2973
+ Array.isArray(value.mounted) &&
2974
+ value.mounted.every(isMountedWorkflowSource)
2975
+ ) {
2966
2976
  return { root: value.root, mounted: value.mounted };
2967
2977
  }
2968
2978
  throw new Error("Stored workflow source identity is invalid");
2969
2979
  }
2970
2980
 
2971
- function sourceParts(
2972
- ref: string,
2973
- revision: string,
2974
- ): { type: "builtin" | "file"; ref: string; revision: string } {
2975
- return ref.startsWith("builtin:")
2976
- ? { type: "builtin", ref: ref.slice(8), revision }
2977
- : { type: "file", ref, revision };
2981
+ function isWorkflowSource(value: unknown): value is QueuedSource["root"] {
2982
+ return (
2983
+ isRecord(value) &&
2984
+ ((value.kind === "builtin" &&
2985
+ typeof value.id === "string" &&
2986
+ typeof value.revision === "string") ||
2987
+ (value.kind === "file" && typeof value.path === "string" && typeof value.hash === "string"))
2988
+ );
2989
+ }
2990
+
2991
+ function isMountedWorkflowSource(value: unknown): value is QueuedSource["mounted"][number] {
2992
+ return (
2993
+ isRecord(value) &&
2994
+ Array.isArray(value.mountPath) &&
2995
+ value.mountPath.every((part) => typeof part === "string") &&
2996
+ typeof value.workflowName === "string" &&
2997
+ isWorkflowSource(value.source)
2998
+ );
2999
+ }
3000
+
3001
+ function insertQueuedRunSources(state: StateDatabase, runId: string, value: QueuedSource): void {
3002
+ const insert = state.connection.prepare(
3003
+ `INSERT INTO run_sources(run_id, mount_path, source_type, source_ref, source_revision)
3004
+ VALUES (?, ?, ?, ?, ?)`,
3005
+ );
3006
+ const root = queuedSourceParts(value.root);
3007
+ insert.run(runId, "", root.type, root.ref, root.revision);
3008
+ for (const mounted of value.mounted) {
3009
+ const source = queuedSourceParts(mounted.source);
3010
+ insert.run(runId, mounted.mountPath.join("/"), source.type, source.ref, source.revision);
3011
+ }
3012
+ }
3013
+
3014
+ function queuedSourceParts(source: QueuedSource["root"]): {
3015
+ type: "builtin" | "file";
3016
+ ref: string;
3017
+ revision: string;
3018
+ } {
3019
+ return source.kind === "builtin"
3020
+ ? { type: "builtin", ref: source.id, revision: source.revision }
3021
+ : { type: "file", ref: source.path, revision: source.hash };
3022
+ }
3023
+
3024
+ function readQueuedRunSources(state: StateDatabase, run: RunRow): QueuedSource {
3025
+ const rows = state.connection
3026
+ .prepare(
3027
+ `SELECT mount_path AS mountPath, source_type AS sourceType,
3028
+ source_ref AS sourceRef, source_revision AS sourceRevision
3029
+ FROM run_sources WHERE run_id = ? ORDER BY mount_path`,
3030
+ )
3031
+ .all(run.runId)
3032
+ .filter(isRunSourceIdentityRow);
3033
+ const rootRow = rows.find((row) => row.mountPath === "");
3034
+ if (rootRow === undefined) throw new Error(`Workflow run source is missing: ${run.runId}`);
3035
+ const snapshot = state.readJson(run.definitionHash);
3036
+ const mounts =
3037
+ isRecord(snapshot) &&
3038
+ isRecord(snapshot.composition) &&
3039
+ Array.isArray(snapshot.composition.mounts)
3040
+ ? snapshot.composition.mounts
3041
+ : [];
3042
+ const names = new Map(
3043
+ mounts.flatMap((mount) => {
3044
+ if (
3045
+ !isRecord(mount) ||
3046
+ !Array.isArray(mount.mountPath) ||
3047
+ !mount.mountPath.every((part) => typeof part === "string") ||
3048
+ typeof mount.workflowName !== "string"
3049
+ ) {
3050
+ return [];
3051
+ }
3052
+ return [[mount.mountPath.join("/"), mount.workflowName] as const];
3053
+ }),
3054
+ );
3055
+ return {
3056
+ root: rowToSource(rootRow),
3057
+ mounted: rows
3058
+ .filter((row) => row.mountPath !== "")
3059
+ .map((row) => ({
3060
+ mountPath: row.mountPath.split("/"),
3061
+ workflowName: names.get(row.mountPath) ?? row.mountPath,
3062
+ source: rowToSource(row),
3063
+ })),
3064
+ };
2978
3065
  }
3066
+
3067
+ function rowToSource(row: RunSourceIdentityRow): QueuedSource["root"] {
3068
+ return row.sourceType === "builtin"
3069
+ ? { kind: "builtin", id: row.sourceRef, revision: row.sourceRevision }
3070
+ : { kind: "file", path: row.sourceRef, hash: row.sourceRevision };
3071
+ }
3072
+
2979
3073
  function effectStatus(value: EffectRecord["state"]): string {
2980
3074
  return value === "indeterminate" ? "ambiguous" : value;
2981
3075
  }
@@ -1,3 +1,5 @@
1
+ import type { JsonPatch } from "../workflows/json-patch.js";
2
+
1
3
  export type MaybePromise<T> = T | Promise<T>;
2
4
  export type JsonPrimitive = string | number | boolean | null;
3
5
  export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
@@ -131,8 +133,46 @@ export type ChildWorkflowRecord = ChildWorkflowReference & {
131
133
  error?: string;
132
134
  };
133
135
 
136
+ export type ControllerSettingsChangeRequest = {
137
+ requestKey: string;
138
+ runId: string;
139
+ scopeId?: string;
140
+ expectedChangeNumber?: number;
141
+ patch: JsonPatch;
142
+ };
143
+
144
+ export type ControllerSettingsChangeResult = {
145
+ runId: string;
146
+ scopeId: string;
147
+ changeNumber: number;
148
+ adopted: boolean;
149
+ };
150
+
151
+ export type ControllerQueueFollowUpRequest = {
152
+ requestKey: string;
153
+ runId: string;
154
+ prompt: string;
155
+ };
156
+
157
+ export type ControllerFollowUpResult = {
158
+ runId: string;
159
+ followUpId: string;
160
+ order: number;
161
+ state: string;
162
+ adopted: boolean;
163
+ };
164
+
165
+ export type ControllerRemoveFollowUpRequest = {
166
+ requestKey: string;
167
+ runId: string;
168
+ followUpId: string;
169
+ };
170
+
134
171
  export interface ControllerWorkflows {
135
172
  ensure(request: ChildWorkflowRequest): Promise<ChildWorkflowRecord>;
173
+ changeSettings(request: ControllerSettingsChangeRequest): Promise<ControllerSettingsChangeResult>;
174
+ queueFollowUp(request: ControllerQueueFollowUpRequest): Promise<ControllerFollowUpResult>;
175
+ removeFollowUp(request: ControllerRemoveFollowUpRequest): Promise<ControllerFollowUpResult>;
136
176
  }
137
177
 
138
178
  export type ReconcileContext<TStatus> = {
@@ -1,4 +1,5 @@
1
- import { WorkflowEngine } from "../workflows/engine.js";
1
+ import { compositionMetadata } from "../workflows/composition.js";
2
+ import { WorkflowEngine, workflowIdentityMismatch } from "../workflows/engine.js";
2
3
  import type { WorkflowRunStore } from "../workflows/store.js";
3
4
  import type {
4
5
  WorkflowDefinition,
@@ -7,6 +8,14 @@ import type {
7
8
  WorkflowSource,
8
9
  } from "../workflows/types.js";
9
10
  import type {
11
+ ControllerFollowUpResult,
12
+ ControllerQueueFollowUpRequest,
13
+ ControllerRemoveFollowUpRequest,
14
+ ControllerSettingsChangeRequest,
15
+ ControllerSettingsChangeResult,
16
+ } from "./types.js";
17
+ import type {
18
+ ControllerWorkflowControlRequest,
10
19
  ControllerWorkflowScheduler,
11
20
  WorkflowSchedulerRequest,
12
21
  WorkflowSchedulerResult,
@@ -96,6 +105,99 @@ export class WorkflowEngineScheduler implements ControllerWorkflowScheduler {
96
105
  return { state: "running", runId };
97
106
  }
98
107
 
108
+ async changeSettings(
109
+ request: ControllerWorkflowControlRequest<ControllerSettingsChangeRequest>,
110
+ signal: AbortSignal,
111
+ ): Promise<ControllerSettingsChangeResult> {
112
+ signal.throwIfAborted();
113
+ const bundle = this.options.store.readRun(request.runId);
114
+ if (bundle === null) throw new Error(`Workflow run not found: ${request.runId}`);
115
+ const resolved = await this.options.resolveWorkflow(bundle.state.workflowName);
116
+ signal.throwIfAborted();
117
+ if (workflowIdentityMismatch(bundle.state, resolved.workflow, resolved.workflowSource)) {
118
+ throw new Error(`Workflow source changed since run ${request.runId} started`);
119
+ }
120
+ const scopes = this.options.store.listSettingsScopes(request.runId);
121
+ const scopeId = request.scopeId ?? (scopes.length === 1 ? scopes[0]?.scopeId : undefined);
122
+ if (scopeId === undefined) {
123
+ throw new Error("Controller settings changes require scopeId when several scopes exist");
124
+ }
125
+ const scope = this.options.store.getSettingsScope(scopeId);
126
+ if (scope === undefined || scope.activeRunId !== request.runId) {
127
+ throw new Error(`Workflow settings scope not found: ${scopeId}`);
128
+ }
129
+ const definition =
130
+ scope.mountPath === ""
131
+ ? resolved.workflow.settings
132
+ : compositionMetadata(resolved.workflow)?.scopes[scope.mountPath]?.settings;
133
+ if (definition === undefined) {
134
+ throw new Error(`Workflow scope ${scopeId} does not declare editable settings`);
135
+ }
136
+ const result = await this.options.store.changeSettings(definition, {
137
+ runId: request.runId,
138
+ scopeId,
139
+ requestId: request.actorRequestKey,
140
+ ...(request.expectedChangeNumber !== undefined
141
+ ? { expectedChangeNumber: request.expectedChangeNumber }
142
+ : {}),
143
+ actor: { type: "controller", id: request.controllerResourceUid },
144
+ source: "controller-request",
145
+ patch: request.patch,
146
+ });
147
+ return {
148
+ runId: request.runId,
149
+ scopeId,
150
+ changeNumber: result.scope.changeNumber,
151
+ adopted: result.adopted,
152
+ };
153
+ }
154
+
155
+ async queueFollowUp(
156
+ request: ControllerWorkflowControlRequest<ControllerQueueFollowUpRequest>,
157
+ signal: AbortSignal,
158
+ ): Promise<ControllerFollowUpResult> {
159
+ signal.throwIfAborted();
160
+ const targetSessionId = this.options.store.originSessionId(request.runId);
161
+ if (targetSessionId === undefined) {
162
+ throw new Error("Controller follow-up requires a workflow run with an origin Pi session");
163
+ }
164
+ const result = this.options.store.queueFollowUp({
165
+ runId: request.runId,
166
+ requestId: request.actorRequestKey,
167
+ targetSessionId,
168
+ actor: { type: "controller", id: request.controllerResourceUid },
169
+ source: "controller-request",
170
+ prompt: request.prompt,
171
+ });
172
+ return {
173
+ runId: request.runId,
174
+ followUpId: result.followUp.followUpId,
175
+ order: result.followUp.order,
176
+ state: result.followUp.state,
177
+ adopted: result.adopted,
178
+ };
179
+ }
180
+
181
+ async removeFollowUp(
182
+ request: ControllerWorkflowControlRequest<ControllerRemoveFollowUpRequest>,
183
+ signal: AbortSignal,
184
+ ): Promise<ControllerFollowUpResult> {
185
+ signal.throwIfAborted();
186
+ const result = this.options.store.removeFollowUp({
187
+ runId: request.runId,
188
+ followUpId: request.followUpId,
189
+ actor: { type: "controller", id: request.controllerResourceUid },
190
+ source: "controller-request",
191
+ });
192
+ return {
193
+ runId: request.runId,
194
+ followUpId: result.followUpId,
195
+ order: result.order,
196
+ state: result.state,
197
+ adopted: false,
198
+ };
199
+ }
200
+
99
201
  async waitForIdle(): Promise<void> {
100
202
  await Promise.all([...this.active.values()].map((entry) => entry.promise));
101
203
  }
@@ -5,8 +5,13 @@ import type {
5
5
  ChildWorkflowRecord,
6
6
  ChildWorkflowRequest,
7
7
  ChildWorkflowState,
8
+ ControllerFollowUpResult,
8
9
  ControllerQueueClaim,
10
+ ControllerQueueFollowUpRequest,
11
+ ControllerRemoveFollowUpRequest,
9
12
  ControllerResource,
13
+ ControllerSettingsChangeRequest,
14
+ ControllerSettingsChangeResult,
10
15
  ControllerWorkflows,
11
16
  } from "./types.js";
12
17
 
@@ -24,12 +29,36 @@ export type WorkflowSchedulerResult = {
24
29
  error?: string;
25
30
  };
26
31
 
32
+ export type ControllerWorkflowControlRequest<TRequest> = TRequest & {
33
+ controllerResourceUid: string;
34
+ actorRequestKey: string;
35
+ };
36
+
27
37
  export interface ControllerWorkflowScheduler {
28
38
  ensure(
29
39
  request: WorkflowSchedulerRequest,
30
40
  signal: AbortSignal,
31
41
  onComplete: (result: WorkflowSchedulerResult) => void,
32
42
  ): Promise<WorkflowSchedulerResult>;
43
+ changeSettings?(
44
+ request: ControllerWorkflowControlRequest<ControllerSettingsChangeRequest>,
45
+ signal: AbortSignal,
46
+ ): Promise<ControllerSettingsChangeResult>;
47
+ queueFollowUp?(
48
+ request: ControllerWorkflowControlRequest<ControllerQueueFollowUpRequest>,
49
+ signal: AbortSignal,
50
+ ): Promise<ControllerFollowUpResult>;
51
+ removeFollowUp?(
52
+ request: ControllerWorkflowControlRequest<ControllerRemoveFollowUpRequest>,
53
+ signal: AbortSignal,
54
+ ): Promise<ControllerFollowUpResult>;
55
+ }
56
+
57
+ function controllerRequestKey(resource: ControllerResource, requestKey: string): string {
58
+ if (requestKey.trim().length === 0) {
59
+ throw new Error("Controller workflow requestKey must not be empty");
60
+ }
61
+ return `controller:${resource.metadata.uid}:${requestKey}`;
33
62
  }
34
63
 
35
64
  export class ControllerWorkflowCoordinator {
@@ -45,6 +74,45 @@ export class ControllerWorkflowCoordinator {
45
74
  ): ControllerWorkflows {
46
75
  return {
47
76
  ensure: async (request) => await this.ensure(resource, claim, request, signal),
77
+ changeSettings: async (request) => {
78
+ if (this.scheduler?.changeSettings === undefined) {
79
+ throw new Error("This controller host does not support workflow settings changes");
80
+ }
81
+ return await this.scheduler.changeSettings(
82
+ {
83
+ ...request,
84
+ controllerResourceUid: resource.metadata.uid,
85
+ actorRequestKey: controllerRequestKey(resource, request.requestKey),
86
+ },
87
+ signal,
88
+ );
89
+ },
90
+ queueFollowUp: async (request) => {
91
+ if (this.scheduler?.queueFollowUp === undefined) {
92
+ throw new Error("This controller host does not support workflow follow-ups");
93
+ }
94
+ return await this.scheduler.queueFollowUp(
95
+ {
96
+ ...request,
97
+ controllerResourceUid: resource.metadata.uid,
98
+ actorRequestKey: controllerRequestKey(resource, request.requestKey),
99
+ },
100
+ signal,
101
+ );
102
+ },
103
+ removeFollowUp: async (request) => {
104
+ if (this.scheduler?.removeFollowUp === undefined) {
105
+ throw new Error("This controller host does not support workflow follow-ups");
106
+ }
107
+ return await this.scheduler.removeFollowUp(
108
+ {
109
+ ...request,
110
+ controllerResourceUid: resource.metadata.uid,
111
+ actorRequestKey: controllerRequestKey(resource, request.requestKey),
112
+ },
113
+ signal,
114
+ );
115
+ },
48
116
  };
49
117
  }
50
118
 
@@ -4,6 +4,7 @@ import {
4
4
  loadDiscoveredControllers,
5
5
  type AnyControllerDefinition,
6
6
  type ControllerResource,
7
+ type ControllerWorkflowScheduler,
7
8
  type WorkflowSchedulerRequest,
8
9
  type WorkflowSchedulerResult,
9
10
  } from "../controllers/index.js";
@@ -80,6 +81,10 @@ export class PiControllerHost {
80
81
  static async create(options: {
81
82
  cwd: string;
82
83
  startChild: PiChildWorkflowStarter;
84
+ workflowControls?: Pick<
85
+ ControllerWorkflowScheduler,
86
+ "changeSettings" | "queueFollowUp" | "removeFollowUp"
87
+ >;
83
88
  }): Promise<PiControllerHost | undefined> {
84
89
  const definitions = await loadDiscoveredControllers({ cwd: options.cwd });
85
90
  if (definitions.length === 0) {
@@ -89,7 +94,7 @@ export class PiControllerHost {
89
94
  const manager = new ControllerManager({
90
95
  store,
91
96
  controllers: definitions,
92
- workflowScheduler: { ensure: options.startChild },
97
+ workflowScheduler: { ensure: options.startChild, ...options.workflowControls },
93
98
  });
94
99
  return new PiControllerHost(definitions, store, manager);
95
100
  }