@osolmaz/pi-workflows 0.2.0 → 0.4.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 (152) hide show
  1. package/README.md +72 -5
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.d.ts +71 -0
  6. package/dist/builtins/monitor.workflow.js +250 -0
  7. package/dist/builtins/monitor.workflow.js.map +1 -0
  8. package/dist/controllers/conditions.d.ts +6 -0
  9. package/dist/controllers/conditions.js +68 -0
  10. package/dist/controllers/conditions.js.map +1 -0
  11. package/dist/controllers/definition.d.ts +6 -0
  12. package/dist/controllers/definition.js +45 -0
  13. package/dist/controllers/definition.js.map +1 -0
  14. package/dist/controllers/effects.d.ts +14 -0
  15. package/dist/controllers/effects.js +104 -0
  16. package/dist/controllers/effects.js.map +1 -0
  17. package/dist/controllers/errors.d.ts +12 -0
  18. package/dist/controllers/errors.js +25 -0
  19. package/dist/controllers/errors.js.map +1 -0
  20. package/dist/controllers/index.d.ts +13 -0
  21. package/dist/controllers/index.js +13 -0
  22. package/dist/controllers/index.js.map +1 -0
  23. package/dist/controllers/json.d.ts +5 -0
  24. package/dist/controllers/json.js +57 -0
  25. package/dist/controllers/json.js.map +1 -0
  26. package/dist/controllers/loader.d.ts +23 -0
  27. package/dist/controllers/loader.js +74 -0
  28. package/dist/controllers/loader.js.map +1 -0
  29. package/dist/controllers/manager.d.ts +58 -0
  30. package/dist/controllers/manager.js +399 -0
  31. package/dist/controllers/manager.js.map +1 -0
  32. package/dist/controllers/results.d.ts +5 -0
  33. package/dist/controllers/results.js +32 -0
  34. package/dist/controllers/results.js.map +1 -0
  35. package/dist/controllers/sqlite.d.ts +235 -0
  36. package/dist/controllers/sqlite.js +1087 -0
  37. package/dist/controllers/sqlite.js.map +1 -0
  38. package/dist/controllers/store.d.ts +112 -0
  39. package/dist/controllers/store.js +32 -0
  40. package/dist/controllers/store.js.map +1 -0
  41. package/dist/controllers/types.d.ts +159 -0
  42. package/dist/controllers/types.js +2 -0
  43. package/dist/controllers/types.js.map +1 -0
  44. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  45. package/dist/controllers/workflow-engine-scheduler.js +95 -0
  46. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  47. package/dist/controllers/workflows.d.ts +27 -0
  48. package/dist/controllers/workflows.js +109 -0
  49. package/dist/controllers/workflows.js.map +1 -0
  50. package/dist/extension/controller-host.d.ts +47 -0
  51. package/dist/extension/controller-host.js +110 -0
  52. package/dist/extension/controller-host.js.map +1 -0
  53. package/dist/extension/executor.d.ts +3 -0
  54. package/dist/extension/executor.js +12 -2
  55. package/dist/extension/executor.js.map +1 -1
  56. package/dist/extension/index.d.ts +7 -0
  57. package/dist/extension/index.js +1118 -106
  58. package/dist/extension/index.js.map +1 -1
  59. package/dist/extension/recorder.d.ts +6 -0
  60. package/dist/extension/recorder.js +20 -8
  61. package/dist/extension/recorder.js.map +1 -1
  62. package/dist/extension/workflow-tool.d.ts +28 -0
  63. package/dist/extension/workflow-tool.js +33 -0
  64. package/dist/extension/workflow-tool.js.map +1 -0
  65. package/dist/host/processes.d.ts +24 -0
  66. package/dist/host/processes.js +114 -0
  67. package/dist/host/processes.js.map +1 -0
  68. package/dist/host/rpc-bridge.d.ts +9 -0
  69. package/dist/host/rpc-bridge.js +39 -0
  70. package/dist/host/rpc-bridge.js.map +1 -0
  71. package/dist/host/rpc-executor.d.ts +38 -0
  72. package/dist/host/rpc-executor.js +254 -0
  73. package/dist/host/rpc-executor.js.map +1 -0
  74. package/dist/host/runner.d.ts +50 -0
  75. package/dist/host/runner.js +379 -0
  76. package/dist/host/runner.js.map +1 -0
  77. package/dist/viewer/cli.d.ts +7 -3
  78. package/dist/viewer/cli.js +150 -19
  79. package/dist/viewer/cli.js.map +1 -1
  80. package/dist/workflows/catalog.d.ts +43 -0
  81. package/dist/workflows/catalog.js +79 -0
  82. package/dist/workflows/catalog.js.map +1 -0
  83. package/dist/workflows/engine.d.ts +37 -2
  84. package/dist/workflows/engine.js +297 -29
  85. package/dist/workflows/engine.js.map +1 -1
  86. package/dist/workflows/errors.d.ts +23 -0
  87. package/dist/workflows/errors.js +38 -0
  88. package/dist/workflows/errors.js.map +1 -1
  89. package/dist/workflows/graph.js +0 -5
  90. package/dist/workflows/graph.js.map +1 -1
  91. package/dist/workflows/index.d.ts +2 -2
  92. package/dist/workflows/index.js +1 -1
  93. package/dist/workflows/index.js.map +1 -1
  94. package/dist/workflows/loader.d.ts +19 -15
  95. package/dist/workflows/loader.js +63 -19
  96. package/dist/workflows/loader.js.map +1 -1
  97. package/dist/workflows/migrate-sources.d.ts +41 -0
  98. package/dist/workflows/migrate-sources.js +129 -0
  99. package/dist/workflows/migrate-sources.js.map +1 -0
  100. package/dist/workflows/schema.js +3 -2
  101. package/dist/workflows/schema.js.map +1 -1
  102. package/dist/workflows/store.d.ts +50 -6
  103. package/dist/workflows/store.js +448 -53
  104. package/dist/workflows/store.js.map +1 -1
  105. package/dist/workflows/types.d.ts +27 -3
  106. package/docs/CONTROLLERS.md +215 -0
  107. package/docs/development.md +14 -9
  108. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  109. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  110. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  111. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  112. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  113. package/docs/run-bundles.md +85 -28
  114. package/docs/workflows.md +161 -17
  115. package/examples/controllers/pull-request.controller.ts +215 -0
  116. package/package.json +10 -2
  117. package/src/builtins/catalog.ts +22 -0
  118. package/src/builtins/monitor.workflow.ts +302 -0
  119. package/src/controllers/conditions.ts +110 -0
  120. package/src/controllers/definition.ts +65 -0
  121. package/src/controllers/effects.ts +123 -0
  122. package/src/controllers/errors.ts +27 -0
  123. package/src/controllers/index.ts +90 -0
  124. package/src/controllers/json.ts +62 -0
  125. package/src/controllers/loader.ts +104 -0
  126. package/src/controllers/manager.ts +533 -0
  127. package/src/controllers/results.ts +46 -0
  128. package/src/controllers/sqlite.ts +1545 -0
  129. package/src/controllers/store.ts +160 -0
  130. package/src/controllers/types.ts +183 -0
  131. package/src/controllers/workflow-engine-scheduler.ts +148 -0
  132. package/src/controllers/workflows.ts +152 -0
  133. package/src/extension/controller-host.ts +163 -0
  134. package/src/extension/executor.ts +13 -2
  135. package/src/extension/index.ts +1325 -130
  136. package/src/extension/recorder.ts +65 -36
  137. package/src/extension/workflow-tool.ts +59 -0
  138. package/src/host/processes.ts +119 -0
  139. package/src/host/rpc-bridge.ts +44 -0
  140. package/src/host/rpc-executor.ts +299 -0
  141. package/src/host/runner.ts +445 -0
  142. package/src/viewer/cli.ts +167 -21
  143. package/src/workflows/catalog.ts +135 -0
  144. package/src/workflows/engine.ts +404 -41
  145. package/src/workflows/errors.ts +45 -0
  146. package/src/workflows/graph.ts +0 -5
  147. package/src/workflows/index.ts +2 -0
  148. package/src/workflows/loader.ts +76 -22
  149. package/src/workflows/migrate-sources.ts +167 -0
  150. package/src/workflows/schema.ts +3 -2
  151. package/src/workflows/store.ts +557 -48
  152. package/src/workflows/types.ts +22 -3
@@ -0,0 +1,445 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
+ import {
6
+ ControllerManager,
7
+ loadDiscoveredControllers,
8
+ projectControllerStorePath,
9
+ SqliteControllerStore,
10
+ WorkflowEngineScheduler,
11
+ type WorkflowRunQueueRecord,
12
+ } from "../controllers/index.js";
13
+ import type { JsonObject } from "../controllers/types.js";
14
+ import { WorkflowEngine } from "../workflows/engine.js";
15
+ import {
16
+ ClaimLostError,
17
+ errorMessage,
18
+ isClaimLostError,
19
+ WorkflowSourceChangedError,
20
+ } from "../workflows/errors.js";
21
+ import { resolveWorkflowRef, resolveWorkflowSource } from "../workflows/loader.js";
22
+ import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
23
+ import { WorkflowRunStore, readRunBundle } from "../workflows/store.js";
24
+ import type { WorkflowDefinition } from "../workflows/types.js";
25
+ import { HostProcessRegistry } from "./processes.js";
26
+ import { RpcStepExecutor } from "./rpc-executor.js";
27
+
28
+ const CLAIM_POLL_MS = 2_000;
29
+ const RUN_CLAIM_LEASE_MS = 30_000;
30
+ const RUN_CLAIM_RENEW_MS = 10_000;
31
+
32
+ export type WorkflowHostOptions = {
33
+ cwd: string;
34
+ runnerId?: string;
35
+ /** Explicit store path; defaults to the project-scoped controller store. */
36
+ storeFile?: string;
37
+ /** Explicit run-bundle root; defaults to the shared runs directory. */
38
+ runsDir?: string;
39
+ registry?: HostProcessRegistry;
40
+ piArgs?: string[];
41
+ /** Extra environment for headless children (for example a test provider). */
42
+ env?: Record<string, string>;
43
+ /** Poll interval for the claim loop; tests use a faster cadence. */
44
+ claimPollMs?: number;
45
+ onLog?: (message: string) => void;
46
+ };
47
+
48
+ /**
49
+ * The always-on runner: claims parked workflow runs and reconciles durable
50
+ * controllers without a Pi session. Conversation nodes execute in headless
51
+ * `pi --mode rpc` children. Everything the host does is recoverable: claims
52
+ * expire, bundles fence stale writers, and child processes are reaped by
53
+ * the next host.
54
+ */
55
+ export class WorkflowHost {
56
+ private readonly options: WorkflowHostOptions;
57
+ private readonly runnerId: string;
58
+ private readonly registry: HostProcessRegistry;
59
+ private readonly store: SqliteControllerStore;
60
+ private readonly childRunStore: WorkflowRunStore;
61
+ private manager: ControllerManager | null = null;
62
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
63
+ private readonly activeRuns = new Map<string, Promise<void>>();
64
+ private readonly migrationBlockedRuns = new Set<string>();
65
+ private readonly schedulerExecutors = new Map<WorkflowEngine, RpcStepExecutor>();
66
+ /** Runs whose resume refused (edited source); skipped until a host restart. */
67
+ private readonly skippedRuns = new Set<string>();
68
+ private stopping = false;
69
+
70
+ private readonly stateDir: string;
71
+
72
+ constructor(options: WorkflowHostOptions) {
73
+ this.options = options;
74
+ this.runnerId = options.runnerId ?? `host-${randomUUID().slice(0, 8)}`;
75
+ this.store = new SqliteControllerStore(
76
+ options.storeFile ?? projectControllerStorePath(options.cwd),
77
+ );
78
+ this.stateDir = path.dirname(this.store.filePath);
79
+ this.registry = options.registry ?? new HostProcessRegistry(this.stateDir);
80
+ this.childRunStore = new WorkflowRunStore(
81
+ options.runsDir ?? process.env.PI_WORKFLOWS_RUNS_DIR ?? undefined,
82
+ );
83
+ }
84
+
85
+ private log(message: string): void {
86
+ this.options.onLog?.(message);
87
+ }
88
+
89
+ /** Take the advisory lock, reap orphans, and start claiming. */
90
+ async start(): Promise<void> {
91
+ // The lock comes first: a second host must refuse before touching the
92
+ // children registry, or it would kill the live host's child processes
93
+ // as supposed orphans.
94
+ acquireHostLock(this.stateDir, this.runnerId, this.options.cwd);
95
+ const reaped = this.registry.reapOrphans();
96
+ if (reaped.length > 0) {
97
+ this.log(`reaped ${reaped.length} orphaned headless session(s): ${reaped.join(", ")}`);
98
+ }
99
+
100
+ const migration = await migrateLegacyWorkflowSources({
101
+ catalog: builtinWorkflowCatalog,
102
+ store: this.childRunStore,
103
+ queue: this.store,
104
+ });
105
+ for (const blocked of migration.blocked) {
106
+ this.migrationBlockedRuns.add(blocked.runId);
107
+ this.log(`run ${blocked.runId} parked: ${blocked.reason}`);
108
+ }
109
+
110
+ const definitions = await loadDiscoveredControllers({ cwd: this.options.cwd });
111
+ if (definitions.length > 0) {
112
+ const scheduler = new WorkflowEngineScheduler({
113
+ store: this.childRunStore,
114
+ resolveWorkflow: async (name) => {
115
+ const resolved = await resolveWorkflowRef(
116
+ name,
117
+ { cwd: this.options.cwd },
118
+ builtinWorkflowCatalog,
119
+ );
120
+ return { workflow: resolved.definition, workflowSource: resolved.source };
121
+ },
122
+ createEngine: () => {
123
+ const executor = new RpcStepExecutor({
124
+ cwd: this.options.cwd,
125
+ registry: this.registry,
126
+ ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
127
+ ...(this.options.env !== undefined ? { env: this.options.env } : {}),
128
+ });
129
+ const engine = new WorkflowEngine({ executor, store: this.childRunStore });
130
+ this.schedulerExecutors.set(engine, executor);
131
+ return engine;
132
+ },
133
+ disposeEngine: async (engine) => {
134
+ const executor = this.schedulerExecutors.get(engine);
135
+ if (executor !== undefined) {
136
+ this.schedulerExecutors.delete(engine);
137
+ await executor.close();
138
+ }
139
+ },
140
+ });
141
+ this.manager = new ControllerManager({
142
+ store: this.store,
143
+ controllers: definitions,
144
+ workflowScheduler: scheduler,
145
+ });
146
+ this.manager.start();
147
+ this.log(`controller workers started for ${definitions.length} controller(s)`);
148
+ }
149
+
150
+ this.pollTimer = setInterval(() => {
151
+ this.claimOnce();
152
+ }, this.options.claimPollMs ?? CLAIM_POLL_MS);
153
+ this.pollTimer.unref?.();
154
+ this.claimOnce();
155
+ this.log(`host ${this.runnerId} watching ${this.options.cwd}`);
156
+ }
157
+
158
+ /** Drain: stop claiming, park in-flight runs, stop controllers, kill children. */
159
+ async stop(): Promise<void> {
160
+ if (this.stopping) {
161
+ return;
162
+ }
163
+ this.stopping = true;
164
+ if (this.pollTimer !== null) {
165
+ clearInterval(this.pollTimer);
166
+ this.pollTimer = null;
167
+ }
168
+ const pending = [...this.activeRuns.values()];
169
+ for (const run of this.parkedEngines.splice(0)) {
170
+ run();
171
+ }
172
+ await Promise.allSettled(pending);
173
+ await this.manager?.stop().catch(() => undefined);
174
+ for (const executor of this.schedulerExecutors.values()) {
175
+ await executor.close().catch(() => undefined);
176
+ }
177
+ this.schedulerExecutors.clear();
178
+ this.registry.killAll();
179
+ releaseHostLock(this.stateDir, this.runnerId);
180
+ this.store.close();
181
+ }
182
+
183
+ private readonly parkedEngines: Array<() => void> = [];
184
+
185
+ private claimOnce(): void {
186
+ if (this.stopping || this.activeRuns.size > 0) {
187
+ return;
188
+ }
189
+ let claimed: WorkflowRunQueueRecord | undefined;
190
+ try {
191
+ claimed = this.store.claimNextWorkflowRun({
192
+ runnerId: this.runnerId,
193
+ claimToken: randomUUID(),
194
+ leaseMs: RUN_CLAIM_LEASE_MS,
195
+ excludeRunIds: [...this.skippedRuns, ...this.migrationBlockedRuns],
196
+ });
197
+ } catch (error) {
198
+ // Store contention or corruption must not kill the host's loop.
199
+ this.log(`claim failed, retrying shortly: ${errorMessage(error)}`);
200
+ return;
201
+ }
202
+ if (claimed === undefined) {
203
+ return;
204
+ }
205
+ const task = this.runClaimed(claimed).finally(() => {
206
+ this.activeRuns.delete(claimed.runId);
207
+ });
208
+ this.activeRuns.set(claimed.runId, task);
209
+ }
210
+
211
+ private async runClaimed(record: WorkflowRunQueueRecord): Promise<void> {
212
+ const claimToken = record.claimToken as string;
213
+ const runId = record.runId;
214
+ this.log(`resuming ${record.workflowName} run ${runId}`);
215
+ let workflow: WorkflowDefinition;
216
+ let workflowSource: import("../workflows/types.js").WorkflowSource;
217
+ try {
218
+ const bundle = await readRunBundle(this.childRunStore.runDirFor(runId));
219
+ if (bundle?.state.workflowSource === undefined) {
220
+ throw new Error(`Workflow run ${runId} has no canonical workflow source`);
221
+ }
222
+ workflow = await resolveWorkflowSource(
223
+ bundle.state.workflowSource,
224
+ builtinWorkflowCatalog,
225
+ runId,
226
+ );
227
+ workflowSource = bundle.state.workflowSource;
228
+ } catch (error) {
229
+ if (error instanceof WorkflowSourceChangedError) {
230
+ try {
231
+ this.store.parkWorkflowRun({ runId, claimToken });
232
+ } catch {
233
+ // Best-effort.
234
+ }
235
+ this.skippedRuns.add(runId);
236
+ this.recordEvent(runId, record.workflowName, "parked", {
237
+ reason: "workflow source changed",
238
+ });
239
+ this.log(
240
+ `run ${runId} skipped: workflow source changed; install the matching package revision, then restart the host`,
241
+ );
242
+ return;
243
+ }
244
+ await this.failUnresumable(record, claimToken, errorMessage(error));
245
+ return;
246
+ }
247
+ if (this.stopping) {
248
+ // The drain started during setup: park before anything executes.
249
+ try {
250
+ this.store.parkWorkflowRun({ runId, claimToken });
251
+ } catch {
252
+ // Best-effort.
253
+ }
254
+ return;
255
+ }
256
+
257
+ const store = this.store;
258
+ const fence = () => {
259
+ if (!store.verifyWorkflowRunClaim({ runId, claimToken })) {
260
+ throw new ClaimLostError(runId);
261
+ }
262
+ };
263
+ const fencedStore = new WorkflowRunStore(this.childRunStore.outputRoot, {
264
+ fenceProvider: () => fence,
265
+ });
266
+ const executor = new RpcStepExecutor({
267
+ cwd: this.options.cwd,
268
+ registry: this.registry,
269
+ ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
270
+ ...(this.options.env !== undefined ? { env: this.options.env } : {}),
271
+ });
272
+ const engine = new WorkflowEngine({ executor, store: fencedStore });
273
+ const parkEngine = () => engine.park();
274
+ this.parkedEngines.push(parkEngine);
275
+
276
+ const renewTimer = setInterval(() => {
277
+ try {
278
+ if (!store.renewWorkflowRunClaim({ runId, claimToken, leaseMs: RUN_CLAIM_LEASE_MS })) {
279
+ engine.cancel();
280
+ }
281
+ } catch {
282
+ // Transient store errors leave fencing to decide ownership.
283
+ }
284
+ }, RUN_CLAIM_RENEW_MS);
285
+ renewTimer.unref?.();
286
+
287
+ this.recordEvent(runId, record.workflowName, "resumed", { runnerId: this.runnerId });
288
+ try {
289
+ const result = await engine.resumeRun(workflow, runId, { workflowSource });
290
+ clearInterval(renewTimer);
291
+ if (result.state.status === "running") {
292
+ // Parked again mid-drain: leave it claimable for the next runner.
293
+ this.store.parkWorkflowRun({ runId, claimToken });
294
+ this.recordEvent(runId, record.workflowName, "parked", {});
295
+ this.log(`parked ${record.workflowName} run ${runId}`);
296
+ return;
297
+ }
298
+ this.store.completeWorkflowRun({ runId, claimToken });
299
+ this.recordEvent(runId, record.workflowName, result.state.status, {
300
+ ...(result.state.error !== undefined ? { error: result.state.error } : {}),
301
+ ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
302
+ });
303
+ this.log(`${record.workflowName} run ${runId} ${result.state.status}`);
304
+ } catch (error) {
305
+ clearInterval(renewTimer);
306
+ if (isClaimLostError(error)) {
307
+ this.log(`run ${runId} continues under another runner`);
308
+ return;
309
+ }
310
+ if (error instanceof WorkflowSourceChangedError) {
311
+ // Edited source is a refusal, not a failure: keep the run claimable
312
+ // for a later fix, and stop spinning on it for this host's lifetime.
313
+ try {
314
+ this.store.parkWorkflowRun({ runId, claimToken });
315
+ } catch {
316
+ // Best-effort.
317
+ }
318
+ this.skippedRuns.add(runId);
319
+ this.recordEvent(runId, record.workflowName, "parked", {
320
+ reason: "workflow source changed",
321
+ });
322
+ this.log(
323
+ `run ${runId} skipped: workflow source changed; revert or force-resume it, then restart the host`,
324
+ );
325
+ return;
326
+ }
327
+ await this.failUnresumable(record, claimToken, errorMessage(error));
328
+ } finally {
329
+ const index = this.parkedEngines.indexOf(parkEngine);
330
+ if (index !== -1) {
331
+ this.parkedEngines.splice(index, 1);
332
+ }
333
+ await executor.close().catch(() => undefined);
334
+ }
335
+ }
336
+
337
+ private async failUnresumable(
338
+ record: WorkflowRunQueueRecord,
339
+ claimToken: string,
340
+ message: string,
341
+ ): Promise<void> {
342
+ let actualStatus: string | undefined;
343
+ let heldClaim = false;
344
+ try {
345
+ // The interruption write obeys the same fencing rule as every other
346
+ // bundle write: without a live claim, the current owner decides.
347
+ heldClaim = this.store.verifyWorkflowRunClaim({ runId: record.runId, claimToken });
348
+ if (heldClaim) {
349
+ const bundle = await this.childRunStore.markRunInterrupted(record.runId, message);
350
+ actualStatus = bundle?.state.status;
351
+ }
352
+ } catch {
353
+ // The bundle may be unreadable; the queue row still needs closure.
354
+ }
355
+ try {
356
+ this.store.completeWorkflowRun({ runId: record.runId, claimToken });
357
+ } catch {
358
+ // Best-effort.
359
+ }
360
+ if (!heldClaim) {
361
+ // Another runner owns the run; its owner reports from here.
362
+ this.log(`run ${record.runId} continues under another runner`);
363
+ return;
364
+ }
365
+ // Report the bundle's real terminal state when the interruption was a
366
+ // no-op (the bundle was already waiting or completed), so the feed
367
+ // stays truthful for sessions syncing from it.
368
+ if (actualStatus !== undefined && actualStatus !== "failed") {
369
+ this.recordEvent(record.runId, record.workflowName, actualStatus, {});
370
+ } else {
371
+ this.recordEvent(record.runId, record.workflowName, "failed", { error: message });
372
+ }
373
+ this.log(`run ${record.runId} cannot resume: ${message}`);
374
+ }
375
+
376
+ private recordEvent(runId: string, workflowRef: string, type: string, payload: JsonObject): void {
377
+ try {
378
+ this.store.recordRunEvent({ runId, workflowRef, type, payload, runnerId: this.runnerId });
379
+ } catch {
380
+ // The event feed is best-effort.
381
+ }
382
+ }
383
+ }
384
+
385
+ function hostLockPath(stateDir: string): string {
386
+ return path.join(stateDir, "host.lock");
387
+ }
388
+
389
+ /**
390
+ * The advisory lock guards host-versus-host only: the embedded runner in a
391
+ * Pi session does not take it. A second host refuses to start while the
392
+ * recorded PID is alive.
393
+ */
394
+ function acquireHostLock(stateDir: string, runnerId: string, cwd: string): void {
395
+ const lockPath = hostLockPath(stateDir);
396
+ const existing = readLock(lockPath);
397
+ if (existing !== null && existing.runnerId !== runnerId && isAlive(existing.pid)) {
398
+ throw new Error(
399
+ `Another workflow host (pid ${existing.pid}, ${existing.runnerId}) is already running for ${cwd}`,
400
+ );
401
+ }
402
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
403
+ fs.writeFileSync(
404
+ lockPath,
405
+ `${JSON.stringify({ pid: process.pid, runnerId, startedAt: new Date().toISOString() })}\n`,
406
+ { encoding: "utf8", mode: 0o600 },
407
+ );
408
+ }
409
+
410
+ function releaseHostLock(stateDir: string, runnerId: string): void {
411
+ const lockPath = hostLockPath(stateDir);
412
+ const existing = readLock(lockPath);
413
+ if (existing?.runnerId !== runnerId) {
414
+ return;
415
+ }
416
+ try {
417
+ fs.rmSync(lockPath);
418
+ } catch {
419
+ // Already gone.
420
+ }
421
+ }
422
+
423
+ function readLock(lockPath: string): { pid: number; runnerId: string } | null {
424
+ try {
425
+ const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as {
426
+ pid?: unknown;
427
+ runnerId?: unknown;
428
+ };
429
+ if (typeof parsed.pid !== "number" || typeof parsed.runnerId !== "string") {
430
+ return null;
431
+ }
432
+ return { pid: parsed.pid, runnerId: parsed.runnerId };
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+
438
+ function isAlive(pid: number): boolean {
439
+ try {
440
+ process.kill(pid, 0);
441
+ return true;
442
+ } catch {
443
+ return false;
444
+ }
445
+ }
package/src/viewer/cli.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import fs, { realpathSync } from "node:fs";
3
+ import path from "node:path";
3
4
  import { pathToFileURL } from "node:url";
5
+ import { SqliteControllerStore } from "../controllers/sqlite.js";
6
+ import { projectControllerStoreBaseDir } from "../controllers/store.js";
4
7
  import { sanitizeText } from "../render/ansi.js";
5
8
  import { listRunBundles, readRunBundle, workflowRunsBaseDir } from "../workflows/store.js";
6
9
  import {
@@ -12,54 +15,99 @@ import {
12
15
  } from "./render.js";
13
16
  import { runViewer } from "./tui.js";
14
17
 
15
- const USAGE = `pi-workflows — live terminal viewer for pi workflow runs
18
+ const USAGE = `pi-workflows — workflow runs and controller resources
16
19
 
17
20
  Usage:
18
21
  pi-workflows view [runId] [--dir <runsDir>] [--once]
19
22
  pi-workflows runs [--dir <runsDir>]
23
+ pi-workflows controllers [--controller-dir <dir>]
24
+ pi-workflows controller <controller> <key> [--controller-dir <dir>]
25
+ pi-workflows host [--project <dir>] [-- <extra pi args>]
20
26
 
21
27
  Commands:
22
- view Open the live TUI viewer. With --once, print a snapshot and exit.
23
- runs List recent workflow runs.
28
+ view Open the live workflow TUI. With --once, print a snapshot.
29
+ runs List recent workflow runs.
30
+ controllers List durable controller resources.
31
+ controller Show one resource, its effects, child workflows, and events.
32
+ host Run the always-on workflow host in the foreground.
24
33
 
25
34
  Options:
26
- --dir <runsDir> Runs directory (default: ~/.pi/agent/workflows/runs)
27
- --once Render once to stdout without the interactive TUI
35
+ --dir <runsDir> Runs directory (default: ~/.pi/agent/workflows/runs)
36
+ --controller-dir <dir> Controller directory (default: project-scoped local store)
37
+ --once Render once without the interactive TUI
38
+ --project <dir> Project directory for the host (default: cwd)
28
39
  `;
29
40
 
30
- type CliArgs = {
41
+ export type CliArgs = {
31
42
  command: string;
32
- runId?: string | undefined;
43
+ runId?: string;
44
+ controllerName?: string;
45
+ resourceKey?: string;
33
46
  dir: string;
47
+ controllerDir: string;
34
48
  once: boolean;
49
+ project?: string | undefined;
50
+ piArgs?: string[] | undefined;
35
51
  };
36
52
 
37
53
  export function parseCliArgs(argv: string[]): CliArgs {
38
54
  const args = [...argv];
39
55
  const command = args[0] && !args[0].startsWith("-") ? (args.shift() as string) : "view";
40
56
  let dir = workflowRunsBaseDir();
57
+ let controllerDir = projectControllerStoreBaseDir(process.cwd());
41
58
  let once = false;
42
- let runId: string | undefined;
59
+ const positionals: string[] = [];
60
+ let project: string | undefined;
61
+ const piArgs: string[] = [];
43
62
 
44
63
  while (args.length > 0) {
45
64
  const arg = args.shift() as string;
46
65
  if (arg === "--dir") {
47
- const value = args.shift();
48
- if (!value) {
49
- throw new Error("--dir requires a path");
50
- }
51
- dir = value;
66
+ dir = requiredValue(args, "--dir");
67
+ } else if (arg === "--controller-dir") {
68
+ controllerDir = requiredValue(args, "--controller-dir");
69
+ } else if (arg === "--project") {
70
+ project = requiredValue(args, "--project");
52
71
  } else if (arg === "--once") {
53
72
  once = true;
54
73
  } else if (arg === "--help" || arg === "-h") {
55
- return { command: "help", dir, once };
56
- } else if (!arg.startsWith("-") && runId === undefined) {
57
- runId = arg;
58
- } else {
74
+ return { command: "help", dir, controllerDir, once };
75
+ } else if (arg === "--") {
76
+ piArgs.push(...args.splice(0));
77
+ } else if (arg.startsWith("-")) {
59
78
  throw new Error(`Unknown argument: ${arg}`);
79
+ } else {
80
+ positionals.push(arg);
81
+ }
82
+ }
83
+
84
+ if (command === "host") {
85
+ return { command, dir, controllerDir, once, project, piArgs };
86
+ }
87
+
88
+ if (command === "controller") {
89
+ if (positionals.length !== 2) {
90
+ throw new Error("controller requires <controller> and <key>");
60
91
  }
92
+ return {
93
+ command,
94
+ controllerName: positionals[0] as string,
95
+ resourceKey: positionals[1] as string,
96
+ dir,
97
+ controllerDir,
98
+ once,
99
+ };
61
100
  }
62
- return { command, runId, dir, once };
101
+ if (positionals.length > 1) {
102
+ throw new Error(`Unexpected argument: ${positionals[1]}`);
103
+ }
104
+ return {
105
+ command,
106
+ ...(positionals[0] !== undefined ? { runId: positionals[0] } : {}),
107
+ dir,
108
+ controllerDir,
109
+ once,
110
+ };
63
111
  }
64
112
 
65
113
  async function printRuns(dir: string): Promise<void> {
@@ -70,7 +118,6 @@ async function printRuns(dir: string): Promise<void> {
70
118
  }
71
119
  for (const bundle of bundles) {
72
120
  const state = bundle.state;
73
- // Titles can interpolate untrusted run input; strip control sequences.
74
121
  const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
75
122
  process.stdout.write(
76
123
  `${statusLabel(state.status)} ${sanitizeText(state.workflowName)}${title} ${state.runId} ${formatDuration(
@@ -98,6 +145,57 @@ async function printOnce(dir: string, runId: string | undefined): Promise<void>
98
145
  process.stdout.write(`${renderRunDetailLines(bundle, size).join("\n")}\n`);
99
146
  }
100
147
 
148
+ function printControllers(controllerDir: string): void {
149
+ const store = openControllerStore(controllerDir);
150
+ if (store === undefined) {
151
+ process.stdout.write(`No controller resources found in ${controllerDir}\n`);
152
+ return;
153
+ }
154
+ try {
155
+ const resources = store.listResources();
156
+ if (resources.length === 0) {
157
+ process.stdout.write(`No controller resources found in ${controllerDir}\n`);
158
+ return;
159
+ }
160
+ for (const resource of resources) {
161
+ const condition =
162
+ resource.status.conditions.find((item) => item.type === "Ready") ??
163
+ resource.status.conditions[0];
164
+ const conditionText =
165
+ condition === undefined
166
+ ? "unknown"
167
+ : `${String(condition.status)}:${sanitizeText(condition.reason)}`;
168
+ process.stdout.write(
169
+ `${sanitizeText(resource.metadata.controller)} ${sanitizeText(resource.metadata.key)} generation=${resource.metadata.generation} ready=${conditionText}\n`,
170
+ );
171
+ }
172
+ } finally {
173
+ store.close();
174
+ }
175
+ }
176
+
177
+ function printController(controllerDir: string, controller: string, key: string): void {
178
+ const store = openControllerStore(controllerDir);
179
+ if (store === undefined) {
180
+ throw new Error(`Controller store not found in ${controllerDir}`);
181
+ }
182
+ try {
183
+ const resource = store.getResource({ controller, key });
184
+ if (resource === undefined) {
185
+ throw new Error(`Controller resource not found: ${controller}/${key}`);
186
+ }
187
+ const value = {
188
+ resource,
189
+ effects: store.listEffects(resource.metadata.uid),
190
+ workflows: store.listWorkflows(resource.metadata.uid),
191
+ events: store.listEvents({ controller, key, limit: 50 }),
192
+ };
193
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
194
+ } finally {
195
+ store.close();
196
+ }
197
+ }
198
+
101
199
  export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
102
200
  let args: CliArgs;
103
201
  try {
@@ -116,6 +214,21 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
116
214
  await printRuns(args.dir);
117
215
  return 0;
118
216
  }
217
+ if (args.command === "controllers") {
218
+ printControllers(args.controllerDir);
219
+ return 0;
220
+ }
221
+ if (args.command === "controller") {
222
+ printController(
223
+ args.controllerDir,
224
+ args.controllerName as string,
225
+ args.resourceKey as string,
226
+ );
227
+ return 0;
228
+ }
229
+ if (args.command === "host") {
230
+ return await runHost(args.project ?? process.cwd(), args.piArgs);
231
+ }
119
232
  if (args.command === "view") {
120
233
  if (args.once || !process.stdout.isTTY) {
121
234
  await printOnce(args.dir, args.runId);
@@ -132,8 +245,41 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
132
245
  }
133
246
  }
134
247
 
248
+ async function runHost(project: string, piArgs: string[] | undefined): Promise<number> {
249
+ const { WorkflowHost } = await import("../host/runner.js");
250
+ const host = new WorkflowHost({
251
+ cwd: project,
252
+ piArgs: piArgs ?? [],
253
+ onLog: (message) => process.stdout.write(`[host] ${message}\n`),
254
+ });
255
+ await host.start();
256
+ await new Promise<void>((resolve) => {
257
+ const shutdown = () => {
258
+ void host.stop().then(() => resolve());
259
+ };
260
+ process.once("SIGTERM", shutdown);
261
+ process.once("SIGINT", shutdown);
262
+ });
263
+ return 0;
264
+ }
265
+
266
+ function openControllerStore(controllerDir: string): SqliteControllerStore | undefined {
267
+ const file = path.join(controllerDir, "controller.sqlite");
268
+ if (!fs.existsSync(file)) {
269
+ return undefined;
270
+ }
271
+ return new SqliteControllerStore(file, { readOnly: true });
272
+ }
273
+
274
+ function requiredValue(args: string[], option: string): string {
275
+ const value = args.shift();
276
+ if (!value) {
277
+ throw new Error(`${option} requires a path`);
278
+ }
279
+ return value;
280
+ }
281
+
135
282
  const entryPath = process.argv[1];
136
- // realpath so invocation through a bin symlink (npm link) still matches.
137
283
  const resolvedEntry = entryPath !== undefined ? realpathSyncSafe(entryPath) : undefined;
138
284
  if (resolvedEntry !== undefined && import.meta.url === pathToFileURL(resolvedEntry).href) {
139
285
  main().then((code) => {