faberun 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `contract validate`: validate an authored contract from the command line and
|
|
3
|
+
* print the report the authoring turn reads (TECH-SPEC lean, rule 4: a
|
|
4
|
+
* partly finished run is continued by `resume`, never re-authored, so this is
|
|
5
|
+
* the only contract operation the CLI carries).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { loadPersistedContract, validateContract } from "../contract/index.mjs";
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
12
|
+
|
|
13
|
+
/** Flags are scoped to the operation that declares them; all others are rejected. */
|
|
14
|
+
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
15
|
+
const OPERATION_OPTIONS = {
|
|
16
|
+
validate: {},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string[]} args
|
|
21
|
+
* @returns {void}
|
|
22
|
+
*/
|
|
23
|
+
export function contractCli(args) {
|
|
24
|
+
const operation = args[0];
|
|
25
|
+
if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
|
|
26
|
+
const target = args[1];
|
|
27
|
+
if (!target || args.length > 2) return usage();
|
|
28
|
+
if (operation === "validate") validateContractFile(resolve(target));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate an authored contract and print the same report the `validate`
|
|
33
|
+
* command has always printed. A single-node contract is simply valid.
|
|
34
|
+
*
|
|
35
|
+
* A contract.json that sits beside a run.json is a persisted run's frozen copy,
|
|
36
|
+
* not an authored contract: it takes the tree-free persisted path and must
|
|
37
|
+
* match the digest recorded at launch. Every other target keeps today's
|
|
38
|
+
* authoring validation, tree reads included.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} path
|
|
41
|
+
* @returns {ValidatedContract}
|
|
42
|
+
*/
|
|
43
|
+
export function validateContractFile(path) {
|
|
44
|
+
const runJsonPath = join(dirname(path), "run.json");
|
|
45
|
+
const contract = existsSync(runJsonPath)
|
|
46
|
+
? loadPersistedContract(path, readRunDigest(runJsonPath))
|
|
47
|
+
: validateContract(JSON.parse(readFileSync(path, "utf8")), path);
|
|
48
|
+
const count = contract.warnings.length;
|
|
49
|
+
process.stdout.write(`valid${count ? ` (${count} warning${count === 1 ? "" : "s"})` : ""}\n`);
|
|
50
|
+
for (const warning of contract.warnings) process.stdout.write(`[warn] ${warning}\n`);
|
|
51
|
+
return contract;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} runJsonPath
|
|
56
|
+
* @returns {string|undefined}
|
|
57
|
+
*/
|
|
58
|
+
function readRunDigest(runJsonPath) {
|
|
59
|
+
const metadata = JSON.parse(readFileSync(runJsonPath, "utf8"));
|
|
60
|
+
return typeof metadata.contractDigest === "string" ? metadata.contractDigest : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @returns {void} */
|
|
64
|
+
function usage() {
|
|
65
|
+
process.stderr.write("usage: faberun contract validate <contract.json>\n");
|
|
66
|
+
process.exitCode = 2;
|
|
67
|
+
}
|
package/src/cli/init.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `faberun init [--cwd <dir>] [--yes] [--no-skill] [--agentkit]
|
|
3
|
+
* [--greenfield|--stable] [--json]`: prepare a target repository for campaigns.
|
|
4
|
+
*
|
|
5
|
+
* Three facts are established in order: the target is a git work tree, `.runs/`
|
|
6
|
+
* is ignored, and the `faberun` skill is installed under `.claude/skills/`. The
|
|
7
|
+
* agent kit is optional, and its compatibility rule is always asked or given by
|
|
8
|
+
* flag -- never defaulted silently. The shipped shell script does the writing;
|
|
9
|
+
* this module owns only the questions and the check lines.
|
|
10
|
+
*
|
|
11
|
+
* Questions, streams and process facts are injected so tests never touch a
|
|
12
|
+
* terminal, the same split `cli/setup.mjs` uses.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { createInterface } from "node:readline/promises";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { colorLevel, statusToken } from "./brand.mjs";
|
|
20
|
+
import { installSkills } from "./skills.mjs";
|
|
21
|
+
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
22
|
+
|
|
23
|
+
/** @typedef {(text: string) => void} Writer */
|
|
24
|
+
/** @typedef {(question: string) => Promise<string>} Asker */
|
|
25
|
+
/** @typedef {{ask: Asker, close: () => void}} AskerHandle */
|
|
26
|
+
/** @typedef {"greenfield"|"stable"} Variant */
|
|
27
|
+
/** @typedef {{cwd: string, runsIgnored: boolean, skillInstalled: boolean, agentkit: boolean}} InitReport */
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} InitOptions
|
|
30
|
+
* @property {string} [cwd]
|
|
31
|
+
* @property {boolean} [yes]
|
|
32
|
+
* @property {boolean} [skill] whether to install the faberun skill (default true)
|
|
33
|
+
* @property {boolean} [agentkit] install the agent kit without asking
|
|
34
|
+
* @property {Variant} [variant] the already-chosen compatibility rule
|
|
35
|
+
* @property {boolean} [json]
|
|
36
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
37
|
+
* @property {Asker} [ask]
|
|
38
|
+
* @property {Writer} [stdout]
|
|
39
|
+
* @property {Writer} [stderr]
|
|
40
|
+
* @property {boolean} [isTTY]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** The shipped installer that lays the agent kit down, resolved from this module rather than the caller's cwd. */
|
|
44
|
+
const AGENTKIT_INSTALLER = fileURLToPath(new URL("../../skills/init-agentkit/scripts/install-agentkit.sh", import.meta.url));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prepare `cwd` for campaigns and return the process exit code.
|
|
48
|
+
*
|
|
49
|
+
* @param {InitOptions} [options]
|
|
50
|
+
* @returns {Promise<number>}
|
|
51
|
+
*/
|
|
52
|
+
export async function initCommand(options = {}) {
|
|
53
|
+
const env = options.env ?? process.env;
|
|
54
|
+
const stdout = options.stdout ?? ((text) => process.stdout.write(text));
|
|
55
|
+
const stderr = options.stderr ?? ((text) => process.stderr.write(text));
|
|
56
|
+
const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
57
|
+
const json = options.json === true;
|
|
58
|
+
const cwd = resolve(options.cwd ?? ".");
|
|
59
|
+
const level = colorLevel(env, isTTY);
|
|
60
|
+
// `--yes` and `--json` are the two non-interactive paths; both take the
|
|
61
|
+
// flags as given and never wait on a terminal.
|
|
62
|
+
const interactive = isTTY && options.yes !== true && !json;
|
|
63
|
+
const report = /** @type {InitReport} */ ({ cwd, runsIgnored: false, skillInstalled: false, agentkit: false });
|
|
64
|
+
|
|
65
|
+
// The readline interface is created only if a question is actually asked, so
|
|
66
|
+
// a flag-driven or `--json` invocation never opens stdin. The holder object
|
|
67
|
+
// keeps the assignment inside the closure visible to the `finally` close.
|
|
68
|
+
const state = { asker: /** @type {AskerHandle|null} */ (null) };
|
|
69
|
+
/** @param {string} question @returns {Promise<string>} */
|
|
70
|
+
const askQuestion = async (question) => {
|
|
71
|
+
if (state.asker === null) {
|
|
72
|
+
if (options.ask) {
|
|
73
|
+
state.asker = { ask: options.ask, close: () => {} };
|
|
74
|
+
} else {
|
|
75
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
76
|
+
state.asker = { ask: (text) => rl.question(text), close: () => rl.close() };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return (await state.asker.ask(question)).trim();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
if (!isGitWorkTree(cwd)) {
|
|
84
|
+
if (json) stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
85
|
+
else stdout(`${statusToken("fail", level)} git · ${cwd} is not a git work tree\n`);
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const gitignore = ensureRunsIgnored(cwd);
|
|
90
|
+
report.runsIgnored = true;
|
|
91
|
+
if (!json) stdout(`${statusToken("ok", level)} .runs ignored · ${gitignore}\n`);
|
|
92
|
+
|
|
93
|
+
let skill = options.skill !== false;
|
|
94
|
+
if (skill && interactive) {
|
|
95
|
+
skill = !(await askQuestion("Install the faberun skill into .claude/skills? [Y/n] ")).toLowerCase().startsWith("n");
|
|
96
|
+
}
|
|
97
|
+
if (skill) {
|
|
98
|
+
// `--json` still installs; the skill's own report lines are human output
|
|
99
|
+
// and would otherwise corrupt the one machine-readable object.
|
|
100
|
+
const skills = installSkills({
|
|
101
|
+
names: ["faberun"],
|
|
102
|
+
skillsDir: join(cwd, ".claude", "skills"),
|
|
103
|
+
force: false,
|
|
104
|
+
stdout: json ? { write: () => {} } : { write: stdout },
|
|
105
|
+
});
|
|
106
|
+
report.skillInstalled = skills.installed + skills.skipped > 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let agentkit = options.agentkit === true;
|
|
110
|
+
if (!agentkit && interactive) {
|
|
111
|
+
agentkit = (await askQuestion("Install the agent kit (AGENTS.md, docs/, ADRs, Sentrux gate)? [y/N] ")).toLowerCase().startsWith("y");
|
|
112
|
+
}
|
|
113
|
+
if (agentkit) {
|
|
114
|
+
let variant = /** @type {Variant} */ (options.variant === "stable" ? "stable" : "greenfield");
|
|
115
|
+
if (interactive && options.variant === undefined) {
|
|
116
|
+
const answer = (await askQuestion("Compatibility rule: greenfield (break freely) or stable (preserve published contracts)? [greenfield] ")).toLowerCase();
|
|
117
|
+
variant = answer.startsWith("s") ? "stable" : "greenfield";
|
|
118
|
+
}
|
|
119
|
+
const result = spawnSync("bash", [AGENTKIT_INSTALLER, cwd, `--${variant}`], { stdio: "inherit" });
|
|
120
|
+
if (result.error) {
|
|
121
|
+
stderr(`${result.error.message}\n`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
if (result.status !== 0) return result.status ?? 1;
|
|
125
|
+
report.agentkit = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (json) stdout(`${JSON.stringify(report, null, 2)}\n`);
|
|
129
|
+
else stdout(`next · faberun doctor --cwd ${cwd} · faberun campaign init <id> --cwd ${cwd} --goal "..."\n`);
|
|
130
|
+
return 0;
|
|
131
|
+
} finally {
|
|
132
|
+
state.asker?.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether `git -C cwd rev-parse --is-inside-work-tree` answers `true`.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} cwd
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
function isGitWorkTree(cwd) {
|
|
143
|
+
const result = boundedGitSync(["-C", cwd, "rev-parse", "--is-inside-work-tree"], {
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
146
|
+
});
|
|
147
|
+
return !result.error && result.status === 0 && String(result.stdout).trim() === "true";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Add a `.runs/` line to `cwd/.gitignore` unless an equivalent line already
|
|
152
|
+
* exists, creating the file when missing. A file that does not end in a newline
|
|
153
|
+
* gets one before the appended line, so the result is always a whole line.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} cwd
|
|
156
|
+
* @returns {string} the `.gitignore` path written or confirmed
|
|
157
|
+
*/
|
|
158
|
+
function ensureRunsIgnored(cwd) {
|
|
159
|
+
const path = join(cwd, ".gitignore");
|
|
160
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
161
|
+
const ignored = existing.split(/\r?\n/u).some((line) => {
|
|
162
|
+
const trimmed = line.trim();
|
|
163
|
+
return trimmed === ".runs/" || trimmed === ".runs";
|
|
164
|
+
});
|
|
165
|
+
if (!ignored) {
|
|
166
|
+
const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
167
|
+
writeFileSync(path, `${existing}${separator}.runs/\n`);
|
|
168
|
+
}
|
|
169
|
+
return path;
|
|
170
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher half of `--detach`: spawn the controller, wait for it to prove it
|
|
3
|
+
* owns the run, acknowledge, and exit.
|
|
4
|
+
*
|
|
5
|
+
* The controller half is `engine/detach.mjs`. They are separate because only
|
|
6
|
+
* this side knows about `process.argv` and about exiting -- and because the
|
|
7
|
+
* engine importing the CLI was the one import cycle a layered tree could not
|
|
8
|
+
* express.
|
|
9
|
+
*
|
|
10
|
+
* `writeBootstrapFailure` is what makes a detached failure visible at all: a
|
|
11
|
+
* child that dies before taking the lock leaves nothing behind unless it writes
|
|
12
|
+
* why, and the launcher is already gone by then.
|
|
13
|
+
*/
|
|
14
|
+
import { SETTLED } from "../engine/prompts.mjs";
|
|
15
|
+
import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrapAttempts, readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
16
|
+
import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
|
|
17
|
+
import { cleanupBootstrapNonce } from "../engine/detach.mjs";
|
|
18
|
+
import { delay, errorCode } from "../util.mjs";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import { readRunNodes } from "../engine/scheduler.mjs";
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { validateContract } from "../contract/index.mjs";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The file a detached child is spawned as. It must be the CLI and not this
|
|
29
|
+
* module: `cli.mjs` guards its dispatch on `process.argv[1]` being itself, so a
|
|
30
|
+
* child started as `launch.mjs` would run no command at all -- which is exactly
|
|
31
|
+
* what happened when this code moved out of `cli.mjs` and kept using
|
|
32
|
+
* `import.meta.url`.
|
|
33
|
+
*/
|
|
34
|
+
const CLI_ENTRY = fileURLToPath(new URL("../cli.mjs", import.meta.url));
|
|
35
|
+
|
|
36
|
+
/** @typedef {import("../cli.mjs").BootstrapRecord} BootstrapRecord */
|
|
37
|
+
/** @typedef {import("node:child_process").ChildProcess & {bootstrapNonce?: string, bootstrapProcessStartToken?: string|null}} DetachedChild */
|
|
38
|
+
/** @typedef {import("../run/lock.mjs").ReadLockResult} ReadLockResult */
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} command
|
|
42
|
+
* @param {string} target
|
|
43
|
+
* @param {string[]} [extraArgs]
|
|
44
|
+
* @returns {DetachedChild}
|
|
45
|
+
*/
|
|
46
|
+
export function detachSelf(command, target, extraArgs = []) {
|
|
47
|
+
return detachArgv([command, target, ...extraArgs]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Spawn this CLI as a detached child with a fresh bootstrap nonce, for argv
|
|
52
|
+
* shapes `detachSelf`'s (command, target) pair cannot express — a campaign
|
|
53
|
+
* watch is `campaign watch <id>`, not `<command> <target>`. stdio is discarded
|
|
54
|
+
* exactly as for every detached controller: a foreground launcher is the only
|
|
55
|
+
* moment an operator is present.
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]} argv
|
|
58
|
+
* @param {{nonce?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
59
|
+
* @returns {DetachedChild}
|
|
60
|
+
*/
|
|
61
|
+
export function detachArgv(argv, options = {}) {
|
|
62
|
+
const nonce = options.nonce ?? randomUUID();
|
|
63
|
+
const child = /** @type {DetachedChild} */ (spawn(process.execPath, [CLI_ENTRY, ...argv], {
|
|
64
|
+
cwd: process.cwd(),
|
|
65
|
+
env: { ...process.env, ...options.env, FABERUN_BOOTSTRAP_NONCE: nonce },
|
|
66
|
+
detached: process.platform !== "win32",
|
|
67
|
+
stdio: "ignore",
|
|
68
|
+
}));
|
|
69
|
+
child.unref();
|
|
70
|
+
child.bootstrapNonce = nonce;
|
|
71
|
+
child.bootstrapProcessStartToken = processStartToken(child.pid ?? null);
|
|
72
|
+
return child;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} runDir
|
|
76
|
+
* @param {number} pid
|
|
77
|
+
* @param {DetachedChild|null} [child]
|
|
78
|
+
* @param {number} [timeoutMs]
|
|
79
|
+
* @returns {Promise<BootstrapRecord>}
|
|
80
|
+
*/
|
|
81
|
+
export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30_000) {
|
|
82
|
+
const deadline = Date.now() + timeoutMs;
|
|
83
|
+
const nonce = child?.bootstrapNonce;
|
|
84
|
+
if (!nonce) throw new Error(`detached bootstrap has no start nonce for pid ${pid}`);
|
|
85
|
+
if (!validBootstrapNonce(nonce) || child.pid !== pid) throw new Error(`detached bootstrap has invalid child identity for pid ${pid}`);
|
|
86
|
+
let expectedProcessStartToken = child.bootstrapProcessStartToken ?? null;
|
|
87
|
+
let exited = false;
|
|
88
|
+
child?.once("exit", () => { exited = true; });
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
const childExited = exited || child?.exitCode !== null || child?.signalCode !== null;
|
|
91
|
+
if (expectedProcessStartToken === null && !childExited) expectedProcessStartToken = processStartToken(pid);
|
|
92
|
+
for (const path of [bootstrapAttemptPath(runDir, nonce), bootstrapPath(runDir)]) {
|
|
93
|
+
try {
|
|
94
|
+
const bootstrap = /** @type {BootstrapRecord} */ (readJson(path));
|
|
95
|
+
const childIdentity = bootstrapMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken);
|
|
96
|
+
if (bootstrap.status === "failed" && bootstrapFailureMatchesChild(bootstrap, pid, nonce, expectedProcessStartToken)) {
|
|
97
|
+
cleanupBootstrapAttempts(runDir);
|
|
98
|
+
throw new Error(`detached bootstrap failed: ${bootstrap.error}`);
|
|
99
|
+
}
|
|
100
|
+
const lock = readLock(runDir);
|
|
101
|
+
const currentOwner = lockOwnedBy(lock, pid, expectedProcessStartToken);
|
|
102
|
+
if (!childExited && bootstrap.status === "ready" && childIdentity && currentOwner && runIsNonterminal(runDir)) {
|
|
103
|
+
cleanupBootstrapAttempts(runDir);
|
|
104
|
+
try {
|
|
105
|
+
writeBootstrapAcknowledgement(runDir, bootstrap, expectedProcessStartToken);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
cleanupBootstrapNonce(runDir, nonce);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return bootstrap;
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (errorCode(error) !== "ENOENT") {
|
|
114
|
+
cleanupBootstrapNonce(runDir, nonce);
|
|
115
|
+
cleanupBootstrapAttempts(runDir);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (childExited) {
|
|
121
|
+
try {
|
|
122
|
+
const failure = /** @type {BootstrapRecord} */ (readJson(bootstrapAttemptPath(runDir, nonce)));
|
|
123
|
+
if (failure.status === "failed" && bootstrapFailureMatchesChild(failure, pid, nonce, expectedProcessStartToken)) {
|
|
124
|
+
cleanupBootstrapAttempts(runDir);
|
|
125
|
+
throw new Error(`detached bootstrap failed: ${failure.error}`);
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (errorCode(error) !== "ENOENT") {
|
|
129
|
+
cleanupBootstrapNonce(runDir, nonce);
|
|
130
|
+
cleanupBootstrapAttempts(runDir);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
cleanupBootstrapNonce(runDir, nonce);
|
|
135
|
+
cleanupBootstrapAttempts(runDir);
|
|
136
|
+
throw new Error(`detached bootstrap failed before readiness for pid ${pid}`);
|
|
137
|
+
}
|
|
138
|
+
await delay(50);
|
|
139
|
+
}
|
|
140
|
+
cleanupBootstrapNonce(runDir, nonce);
|
|
141
|
+
cleanupBootstrapAttempts(runDir);
|
|
142
|
+
throw new Error(`detached bootstrap did not become ready for pid ${pid}`);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} runDir
|
|
146
|
+
* @param {BootstrapRecord} bootstrap
|
|
147
|
+
* @param {string|null} expectedProcessStartToken
|
|
148
|
+
*/
|
|
149
|
+
export function writeBootstrapAcknowledgement(runDir, bootstrap, expectedProcessStartToken) {
|
|
150
|
+
if (!bootstrap.nonce) throw new Error("bootstrap record has no nonce");
|
|
151
|
+
writeJsonAtomic(bootstrapAckPath(runDir, bootstrap.nonce), {
|
|
152
|
+
status: "acknowledged",
|
|
153
|
+
nonce: bootstrap.nonce,
|
|
154
|
+
pid: bootstrap.pid ?? null,
|
|
155
|
+
processStartToken: expectedProcessStartToken,
|
|
156
|
+
at: new Date().toISOString(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} runDir
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
export function runIsNonterminal(runDir) {
|
|
164
|
+
try {
|
|
165
|
+
const contractPath = join(runDir, "contract.json");
|
|
166
|
+
const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
|
|
167
|
+
const nodes = readRunNodes(runDir, contract);
|
|
168
|
+
return nodes.length > 0 && nodes.some((node) => !SETTLED.has(node.status));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (errorCode(error) === "ENOENT") return false;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* @param {string} command
|
|
176
|
+
* @param {string|undefined} target
|
|
177
|
+
* @returns {string|null}
|
|
178
|
+
*/
|
|
179
|
+
export function bootstrapRunDir(command, target) {
|
|
180
|
+
try {
|
|
181
|
+
if (command === "run") {
|
|
182
|
+
if (!target) return null;
|
|
183
|
+
const path = resolve(target);
|
|
184
|
+
const contract = validateContract(JSON.parse(readFileSync(path, "utf8")), path);
|
|
185
|
+
return join(contract.cwd, ".runs", contract.id);
|
|
186
|
+
}
|
|
187
|
+
if (["resume", "cancel"].includes(command)) {
|
|
188
|
+
if (!target) return null;
|
|
189
|
+
return resolve(target);
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* @param {string} command
|
|
198
|
+
* @param {string|undefined} target
|
|
199
|
+
* @param {Error} error
|
|
200
|
+
*/
|
|
201
|
+
export function writeBootstrapFailure(command, target, error) {
|
|
202
|
+
const runDir = bootstrapRunDir(command, target);
|
|
203
|
+
if (!runDir || !existsSync(runDir)) return;
|
|
204
|
+
const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
|
|
205
|
+
const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
|
|
206
|
+
if (command === "run" && existsSync(join(runDir, "contract.json"))) return;
|
|
207
|
+
/** @type {BootstrapRecord|null} */
|
|
208
|
+
let current = null;
|
|
209
|
+
try {
|
|
210
|
+
current = /** @type {BootstrapRecord} */ (readJson(bootstrapPath(runDir)));
|
|
211
|
+
const controllerLock = readLock(runDir);
|
|
212
|
+
const currentOwnerActive = current.status === "ready" && current.pid !== process.pid
|
|
213
|
+
&& lockOwnedBy(controllerLock, current.pid, current.processStartToken);
|
|
214
|
+
if (currentOwnerActive) return;
|
|
215
|
+
} catch (readError) {
|
|
216
|
+
if (errorCode(readError) !== "ENOENT") return;
|
|
217
|
+
}
|
|
218
|
+
// Only a detached bootstrap child records an attempt file for its own nonce;
|
|
219
|
+
// a --detach parent that observed the failure must not recreate attempt
|
|
220
|
+
// artifacts with an ambient nonce it does not own.
|
|
221
|
+
if (nonce && !process.argv.includes("--detach") && !(current?.status === "ready" && current.pid === process.pid)) {
|
|
222
|
+
try { writeJsonAtomic(bootstrapAttemptPath(runDir, nonce), failure); } catch {
|
|
223
|
+
// Best-effort record: a failed attempt write must not mask the bootstrap error being reported.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
try { writeJsonAtomic(bootstrapPath(runDir), failure); } catch {
|
|
227
|
+
// Best-effort record: there is nothing left to do if this write fails too.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* @param {import("../run/lock.mjs").ReadLockResult} lock
|
|
232
|
+
* @param {number|undefined} pid
|
|
233
|
+
* @param {string|null|undefined} processStartToken
|
|
234
|
+
* @returns {boolean}
|
|
235
|
+
*/
|
|
236
|
+
function lockOwnedBy(lock, pid, processStartToken) {
|
|
237
|
+
return lock !== null && !lock.invalid && lock.pid === pid
|
|
238
|
+
&& sameProcessStartToken(lock.processStartToken, processStartToken);
|
|
239
|
+
}
|
package/src/cli/seat.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `seat` argv: start, attach, status, stop, switch.
|
|
3
|
+
*
|
|
4
|
+
* Per-operation options only, so a flag declared for one operation is rejected
|
|
5
|
+
* for the others. The facade in `src/seat/index.mjs` owns the behavior; this
|
|
6
|
+
* file owns the wire, the same split `campaign.mjs` uses.
|
|
7
|
+
*/
|
|
8
|
+
import { parseArgs as parseFlags } from "node:util";
|
|
9
|
+
import { attachSeat, seatStatus, startSeat, stopSeat, switchSeat } from "../seat/index.mjs";
|
|
10
|
+
|
|
11
|
+
/** @typedef {{cwd?: string, harness?: string, ssh?: string, json?: boolean}} SeatValues */
|
|
12
|
+
|
|
13
|
+
/** Flags are scoped to the operations that declare them; all other flags are rejected. */
|
|
14
|
+
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
15
|
+
const OPERATION_OPTIONS = {
|
|
16
|
+
start: { cwd: { type: "string" }, harness: { type: "string" } },
|
|
17
|
+
attach: { cwd: { type: "string" }, ssh: { type: "string" } },
|
|
18
|
+
status: { cwd: { type: "string" }, json: { type: "boolean" } },
|
|
19
|
+
stop: { cwd: { type: "string" } },
|
|
20
|
+
switch: { cwd: { type: "string" }, harness: { type: "string" } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string[]} args
|
|
25
|
+
* @returns {void}
|
|
26
|
+
*/
|
|
27
|
+
export function seatCli(args) {
|
|
28
|
+
const operation = args[0];
|
|
29
|
+
if (!operation || !(operation in OPERATION_OPTIONS)) return usage();
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
35
|
+
return usage();
|
|
36
|
+
}
|
|
37
|
+
const [campaign, ...extra] = parsed.positionals;
|
|
38
|
+
const values = /** @type {SeatValues} */ (parsed.values);
|
|
39
|
+
if (extra.length) return usage();
|
|
40
|
+
if (operation === "status") {
|
|
41
|
+
if (campaign !== undefined) return usage();
|
|
42
|
+
return reportStatus(values);
|
|
43
|
+
}
|
|
44
|
+
if (operation === "start") {
|
|
45
|
+
if (!campaign) return usage();
|
|
46
|
+
return reportStart(campaign, values);
|
|
47
|
+
}
|
|
48
|
+
if (operation === "attach") return reportAttach(campaign, values);
|
|
49
|
+
if (operation === "stop") return reportStop(campaign);
|
|
50
|
+
if (operation === "switch") {
|
|
51
|
+
if (!values.harness) return usage();
|
|
52
|
+
return reportSwitch(campaign, values);
|
|
53
|
+
}
|
|
54
|
+
return usage();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} campaign
|
|
59
|
+
* @param {SeatValues} values
|
|
60
|
+
*/
|
|
61
|
+
function reportStart(campaign, values) {
|
|
62
|
+
const result = startSeat({ campaign, harness: values.harness, cwd: values.cwd });
|
|
63
|
+
if (!result.ok) {
|
|
64
|
+
process.stderr.write(`[seat] ${result.message ?? result.reason}\n`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
process.stdout.write(`[seat] ${campaign} started with ${result.harness} · ${result.session}:${campaign}\n`);
|
|
69
|
+
process.stdout.write(`${result.attachCommand}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string|undefined} campaign
|
|
74
|
+
* @param {SeatValues} values
|
|
75
|
+
*/
|
|
76
|
+
function reportAttach(campaign, values) {
|
|
77
|
+
const result = attachSeat({ campaign, ssh: values.ssh });
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
process.stderr.write(`[seat] ${result.message ?? "cannot reattach to the seat"}\n`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
process.stdout.write(`${result.command}\n`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {SeatValues} values
|
|
88
|
+
*/
|
|
89
|
+
function reportStatus(values) {
|
|
90
|
+
const status = seatStatus();
|
|
91
|
+
if (values.json === true) {
|
|
92
|
+
process.stdout.write(`${JSON.stringify(status)}\n`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!status.tmux) {
|
|
96
|
+
process.stdout.write("[seat] tmux is not available; no seat can be listed\n");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!status.seats.length) {
|
|
100
|
+
process.stdout.write(`[seat] ${status.session} has no windows\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const seat of status.seats) {
|
|
104
|
+
process.stdout.write(`[seat] ${seat.campaign} · harness ${seat.harness ?? "unknown"} · ambient ${seat.canRenderAmbient ? "yes" : "no"}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string|undefined} campaign
|
|
110
|
+
*/
|
|
111
|
+
function reportStop(campaign) {
|
|
112
|
+
const result = stopSeat({ campaign });
|
|
113
|
+
if (!result.ok) {
|
|
114
|
+
process.stderr.write(`[seat] ${result.reason ?? "stop failed"}\n`);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
process.stdout.write(campaign ? `[seat] ${campaign} stopped\n` : "[seat] seat session stopped\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string|undefined} campaign
|
|
123
|
+
* @param {SeatValues} values
|
|
124
|
+
*/
|
|
125
|
+
function reportSwitch(campaign, values) {
|
|
126
|
+
const result = switchSeat({ campaign, harness: values.harness, cwd: values.cwd });
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
process.stderr.write(`[seat] ${result.message ?? result.reason}\n`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
process.stdout.write(`[seat] ${result.campaign} switched to ${result.harness} · ${result.session}:${result.window}\n`);
|
|
133
|
+
process.stdout.write(`${result.attachCommand}\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function usage() {
|
|
137
|
+
process.stderr.write("usage: faberun seat <start|attach|status|stop|switch> [<campaign-id>] [--cwd <dir>] ...\n");
|
|
138
|
+
process.exitCode = 2;
|
|
139
|
+
}
|