@oisincoveney/pipeline 2.8.4 → 2.9.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/.agents/skills/orchestrate/SKILL.md +45 -32
- package/README.md +51 -41
- package/defaults/profiles.yaml +1 -1
- package/dist/argo-submit.js +1 -1
- package/dist/cli/doctor.d.ts +21 -0
- package/dist/cli/doctor.js +268 -0
- package/dist/cli/format.js +6 -3
- package/dist/cli/program.d.ts +14 -16
- package/dist/cli/program.js +291 -104
- package/dist/cli/run-resolver.js +58 -0
- package/dist/commands/bench-command.js +12 -4
- package/dist/commands/pipeline-command.js +22 -5
- package/dist/commands/runner-command-command.js +32 -9
- package/dist/config/lint.js +44 -26
- package/dist/context/repo-map.js +72 -56
- package/dist/gates.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +20 -14
- package/dist/install-commands/claude-code.js +4 -33
- package/dist/install-commands/opencode.js +119 -171
- package/dist/mcp/repo-local-backends.js +51 -39
- package/dist/moka-submit.d.ts +6 -6
- package/dist/moka-submit.js +3 -3
- package/dist/pipeline-runtime.js +15 -5
- package/dist/planning/generate.js +2 -2
- package/dist/run-control/commands.js +340 -0
- package/dist/run-control/contracts.d.ts +21 -0
- package/dist/run-control/contracts.js +129 -0
- package/dist/run-control/detach.js +79 -0
- package/dist/run-control/runtime-reporter.js +187 -0
- package/dist/run-control/store.js +304 -0
- package/dist/run-control/supervisor.js +192 -0
- package/dist/runner-command/finalize.js +28 -37
- package/dist/runner-command/lifecycle-context.js +130 -63
- package/dist/runner-command/lifecycle.js +22 -31
- package/dist/runner-command/run.js +120 -72
- package/dist/runner-command/task-descriptor.js +11 -4
- package/dist/runner-event-schema.d.ts +6 -6
- package/dist/runner.js +1 -1
- package/dist/runtime/agent-node/agent-node.js +3 -3
- package/dist/runtime/builtins/builtins.js +1 -1
- package/dist/runtime/changed-files/changed-files.js +1 -1
- package/dist/runtime/context/context.js +1 -1
- package/dist/runtime/contracts/contracts.d.ts +4 -0
- package/dist/runtime/hooks/hooks.js +1 -1
- package/dist/runtime/json-validation/json-validation.js +49 -23
- package/dist/runtime/local-scheduler.js +49 -26
- package/dist/runtime/opencode-adapter.js +14 -10
- package/dist/runtime/opencode-runtime.js +22 -20
- package/dist/runtime/opencode-session-executor.js +1 -1
- package/dist/runtime/parallel-node/parallel-node.js +10 -0
- package/dist/runtime/run-journal.js +17 -10
- package/dist/runtime/services/file-system-service.js +29 -0
- package/dist/runtime/services/opencode-runtime-server-service.js +27 -0
- package/dist/runtime/services/repo-io-service.js +48 -0
- package/dist/runtime/services/run-journal-file-service.js +20 -0
- package/dist/runtime/services/runner-command-io-service.js +88 -0
- package/dist/runtime/workflow-lifecycle.js +76 -39
- package/dist/schedule/backlog-context.js +55 -29
- package/docs/operator-guide.md +73 -32
- package/package.json +1 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { listRunsEffect, readRunEffect, updateNodeStatusEffect, updateRunStatusEffect } from "./store.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
5
|
+
import { setTimeout } from "node:timers/promises";
|
|
6
|
+
//#region src/run-control/commands.ts
|
|
7
|
+
const RUNS_DIRECTORY = ".pipeline/runs";
|
|
8
|
+
const MANIFEST_FILE = "manifest.json";
|
|
9
|
+
const NODES_DIRECTORY = "nodes";
|
|
10
|
+
const WATCH_INTERVAL_MS = 1e3;
|
|
11
|
+
const PATH_SEPARATOR_RE = /[\\/]/;
|
|
12
|
+
const ACTIVE_RUN_STATUSES = new Set([
|
|
13
|
+
"queued",
|
|
14
|
+
"starting",
|
|
15
|
+
"running",
|
|
16
|
+
"stalled"
|
|
17
|
+
]);
|
|
18
|
+
const ACTIVE_NODE_STATUSES = new Set([
|
|
19
|
+
"queued",
|
|
20
|
+
"starting",
|
|
21
|
+
"running",
|
|
22
|
+
"stalled"
|
|
23
|
+
]);
|
|
24
|
+
function registerRunControlCommands(program) {
|
|
25
|
+
program.command("runs").description("List known Moka runs, newest first").action(async () => {
|
|
26
|
+
await Effect.runPromise(printRunsEffect(workspaceRoot()));
|
|
27
|
+
});
|
|
28
|
+
program.command("status").description("Show run-control status for a Moka run").argument("[run-id]", "run id to inspect; defaults to latest active run").option("--watch", "poll status until the selected run is no longer active").option("--json", "print machine-readable run status").action(async (runId, flags) => {
|
|
29
|
+
await Effect.runPromise(printStatusEffect({
|
|
30
|
+
flags,
|
|
31
|
+
runId,
|
|
32
|
+
workspaceRoot: workspaceRoot()
|
|
33
|
+
}));
|
|
34
|
+
});
|
|
35
|
+
program.command("logs").description("Print whole-run or node-specific run-control artifacts").argument("<run-id>", "run id to inspect").argument("[node-id]", "node id whose artifacts should be printed").option("--follow", "continue printing appended artifact content while the run is active").action(async (runId, nodeId, flags) => {
|
|
36
|
+
await Effect.runPromise(printLogsEffect({
|
|
37
|
+
flags,
|
|
38
|
+
nodeId,
|
|
39
|
+
runId,
|
|
40
|
+
workspaceRoot: workspaceRoot()
|
|
41
|
+
}));
|
|
42
|
+
});
|
|
43
|
+
program.command("stop").description("Mark a Moka run or node as aborted").argument("<run-id>", "run id to stop").argument("[node-id]", "node id to stop without aborting sibling work").action(async (runId, nodeId) => {
|
|
44
|
+
await Effect.runPromise(stopRunOrNodeEffect({
|
|
45
|
+
nodeId,
|
|
46
|
+
runId,
|
|
47
|
+
workspaceRoot: workspaceRoot()
|
|
48
|
+
}).pipe(Effect.flatMap(logEffect)));
|
|
49
|
+
});
|
|
50
|
+
program.command("export").description("Print a sanitized portable run evidence bundle").argument("<run-id>", "run id to export").requiredOption("--sanitize", "omit prompt and session body text from exported artifacts").action(async (runId, flags) => {
|
|
51
|
+
if (!flags.sanitize) throw new Error("Run exports must be requested with --sanitize.");
|
|
52
|
+
await Effect.runPromise(exportSanitizedRunBundleEffect({
|
|
53
|
+
runId,
|
|
54
|
+
workspaceRoot: workspaceRoot()
|
|
55
|
+
}).pipe(Effect.map((bundle) => JSON.stringify(bundle)), Effect.flatMap(logEffect)));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function printRunsEffect(workspaceRoot) {
|
|
59
|
+
return listRunsNewestFirstEffect(workspaceRoot).pipe(Effect.map(formatRuns), Effect.flatMap(logEffect));
|
|
60
|
+
}
|
|
61
|
+
function printStatusEffect(input) {
|
|
62
|
+
return Effect.gen(function* () {
|
|
63
|
+
for (;;) {
|
|
64
|
+
const run = yield* resolveStatusRunEffect(input.workspaceRoot, input.runId);
|
|
65
|
+
yield* logEffect(input.flags.json ? JSON.stringify(runStatus(run)) : formatRunStatus(run));
|
|
66
|
+
if (!(input.flags.watch && isRunActive(run))) return;
|
|
67
|
+
yield* delayEffect(WATCH_INTERVAL_MS);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function printLogsEffect(input) {
|
|
72
|
+
return Effect.gen(function* () {
|
|
73
|
+
const run = yield* requireRunEffect(input.workspaceRoot, input.runId);
|
|
74
|
+
let artifacts = yield* readArtifactsEffect(input.workspaceRoot, run, input.nodeId);
|
|
75
|
+
yield* logEffect(formatArtifacts(artifacts));
|
|
76
|
+
if (!(input.flags.follow && isRunActive(run))) return;
|
|
77
|
+
const printedLengths = new Map(artifacts.map((artifact) => [artifactKey(artifact), artifact.content.length]));
|
|
78
|
+
for (;;) {
|
|
79
|
+
yield* delayEffect(WATCH_INTERVAL_MS);
|
|
80
|
+
const latestRun = yield* requireRunEffect(input.workspaceRoot, input.runId);
|
|
81
|
+
artifacts = yield* readArtifactsEffect(input.workspaceRoot, latestRun, input.nodeId);
|
|
82
|
+
const deltas = artifacts.map((artifact) => artifactDelta(artifact, printedLengths)).filter((artifact) => artifact !== void 0);
|
|
83
|
+
if (deltas.length > 0) yield* logEffect(formatArtifacts(deltas));
|
|
84
|
+
if (!isRunActive(latestRun)) return;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function stopRunOrNodeEffect(input) {
|
|
89
|
+
return Effect.gen(function* () {
|
|
90
|
+
const run = yield* requireRunEffect(input.workspaceRoot, input.runId);
|
|
91
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
92
|
+
if (input.nodeId) {
|
|
93
|
+
const nodeId = yield* requireKnownNodeEffect(run, input.nodeId);
|
|
94
|
+
yield* updateNodeStatusEffect({
|
|
95
|
+
at,
|
|
96
|
+
nodeId,
|
|
97
|
+
runId: run.runId,
|
|
98
|
+
status: "aborted",
|
|
99
|
+
workspaceRoot: input.workspaceRoot
|
|
100
|
+
});
|
|
101
|
+
return `Run ${run.runId} node ${nodeId} aborted.`;
|
|
102
|
+
}
|
|
103
|
+
yield* stopControllerProcessEffect(run);
|
|
104
|
+
yield* updateRunStatusEffect({
|
|
105
|
+
at,
|
|
106
|
+
runId: run.runId,
|
|
107
|
+
status: "aborted",
|
|
108
|
+
workspaceRoot: input.workspaceRoot
|
|
109
|
+
});
|
|
110
|
+
for (const [nodeId, status] of Object.entries(run.nodes)) {
|
|
111
|
+
if (!ACTIVE_NODE_STATUSES.has(status)) continue;
|
|
112
|
+
yield* updateNodeStatusEffect({
|
|
113
|
+
at,
|
|
114
|
+
nodeId,
|
|
115
|
+
runId: run.runId,
|
|
116
|
+
status: "aborted",
|
|
117
|
+
workspaceRoot: input.workspaceRoot
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return `Run ${run.runId} aborted.`;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function exportSanitizedRunBundleEffect(input) {
|
|
124
|
+
return Effect.gen(function* () {
|
|
125
|
+
const run = yield* requireRunEffect(input.workspaceRoot, input.runId);
|
|
126
|
+
return {
|
|
127
|
+
artifacts: (yield* readArtifactsEffect(input.workspaceRoot, run)).filter((artifact) => !isSensitiveArtifactName(artifact.name)).map((artifact) => ({
|
|
128
|
+
content: artifact.content,
|
|
129
|
+
name: artifact.name,
|
|
130
|
+
nodeId: artifact.nodeId,
|
|
131
|
+
path: artifact.path
|
|
132
|
+
})),
|
|
133
|
+
run,
|
|
134
|
+
version: 1
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function listRunsNewestFirstEffect(workspaceRoot) {
|
|
139
|
+
return Effect.gen(function* () {
|
|
140
|
+
const runs = yield* listRunsEffect({ workspaceRoot });
|
|
141
|
+
return (yield* Effect.forEach(runs, (run) => runSortTimeEffect(workspaceRoot, run.runId).pipe(Effect.map((sortTime) => ({
|
|
142
|
+
run,
|
|
143
|
+
sortTime
|
|
144
|
+
}))))).sort(compareRunsNewestFirst).map((record) => record.run);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function compareRunsNewestFirst(left, right) {
|
|
148
|
+
const timeOrder = right.sortTime - left.sortTime;
|
|
149
|
+
if (timeOrder !== 0) return timeOrder;
|
|
150
|
+
return right.run.runId.localeCompare(left.run.runId);
|
|
151
|
+
}
|
|
152
|
+
function runSortTimeEffect(workspaceRoot, runId) {
|
|
153
|
+
return Effect.tryPromise({
|
|
154
|
+
catch: (error) => error,
|
|
155
|
+
try: () => stat(join(workspaceRoot, RUNS_DIRECTORY, logicalSegment("runId", runId), MANIFEST_FILE))
|
|
156
|
+
}).pipe(Effect.map((manifest) => manifest.mtimeMs), Effect.catchAll((error) => isNotFound(error) ? Effect.succeed(0) : Effect.fail(error)));
|
|
157
|
+
}
|
|
158
|
+
function resolveStatusRunEffect(workspaceRoot, runId) {
|
|
159
|
+
return Effect.gen(function* () {
|
|
160
|
+
if (runId) return yield* requireRunEffect(workspaceRoot, runId);
|
|
161
|
+
const runs = yield* listRunsNewestFirstEffect(workspaceRoot);
|
|
162
|
+
const activeRuns = runs.filter(isRunActive);
|
|
163
|
+
if (activeRuns.length === 1) return activeRuns[0];
|
|
164
|
+
if (activeRuns.length > 1) return yield* Effect.fail(new Error(formatMultipleActiveRuns(activeRuns)));
|
|
165
|
+
if (runs.length === 0) return yield* Effect.fail(/* @__PURE__ */ new Error("No Moka runs found."));
|
|
166
|
+
return yield* Effect.fail(/* @__PURE__ */ new Error(`No active Moka runs found. Latest run is ${runs[0].runId} (${runs[0].status}); pass a run id explicitly.`));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function requireRunEffect(workspaceRoot, runId) {
|
|
170
|
+
return Effect.gen(function* () {
|
|
171
|
+
const run = yield* readRunEffect({
|
|
172
|
+
runId,
|
|
173
|
+
workspaceRoot
|
|
174
|
+
});
|
|
175
|
+
if (!run) return yield* Effect.fail(/* @__PURE__ */ new Error(`Run ${runId} does not exist.`));
|
|
176
|
+
return run;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function formatRuns(runs) {
|
|
180
|
+
if (runs.length === 0) return "No Moka runs found.";
|
|
181
|
+
return ["RUN ID STATUS TARGET EFFORT MODE NODES"].concat(runs.map((run) => [
|
|
182
|
+
run.runId,
|
|
183
|
+
run.status,
|
|
184
|
+
run.target,
|
|
185
|
+
run.effort,
|
|
186
|
+
run.mode,
|
|
187
|
+
formatNodeSummary(run.nodes)
|
|
188
|
+
].join(" "))).join("\n");
|
|
189
|
+
}
|
|
190
|
+
function formatRunStatus(run) {
|
|
191
|
+
return [
|
|
192
|
+
`Run: ${run.runId}`,
|
|
193
|
+
`Status: ${run.status}`,
|
|
194
|
+
`Active: ${isRunActive(run) ? "yes" : "no"}`,
|
|
195
|
+
`Target: ${run.target}`,
|
|
196
|
+
`Effort: ${run.effort}`,
|
|
197
|
+
`Mode: ${run.mode}`,
|
|
198
|
+
"Nodes:",
|
|
199
|
+
...Object.entries(run.nodes).sort(([left], [right]) => left.localeCompare(right)).map(([nodeId, status]) => `- ${nodeId}: ${status}`),
|
|
200
|
+
`Events: ${run.events.length}`
|
|
201
|
+
].join("\n");
|
|
202
|
+
}
|
|
203
|
+
function runStatus(run) {
|
|
204
|
+
return {
|
|
205
|
+
active: isRunActive(run),
|
|
206
|
+
effort: run.effort,
|
|
207
|
+
events: run.events,
|
|
208
|
+
mode: run.mode,
|
|
209
|
+
nodes: run.nodes,
|
|
210
|
+
runId: run.runId,
|
|
211
|
+
status: run.status,
|
|
212
|
+
target: run.target
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function formatArtifacts(artifacts) {
|
|
216
|
+
if (artifacts.length === 0) return "No artifacts found.";
|
|
217
|
+
return artifacts.map((artifact) => {
|
|
218
|
+
const content = artifact.content.endsWith("\n") ? artifact.content : `${artifact.content}\n`;
|
|
219
|
+
return `== ${artifact.nodeId}/${artifact.name} ==\n${content}`;
|
|
220
|
+
}).join("\n");
|
|
221
|
+
}
|
|
222
|
+
function formatMultipleActiveRuns(activeRuns) {
|
|
223
|
+
return ["Multiple active runs found; pass a run id explicitly:", ...activeRuns.map((run) => `- ${run.runId} (${run.status})`)].join("\n");
|
|
224
|
+
}
|
|
225
|
+
function readArtifactsEffect(workspaceRoot, run, nodeId) {
|
|
226
|
+
return Effect.gen(function* () {
|
|
227
|
+
const nodeIds = nodeId ? [yield* requireKnownNodeEffect(run, nodeId)] : Object.keys(run.nodes).sort((left, right) => left.localeCompare(right));
|
|
228
|
+
return (yield* Effect.forEach(nodeIds, (id) => readNodeArtifactsEffect(workspaceRoot, run.runId, id))).flat().sort((left, right) => `${left.nodeId}/${left.name}`.localeCompare(`${right.nodeId}/${right.name}`));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function readNodeArtifactsEffect(workspaceRoot, runId, nodeId) {
|
|
232
|
+
const nodeRoot = join(workspaceRoot, RUNS_DIRECTORY, logicalSegment("runId", runId), NODES_DIRECTORY, logicalSegment("nodeId", nodeId));
|
|
233
|
+
return Effect.gen(function* () {
|
|
234
|
+
const artifactPaths = yield* readArtifactPathsEffect(nodeRoot);
|
|
235
|
+
return yield* Effect.forEach(artifactPaths, (path) => readFileUtf8Effect(path).pipe(Effect.map((content) => ({
|
|
236
|
+
content,
|
|
237
|
+
name: normalizeRelative(nodeRoot, path),
|
|
238
|
+
nodeId,
|
|
239
|
+
path: normalizeRelative(workspaceRoot, path)
|
|
240
|
+
}))));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
function readArtifactPathsEffect(current) {
|
|
244
|
+
return Effect.gen(function* () {
|
|
245
|
+
const entries = yield* readDirectoryEntriesEffect(current);
|
|
246
|
+
return (yield* Effect.forEach(entries.sort((left, right) => left.name.localeCompare(right.name)), (entry) => {
|
|
247
|
+
const path = join(current, entry.name);
|
|
248
|
+
if (entry.isDirectory()) return readArtifactPathsEffect(path);
|
|
249
|
+
if (!entry.isFile()) return Effect.succeed([]);
|
|
250
|
+
return Effect.succeed([path]);
|
|
251
|
+
})).flat();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function artifactDelta(artifact, printedLengths) {
|
|
255
|
+
const key = artifactKey(artifact);
|
|
256
|
+
const previousLength = printedLengths.get(key) ?? 0;
|
|
257
|
+
printedLengths.set(key, artifact.content.length);
|
|
258
|
+
if (artifact.content.length <= previousLength) return;
|
|
259
|
+
return {
|
|
260
|
+
...artifact,
|
|
261
|
+
content: artifact.content.slice(previousLength)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function artifactKey(artifact) {
|
|
265
|
+
return `${artifact.nodeId}/${artifact.name}`;
|
|
266
|
+
}
|
|
267
|
+
function formatNodeSummary(nodes) {
|
|
268
|
+
const entries = Object.entries(nodes).sort(([left], [right]) => left.localeCompare(right));
|
|
269
|
+
return entries.length === 0 ? "none" : entries.map(([nodeId, status]) => `${nodeId}=${status}`).join(",");
|
|
270
|
+
}
|
|
271
|
+
function isRunActive(run) {
|
|
272
|
+
return ACTIVE_RUN_STATUSES.has(run.status);
|
|
273
|
+
}
|
|
274
|
+
function requireKnownNode(run, nodeId) {
|
|
275
|
+
const logicalNodeId = logicalSegment("nodeId", nodeId);
|
|
276
|
+
if (!Object.hasOwn(run.nodes, logicalNodeId)) throw new Error(`Run ${run.runId} does not have node ${logicalNodeId}.`);
|
|
277
|
+
return logicalNodeId;
|
|
278
|
+
}
|
|
279
|
+
function requireKnownNodeEffect(run, nodeId) {
|
|
280
|
+
return Effect.try({
|
|
281
|
+
catch: (error) => error,
|
|
282
|
+
try: () => requireKnownNode(run, nodeId)
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function stopControllerProcessEffect(run) {
|
|
286
|
+
return Effect.sync(() => {
|
|
287
|
+
const pid = run.controller?.pid;
|
|
288
|
+
if (!pid) return;
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-pid, "SIGTERM");
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (isNoSuchProcess(error)) return;
|
|
293
|
+
process.kill(pid, "SIGTERM");
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function readDirectoryEntriesEffect(current) {
|
|
298
|
+
return Effect.tryPromise({
|
|
299
|
+
catch: (error) => error,
|
|
300
|
+
try: () => readdir(current, { withFileTypes: true })
|
|
301
|
+
}).pipe(Effect.catchAll((error) => isNotFound(error) ? Effect.succeed([]) : Effect.fail(error)));
|
|
302
|
+
}
|
|
303
|
+
function readFileUtf8Effect(path) {
|
|
304
|
+
return Effect.tryPromise({
|
|
305
|
+
catch: (error) => error,
|
|
306
|
+
try: () => readFile(path, "utf8")
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function delayEffect(milliseconds) {
|
|
310
|
+
return Effect.tryPromise({
|
|
311
|
+
catch: (error) => error,
|
|
312
|
+
try: () => setTimeout(milliseconds)
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
function logEffect(message) {
|
|
316
|
+
return Effect.sync(() => console.log(message));
|
|
317
|
+
}
|
|
318
|
+
function isSensitiveArtifactName(name) {
|
|
319
|
+
const normalized = name.toLowerCase();
|
|
320
|
+
const basename = normalized.split(PATH_SEPARATOR_RE).pop() ?? normalized;
|
|
321
|
+
return basename.includes("prompt") || basename.includes("session") || basename === "body" || basename.startsWith("body.") || normalized.includes("session-body") || normalized.includes("session_body");
|
|
322
|
+
}
|
|
323
|
+
function workspaceRoot() {
|
|
324
|
+
return process.env.PIPELINE_TARGET_PATH ?? process.cwd();
|
|
325
|
+
}
|
|
326
|
+
function logicalSegment(label, value) {
|
|
327
|
+
if (value.length === 0 || value.includes("/") || value.includes("\\") || value === "." || value === "..") throw new Error(`${label} must be a non-empty logical identifier.`);
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
function normalizeRelative(from, path) {
|
|
331
|
+
return relative(from, path).split(sep).join("/");
|
|
332
|
+
}
|
|
333
|
+
function isNotFound(error) {
|
|
334
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
335
|
+
}
|
|
336
|
+
function isNoSuchProcess(error) {
|
|
337
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH";
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
export { registerRunControlCommands };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/run-control/contracts.d.ts
|
|
4
|
+
declare const runTargetSchema: z.ZodEnum<{
|
|
5
|
+
local: "local";
|
|
6
|
+
remote: "remote";
|
|
7
|
+
}>;
|
|
8
|
+
declare const runEffortSchema: z.ZodEnum<{
|
|
9
|
+
quick: "quick";
|
|
10
|
+
normal: "normal";
|
|
11
|
+
thorough: "thorough";
|
|
12
|
+
}>;
|
|
13
|
+
declare const runModeSchema: z.ZodEnum<{
|
|
14
|
+
"read-only": "read-only";
|
|
15
|
+
write: "write";
|
|
16
|
+
}>;
|
|
17
|
+
type RunTarget = z.infer<typeof runTargetSchema>;
|
|
18
|
+
type RunEffort = z.infer<typeof runEffortSchema>;
|
|
19
|
+
type RunMode = z.infer<typeof runModeSchema>;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { RunEffort, RunMode, RunTarget };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/run-control/contracts.ts
|
|
3
|
+
const RUN_TARGETS = ["local", "remote"];
|
|
4
|
+
const RUN_EFFORTS = [
|
|
5
|
+
"quick",
|
|
6
|
+
"normal",
|
|
7
|
+
"thorough"
|
|
8
|
+
];
|
|
9
|
+
const RUN_MODES = ["read-only", "write"];
|
|
10
|
+
/** Default fixed cadence for run-control heartbeat events. */
|
|
11
|
+
const DEFAULT_RUN_CONTROL_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
12
|
+
/**
|
|
13
|
+
* Default silence threshold before an active run-control node is marked
|
|
14
|
+
* stalled. This only changes observability state; it does not kill work.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_RUN_CONTROL_NODE_STALE_AFTER_MS = 12e4;
|
|
17
|
+
const DEFAULT_RUN_CONTROL_STALE_DETECTION = {
|
|
18
|
+
heartbeatIntervalMs: DEFAULT_RUN_CONTROL_HEARTBEAT_INTERVAL_MS,
|
|
19
|
+
nodeStaleAfterMs: DEFAULT_RUN_CONTROL_NODE_STALE_AFTER_MS
|
|
20
|
+
};
|
|
21
|
+
const MOKA_RUN_STATUSES = [
|
|
22
|
+
"queued",
|
|
23
|
+
"starting",
|
|
24
|
+
"running",
|
|
25
|
+
"stalled",
|
|
26
|
+
"passed",
|
|
27
|
+
"failed",
|
|
28
|
+
"timed_out",
|
|
29
|
+
"aborted",
|
|
30
|
+
"blocked"
|
|
31
|
+
];
|
|
32
|
+
const MOKA_NODE_STATUSES = [
|
|
33
|
+
"queued",
|
|
34
|
+
"starting",
|
|
35
|
+
"running",
|
|
36
|
+
"stalled",
|
|
37
|
+
"passed",
|
|
38
|
+
"failed",
|
|
39
|
+
"timed_out",
|
|
40
|
+
"aborted",
|
|
41
|
+
"blocked"
|
|
42
|
+
];
|
|
43
|
+
const runTargetSchema = z.enum(RUN_TARGETS);
|
|
44
|
+
const runEffortSchema = z.enum(RUN_EFFORTS);
|
|
45
|
+
const runModeSchema = z.enum(RUN_MODES);
|
|
46
|
+
const mokaRunStatusSchema = z.enum(MOKA_RUN_STATUSES);
|
|
47
|
+
const mokaNodeStatusSchema = z.enum(MOKA_NODE_STATUSES);
|
|
48
|
+
const nonEmptyStringSchema = z.string().min(1);
|
|
49
|
+
const eventTimestampSchema = z.string().datetime();
|
|
50
|
+
const positiveMillisecondsSchema = z.number().int().positive();
|
|
51
|
+
const runControlStaleDetectionSchema = z.object({
|
|
52
|
+
heartbeatIntervalMs: positiveMillisecondsSchema,
|
|
53
|
+
nodeStaleAfterMs: positiveMillisecondsSchema
|
|
54
|
+
}).strict();
|
|
55
|
+
const mokaRunControllerSchema = z.object({
|
|
56
|
+
argv: z.array(nonEmptyStringSchema),
|
|
57
|
+
cwd: nonEmptyStringSchema,
|
|
58
|
+
paths: z.object({
|
|
59
|
+
events: nonEmptyStringSchema,
|
|
60
|
+
manifest: nonEmptyStringSchema,
|
|
61
|
+
status: nonEmptyStringSchema
|
|
62
|
+
}).strict(),
|
|
63
|
+
pid: z.number().int().positive(),
|
|
64
|
+
startedAt: eventTimestampSchema
|
|
65
|
+
}).strict();
|
|
66
|
+
const mokaRunStatusEventSchema = z.object({
|
|
67
|
+
at: eventTimestampSchema,
|
|
68
|
+
status: mokaRunStatusSchema,
|
|
69
|
+
type: z.literal("run.status")
|
|
70
|
+
}).strict();
|
|
71
|
+
const mokaNodeStatusEventSchema = z.object({
|
|
72
|
+
at: eventTimestampSchema,
|
|
73
|
+
nodeId: nonEmptyStringSchema,
|
|
74
|
+
status: mokaNodeStatusSchema,
|
|
75
|
+
type: z.literal("node.status")
|
|
76
|
+
}).strict();
|
|
77
|
+
const mokaRunHeartbeatEventSchema = z.object({
|
|
78
|
+
at: eventTimestampSchema,
|
|
79
|
+
heartbeatIntervalMs: positiveMillisecondsSchema,
|
|
80
|
+
nodeId: z.never().optional(),
|
|
81
|
+
status: z.never().optional(),
|
|
82
|
+
type: z.literal("run.heartbeat")
|
|
83
|
+
}).strict();
|
|
84
|
+
const mokaRunEventSchema = z.discriminatedUnion("type", [mokaRunStatusEventSchema, mokaNodeStatusEventSchema]);
|
|
85
|
+
const mokaRunControlEventSchema = z.discriminatedUnion("type", [
|
|
86
|
+
mokaRunHeartbeatEventSchema,
|
|
87
|
+
mokaRunStatusEventSchema,
|
|
88
|
+
mokaNodeStatusEventSchema
|
|
89
|
+
]);
|
|
90
|
+
const mokaRunManifestSchema = z.object({
|
|
91
|
+
controller: mokaRunControllerSchema.optional(),
|
|
92
|
+
effort: runEffortSchema,
|
|
93
|
+
events: z.array(mokaRunEventSchema),
|
|
94
|
+
mode: runModeSchema,
|
|
95
|
+
nodes: z.record(nonEmptyStringSchema, mokaNodeStatusSchema),
|
|
96
|
+
runId: nonEmptyStringSchema,
|
|
97
|
+
staleDetection: runControlStaleDetectionSchema.optional(),
|
|
98
|
+
status: mokaRunStatusSchema,
|
|
99
|
+
target: runTargetSchema
|
|
100
|
+
}).strict();
|
|
101
|
+
function parseRunTarget(input) {
|
|
102
|
+
return runTargetSchema.parse(input);
|
|
103
|
+
}
|
|
104
|
+
function parseRunEffort(input) {
|
|
105
|
+
return runEffortSchema.parse(input);
|
|
106
|
+
}
|
|
107
|
+
function parseRunMode(input) {
|
|
108
|
+
return runModeSchema.parse(input);
|
|
109
|
+
}
|
|
110
|
+
function parseMokaRunStatus(input) {
|
|
111
|
+
return mokaRunStatusSchema.parse(input);
|
|
112
|
+
}
|
|
113
|
+
function parseMokaNodeStatus(input) {
|
|
114
|
+
return mokaNodeStatusSchema.parse(input);
|
|
115
|
+
}
|
|
116
|
+
function parseRunControlStaleDetection(input) {
|
|
117
|
+
return runControlStaleDetectionSchema.parse(input);
|
|
118
|
+
}
|
|
119
|
+
function parseMokaRunController(input) {
|
|
120
|
+
return mokaRunControllerSchema.parse(input);
|
|
121
|
+
}
|
|
122
|
+
function parseMokaRunEvent(input) {
|
|
123
|
+
return mokaRunControlEventSchema.parse(input);
|
|
124
|
+
}
|
|
125
|
+
function parseMokaRunManifest(input) {
|
|
126
|
+
return mokaRunManifestSchema.parse(input);
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
export { DEFAULT_RUN_CONTROL_HEARTBEAT_INTERVAL_MS, DEFAULT_RUN_CONTROL_NODE_STALE_AFTER_MS, DEFAULT_RUN_CONTROL_STALE_DETECTION, parseMokaNodeStatus, parseMokaRunController, parseMokaRunEvent, parseMokaRunManifest, parseMokaRunStatus, parseRunControlStaleDetection, parseRunEffort, parseRunMode, parseRunTarget };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
//#region src/run-control/detach.ts
|
|
7
|
+
function startDetachedRunController(input) {
|
|
8
|
+
return Effect.runPromise(startDetachedRunControllerEffect(input));
|
|
9
|
+
}
|
|
10
|
+
function startDetachedRunControllerEffect(input) {
|
|
11
|
+
return Effect.gen(function* () {
|
|
12
|
+
const command = process.execPath;
|
|
13
|
+
const args = yield* controllerArgsEffect(input);
|
|
14
|
+
const child = yield* Effect.sync(() => spawn(command, args, {
|
|
15
|
+
cwd: input.workspaceRoot,
|
|
16
|
+
detached: true,
|
|
17
|
+
env: {
|
|
18
|
+
...process.env,
|
|
19
|
+
PIPELINE_TARGET_PATH: input.workspaceRoot
|
|
20
|
+
},
|
|
21
|
+
stdio: "ignore"
|
|
22
|
+
}));
|
|
23
|
+
if (!child.pid) return yield* Effect.fail(/* @__PURE__ */ new Error("Detached run controller did not expose a process id."));
|
|
24
|
+
yield* waitForControllerSpawnEffect(child);
|
|
25
|
+
yield* Effect.sync(() => child.unref());
|
|
26
|
+
return {
|
|
27
|
+
argv: [command, ...args],
|
|
28
|
+
pid: child.pid,
|
|
29
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function controllerArgsEffect(input) {
|
|
34
|
+
return Effect.gen(function* () {
|
|
35
|
+
return [
|
|
36
|
+
yield* cliEntrypointPathEffect(),
|
|
37
|
+
"run-controller",
|
|
38
|
+
"--run-id",
|
|
39
|
+
input.runId,
|
|
40
|
+
...optionalOption("--schedule", input.schedule),
|
|
41
|
+
...optionalOption("--entrypoint", input.entrypoint),
|
|
42
|
+
...optionalOption("--workflow", input.workflow),
|
|
43
|
+
"--",
|
|
44
|
+
input.task
|
|
45
|
+
];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function optionalOption(name, value) {
|
|
49
|
+
return value ? [name, value] : [];
|
|
50
|
+
}
|
|
51
|
+
function cliEntrypointPathEffect() {
|
|
52
|
+
return Effect.gen(function* () {
|
|
53
|
+
const envEntrypoint = process.env.MOKA_CLI_ENTRYPOINT;
|
|
54
|
+
if (envEntrypoint) return envEntrypoint;
|
|
55
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
const compiledEntrypoint = join(moduleDir, "..", "index.js");
|
|
57
|
+
if (yield* pathExistsEffect(compiledEntrypoint)) return compiledEntrypoint;
|
|
58
|
+
const sourceEntrypoint = join(moduleDir, "..", "index.ts");
|
|
59
|
+
if (yield* pathExistsEffect(sourceEntrypoint)) return sourceEntrypoint;
|
|
60
|
+
return process.argv[1] ?? compiledEntrypoint;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function pathExistsEffect(path) {
|
|
64
|
+
return Effect.sync(() => existsSync(path));
|
|
65
|
+
}
|
|
66
|
+
function waitForControllerSpawnEffect(child) {
|
|
67
|
+
return Effect.async((resume) => {
|
|
68
|
+
const onSpawn = () => resume(Effect.void);
|
|
69
|
+
const onError = (error) => resume(Effect.fail(error));
|
|
70
|
+
child.once("spawn", onSpawn);
|
|
71
|
+
child.once("error", onError);
|
|
72
|
+
return Effect.sync(() => {
|
|
73
|
+
child.off("spawn", onSpawn);
|
|
74
|
+
child.off("error", onError);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { startDetachedRunController };
|