@osolmaz/pi-workflows 0.1.0 → 0.3.0

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 (171) hide show
  1. package/README.md +107 -26
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.d.ts +14 -1
  51. package/dist/extension/executor.js +12 -2
  52. package/dist/extension/executor.js.map +1 -1
  53. package/dist/extension/index.d.ts +7 -0
  54. package/dist/extension/index.js +1118 -89
  55. package/dist/extension/index.js.map +1 -1
  56. package/dist/extension/recorder.d.ts +91 -0
  57. package/dist/extension/recorder.js +537 -0
  58. package/dist/extension/recorder.js.map +1 -0
  59. package/dist/extension/session-events.d.ts +134 -0
  60. package/dist/extension/session-events.js +60 -0
  61. package/dist/extension/session-events.js.map +1 -0
  62. package/dist/extension/widget.js +25 -24
  63. package/dist/extension/widget.js.map +1 -1
  64. package/dist/extension/workflow-tool.d.ts +28 -0
  65. package/dist/extension/workflow-tool.js +33 -0
  66. package/dist/extension/workflow-tool.js.map +1 -0
  67. package/dist/host/processes.d.ts +24 -0
  68. package/dist/host/processes.js +114 -0
  69. package/dist/host/processes.js.map +1 -0
  70. package/dist/host/rpc-bridge.d.ts +9 -0
  71. package/dist/host/rpc-bridge.js +39 -0
  72. package/dist/host/rpc-bridge.js.map +1 -0
  73. package/dist/host/rpc-executor.d.ts +38 -0
  74. package/dist/host/rpc-executor.js +254 -0
  75. package/dist/host/rpc-executor.js.map +1 -0
  76. package/dist/host/runner.d.ts +49 -0
  77. package/dist/host/runner.js +350 -0
  78. package/dist/host/runner.js.map +1 -0
  79. package/dist/render/canvas.d.ts +1 -1
  80. package/dist/render/canvas.js +5 -0
  81. package/dist/render/canvas.js.map +1 -1
  82. package/dist/render/graph-render.d.ts +5 -0
  83. package/dist/render/graph-render.js +211 -48
  84. package/dist/render/graph-render.js.map +1 -1
  85. package/dist/viewer/cli.d.ts +7 -3
  86. package/dist/viewer/cli.js +150 -19
  87. package/dist/viewer/cli.js.map +1 -1
  88. package/dist/viewer/render.js +19 -3
  89. package/dist/viewer/render.js.map +1 -1
  90. package/dist/viewer/session-reducer.d.ts +45 -0
  91. package/dist/viewer/session-reducer.js +266 -0
  92. package/dist/viewer/session-reducer.js.map +1 -0
  93. package/dist/workflows/artifacts.d.ts +40 -0
  94. package/dist/workflows/artifacts.js +155 -0
  95. package/dist/workflows/artifacts.js.map +1 -0
  96. package/dist/workflows/engine.d.ts +38 -0
  97. package/dist/workflows/engine.js +281 -20
  98. package/dist/workflows/engine.js.map +1 -1
  99. package/dist/workflows/errors.d.ts +23 -0
  100. package/dist/workflows/errors.js +38 -0
  101. package/dist/workflows/errors.js.map +1 -1
  102. package/dist/workflows/graph.js +0 -5
  103. package/dist/workflows/graph.js.map +1 -1
  104. package/dist/workflows/index.d.ts +3 -2
  105. package/dist/workflows/index.js +2 -1
  106. package/dist/workflows/index.js.map +1 -1
  107. package/dist/workflows/loader.d.ts +5 -3
  108. package/dist/workflows/loader.js +10 -1
  109. package/dist/workflows/loader.js.map +1 -1
  110. package/dist/workflows/schema.js +1 -1
  111. package/dist/workflows/schema.js.map +1 -1
  112. package/dist/workflows/store.d.ts +98 -10
  113. package/dist/workflows/store.js +921 -46
  114. package/dist/workflows/store.js.map +1 -1
  115. package/dist/workflows/types.d.ts +136 -3
  116. package/docs/CONTROLLERS.md +215 -0
  117. package/docs/development.md +51 -24
  118. package/docs/live-replay-protocol.md +155 -0
  119. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  120. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  121. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  122. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  123. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  124. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  125. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  126. package/docs/run-bundles.md +359 -55
  127. package/docs/session-event-journal.md +470 -0
  128. package/docs/tui-viewer.md +218 -0
  129. package/docs/workflows.md +131 -11
  130. package/examples/controllers/pull-request.controller.ts +215 -0
  131. package/package.json +11 -2
  132. package/src/builtins/monitor.workflow.ts +278 -0
  133. package/src/controllers/conditions.ts +110 -0
  134. package/src/controllers/definition.ts +65 -0
  135. package/src/controllers/effects.ts +123 -0
  136. package/src/controllers/errors.ts +27 -0
  137. package/src/controllers/index.ts +90 -0
  138. package/src/controllers/json.ts +62 -0
  139. package/src/controllers/loader.ts +104 -0
  140. package/src/controllers/manager.ts +533 -0
  141. package/src/controllers/results.ts +46 -0
  142. package/src/controllers/sqlite.ts +1427 -0
  143. package/src/controllers/store.ts +160 -0
  144. package/src/controllers/types.ts +183 -0
  145. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  146. package/src/controllers/workflows.ts +152 -0
  147. package/src/extension/controller-host.ts +163 -0
  148. package/src/extension/executor.ts +29 -2
  149. package/src/extension/index.ts +1315 -109
  150. package/src/extension/recorder.ts +662 -0
  151. package/src/extension/session-events.ts +119 -0
  152. package/src/extension/widget.ts +26 -24
  153. package/src/extension/workflow-tool.ts +59 -0
  154. package/src/host/processes.ts +119 -0
  155. package/src/host/rpc-bridge.ts +44 -0
  156. package/src/host/rpc-executor.ts +299 -0
  157. package/src/host/runner.ts +406 -0
  158. package/src/render/canvas.ts +19 -1
  159. package/src/render/graph-render.ts +277 -44
  160. package/src/viewer/cli.ts +167 -21
  161. package/src/viewer/render.ts +21 -3
  162. package/src/viewer/session-reducer.ts +347 -0
  163. package/src/workflows/artifacts.ts +188 -0
  164. package/src/workflows/engine.ts +365 -19
  165. package/src/workflows/errors.ts +45 -0
  166. package/src/workflows/graph.ts +0 -5
  167. package/src/workflows/index.ts +15 -0
  168. package/src/workflows/loader.ts +13 -3
  169. package/src/workflows/schema.ts +1 -1
  170. package/src/workflows/store.ts +1157 -48
  171. package/src/workflows/types.ts +151 -3
