@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.
- package/README.md +72 -5
- package/dist/builtins/catalog.d.ts +2 -0
- package/dist/builtins/catalog.js +22 -0
- package/dist/builtins/catalog.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +250 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +235 -0
- package/dist/controllers/sqlite.js +1087 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +95 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +12 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1118 -106
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +6 -0
- package/dist/extension/recorder.js +20 -8
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +50 -0
- package/dist/host/runner.js +379 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/catalog.d.ts +43 -0
- package/dist/workflows/catalog.js +79 -0
- package/dist/workflows/catalog.js.map +1 -0
- package/dist/workflows/engine.d.ts +37 -2
- package/dist/workflows/engine.js +297 -29
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -2
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +19 -15
- package/dist/workflows/loader.js +63 -19
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +41 -0
- package/dist/workflows/migrate-sources.js +129 -0
- package/dist/workflows/migrate-sources.js.map +1 -0
- package/dist/workflows/schema.js +3 -2
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +50 -6
- package/dist/workflows/store.js +448 -53
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +27 -3
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +14 -9
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
- package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
- package/docs/run-bundles.md +85 -28
- package/docs/workflows.md +161 -17
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +10 -2
- package/src/builtins/catalog.ts +22 -0
- package/src/builtins/monitor.workflow.ts +302 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1545 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +148 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +13 -2
- package/src/extension/index.ts +1325 -130
- package/src/extension/recorder.ts +65 -36
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +445 -0
- package/src/viewer/cli.ts +167 -21
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/engine.ts +404 -41
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/index.ts +2 -0
- package/src/workflows/loader.ts +76 -22
- package/src/workflows/migrate-sources.ts +167 -0
- package/src/workflows/schema.ts +3 -2
- package/src/workflows/store.ts +557 -48
- 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 —
|
|
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
|
|
23
|
-
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>
|
|
27
|
-
--
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
57
|
-
|
|
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
|
-
|
|
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) => {
|