muse-crew 0.15.0 → 0.17.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/API.md +14 -0
- package/lib/AGENTS.md +3 -0
- package/lib/compare-dispatch-shadow.js +150 -0
- package/lib/crew-api.js +31 -0
- package/lib/crew-dispatch-worker.js +1059 -0
- package/lib/qa-deploy.mjs +498 -0
- package/lib/schema.sql +19 -0
- package/lib/spawn-boundary.js +101 -0
- package/package.json +1 -1
- package/seed/cron-body-template.md +8 -0
- package/workflows/bugfix.js +84 -50
- package/workflows/chore.js +8 -2
- package/workflows/crew-dispatch.js +8 -5
- package/workflows/standard.js +97 -50
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// qa-deploy.mjs — deterministic deploy for the QA environment.
|
|
3
|
+
//
|
|
4
|
+
// Ensures the pipeline-owned QA environment (<qa-dir>) serves a client/server
|
|
5
|
+
// bundle built from the repo's merged HEAD. This is a substrate-independent
|
|
6
|
+
// deterministic core: it runs as a plain process with shell and filesystem
|
|
7
|
+
// access (worker layer) and must NEVER be imported or executed inside the
|
|
8
|
+
// workflow sandbox — the sandbox has no child processes. The workflow reaches
|
|
9
|
+
// it only through a thin mechanical agent ferry that runs one exact pinned
|
|
10
|
+
// command and returns stdout verbatim; the ferry carries a single marker
|
|
11
|
+
// line, the full evidence is self-recorded here.
|
|
12
|
+
//
|
|
13
|
+
// The QA environment is SEPARATE from the web artifact directory
|
|
14
|
+
// (~/workspace/ts-spaces/<slug>/). That directory's own house rules forbid
|
|
15
|
+
// building it by hand, and the SDK's client bundler refuses to run outside
|
|
16
|
+
// the artifact pipeline — this module never touches it. The QA dir lives
|
|
17
|
+
// under the crew home (e.g. <crewHome>/qa-envs/<project>/) and is owned
|
|
18
|
+
// entirely by this pipeline: we clone, sync, build, validate, and serve it.
|
|
19
|
+
//
|
|
20
|
+
// Usage:
|
|
21
|
+
// node qa-deploy.mjs --repo <path> --qa-dir <path> --task <id>
|
|
22
|
+
// --crew-home <path> --crew-api <path> --serve-artifact <path>
|
|
23
|
+
// [--ensure] [--hash-only]
|
|
24
|
+
//
|
|
25
|
+
// --ensure Idempotent precondition: fast-path (skip sync+build) when the
|
|
26
|
+
// existing bundle already proves a hash that is a
|
|
27
|
+
// descendant-or-equal of this task's newest recorded deploy;
|
|
28
|
+
// otherwise run the full deploy. The servability probe always
|
|
29
|
+
// runs.
|
|
30
|
+
// --hash-only Read the bundle's baked hash and print BUNDLE_HASH <sha>|none.
|
|
31
|
+
// A pure read for QA closeout contamination checks; changes
|
|
32
|
+
// nothing.
|
|
33
|
+
//
|
|
34
|
+
// Exit codes: 0 = DEPLOY_OK (or hash read), 1 = DEPLOY_FAIL (operational),
|
|
35
|
+
// 2 = usage error. Stdout carries exactly one marker line; all diagnostics
|
|
36
|
+
// go to stderr. Deploy failures are operational and retryable — they never
|
|
37
|
+
// park the task; the workflow marks the step failed for retry.
|
|
38
|
+
//
|
|
39
|
+
// Deploy record (self-recorded via crew-api log-event, type "note"):
|
|
40
|
+
/// deploy: ok hash=<short> built=<full> mode=<full|fast> task=<id>
|
|
41
|
+
// deploy: fail layer=<layer> reason=<reason> task=<id>
|
|
42
|
+
// The details bypass the ferry; the ferry carries only:
|
|
43
|
+
// DEPLOY_OK <short-hash>
|
|
44
|
+
// DEPLOY_FAIL <layer>:<reason>
|
|
45
|
+
|
|
46
|
+
import { spawn, execFile } from "node:child_process";
|
|
47
|
+
import { existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
48
|
+
import { join } from "node:path";
|
|
49
|
+
|
|
50
|
+
const BUILD_TIMEOUT_MS = 600000;
|
|
51
|
+
const INSTALL_TIMEOUT_MS = 600000;
|
|
52
|
+
const SERVE_READY_TIMEOUT_MS = 30000;
|
|
53
|
+
const SERVE_KILL_TIMEOUT_MS = 10000;
|
|
54
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
55
|
+
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (a === "--ensure") { out.ensure = true; continue; }
|
|
61
|
+
if (a === "--hash-only") { out.hash_only = true; continue; }
|
|
62
|
+
if (a.startsWith("--")) { out[a.slice(2).replace(/-/g, "_")] = argv[++i]; continue; }
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function log(...parts) { process.stderr.write(parts.join(" ") + "\n"); }
|
|
68
|
+
|
|
69
|
+
// Run a command, capture stdout. Rejects on non-zero exit or spawn error.
|
|
70
|
+
function run(cmd, args, opts = {}) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const env = opts.env || process.env;
|
|
73
|
+
const child = execFile(cmd, args, {
|
|
74
|
+
cwd: opts.cwd, timeout: opts.timeout || 120000, maxBuffer: 4 * 1024 * 1024, env,
|
|
75
|
+
}, (err, stdout, stderr) => {
|
|
76
|
+
if (err) {
|
|
77
|
+
const e = new Error(cmd + " " + args.join(" ") + " failed: " +
|
|
78
|
+
String((stderr || "") + (err.message || "")).slice(0, 300));
|
|
79
|
+
e.code = err.code; e.stdout = stdout; e.stderr = stderr;
|
|
80
|
+
reject(e);
|
|
81
|
+
} else resolve(String(stdout || ""));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const git = (repo, ...args) => run("git", ["-C", repo, ...args]);
|
|
87
|
+
|
|
88
|
+
async function crewApi(crewApiPath, crewHome, command, json) {
|
|
89
|
+
const out = await run("node", [crewApiPath, "--crew-home", crewHome, command, "--json", JSON.stringify(json)]);
|
|
90
|
+
return JSON.parse(out);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function recordDeploy(a, ok, fields) {
|
|
94
|
+
const msg = ok
|
|
95
|
+
? "deploy: ok hash=" + fields.short + " built=" + fields.full + " mode=" + fields.mode + " task=" + a.task
|
|
96
|
+
: "deploy: fail layer=" + fields.layer + " reason=" + String(fields.reason).replace(/\s+/g, " ").slice(0, 200) + " task=" + a.task;
|
|
97
|
+
try {
|
|
98
|
+
await crewApi(a.crew_api, a.crew_home, "log-event",
|
|
99
|
+
{ task_id: a.task, type: "note", identity: "qa-deploy", message: msg });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
log("WARN: deploy record not written:", e.message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Newest recorded deploy for this task: { ok, full } or null.
|
|
106
|
+
async function newestDeployRecord(a) {
|
|
107
|
+
try {
|
|
108
|
+
const res = await crewApi(a.crew_api, a.crew_home, "get-events", { task_id: a.task, limit: 100 });
|
|
109
|
+
for (const ev of (res.events || [])) {
|
|
110
|
+
const m = /^deploy: (ok|fail) /.exec(ev.message || "");
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
if (m[1] === "ok") {
|
|
113
|
+
const h = /built=([0-9a-f]{40})/.exec(ev.message);
|
|
114
|
+
if (h) return { ok: true, full: h[1] };
|
|
115
|
+
}
|
|
116
|
+
return { ok: false };
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
log("WARN: cannot read deploy history:", e.message);
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Read the baked source hash from the built server bundle. The project's own
|
|
125
|
+
// prebuild (bake-binding.mjs) inlines server/src/binding.gen.json — carrying
|
|
126
|
+
// source_commit — into server/dist/actions.js. Corroborate with space.json's
|
|
127
|
+
// build_source_commit (also written by the prebuild). Returns the full SHA or
|
|
128
|
+
// throws with a hash-layer reason.
|
|
129
|
+
function readBundleHash(qaDir) {
|
|
130
|
+
const bundlePath = join(qaDir, "server", "dist", "actions.js");
|
|
131
|
+
if (!existsSync(bundlePath)) throw new Error("server bundle missing");
|
|
132
|
+
const src = readFileSync(bundlePath, "utf8");
|
|
133
|
+
const found = new Set();
|
|
134
|
+
// The inlined binding.gen.json may appear as a JSON object ("source_commit")
|
|
135
|
+
// or as a JS object literal (source_commit) depending on the bundler.
|
|
136
|
+
const re = /["']?source_commit["']?\s*:\s*["']([0-9a-f]{40})["']/g;
|
|
137
|
+
let m;
|
|
138
|
+
while ((m = re.exec(src)) !== null) found.add(m[1]);
|
|
139
|
+
if (found.size === 0) throw new Error("no source_commit baked in server bundle");
|
|
140
|
+
if (found.size > 1) throw new Error("ambiguous source_commit in server bundle: " + [...found].join(","));
|
|
141
|
+
const h = [...found][0];
|
|
142
|
+
const spaceJsonPath = join(qaDir, "space.json");
|
|
143
|
+
if (existsSync(spaceJsonPath)) {
|
|
144
|
+
let sj;
|
|
145
|
+
try { sj = JSON.parse(readFileSync(spaceJsonPath, "utf8")); }
|
|
146
|
+
catch (e) { throw new Error("space.json unparseable: " + e.message); }
|
|
147
|
+
if (sj.build_source_commit && sj.build_source_commit !== h) {
|
|
148
|
+
throw new Error("space.json build_source_commit " + sj.build_source_commit +
|
|
149
|
+
" disagrees with bundle " + h);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return h;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function distPresent(qaDir) {
|
|
156
|
+
for (const rel of ["client/dist/index.html", "server/dist/actions.js"]) {
|
|
157
|
+
const p = join(qaDir, rel);
|
|
158
|
+
if (!existsSync(p)) return { ok: false, missing: rel };
|
|
159
|
+
try { if (statSync(p).size === 0) return { ok: false, missing: rel + " (empty)" }; }
|
|
160
|
+
catch (e) { return { ok: false, missing: rel }; }
|
|
161
|
+
}
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Layer 1: the repo (source of truth) is a clean tree at a valid HEAD.
|
|
166
|
+
async function checkCleanTree(repo) {
|
|
167
|
+
let head;
|
|
168
|
+
try { head = (await git(repo, "rev-parse", "HEAD")).trim(); }
|
|
169
|
+
catch (e) { throw new Error("not a git repo or HEAD unreadable"); }
|
|
170
|
+
if (!SHA_RE.test(head)) throw new Error("HEAD is not a full SHA: " + head);
|
|
171
|
+
const status = await git(repo, "status", "--porcelain");
|
|
172
|
+
if (status.trim() !== "") throw new Error("dirty tree: " + status.trim().split("\n").slice(0, 3).join("; "));
|
|
173
|
+
return head;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Layer 2a: first-time setup of the pipeline-owned QA environment — a local
|
|
177
|
+
// clone of the source repo plus installed dependencies. Idempotent: skips
|
|
178
|
+
// when the qa dir is already a git checkout with node_modules.
|
|
179
|
+
async function setupQaDir(repo, qaDir) {
|
|
180
|
+
let isRepo = false;
|
|
181
|
+
try { await git(qaDir, "rev-parse", "HEAD"); isRepo = true; }
|
|
182
|
+
catch (e) { isRepo = false; }
|
|
183
|
+
if (!isRepo) {
|
|
184
|
+
if (existsSync(qaDir)) {
|
|
185
|
+
throw new Error("qa dir exists but is not a git checkout — refusing to clobber; move it aside");
|
|
186
|
+
}
|
|
187
|
+
log("cloning repo into qa dir");
|
|
188
|
+
try { await run("git", ["clone", repo, qaDir], { timeout: INSTALL_TIMEOUT_MS }); }
|
|
189
|
+
catch (e) { throw new Error("git clone failed: " + e.message); }
|
|
190
|
+
}
|
|
191
|
+
if (!existsSync(join(qaDir, "node_modules"))) {
|
|
192
|
+
log("installing dependencies in qa dir");
|
|
193
|
+
try { await run("bun", ["install"], { cwd: qaDir, timeout: INSTALL_TIMEOUT_MS }); }
|
|
194
|
+
catch (e) { throw new Error("bun install failed: " + e.message); }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Layer 2b: sync the QA environment's source to the repo's HEAD via git.
|
|
199
|
+
async function syncSource(repo, qaDir, head) {
|
|
200
|
+
try {
|
|
201
|
+
await git(qaDir, "fetch", repo, head);
|
|
202
|
+
} catch (e) { throw new Error("fetch from repo failed: " + e.message); }
|
|
203
|
+
try {
|
|
204
|
+
await git(qaDir, "reset", "--hard", head);
|
|
205
|
+
} catch (e) { throw new Error("reset --hard failed: " + e.message); }
|
|
206
|
+
const now = (await git(qaDir, "rev-parse", "HEAD")).trim();
|
|
207
|
+
if (now !== head) throw new Error("qa dir HEAD " + now + " != " + head + " after reset");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Layer 2c: reproduce the artifact builder's codegen — extract the
|
|
211
|
+
// `definePrivilegedContracts({...})` block from server/src/privileged.ts into
|
|
212
|
+
// server/.generated/privileged.contract.ts. The project's tsconfig maps
|
|
213
|
+
// `@space/privileged` to that generated file; the directory is gitignored, so
|
|
214
|
+
// a fresh clone lacks it and the server bundle cannot resolve the import.
|
|
215
|
+
// web_artifact_build performs this extraction in the artifact pipeline; our
|
|
216
|
+
// pipeline reproduces it deterministically (verified byte-identical modulo
|
|
217
|
+
// header). The extraction is a pure data copy — the privileged handlers stay
|
|
218
|
+
// in privileged.ts and never enter the action bundle, preserving the
|
|
219
|
+
// platform's security boundary.
|
|
220
|
+
function extractContractBlock(src) {
|
|
221
|
+
const marker = "export const privileged = definePrivilegedContracts({";
|
|
222
|
+
const start = src.indexOf(marker);
|
|
223
|
+
if (start === -1) throw new Error("contract marker not found in server/src/privileged.ts");
|
|
224
|
+
let i = start + marker.length - 1; // at the opening {
|
|
225
|
+
let depth = 0;
|
|
226
|
+
let state = "code"; // code | squote | dquote | tquote | linecomment | blockcomment | regex
|
|
227
|
+
let tmplDepth = 0;
|
|
228
|
+
for (; i < src.length; i++) {
|
|
229
|
+
const c = src[i], n = src[i + 1];
|
|
230
|
+
if (state === "code") {
|
|
231
|
+
if (c === "'" ) { state = "squote"; }
|
|
232
|
+
else if (c === '"') { state = "dquote"; }
|
|
233
|
+
else if (c === "`") { state = "tquote"; tmplDepth = 0; }
|
|
234
|
+
else if (c === "/" && n === "/") { state = "linecomment"; i++; }
|
|
235
|
+
else if (c === "/" && n === "*") { state = "blockcomment"; i++; }
|
|
236
|
+
else if (c === "/" && isRegexStart(src, i)) { state = "regex"; }
|
|
237
|
+
else if (c === "{") { depth++; }
|
|
238
|
+
else if (c === "}") {
|
|
239
|
+
depth--;
|
|
240
|
+
if (depth === 0) {
|
|
241
|
+
// Expect the closing ");" of the definePrivilegedContracts call.
|
|
242
|
+
const rest = src.slice(i, i + 3);
|
|
243
|
+
if (rest[1] !== ")" || rest[2] !== ";") {
|
|
244
|
+
throw new Error("contract block does not end with }); — source shape changed");
|
|
245
|
+
}
|
|
246
|
+
return src.slice(start, i + 3);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else if (state === "squote" || state === "dquote") {
|
|
250
|
+
const q = state === "squote" ? "'" : '"';
|
|
251
|
+
if (c === "\\") i++;
|
|
252
|
+
else if (c === q) state = "code";
|
|
253
|
+
} else if (state === "tquote") {
|
|
254
|
+
if (c === "\\") i++;
|
|
255
|
+
else if (c === "`" && tmplDepth === 0) state = "code";
|
|
256
|
+
else if (c === "$" && n === "{") { tmplDepth++; i++; }
|
|
257
|
+
else if (c === "}" && tmplDepth > 0) tmplDepth--;
|
|
258
|
+
} else if (state === "linecomment") {
|
|
259
|
+
if (c === "\n") state = "code";
|
|
260
|
+
} else if (state === "blockcomment") {
|
|
261
|
+
if (c === "*" && n === "/") { state = "code"; i++; }
|
|
262
|
+
} else if (state === "regex") {
|
|
263
|
+
if (c === "\\") i++;
|
|
264
|
+
else if (c === "[") { // character class — skip to ]
|
|
265
|
+
let j = i + 1;
|
|
266
|
+
if (src[j] === "^") j++;
|
|
267
|
+
if (src[j] === "]") j++;
|
|
268
|
+
while (j < src.length && src[j] !== "]") { if (src[j] === "\\") j++; j++; }
|
|
269
|
+
i = j;
|
|
270
|
+
}
|
|
271
|
+
else if (c === "/") state = "code";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
throw new Error("unterminated contract block — source shape changed");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Heuristic: a / starts a regex (not division) when the previous significant
|
|
278
|
+
// token ends an expression-opener. Conservative: only true for the positions
|
|
279
|
+
// where the contract block uses regexes (after `(` in `.regex(/.../`).
|
|
280
|
+
function isRegexStart(src, i) {
|
|
281
|
+
let j = i - 1;
|
|
282
|
+
while (j >= 0 && " \t\n\r".includes(src[j])) j--;
|
|
283
|
+
if (j < 0) return true;
|
|
284
|
+
return "(:,=![&|?{;".includes(src[j]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function generateContract(qaDir) {
|
|
288
|
+
const srcPath = join(qaDir, "server", "src", "privileged.ts");
|
|
289
|
+
const outDir = join(qaDir, "server", ".generated");
|
|
290
|
+
const outPath = join(outDir, "privileged.contract.ts");
|
|
291
|
+
let src;
|
|
292
|
+
try { src = readFileSync(srcPath, "utf8"); }
|
|
293
|
+
catch (e) { throw new Error("cannot read server/src/privileged.ts: " + e.message); }
|
|
294
|
+
const block = extractContractBlock(src);
|
|
295
|
+
const out = "// Generated by qa-deploy from server/src/privileged.ts. Do not edit.\n" +
|
|
296
|
+
"import { definePrivilegedContracts, z } from \"@hatch/space-sdk\";\n\n" + block + "\n";
|
|
297
|
+
mkdirSync(outDir, { recursive: true });
|
|
298
|
+
writeFileSync(outPath, out);
|
|
299
|
+
log("contract generated: " + outPath);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Layer 3: deterministic build in the QA environment.
|
|
303
|
+
async function buildQa(qaDir, crewHome) {
|
|
304
|
+
// The project's client/build.mjs delegates to the SDK's buildClient(),
|
|
305
|
+
// which requires HATCH_SPACES_BUILD_DRIVER=1. That guard (and the web
|
|
306
|
+
// artifact's own AGENTS.md) protects the LIVE web artifact directory from
|
|
307
|
+
// hand-built bundles being mistaken for published ones — this pipeline
|
|
308
|
+
// never touches that directory. The QA dir is pipeline-owned; our pipeline
|
|
309
|
+
// IS the build driver here, and the bundle is validated independently
|
|
310
|
+
// (baked hash == HEAD, servability probe) rather than trusted. Setting the
|
|
311
|
+
// variable invokes the SDK's canonical bundler; the guard and the
|
|
312
|
+
// project's build are not modified.
|
|
313
|
+
//
|
|
314
|
+
// CREW_HOME selects the dashboard's binding mode: set (to a directory) it
|
|
315
|
+
// builds the live binding (with source_commit baked); absent it builds a
|
|
316
|
+
// snapshot with empty data. QA needs the live binding — Hazel tests the
|
|
317
|
+
// real dashboard against real crew data, and the hash layer needs the
|
|
318
|
+
// baked source_commit.
|
|
319
|
+
const env = Object.assign({}, process.env, {
|
|
320
|
+
HATCH_SPACES_BUILD_DRIVER: "1",
|
|
321
|
+
CREW_HOME: crewHome,
|
|
322
|
+
});
|
|
323
|
+
await new Promise((resolve, reject) => {
|
|
324
|
+
const child = spawn("bun", ["run", "build"], { cwd: qaDir, stdio: ["ignore", "pipe", "pipe"], env });
|
|
325
|
+
let out = "", err = "";
|
|
326
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
327
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
328
|
+
const timer = setTimeout(() => {
|
|
329
|
+
child.kill("SIGKILL");
|
|
330
|
+
reject(new Error("build timed out after " + (BUILD_TIMEOUT_MS / 1000) + "s"));
|
|
331
|
+
}, BUILD_TIMEOUT_MS);
|
|
332
|
+
child.on("error", (e) => { clearTimeout(timer); reject(new Error("cannot start bun: " + e.message)); });
|
|
333
|
+
child.on("close", (code) => {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
if (code === 0) resolve();
|
|
336
|
+
else reject(new Error("bun run build exited " + code + ": " + (err + out).slice(-500).replace(/\s+/g, " ")));
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Layer 6: the servability probe — the built bundle must boot and print READY.
|
|
342
|
+
async function probeServe(serveArtifactPath, qaDir, tag) {
|
|
343
|
+
await new Promise((resolve, reject) => {
|
|
344
|
+
const child = spawn("node", [serveArtifactPath, "--space-dir", qaDir, "--port", "0", "--tag", tag],
|
|
345
|
+
{ stdio: ["ignore", "pipe", "pipe"] });
|
|
346
|
+
let out = "", err = "";
|
|
347
|
+
const readyTimer = setTimeout(() => {
|
|
348
|
+
child.kill("SIGKILL");
|
|
349
|
+
reject(new Error("no READY within " + (SERVE_READY_TIMEOUT_MS / 1000) + "s; stderr: " + err.slice(-300)));
|
|
350
|
+
}, SERVE_READY_TIMEOUT_MS);
|
|
351
|
+
const finish = (code) => {
|
|
352
|
+
clearTimeout(readyTimer);
|
|
353
|
+
const np = /\{\s*"ok"\s*:\s*false[\s\S]*?"not_possible"\s*:\s*"([^"]*)"/.exec(out);
|
|
354
|
+
reject(new Error(code === 3 && np
|
|
355
|
+
? "NOT POSSIBLE: " + np[1]
|
|
356
|
+
: "server exited " + code + " before READY: " + (out + err).slice(-300).replace(/\s+/g, " ")));
|
|
357
|
+
};
|
|
358
|
+
child.stdout.on("data", (d) => {
|
|
359
|
+
out += d;
|
|
360
|
+
const r = /READY port=(\d+)/.exec(out);
|
|
361
|
+
if (r) {
|
|
362
|
+
clearTimeout(readyTimer);
|
|
363
|
+
log("probe READY on port " + r[1] + " — shutting down");
|
|
364
|
+
child.kill("SIGTERM");
|
|
365
|
+
const kt = setTimeout(() => child.kill("SIGKILL"), SERVE_KILL_TIMEOUT_MS);
|
|
366
|
+
child.on("close", () => { clearTimeout(kt); resolve(); });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
370
|
+
child.on("error", (e) => {
|
|
371
|
+
clearTimeout(readyTimer);
|
|
372
|
+
reject(new Error("cannot start serve-artifact: " + e.message));
|
|
373
|
+
});
|
|
374
|
+
child.on("close", (code) => { if (!/READY port=/.test(out)) finish(code); });
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const a = parseArgs(process.argv.slice(2));
|
|
379
|
+
|
|
380
|
+
// lib/AGENTS.md shebang⇔CLI contract: --help answers with a usage line on
|
|
381
|
+
// stdout and exit 0, handled before required-argument parsing (the release
|
|
382
|
+
// entry gate executes every shebang'd JS entry as `node <file> --help`).
|
|
383
|
+
if (process.argv.includes("--help")) {
|
|
384
|
+
process.stdout.write("usage: node qa-deploy.mjs --repo <path> --qa-dir <path> --task <id> --crew-home <path> --crew-api <path> --serve-artifact <path> [--ensure] [--hash-only]\n");
|
|
385
|
+
process.exit(0);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function main() {
|
|
389
|
+
// `a` is module-scoped so the top-level catch can self-record.
|
|
390
|
+
if (a.hash_only) {
|
|
391
|
+
// Pure read: only --qa-dir is required.
|
|
392
|
+
if (!a.qa_dir) { process.stderr.write("usage: --qa-dir required\n"); process.exit(2); }
|
|
393
|
+
try {
|
|
394
|
+
const h = readBundleHash(a.qa_dir);
|
|
395
|
+
process.stdout.write("BUNDLE_HASH " + h + "\n");
|
|
396
|
+
} catch (e) {
|
|
397
|
+
process.stdout.write("BUNDLE_HASH none\n");
|
|
398
|
+
}
|
|
399
|
+
process.exit(0);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const k of ["repo", "qa_dir", "task", "crew_home", "crew_api", "serve_artifact"]) {
|
|
403
|
+
if (!a[k]) { process.stderr.write("usage: missing --" + k.replace(/_/g, "-") + "\n"); process.exit(2); }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const fail = async (layer, reason) => {
|
|
407
|
+
log("DEPLOY_FAIL " + layer + ": " + reason);
|
|
408
|
+
await recordDeploy(a, false, { layer, reason });
|
|
409
|
+
process.stdout.write("DEPLOY_FAIL " + layer + ":" + String(reason).replace(/[\r\n]+/g, " ").slice(0, 200) + "\n");
|
|
410
|
+
process.exit(1);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// Layer 1: clean tree at HEAD (source of truth).
|
|
414
|
+
let head;
|
|
415
|
+
try { head = await checkCleanTree(a.repo); }
|
|
416
|
+
catch (e) { await fail("clean-tree", e.message); return; }
|
|
417
|
+
const short = head.slice(0, 7);
|
|
418
|
+
log("HEAD " + head);
|
|
419
|
+
|
|
420
|
+
let mode = "full";
|
|
421
|
+
if (a.ensure) {
|
|
422
|
+
// Idempotent precondition: does the existing bundle already prove a hash
|
|
423
|
+
// that is a descendant-or-equal of this task's newest recorded deploy?
|
|
424
|
+
// The chain is sound: every recorded deploy built a HEAD that contained
|
|
425
|
+
// the task's merge (Integrate verified the merge first), so a bundle
|
|
426
|
+
// descended from a recorded deploy contains the task's work.
|
|
427
|
+
try {
|
|
428
|
+
const dp = distPresent(a.qa_dir);
|
|
429
|
+
if (dp.ok) {
|
|
430
|
+
const h = readBundleHash(a.qa_dir);
|
|
431
|
+
const rec = await newestDeployRecord(a);
|
|
432
|
+
if (rec && rec.ok) {
|
|
433
|
+
try {
|
|
434
|
+
await git(a.repo, "merge-base", "--is-ancestor", rec.full, h);
|
|
435
|
+
mode = "fast";
|
|
436
|
+
log("fast path: bundle " + h.slice(0, 7) + " descends from recorded deploy " + rec.full.slice(0, 7));
|
|
437
|
+
} catch (e) {
|
|
438
|
+
log("bundle " + h.slice(0, 7) + " does not descend from recorded deploy — full deploy");
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
log("no successful recorded deploy — full deploy");
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
log("dist missing (" + dp.missing + ") — full deploy");
|
|
445
|
+
}
|
|
446
|
+
} catch (e) {
|
|
447
|
+
log("fast-path check inconclusive (" + e.message + ") — full deploy");
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (mode === "full") {
|
|
452
|
+
// Layer 2: set up and sync the QA environment.
|
|
453
|
+
try { await setupQaDir(a.repo, a.qa_dir); }
|
|
454
|
+
catch (e) { await fail("setup", e.message); return; }
|
|
455
|
+
try { await syncSource(a.repo, a.qa_dir, head); }
|
|
456
|
+
catch (e) { await fail("sync", e.message); return; }
|
|
457
|
+
log("source synced to " + short);
|
|
458
|
+
// Layer 2c: reproduce the artifact builder's codegen.
|
|
459
|
+
try { await generateContract(a.qa_dir); }
|
|
460
|
+
catch (e) { await fail("codegen", e.message); return; }
|
|
461
|
+
// Layer 3: deterministic build.
|
|
462
|
+
try { await buildQa(a.qa_dir, a.crew_home); }
|
|
463
|
+
catch (e) { await fail("build", e.message); return; }
|
|
464
|
+
log("build ok");
|
|
465
|
+
// Layer 4: dist present and non-empty.
|
|
466
|
+
const dp = distPresent(a.qa_dir);
|
|
467
|
+
if (!dp.ok) { await fail("dist", "missing: " + dp.missing); return; }
|
|
468
|
+
// Layer 5: the bundle's baked hash reads back equal to HEAD.
|
|
469
|
+
let h;
|
|
470
|
+
try { h = readBundleHash(a.qa_dir); }
|
|
471
|
+
catch (e) { await fail("hash", e.message); return; }
|
|
472
|
+
if (h !== head) { await fail("hash", "bundle baked " + h + " != HEAD " + head); return; }
|
|
473
|
+
log("hash ok: bundle proves " + short);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Layer 6: servability probe (always runs, even on the fast path).
|
|
477
|
+
try {
|
|
478
|
+
await probeServe(a.serve_artifact, a.qa_dir, a.task + "-deploy");
|
|
479
|
+
} catch (e) { await fail("serve", e.message); return; }
|
|
480
|
+
log("serve ok");
|
|
481
|
+
|
|
482
|
+
await recordDeploy(a, true, { short, full: head, mode });
|
|
483
|
+
process.stdout.write("DEPLOY_OK " + short + "\n");
|
|
484
|
+
process.exit(0);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
main().catch(async (e) => {
|
|
488
|
+
process.stderr.write("unexpected: " + (e && e.stack || e) + "\n");
|
|
489
|
+
try {
|
|
490
|
+
if (a.task && a.crew_api && a.crew_home) {
|
|
491
|
+
await recordDeploy(a, false, { layer: "internal", reason: String((e && e.message) || e).slice(0, 200) });
|
|
492
|
+
}
|
|
493
|
+
} catch (recErr) {
|
|
494
|
+
process.stderr.write("WARN: could not record internal failure: " + recErr.message + "\n");
|
|
495
|
+
}
|
|
496
|
+
process.stdout.write("DEPLOY_FAIL internal:unexpected error\n");
|
|
497
|
+
process.exit(1);
|
|
498
|
+
});
|
package/lib/schema.sql
CHANGED
|
@@ -281,3 +281,22 @@ CREATE TABLE IF NOT EXISTS builder_reports (
|
|
|
281
281
|
);
|
|
282
282
|
CREATE INDEX IF NOT EXISTS builder_reports_task_id_idx ON builder_reports(task_id);
|
|
283
283
|
CREATE INDEX IF NOT EXISTS builder_reports_version_idx ON builder_reports(version);
|
|
284
|
+
|
|
285
|
+
-- Worker-layer run ledger (Piece 1, 2026-09-26): every worker-layer
|
|
286
|
+
-- orchestration run (dispatcher, and later workflow phases) records its
|
|
287
|
+
-- start/finish here. The sandboxed workflow runtime keeps writing
|
|
288
|
+
-- workflow_runs; this table is the worker-layer counterpart. Timestamps
|
|
289
|
+
-- are written by SQLite (strftime), never by worker JS — the determinism
|
|
290
|
+
-- guard forbids wall-clock in orchestration code (G3).
|
|
291
|
+
CREATE TABLE IF NOT EXISTS worker_runs (
|
|
292
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
293
|
+
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
|
294
|
+
phase TEXT NOT NULL,
|
|
295
|
+
executor TEXT NOT NULL DEFAULT 'worker' CHECK (executor = 'worker'),
|
|
296
|
+
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed')),
|
|
297
|
+
error TEXT,
|
|
298
|
+
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
299
|
+
ended_at TEXT
|
|
300
|
+
);
|
|
301
|
+
CREATE INDEX IF NOT EXISTS worker_runs_task_id_idx ON worker_runs(task_id);
|
|
302
|
+
CREATE INDEX IF NOT EXISTS worker_runs_phase_idx ON worker_runs(phase);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// lib/spawn-boundary.js — fail-closed spawn bounds validator (G1a).
|
|
2
|
+
//
|
|
3
|
+
// Piece 1 of the sandbox exit. Every creative-agent spawn on the worker
|
|
4
|
+
// layer goes through this module BEFORE the orchestrator emits NEED_SPAWN.
|
|
5
|
+
// It does not perform the spawn — the worker agent does that — it validates
|
|
6
|
+
// the bounds and FAILS CLOSED (throws, no spawn) unless every rule holds.
|
|
7
|
+
//
|
|
8
|
+
// Rules (DESIGN-v2 G1a):
|
|
9
|
+
// - prompt_file, schema_file, cwd, env, workdir, crewHome must ALL be declared.
|
|
10
|
+
// No spawn without declared bounds. Ever.
|
|
11
|
+
// - prompt_file and schema_file must be co-located (same directory).
|
|
12
|
+
// - cwd must resolve inside workdir (the task's own worktree or a designated
|
|
13
|
+
// scratch dir). `..` escapes fail closed.
|
|
14
|
+
// - cwd must NOT contain denied fragments (defense in depth): crew-state
|
|
15
|
+
// databases, crew homes, observer state, dispatch logs.
|
|
16
|
+
// - env must be an object; no key may match a denied sensitive pattern.
|
|
17
|
+
//
|
|
18
|
+
// This module is import-safe: no side effects on import, bare `node` exits 0.
|
|
19
|
+
|
|
20
|
+
import { resolve, dirname, relative } from "node:path";
|
|
21
|
+
|
|
22
|
+
const DENIED_PATH_FRAGMENTS = [
|
|
23
|
+
"crew-state.db",
|
|
24
|
+
".crew-soak-gate2",
|
|
25
|
+
".observer",
|
|
26
|
+
".tick-releases.jsonl",
|
|
27
|
+
".dispatch-decisions.jsonl",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const DENIED_ENV_PATTERNS = [
|
|
31
|
+
"KEY",
|
|
32
|
+
"TOKEN",
|
|
33
|
+
"SECRET",
|
|
34
|
+
"PASSWORD",
|
|
35
|
+
"PRIVATE",
|
|
36
|
+
"CREDENTIAL",
|
|
37
|
+
"AUTH",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function fail(violation) {
|
|
41
|
+
const err = new Error("spawn-boundary: BOUNDS_REJECTED: " + violation);
|
|
42
|
+
err.code = "BOUNDS_REJECTED";
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate spawn bounds. Returns { cwd, env } (resolved/normalized) on
|
|
47
|
+
// success. Throws with code BOUNDS_REJECTED on any violation.
|
|
48
|
+
export function validateSpawnBounds(bounds) {
|
|
49
|
+
if (!bounds || typeof bounds !== "object") fail("bounds must be an object");
|
|
50
|
+
|
|
51
|
+
const { prompt_file, schema_file, cwd, env, workdir, crewHome } = bounds;
|
|
52
|
+
|
|
53
|
+
if (typeof prompt_file !== "string" || prompt_file.length === 0)
|
|
54
|
+
fail("prompt_file must be a declared non-empty string");
|
|
55
|
+
if (typeof schema_file !== "string" || schema_file.length === 0)
|
|
56
|
+
fail("schema_file must be a declared non-empty string");
|
|
57
|
+
if (typeof cwd !== "string" || cwd.length === 0)
|
|
58
|
+
fail("cwd must be a declared non-empty string");
|
|
59
|
+
if (!env || typeof env !== "object" || Array.isArray(env))
|
|
60
|
+
fail("env must be a declared object");
|
|
61
|
+
if (typeof workdir !== "string" || workdir.length === 0)
|
|
62
|
+
fail("workdir must be a declared non-empty string");
|
|
63
|
+
if (typeof crewHome !== "string" || crewHome.length === 0)
|
|
64
|
+
fail("crewHome must be a declared non-empty string");
|
|
65
|
+
|
|
66
|
+
// Schema co-location: prompt and schema travel together.
|
|
67
|
+
if (dirname(resolve(prompt_file)) !== dirname(resolve(schema_file)))
|
|
68
|
+
fail("prompt_file and schema_file must be co-located (same directory)");
|
|
69
|
+
|
|
70
|
+
// cwd must resolve inside workdir — no `..` escapes.
|
|
71
|
+
const resolvedCwd = resolve(cwd);
|
|
72
|
+
const resolvedWorkdir = resolve(workdir);
|
|
73
|
+
const rel = relative(resolvedWorkdir, resolvedCwd);
|
|
74
|
+
if (rel === ".." || rel.startsWith("../") || resolve(resolvedWorkdir, rel) !== resolvedCwd)
|
|
75
|
+
fail("cwd must resolve inside workdir (got " + cwd + ")");
|
|
76
|
+
|
|
77
|
+
// Defense in depth: denied fragments anywhere in the resolved cwd.
|
|
78
|
+
for (const frag of DENIED_PATH_FRAGMENTS) {
|
|
79
|
+
if (resolvedCwd.includes(frag)) fail("cwd inside denied path: " + frag);
|
|
80
|
+
}
|
|
81
|
+
// The crew home itself is never a spawn cwd, even without a fragment hit.
|
|
82
|
+
const crewRel = relative(resolve(crewHome), resolvedCwd);
|
|
83
|
+
if (crewRel === "" || (!crewRel.startsWith("../") && crewRel !== ".."))
|
|
84
|
+
fail("cwd must not be inside crewHome");
|
|
85
|
+
|
|
86
|
+
// Sensitive environment denied by default.
|
|
87
|
+
for (const key of Object.keys(env)) {
|
|
88
|
+
const upper = String(key).toUpperCase();
|
|
89
|
+
for (const pat of DENIED_ENV_PATTERNS) {
|
|
90
|
+
if (upper.includes(pat)) fail("env key denied: " + key);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { cwd: resolvedCwd, env: { ...env } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Declared constants for tests and for orchestrators that build bounds.
|
|
98
|
+
export const BOUNDARY = {
|
|
99
|
+
DENIED_PATH_FRAGMENTS: [...DENIED_PATH_FRAGMENTS],
|
|
100
|
+
DENIED_ENV_PATTERNS: [...DENIED_ENV_PATTERNS],
|
|
101
|
+
};
|
package/package.json
CHANGED
|
@@ -43,6 +43,10 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
|
|
|
43
43
|
|
|
44
44
|
2. **Load the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and keep its RAW TEXT — do NOT parse it, do NOT re-emit it as a JSON object. Pass the text verbatim: never reformat, summarize, or hand-transcribe it (room #26: 15+ launch rejections 09-19→09-21 from garbled nested-object args, and one hallucinated registry that passed validation). If the file does not exist (the live release predates the registry), proceed without it — omit the `registryText` arg and the dispatcher will load the registry the slow way and log a warning. If the read FAILS on a file that exists (transient read error — observed 2026-09-13; the file itself was healthy and later ticks read it fine), retry the read once; if it still fails, write the error text into your run summary (observability — never silently swallow a failed read) and proceed without the registry the same way.
|
|
45
45
|
|
|
46
|
+
2.5. **Shadow dispatch (Piece 1, 2026-09-26):** the worker-layer dispatcher (`lib/crew-dispatch-worker.js`) is the sandbox exit's replacement for the sandboxed dispatcher in Step 3. It is NOT authoritative yet — Step 3's sandboxed dispatcher still owns every launch decision. Run the worker dispatcher in read-only shadow mode BEFORE Step 3 (both must read the same board state; the shadow never mutates it). Run in shell:
|
|
47
|
+
`node {crewHome}/lib/crew-dispatch-worker.js --crew-home {crewHome} --read-only`
|
|
48
|
+
The last stdout line is a JSON result (`{status, claims, ...}`); lines before it are the decision log. The script appends its own shadow evidence to `{crewHome}/.dispatch-shadow.jsonl` — do NOT transcribe or re-emit the claims yourself. If the script exits non-zero, log `SHADOW_FAIL <stderr tail>` and continue to Step 3 (the shadow is evidence, never a gate). A zero exit with claims is normal — the claims are recommendations only; NOTHING is launched from them. Every claim carries `executor: "worker"`; the shadow comparison ignores it.
|
|
49
|
+
|
|
46
50
|
3. **Run the dispatcher:** Call workflow_launch with scriptPath "{crewHome}/workflows/crew-dispatch.js" and args {"crewHome": "{crewHome}", "registryText": <raw registry file text, or omit the key when the file was missing>}. The dispatcher parses the text itself — a nested-object `registry` arg is not accepted. If the launch is rejected, retry once with only {"crewHome": "{crewHome}"} (the sanctioned fallback — the dispatcher loads the registry the slow way); a second rejection is a platform problem, not an args problem — write it in your run summary and move on.
|
|
47
51
|
|
|
48
52
|
Wait for it to complete. It reads the crew's task state, determines eligibility, claims tasks, acknowledges the poll, and returns structured results.
|
|
@@ -106,6 +110,10 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
|
|
|
106
110
|
- `timeouts` = fully handled by code — take NO action, just log. The 2-hour total budget from first issuance is exhausted with no acknowledgement; code writes the terminal `publish: version-timeout` note — parked for human attention. `timeouts` is terminal-budget exhaustion only — a window expiry is a `reissued`, never a timeout.
|
|
107
111
|
- `reissued` = fresh intents for the next attempt — a 30-minute window expired with no acknowledgement, so code re-issued with a fresh version (by derivation), immediately claimable (no backoff). This tick does NOT issue them; the NEXT tick's `scan-publish-intent` claims them. Log only.
|
|
108
112
|
- `skipped` = window still open or evidence absent — log only, take no action.
|
|
113
|
+
|
|
114
|
+
4.6. **Shadow verdict (Piece 1, 2026-09-26):** after the Step-3 dispatcher's decision is recorded, pair its decision with the Step-2.5 shadow evidence. Run in shell:
|
|
115
|
+
`node {crewHome}/lib/compare-dispatch-shadow.js --crew-home {crewHome}`
|
|
116
|
+
Log every `SHADOW_*` line verbatim. `SHADOW_MATCH` means the worker layer agreed with the sandbox on this tick's claims. `SHADOW_DIVERGE` names the differing claim triples (`task_id|workflow|step`) — log them; divergence is evidence for the cutover review, not a tick failure, so continue the tick. `SHADOW_UNPAIRED` means the authoritative decision line is missing (the Step-3 dispatcher died after the shadow ran) — log it loudly and continue. `SHADOW_NO_EVIDENCE` on the first shadowed tick is normal. Verdicts append to `{crewHome}/.dispatch-shadow-verdicts.jsonl`; the cutover decision after ~100-200 ticks is made from that file, never from a single tick.
|
|
109
117
|
- Never stamp provenance from prose. Never infer a verdict from an inspector's summary text. The exact version string is the sole positive signal.
|
|
110
118
|
|
|
111
119
|
5. **Monitor launched workflows until terminal (stay-alive — 2026-09-13):** The platform ties async workflow `agent()` authorization to the launcher's lifetime: if THIS tick ends while a workflow is still running, the workflow's next `agent()` call fails with "subagent bootstrap is no longer authorized" / "subagent reservation owner is terminal". Prevention beats recovery here, so this tick is configured with a 90-minute execution timeout (`timeout_secs: 5400` in seed/crons.json) and you MUST stay alive until every launched run reaches a terminal state. Do not exit early while a launched run is still `running` — your death is what kills it.
|