@@ -0,0 +1,299 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import type {
5
+ AgentStepExecutor,
6
+ AgentStepRequest,
7
+ AgentStepSubmission,
8
+ } from "../workflows/types.js";
9
+ import type { HostProcessRegistry } from "./processes.js";
10
+ import { RPC_SUBMISSION_PREFIX } from "./rpc-bridge.js";
11
+
12
+ // Production resolves the compiled .js; tests and dev checkouts load the .ts
13
+ // source, which pi compiles itself when loading extensions.
14
+ const BRIDGE_PATH = ["./rpc-bridge.js", "./rpc-bridge.ts"]
15
+ .map((candidate) => fileURLToPath(new URL(candidate, import.meta.url)))
16
+ .find((candidate) => fs.existsSync(candidate));
17
+ const ABORT_GRACE_MS = 3_000;
18
+
19
+ type StepSubmission = {
20
+ action: "submit";
21
+ step: string;
22
+ attempt: string;
23
+ output: unknown;
24
+ };
25
+
26
+ export type RpcStepExecutorOptions = {
27
+ cwd: string;
28
+ registry: HostProcessRegistry;
29
+ /** Absolute path to the pi binary; defaults to the installed `pi`. */
30
+ piBin?: string;
31
+ /** Extra environment for the child; the host's environment is inherited. */
32
+ env?: Record<string, string>;
33
+ /** Extra pi arguments, for example a model override. */
34
+ piArgs?: string[];
35
+ };
36
+
37
+ /**
38
+ * Runs agent steps in a headless `pi --mode rpc` child. One child serves one
39
+ * workflow run. The rpc-bridge extension inside the child registers the
40
+ * `workflow` tool and reports submissions over stderr; this executor
41
+ * validates them through `request.accept` and re-prompts on rejection, so
42
+ * the model sees the same tool contract as an in-session run.
43
+ */
44
+ export class RpcStepExecutor implements AgentStepExecutor {
45
+ private readonly options: RpcStepExecutorOptions;
46
+ private child: ChildProcess | null = null;
47
+ private childExited: { code: number | null; signal: string | null } | null = null;
48
+ private stderrBuffer = "";
49
+ private submissions: StepSubmission[] = [];
50
+ private submissionWaiters: Array<() => void> = [];
51
+ private stdoutBuffer = "";
52
+
53
+ constructor(options: RpcStepExecutorOptions) {
54
+ this.options = options;
55
+ }
56
+
57
+ async runAgentStep(request: AgentStepRequest, signal: AbortSignal): Promise<AgentStepSubmission> {
58
+ this.ensureStarted();
59
+ let prompt = request.prompt;
60
+ for (;;) {
61
+ throwIfAborted(signal);
62
+ const submission = await this.promptForSubmission(prompt, request, signal);
63
+ const accepted = await request.accept(submission.output);
64
+ if (accepted.ok) {
65
+ return { output: accepted.value };
66
+ }
67
+ prompt = [
68
+ `The workflow rejected your submission for step ${JSON.stringify(request.contract.nodeId)}:`,
69
+ accepted.error,
70
+ "",
71
+ "Call the `workflow` tool again with a corrected output.",
72
+ ].join("\n");
73
+ }
74
+ }
75
+
76
+ /** Stop the child's whole process group, then unregister it. */
77
+ async close(): Promise<void> {
78
+ const child = this.child;
79
+ this.child = null;
80
+ if (child === null) {
81
+ return;
82
+ }
83
+ const pid = child.pid;
84
+ if (this.childExited === null) {
85
+ try {
86
+ child.stdin?.end();
87
+ } catch {
88
+ // The pipe may already be closed.
89
+ }
90
+ // The child spawns in its own group; tool calls can leave
91
+ // grandchildren that leader-only signals would orphan forever.
92
+ if (pid !== undefined) {
93
+ killGroup(pid, "SIGTERM");
94
+ } else {
95
+ child.kill("SIGTERM");
96
+ }
97
+ const exited = await waitForExit(child, ABORT_GRACE_MS);
98
+ if (!exited) {
99
+ if (pid !== undefined) {
100
+ killGroup(pid, "SIGKILL");
101
+ } else {
102
+ child.kill("SIGKILL");
103
+ }
104
+ }
105
+ }
106
+ if (pid !== undefined) {
107
+ this.options.registry.unregister(pid);
108
+ }
109
+ }
110
+
111
+ private currentExit(): { code: number | null; signal: string | null } | null {
112
+ return this.childExited;
113
+ }
114
+
115
+ private ensureStarted(): void {
116
+ if (this.child !== null) {
117
+ const exited = this.currentExit();
118
+ if (exited !== null) {
119
+ throw new Error(
120
+ `Headless pi session exited (code ${exited.code}, signal ${exited.signal})`,
121
+ );
122
+ }
123
+ return;
124
+ }
125
+ if (BRIDGE_PATH === undefined) {
126
+ throw new Error("The pi-workflows rpc-bridge extension is missing from this installation");
127
+ }
128
+ const piBin = this.options.piBin ?? "pi";
129
+ const child = spawn(
130
+ piBin,
131
+ [
132
+ "--mode",
133
+ "rpc",
134
+ "--no-session",
135
+ // Isolation is required: an installed pi-workflows extension would
136
+ // otherwise load beside the bridge, register a competing workflow
137
+ // tool, and start its own resume and controller workers.
138
+ "--no-extensions",
139
+ "-e",
140
+ BRIDGE_PATH,
141
+ ...(this.options.piArgs ?? []),
142
+ ],
143
+ {
144
+ cwd: this.options.cwd,
145
+ env: { ...process.env, ...this.options.env },
146
+ stdio: ["pipe", "pipe", "pipe"],
147
+ // Own process group: the host kills groups, never individual PIDs,
148
+ // so a killed host cannot leave an orphaned agent working.
149
+ detached: true,
150
+ },
151
+ );
152
+ this.child = child;
153
+ if (child.pid !== undefined) {
154
+ this.options.registry.register(child.pid);
155
+ }
156
+ child.on("exit", (code, signal) => {
157
+ this.childExited = { code, signal };
158
+ if (child.pid !== undefined) {
159
+ this.options.registry.unregister(child.pid);
160
+ }
161
+ this.wakeSubmissionWaiters();
162
+ });
163
+ // A failed spawn (missing binary, EACCES) emits error instead of exit;
164
+ // without a listener it would crash the host as an uncaught exception.
165
+ child.on("error", (error) => {
166
+ this.childExited = { code: null, signal: error.message };
167
+ this.wakeSubmissionWaiters();
168
+ });
169
+ child.stdin?.on("error", () => {
170
+ // Writes racing child exit surface asynchronously as EPIPE; the step
171
+ // fails through the exit path instead of an uncaught stream error.
172
+ });
173
+ child.stderr?.on("data", (chunk: Buffer) => {
174
+ this.stderrBuffer += chunk.toString("utf8");
175
+ const lines = this.stderrBuffer.split("\n");
176
+ this.stderrBuffer = lines.pop() ?? "";
177
+ for (const line of lines) {
178
+ if (!line.startsWith(RPC_SUBMISSION_PREFIX)) {
179
+ continue;
180
+ }
181
+ try {
182
+ const parsed = JSON.parse(line.slice(RPC_SUBMISSION_PREFIX.length)) as StepSubmission;
183
+ if (
184
+ parsed.action === "submit" &&
185
+ typeof parsed.step === "string" &&
186
+ typeof parsed.attempt === "string"
187
+ ) {
188
+ this.submissions.push(parsed);
189
+ }
190
+ } catch {
191
+ // A malformed marker line is ignored; the step times out instead.
192
+ }
193
+ }
194
+ this.wakeSubmissionWaiters();
195
+ });
196
+ // stdout carries the RPC event stream. The submission channel is stderr,
197
+ // so stdout is only drained to keep the pipe from blocking.
198
+ child.stdout?.on("data", (chunk: Buffer) => {
199
+ this.stdoutBuffer = (this.stdoutBuffer + chunk.toString("utf8")).slice(-64 * 1024);
200
+ });
201
+ }
202
+
203
+ private async promptForSubmission(
204
+ prompt: string,
205
+ request: AgentStepRequest,
206
+ signal: AbortSignal,
207
+ ): Promise<StepSubmission> {
208
+ const child = this.child;
209
+ if (child === null || this.childExited !== null) {
210
+ throw new Error("Headless pi session is not running");
211
+ }
212
+ const matching = this.takeMatchingSubmission(request);
213
+ if (matching !== undefined) {
214
+ return matching;
215
+ }
216
+ child.stdin?.write(`${JSON.stringify({ type: "prompt", message: prompt })}\n`);
217
+ for (;;) {
218
+ throwIfAborted(signal);
219
+ const exited = this.currentExit();
220
+ if (exited !== null) {
221
+ throw new Error(`Headless pi session exited mid-step (code ${exited.code})`);
222
+ }
223
+ const found = this.takeMatchingSubmission(request);
224
+ if (found !== undefined) {
225
+ return found;
226
+ }
227
+ await new Promise<void>((resolve, reject) => {
228
+ const waiter = () => {
229
+ signal.removeEventListener("abort", onAbort);
230
+ resolve();
231
+ };
232
+ const onAbort = () => {
233
+ this.sendAbortQuietly();
234
+ reject(signal.reason ?? new Error("Workflow step aborted"));
235
+ };
236
+ this.submissionWaiters.push(waiter);
237
+ signal.addEventListener("abort", onAbort, { once: true });
238
+ });
239
+ }
240
+ }
241
+
242
+ private takeMatchingSubmission(request: AgentStepRequest): StepSubmission | undefined {
243
+ const index = this.submissions.findIndex(
244
+ (candidate) =>
245
+ candidate.step === request.contract.nodeId &&
246
+ candidate.attempt === request.contract.attemptId,
247
+ );
248
+ return index === -1 ? undefined : (this.submissions.splice(index, 1)[0] as StepSubmission);
249
+ }
250
+
251
+ private sendAbortQuietly(): void {
252
+ try {
253
+ this.child?.stdin?.write(`${JSON.stringify({ type: "abort" })}\n`);
254
+ } catch {
255
+ // Best-effort.
256
+ }
257
+ }
258
+
259
+ private wakeSubmissionWaiters(): void {
260
+ const waiters = this.submissionWaiters;
261
+ this.submissionWaiters = [];
262
+ for (const waiter of waiters) {
263
+ waiter();
264
+ }
265
+ }
266
+ }
267
+
268
+ function killGroup(pid: number, signal: NodeJS.Signals): void {
269
+ try {
270
+ process.kill(-pid, signal);
271
+ return;
272
+ } catch {
273
+ // Group signals are unsupported here; the leader still needs it.
274
+ }
275
+ try {
276
+ process.kill(pid, signal);
277
+ } catch {
278
+ // Already gone.
279
+ }
280
+ }
281
+
282
+ function throwIfAborted(signal: AbortSignal): void {
283
+ if (signal.aborted) {
284
+ throw (signal.reason as unknown) ?? new Error("Workflow step aborted");
285
+ }
286
+ }
287
+
288
+ async function waitForExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
289
+ if (child.exitCode !== null || child.signalCode !== null) {
290
+ return true;
291
+ }
292
+ return await new Promise<boolean>((resolve) => {
293
+ const timer = setTimeout(() => resolve(false), timeoutMs);
294
+ child.once("exit", () => {
295
+ clearTimeout(timer);
296
+ resolve(true);
297
+ });
298
+ });
299
+ }
@@ -0,0 +1,406 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ ControllerManager,
6
+ loadDiscoveredControllers,
7
+ projectControllerStorePath,
8
+ SqliteControllerStore,
9
+ WorkflowEngineScheduler,
10
+ type WorkflowRunQueueRecord,
11
+ } from "../controllers/index.js";
12
+ import type { JsonObject } from "../controllers/types.js";
13
+ import { WorkflowEngine } from "../workflows/engine.js";
14
+ import {
15
+ ClaimLostError,
16
+ errorMessage,
17
+ isClaimLostError,
18
+ WorkflowSourceChangedError,
19
+ } from "../workflows/errors.js";
20
+ import { hashWorkflowSource, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
21
+ import { WorkflowRunStore } from "../workflows/store.js";
22
+ import type { WorkflowDefinition } from "../workflows/types.js";
23
+ import { HostProcessRegistry } from "./processes.js";
24
+ import { RpcStepExecutor } from "./rpc-executor.js";
25
+
26
+ const CLAIM_POLL_MS = 2_000;
27
+ const RUN_CLAIM_LEASE_MS = 30_000;
28
+ const RUN_CLAIM_RENEW_MS = 10_000;
29
+
30
+ export type WorkflowHostOptions = {
31
+ cwd: string;
32
+ runnerId?: string;
33
+ /** Explicit store path; defaults to the project-scoped controller store. */
34
+ storeFile?: string;
35
+ /** Explicit run-bundle root; defaults to the shared runs directory. */
36
+ runsDir?: string;
37
+ registry?: HostProcessRegistry;
38
+ piArgs?: string[];
39
+ /** Extra environment for headless children (for example a test provider). */
40
+ env?: Record<string, string>;
41
+ /** Poll interval for the claim loop; tests use a faster cadence. */
42
+ claimPollMs?: number;
43
+ onLog?: (message: string) => void;
44
+ };
45
+
46
+ /**
47
+ * The always-on runner: claims parked workflow runs and reconciles durable
48
+ * controllers without a Pi session. Conversation nodes execute in headless
49
+ * `pi --mode rpc` children. Everything the host does is recoverable: claims
50
+ * expire, bundles fence stale writers, and child processes are reaped by
51
+ * the next host.
52
+ */
53
+ export class WorkflowHost {
54
+ private readonly options: WorkflowHostOptions;
55
+ private readonly runnerId: string;
56
+ private readonly registry: HostProcessRegistry;
57
+ private readonly store: SqliteControllerStore;
58
+ private readonly childRunStore: WorkflowRunStore;
59
+ private manager: ControllerManager | null = null;
60
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
61
+ private readonly activeRuns = new Map<string, Promise<void>>();
62
+ private readonly schedulerExecutors = new Map<WorkflowEngine, RpcStepExecutor>();
63
+ /** Runs whose resume refused (edited source); skipped until a host restart. */
64
+ private readonly skippedRuns = new Set<string>();
65
+ private stopping = false;
66
+
67
+ private readonly stateDir: string;
68
+
69
+ constructor(options: WorkflowHostOptions) {
70
+ this.options = options;
71
+ this.runnerId = options.runnerId ?? `host-${randomUUID().slice(0, 8)}`;
72
+ this.store = new SqliteControllerStore(
73
+ options.storeFile ?? projectControllerStorePath(options.cwd),
74
+ );
75
+ this.stateDir = path.dirname(this.store.filePath);
76
+ this.registry = options.registry ?? new HostProcessRegistry(this.stateDir);
77
+ this.childRunStore = new WorkflowRunStore(
78
+ options.runsDir ?? process.env.PI_WORKFLOWS_RUNS_DIR ?? undefined,
79
+ );
80
+ }
81
+
82
+ private log(message: string): void {
83
+ this.options.onLog?.(message);
84
+ }
85
+
86
+ /** Take the advisory lock, reap orphans, and start claiming. */
87
+ async start(): Promise<void> {
88
+ // The lock comes first: a second host must refuse before touching the
89
+ // children registry, or it would kill the live host's child processes
90
+ // as supposed orphans.
91
+ acquireHostLock(this.stateDir, this.runnerId, this.options.cwd);
92
+ const reaped = this.registry.reapOrphans();
93
+ if (reaped.length > 0) {
94
+ this.log(`reaped ${reaped.length} orphaned headless session(s): ${reaped.join(", ")}`);
95
+ }
96
+
97
+ const definitions = await loadDiscoveredControllers({ cwd: this.options.cwd });
98
+ if (definitions.length > 0) {
99
+ const scheduler = new WorkflowEngineScheduler({
100
+ store: this.childRunStore,
101
+ resolveWorkflow: async (name) => {
102
+ const resolved = await resolveWorkflowRef(name, { cwd: this.options.cwd });
103
+ const workflow = await loadWorkflowFile(resolved.path);
104
+ return { workflow };
105
+ },
106
+ createEngine: () => {
107
+ const executor = new RpcStepExecutor({
108
+ cwd: this.options.cwd,
109
+ registry: this.registry,
110
+ ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
111
+ ...(this.options.env !== undefined ? { env: this.options.env } : {}),
112
+ });
113
+ const engine = new WorkflowEngine({ executor, store: this.childRunStore });
114
+ this.schedulerExecutors.set(engine, executor);
115
+ return engine;
116
+ },
117
+ disposeEngine: async (engine) => {
118
+ const executor = this.schedulerExecutors.get(engine);
119
+ if (executor !== undefined) {
120
+ this.schedulerExecutors.delete(engine);
121
+ await executor.close();
122
+ }
123
+ },
124
+ });
125
+ this.manager = new ControllerManager({
126
+ store: this.store,
127
+ controllers: definitions,
128
+ workflowScheduler: scheduler,
129
+ });
130
+ this.manager.start();
131
+ this.log(`controller workers started for ${definitions.length} controller(s)`);
132
+ }
133
+
134
+ this.pollTimer = setInterval(() => {
135
+ this.claimOnce();
136
+ }, this.options.claimPollMs ?? CLAIM_POLL_MS);
137
+ this.pollTimer.unref?.();
138
+ this.claimOnce();
139
+ this.log(`host ${this.runnerId} watching ${this.options.cwd}`);
140
+ }
141
+
142
+ /** Drain: stop claiming, park in-flight runs, stop controllers, kill children. */
143
+ async stop(): Promise<void> {
144
+ if (this.stopping) {
145
+ return;
146
+ }
147
+ this.stopping = true;
148
+ if (this.pollTimer !== null) {
149
+ clearInterval(this.pollTimer);
150
+ this.pollTimer = null;
151
+ }
152
+ const pending = [...this.activeRuns.values()];
153
+ for (const run of this.parkedEngines.splice(0)) {
154
+ run();
155
+ }
156
+ await Promise.allSettled(pending);
157
+ await this.manager?.stop().catch(() => undefined);
158
+ for (const executor of this.schedulerExecutors.values()) {
159
+ await executor.close().catch(() => undefined);
160
+ }
161
+ this.schedulerExecutors.clear();
162
+ this.registry.killAll();
163
+ releaseHostLock(this.stateDir, this.runnerId);
164
+ this.store.close();
165
+ }
166
+
167
+ private readonly parkedEngines: Array<() => void> = [];
168
+
169
+ private claimOnce(): void {
170
+ if (this.stopping || this.activeRuns.size > 0) {
171
+ return;
172
+ }
173
+ let claimed: WorkflowRunQueueRecord | undefined;
174
+ try {
175
+ claimed = this.store.claimNextWorkflowRun({
176
+ runnerId: this.runnerId,
177
+ claimToken: randomUUID(),
178
+ leaseMs: RUN_CLAIM_LEASE_MS,
179
+ excludeRunIds: [...this.skippedRuns],
180
+ });
181
+ } catch (error) {
182
+ // Store contention or corruption must not kill the host's loop.
183
+ this.log(`claim failed, retrying shortly: ${errorMessage(error)}`);
184
+ return;
185
+ }
186
+ if (claimed === undefined) {
187
+ return;
188
+ }
189
+ const task = this.runClaimed(claimed).finally(() => {
190
+ this.activeRuns.delete(claimed.runId);
191
+ });
192
+ this.activeRuns.set(claimed.runId, task);
193
+ }
194
+
195
+ private async runClaimed(record: WorkflowRunQueueRecord): Promise<void> {
196
+ const claimToken = record.claimToken as string;
197
+ const runId = record.runId;
198
+ this.log(`resuming ${record.workflowRef} run ${runId}`);
199
+ let workflow: WorkflowDefinition;
200
+ let workflowHash: string;
201
+ try {
202
+ workflow = await loadWorkflowFile(record.workflowPath);
203
+ workflowHash = await hashWorkflowSource(record.workflowPath);
204
+ } catch (error) {
205
+ await this.failUnresumable(record, claimToken, errorMessage(error));
206
+ return;
207
+ }
208
+ if (this.stopping) {
209
+ // The drain started during setup: park before anything executes.
210
+ try {
211
+ this.store.parkWorkflowRun({ runId, claimToken });
212
+ } catch {
213
+ // Best-effort.
214
+ }
215
+ return;
216
+ }
217
+
218
+ const store = this.store;
219
+ const fence = () => {
220
+ if (!store.verifyWorkflowRunClaim({ runId, claimToken })) {
221
+ throw new ClaimLostError(runId);
222
+ }
223
+ };
224
+ const fencedStore = new WorkflowRunStore(this.childRunStore.outputRoot, {
225
+ fenceProvider: () => fence,
226
+ });
227
+ const executor = new RpcStepExecutor({
228
+ cwd: this.options.cwd,
229
+ registry: this.registry,
230
+ ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
231
+ ...(this.options.env !== undefined ? { env: this.options.env } : {}),
232
+ });
233
+ const engine = new WorkflowEngine({ executor, store: fencedStore });
234
+ const parkEngine = () => engine.park();
235
+ this.parkedEngines.push(parkEngine);
236
+
237
+ const renewTimer = setInterval(() => {
238
+ try {
239
+ if (!store.renewWorkflowRunClaim({ runId, claimToken, leaseMs: RUN_CLAIM_LEASE_MS })) {
240
+ engine.cancel();
241
+ }
242
+ } catch {
243
+ // Transient store errors leave fencing to decide ownership.
244
+ }
245
+ }, RUN_CLAIM_RENEW_MS);
246
+ renewTimer.unref?.();
247
+
248
+ this.recordEvent(runId, record.workflowRef, "resumed", { runnerId: this.runnerId });
249
+ try {
250
+ const result = await engine.resumeRun(workflow, runId, { workflowHash });
251
+ clearInterval(renewTimer);
252
+ if (result.state.status === "running") {
253
+ // Parked again mid-drain: leave it claimable for the next runner.
254
+ this.store.parkWorkflowRun({ runId, claimToken });
255
+ this.recordEvent(runId, record.workflowRef, "parked", {});
256
+ this.log(`parked ${record.workflowRef} run ${runId}`);
257
+ return;
258
+ }
259
+ this.store.completeWorkflowRun({ runId, claimToken });
260
+ this.recordEvent(runId, record.workflowRef, result.state.status, {
261
+ ...(result.state.error !== undefined ? { error: result.state.error } : {}),
262
+ ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
263
+ });
264
+ this.log(`${record.workflowRef} run ${runId} ${result.state.status}`);
265
+ } catch (error) {
266
+ clearInterval(renewTimer);
267
+ if (isClaimLostError(error)) {
268
+ this.log(`run ${runId} continues under another runner`);
269
+ return;
270
+ }
271
+ if (error instanceof WorkflowSourceChangedError) {
272
+ // Edited source is a refusal, not a failure: keep the run claimable
273
+ // for a later fix, and stop spinning on it for this host's lifetime.
274
+ try {
275
+ this.store.parkWorkflowRun({ runId, claimToken });
276
+ } catch {
277
+ // Best-effort.
278
+ }
279
+ this.skippedRuns.add(runId);
280
+ this.recordEvent(runId, record.workflowRef, "parked", {
281
+ reason: "workflow source changed",
282
+ });
283
+ this.log(
284
+ `run ${runId} skipped: workflow source changed; revert or force-resume it, then restart the host`,
285
+ );
286
+ return;
287
+ }
288
+ await this.failUnresumable(record, claimToken, errorMessage(error));
289
+ } finally {
290
+ const index = this.parkedEngines.indexOf(parkEngine);
291
+ if (index !== -1) {
292
+ this.parkedEngines.splice(index, 1);
293
+ }
294
+ await executor.close().catch(() => undefined);
295
+ }
296
+ }
297
+
298
+ private async failUnresumable(
299
+ record: WorkflowRunQueueRecord,
300
+ claimToken: string,
301
+ message: string,
302
+ ): Promise<void> {
303
+ let actualStatus: string | undefined;
304
+ let heldClaim = false;
305
+ try {
306
+ // The interruption write obeys the same fencing rule as every other
307
+ // bundle write: without a live claim, the current owner decides.
308
+ heldClaim = this.store.verifyWorkflowRunClaim({ runId: record.runId, claimToken });
309
+ if (heldClaim) {
310
+ const bundle = await this.childRunStore.markRunInterrupted(record.runId, message);
311
+ actualStatus = bundle?.state.status;
312
+ }
313
+ } catch {
314
+ // The bundle may be unreadable; the queue row still needs closure.
315
+ }
316
+ try {
317
+ this.store.completeWorkflowRun({ runId: record.runId, claimToken });
318
+ } catch {
319
+ // Best-effort.
320
+ }
321
+ if (!heldClaim) {
322
+ // Another runner owns the run; its owner reports from here.
323
+ this.log(`run ${record.runId} continues under another runner`);
324
+ return;
325
+ }
326
+ // Report the bundle's real terminal state when the interruption was a
327
+ // no-op (the bundle was already waiting or completed), so the feed
328
+ // stays truthful for sessions syncing from it.
329
+ if (actualStatus !== undefined && actualStatus !== "failed") {
330
+ this.recordEvent(record.runId, record.workflowRef, actualStatus, {});
331
+ } else {
332
+ this.recordEvent(record.runId, record.workflowRef, "failed", { error: message });
333
+ }
334
+ this.log(`run ${record.runId} cannot resume: ${message}`);
335
+ }
336
+
337
+ private recordEvent(runId: string, workflowRef: string, type: string, payload: JsonObject): void {
338
+ try {
339
+ this.store.recordRunEvent({ runId, workflowRef, type, payload, runnerId: this.runnerId });
340
+ } catch {
341
+ // The event feed is best-effort.
342
+ }
343
+ }
344
+ }
345
+
346
+ function hostLockPath(stateDir: string): string {
347
+ return path.join(stateDir, "host.lock");
348
+ }
349
+
350
+ /**
351
+ * The advisory lock guards host-versus-host only: the embedded runner in a
352
+ * Pi session does not take it. A second host refuses to start while the
353
+ * recorded PID is alive.
354
+ */
355
+ function acquireHostLock(stateDir: string, runnerId: string, cwd: string): void {
356
+ const lockPath = hostLockPath(stateDir);
357
+ const existing = readLock(lockPath);
358
+ if (existing !== null && existing.runnerId !== runnerId && isAlive(existing.pid)) {
359
+ throw new Error(
360
+ `Another workflow host (pid ${existing.pid}, ${existing.runnerId}) is already running for ${cwd}`,
361
+ );
362
+ }
363
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
364
+ fs.writeFileSync(
365
+ lockPath,
366
+ `${JSON.stringify({ pid: process.pid, runnerId, startedAt: new Date().toISOString() })}\n`,
367
+ { encoding: "utf8", mode: 0o600 },
368
+ );
369
+ }
370
+
371
+ function releaseHostLock(stateDir: string, runnerId: string): void {
372
+ const lockPath = hostLockPath(stateDir);
373
+ const existing = readLock(lockPath);
374
+ if (existing?.runnerId !== runnerId) {
375
+ return;
376
+ }
377
+ try {
378
+ fs.rmSync(lockPath);
379
+ } catch {
380
+ // Already gone.
381
+ }
382
+ }
383
+
384
+ function readLock(lockPath: string): { pid: number; runnerId: string } | null {
385
+ try {
386
+ const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as {
387
+ pid?: unknown;
388
+ runnerId?: unknown;
389
+ };
390
+ if (typeof parsed.pid !== "number" || typeof parsed.runnerId !== "string") {
391
+ return null;
392
+ }
393
+ return { pid: parsed.pid, runnerId: parsed.runnerId };
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+
399
+ function isAlive(pid: number): boolean {
400
+ try {
401
+ process.kill(pid, 0);
402
+ return true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }