muse-crew 0.14.13 → 0.16.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 +2 -0
- package/lib/crew-api.js +49 -2
- package/lib/qa-deploy.mjs +498 -0
- package/lib/schema.sql +7 -0
- package/package.json +1 -1
- package/workflows/bugfix.js +89 -3
- package/workflows/chore.js +8 -2
- package/workflows/crew-dispatch.js +6 -1
- package/workflows/standard.js +102 -3
package/API.md
CHANGED
|
@@ -489,6 +489,8 @@ Register a new project.
|
|
|
489
489
|
| `quiesced` | boolean | no | Start paused; defaults to false |
|
|
490
490
|
| `visual_protocol` | boolean or null | no | Tri-state: `null` = inherit crew default (off), `true` = enable for this project, `false` = explicit off. Defaults to null. |
|
|
491
491
|
| `environment_type` | `artifact` · `terminal` · null | no | User-facing surface for experiential QA routing: `artifact` = a rendered web UI (Hazel drives it with the see-act browser loop), `terminal` = a CLI (Hazel drives it herself, keeping transcripts). `null` = unclassified: no experiential QA. `deploy_type` names the deployment target, but `deploy_type: "artifact"` remains a legacy artifact-surface signal so pre-field projects keep today's experiential QA (the migration does not backfill the column); on conflicting config artifact wins. Omitted means auto-classify from `repo_path` (see surface classification below); an explicit value, including explicit `null`, always wins. |
|
|
492
|
+
| `versioned_build` | boolean | no | Opt-in to the QA staleness gate (issue #3): when true, Integrate bumps `BUILD_NUMBER` in `version_file` after each merge (separate commit, pushed), and QA verifies the tested bundle's footer `build <n>` is at/past the recorded version before testing. Defaults to false (unversioned); the migration does not backfill. |
|
|
493
|
+
| `version_file` | string or null | no | Repo-relative path of the TypeScript file exporting `BUILD_NUMBER` (e.g. `client/src/buildNumber.ts`). Only meaningful when `versioned_build` is true; defaults to `client/src/buildNumber.ts` when omitted. |
|
|
492
494
|
|
|
493
495
|
**Surface classification (repo-derived defaults):** when `repo_path` is provided and `environment_type` is omitted, `create-project` mechanically classifies the repo at registration via `lib/classify-surface.js` — `space.json` present → `artifact`; else a non-empty `package.json` `bin` → `terminal`; otherwise unclassified (`null`). The classifier never throws: bad paths and malformed files classify as unclassified. Classification only fills omitted fields — an explicitly supplied `environment_type` (including explicit `null`) always wins, and deploy defaults follow the *effective* surface, so an explicit `"terminal"` never triggers artifact deploy defaults.
|
|
494
496
|
|
package/lib/crew-api.js
CHANGED
|
@@ -73,6 +73,21 @@ function validateEnvironmentType(value) {
|
|
|
73
73
|
if (value === "artifact" || value === "terminal") return value;
|
|
74
74
|
throw usageError("environment_type must be 'artifact', 'terminal', or null.");
|
|
75
75
|
}
|
|
76
|
+
// versioned_build validation: boolean. undefined means "not provided"
|
|
77
|
+
// (create: defaults to false; update: leaves the field alone). Coerces
|
|
78
|
+
// truthy/falsy to 1/0 for the INTEGER column.
|
|
79
|
+
function validateVersionedBuild(value) {
|
|
80
|
+
if (value === undefined) return undefined;
|
|
81
|
+
return value ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
// version_file validation: string or null. undefined means "not provided"
|
|
84
|
+
// (create: defaults to null; update: leaves the field alone). Empty string
|
|
85
|
+
// is rejected — use null to clear.
|
|
86
|
+
function validateVersionFile(value) {
|
|
87
|
+
if (value === undefined || value === null) return null;
|
|
88
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
89
|
+
throw usageError("version_file must be a non-empty string or null.");
|
|
90
|
+
}
|
|
76
91
|
|
|
77
92
|
// already_merged_sha validation (room #16 blocker 11, 2026-09-18): the
|
|
78
93
|
// workflow-verified already-merged sha as structured session control state.
|
|
@@ -315,6 +330,24 @@ function openDb(crewHome) {
|
|
|
315
330
|
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
316
331
|
}
|
|
317
332
|
}
|
|
333
|
+
// Migration: add versioned_build + version_file columns if missing (QA
|
|
334
|
+
// staleness gate, emojimanegg1/muse-crew#3, 2026-09-24). Same idempotent
|
|
335
|
+
// PRAGMA-check pattern; versioned_build defaults 0 (unversioned by
|
|
336
|
+
// design, no backfill), version_file stays NULL.
|
|
337
|
+
if (!projectCols.some((c) => c.name === "versioned_build")) {
|
|
338
|
+
try {
|
|
339
|
+
db.exec("ALTER TABLE projects ADD COLUMN versioned_build INTEGER NOT NULL DEFAULT 0 CHECK (versioned_build IN (0, 1))");
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!projectCols.some((c) => c.name === "version_file")) {
|
|
345
|
+
try {
|
|
346
|
+
db.exec("ALTER TABLE projects ADD COLUMN version_file TEXT");
|
|
347
|
+
} catch (e) {
|
|
348
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
318
351
|
// Migration: per-project provenance columns (room #15 blocker 8,
|
|
319
352
|
// 2026-09-18). Same idempotent PRAGMA-check pattern; ADD COLUMN without
|
|
320
353
|
// a default leaves existing rows NULL, which is "no provenance yet" by
|
|
@@ -446,6 +479,9 @@ function mapProject(row) {
|
|
|
446
479
|
// environment_type: 'artifact' | 'terminal' | null (unclassified).
|
|
447
480
|
// Null stays null — no coercion, no default.
|
|
448
481
|
environment_type: row.environment_type == null ? null : row.environment_type,
|
|
482
|
+
// versioned_build: 0/1 INTEGER → boolean. version_file: string or null.
|
|
483
|
+
versioned_build: !!row.versioned_build,
|
|
484
|
+
version_file: row.version_file == null ? null : row.version_file,
|
|
449
485
|
created_at: row.created_at,
|
|
450
486
|
updated_at: row.updated_at,
|
|
451
487
|
};
|
|
@@ -3074,6 +3110,8 @@ commands["create-project"] = (db, args, ctx) => {
|
|
|
3074
3110
|
quiesced: args.quiesced ? 1 : 0,
|
|
3075
3111
|
visual_protocol: visualProtocol == null ? null : (visualProtocol ? 1 : 0),
|
|
3076
3112
|
environment_type: environmentType,
|
|
3113
|
+
versioned_build: validateVersionedBuild(args.versioned_build) ?? 0,
|
|
3114
|
+
version_file: validateVersionFile(args.version_file),
|
|
3077
3115
|
created_at: timestamp, updated_at: timestamp,
|
|
3078
3116
|
};
|
|
3079
3117
|
if (!Number.isInteger(row.simultaneity) || row.simultaneity < 1 || row.simultaneity > 100) {
|
|
@@ -3081,9 +3119,9 @@ commands["create-project"] = (db, args, ctx) => {
|
|
|
3081
3119
|
}
|
|
3082
3120
|
db.prepare(
|
|
3083
3121
|
`INSERT INTO projects (id, display_name, repo_path, deploy_type, deploy_slug,
|
|
3084
|
-
description, simultaneity, quiesced, visual_protocol, environment_type, created_at, updated_at)
|
|
3122
|
+
description, simultaneity, quiesced, visual_protocol, environment_type, versioned_build, version_file, created_at, updated_at)
|
|
3085
3123
|
VALUES (@id, @display_name, @repo_path, @deploy_type, @deploy_slug,
|
|
3086
|
-
@description, @simultaneity, @quiesced, @visual_protocol, @environment_type, @created_at, @updated_at)`).run(row);
|
|
3124
|
+
@description, @simultaneity, @quiesced, @visual_protocol, @environment_type, @versioned_build, @version_file, @created_at, @updated_at)`).run(row);
|
|
3087
3125
|
// A deferred backfill (zero/ambiguous matches at the last look) gets one
|
|
3088
3126
|
// more evaluation now that the match set changed: a new project whose
|
|
3089
3127
|
// repo descends from the legacy commit inherits the attribution instead
|
|
@@ -3142,6 +3180,15 @@ commands["update-project"] = (db, args) => {
|
|
|
3142
3180
|
if (args.environment_type !== undefined) {
|
|
3143
3181
|
patch.environment_type = validateEnvironmentType(args.environment_type);
|
|
3144
3182
|
}
|
|
3183
|
+
// versioned_build / version_file are NOT covered by the context-change guard:
|
|
3184
|
+
// like environment_type, they are read once from launch args at dispatch
|
|
3185
|
+
// time, so a mid-run change only affects future launches.
|
|
3186
|
+
if (args.versioned_build !== undefined) {
|
|
3187
|
+
patch.versioned_build = validateVersionedBuild(args.versioned_build) ?? 0;
|
|
3188
|
+
}
|
|
3189
|
+
if (args.version_file !== undefined) {
|
|
3190
|
+
patch.version_file = validateVersionFile(args.version_file);
|
|
3191
|
+
}
|
|
3145
3192
|
|
|
3146
3193
|
const contextChanged =
|
|
3147
3194
|
(args.repo_path !== undefined && args.repo_path !== current.repo_path) ||
|
|
@@ -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
|
@@ -46,6 +46,13 @@ CREATE TABLE IF NOT EXISTS projects (
|
|
|
46
46
|
provenance_crew_release TEXT,
|
|
47
47
|
provenance_published_at TEXT,
|
|
48
48
|
provenance_task_id TEXT,
|
|
49
|
+
-- versioned_build: the QA staleness gate (emojimanegg1/muse-crew#3). When 1,
|
|
50
|
+
-- Integrate bumps the version file and QA verifies the tested bundle is at
|
|
51
|
+
-- or past the required version. version_file is the repo-relative path to
|
|
52
|
+
-- the TS file exporting BUILD_NUMBER. Both nullable/0 by design: existing
|
|
53
|
+
-- rows stay unversioned (no backfill).
|
|
54
|
+
versioned_build INTEGER NOT NULL DEFAULT 0 CHECK (versioned_build IN (0, 1)),
|
|
55
|
+
version_file TEXT,
|
|
49
56
|
created_at TEXT NOT NULL,
|
|
50
57
|
updated_at TEXT NOT NULL
|
|
51
58
|
);
|
package/package.json
CHANGED
package/workflows/bugfix.js
CHANGED
|
@@ -72,8 +72,14 @@ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
|
|
|
72
72
|
const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
|
|
73
73
|
const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
|
|
74
74
|
const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
|
|
75
|
+
const QA_DEPLOY_SRC = crewHome + "/current/lib/qa-deploy.mjs";
|
|
76
|
+
const QA_DEPLOY = RUN_LIB + "/qa-deploy.mjs";
|
|
77
|
+
const SERVE_ARTIFACT_SRC = crewHome + "/current/lib/serve-artifact.js";
|
|
78
|
+
const SERVE_ARTIFACT = RUN_LIB + "/serve-artifact.js";
|
|
79
|
+
const QA_DB_SRC = crewHome + "/current/lib/qa-db.js";
|
|
80
|
+
const QA_DB = RUN_LIB + "/qa-db.js";
|
|
75
81
|
// See docs/decisions/qa-reproduce.md#pin-basenames: the pin step materializes the required scripts.
|
|
76
|
-
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
|
|
82
|
+
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB, QA_DEPLOY, SERVE_ARTIFACT, QA_DB].map(function (p) { return p.split("/").pop(); });
|
|
77
83
|
|
|
78
84
|
// Project config — passed by dispatcher, falls back to dashboard defaults
|
|
79
85
|
const projectConfig = inputs.project_config || {};
|
|
@@ -265,7 +271,7 @@ function attemptKey(base, reworkCount) {
|
|
|
265
271
|
function pinLifecycle(key) {
|
|
266
272
|
return agent(
|
|
267
273
|
"Snapshot lifecycle scripts for version pinning.\n" +
|
|
268
|
-
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
274
|
+
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && cp " + QA_DEPLOY_SRC + " " + QA_DEPLOY + " && cp " + SERVE_ARTIFACT_SRC + " " + SERVE_ARTIFACT + " && cp " + QA_DB_SRC + " " + QA_DB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
269
275
|
"Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
|
|
270
276
|
{ key: key, label: "Pinning lifecycle scripts",
|
|
271
277
|
schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
|
|
@@ -563,6 +569,29 @@ function decideIntegrateRetry(o) {
|
|
|
563
569
|
if (rec.malformed) return "park";
|
|
564
570
|
return ancestor ? "skip-to-publish" : "proceed";
|
|
565
571
|
}
|
|
572
|
+
// parseDeployResult: pure, fails closed. Ferry returns qa-deploy.mjs stdout
|
|
573
|
+
// verbatim; the marker line is the only machine-read signal.
|
|
574
|
+
function parseDeployResult(output) {
|
|
575
|
+
var m = /^DEPLOY_OK ([0-9a-f]{7})$/m.exec(String(output || "").trim());
|
|
576
|
+
if (m) return { ok: true, hash: m[1] };
|
|
577
|
+
var f = /^DEPLOY_FAIL ([a-z-]+):(.{1,200})$/m.exec(String(output || "").trim());
|
|
578
|
+
if (f) return { ok: false, reason: f[1] + ": " + f[2].trim() };
|
|
579
|
+
return { ok: false, reason: "unrecognized deploy output" };
|
|
580
|
+
}
|
|
581
|
+
// QA_ENV_DIR: pipeline-owned, under the crew home. Never the web artifact dir.
|
|
582
|
+
var QA_ENV_DIR = crewHome + "/qa-envs/" + LAUNCH_PROJECT_ID;
|
|
583
|
+
// runDeployFerry: thin mechanical agent, one pinned command, stdout verbatim
|
|
584
|
+
function runDeployFerry(mode) {
|
|
585
|
+
var cmd = "node " + QA_DEPLOY + " --repo " + REPO_PATH + " --qa-dir " + QA_ENV_DIR +
|
|
586
|
+
" --task " + taskId + " --crew-home " + crewHome + " --crew-api " + CREW_API_PINNED +
|
|
587
|
+
" --serve-artifact " + SERVE_ARTIFACT + (mode === "--ensure" ? " --ensure" : "") + " 2>&1";
|
|
588
|
+
return agent(
|
|
589
|
+
"Run in shell and return the stdout verbatim:\n" + cmd + "\n" +
|
|
590
|
+
"Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
|
|
591
|
+
{ key: "qa-deploy-" + mode.replace(/-/g, "") + "-" + taskId, label: "Deploying QA environment",
|
|
592
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
593
|
+
);
|
|
594
|
+
}
|
|
566
595
|
// On a dispatcher retry resumed at Integrate, the run-local releaseDecision
|
|
567
596
|
// is null (Build doesn't re-run). Hydrate it from the merge record so the
|
|
568
597
|
// npm Publish gate doesn't park a publishable merge. Pure — pinned by
|
|
@@ -1355,10 +1384,29 @@ while (i < STEPS.length) {
|
|
|
1355
1384
|
// verdicts.jsonl). No parent verdict gate remains.
|
|
1356
1385
|
var qaArtifact = false;
|
|
1357
1386
|
var qaTerminal = false;
|
|
1387
|
+
var qaBundleHash = null;
|
|
1358
1388
|
if (step.name === "QA") {
|
|
1359
1389
|
var qaExp = (await resolveExperiential()) === "yes";
|
|
1360
1390
|
qaArtifact = qaExp && SURFACE_ARTIFACT;
|
|
1361
1391
|
qaTerminal = qaExp && SURFACE_TERMINAL;
|
|
1392
|
+
if (qaArtifact && projectConfig.versioned_build === true) {
|
|
1393
|
+
// ensure-deployed: idempotent precondition. Fast path when bundle is
|
|
1394
|
+
// current. Failed deploy = operational failure (retryable), never a park.
|
|
1395
|
+
var ensureOut = "";
|
|
1396
|
+
try {
|
|
1397
|
+
var ensureResult = await runDeployFerry("--ensure");
|
|
1398
|
+
ensureOut = (ensureResult.output || "").trim();
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
ensureOut = "";
|
|
1401
|
+
}
|
|
1402
|
+
var ensureParsed = parseDeployResult(ensureOut);
|
|
1403
|
+
if (!ensureParsed.ok) {
|
|
1404
|
+
log("QA ensure-deployed failed for task " + taskId + ": " + ensureParsed.reason + " — returning failed for dispatcher retry");
|
|
1405
|
+
return { status: "failed", task_id: taskId, reason: "QA ensure-deployed failed: " + ensureParsed.reason };
|
|
1406
|
+
}
|
|
1407
|
+
qaBundleHash = ensureParsed.hash;
|
|
1408
|
+
log("QA ensure-deployed ok for task " + taskId + ": bundle " + qaBundleHash);
|
|
1409
|
+
}
|
|
1362
1410
|
}
|
|
1363
1411
|
|
|
1364
1412
|
// Reproduce-layer routing (2026-09-16): Sage's Triage classifies the bug's
|
|
@@ -2621,7 +2669,25 @@ while (i < STEPS.length) {
|
|
|
2621
2669
|
// never a park. No automatic FAIL override: if the QA agent still
|
|
2622
2670
|
// reports FAIL, it stands — finding attribution informs follow-up
|
|
2623
2671
|
// filing only.
|
|
2624
|
-
if (step.name === "QA") {
|
|
2672
|
+
if (step.name === "QA" && qaBundleHash !== null) {
|
|
2673
|
+
// Closeout: bundle Hazel tested must match ensure-deployed hash from QA
|
|
2674
|
+
// start. Mismatch/unreadable = operational failure, never a park.
|
|
2675
|
+
var closeHash = "";
|
|
2676
|
+
try {
|
|
2677
|
+
var closeResult = await agent(
|
|
2678
|
+
"Run: node " + QA_DEPLOY + " --hash-only --qa-dir " + QA_ENV_DIR + " 2>&1\n" +
|
|
2679
|
+
"Return JSON { \"output\": \"<stdout, trimmed>\" } and nothing else.",
|
|
2680
|
+
{ key: "qa-closeout-hash-" + taskId, label: "Reading QA bundle hash",
|
|
2681
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
2682
|
+
);
|
|
2683
|
+
var chm = /BUNDLE_HASH ([0-9a-f]{40})/.exec(String(closeResult.output || ""));
|
|
2684
|
+
closeHash = chm ? chm[1].slice(0, 7) : "";
|
|
2685
|
+
} catch (e) {}
|
|
2686
|
+
if (closeHash !== qaBundleHash) {
|
|
2687
|
+
log("QA closeout hash mismatch for task " + taskId + " — marking failed for retry");
|
|
2688
|
+
stepResult.summary = (stepResult.summary || "") + "\nqa_closeout: FAILED — bundle hash " + (closeHash ? "changed during QA" : "unreadable");
|
|
2689
|
+
passed = false;
|
|
2690
|
+
}
|
|
2625
2691
|
var contentFindings = extractContentFindings(workerText);
|
|
2626
2692
|
if (!contentFindings.ok) {
|
|
2627
2693
|
// Unknown attribution fails closed: a missing or malformed
|
|
@@ -2751,6 +2817,26 @@ while (i < STEPS.length) {
|
|
|
2751
2817
|
}
|
|
2752
2818
|
if (/^VERIFIED:/m.test(integrateVerifyOut)) {
|
|
2753
2819
|
log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of the integration target");
|
|
2820
|
+
// QA deploy: after verified merge, ensure QA env serves a bundle from
|
|
2821
|
+
// merged source. Failed deploy = operational failure, never a park.
|
|
2822
|
+
if (projectConfig.versioned_build === true && SURFACE_ARTIFACT) {
|
|
2823
|
+
var deployOut = "";
|
|
2824
|
+
try {
|
|
2825
|
+
var deployResult = await runDeployFerry("");
|
|
2826
|
+
deployOut = (deployResult.output || "").trim();
|
|
2827
|
+
} catch (e) {
|
|
2828
|
+
deployOut = "";
|
|
2829
|
+
}
|
|
2830
|
+
var deployParsed = parseDeployResult(deployOut);
|
|
2831
|
+
if (deployParsed.ok) {
|
|
2832
|
+
log("QA deploy ok for task " + taskId + ": bundle " + deployParsed.hash);
|
|
2833
|
+
stepResult.summary = (stepResult.summary || "") + "\ndeploy: ok " + deployParsed.hash;
|
|
2834
|
+
} else {
|
|
2835
|
+
log("QA deploy failed for task " + taskId + ": " + deployParsed.reason + " — marking failed for retry");
|
|
2836
|
+
stepResult.summary = (stepResult.summary || "") + "\ndeploy: FAILED — " + deployParsed.reason;
|
|
2837
|
+
passed = false;
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2754
2840
|
} else {
|
|
2755
2841
|
var integrateVerifyReason = integrateVerifyOut
|
|
2756
2842
|
? integrateVerifyOut.split("\n")[0].slice(0, 200)
|
package/workflows/chore.js
CHANGED
|
@@ -136,8 +136,14 @@ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
|
|
|
136
136
|
const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
|
|
137
137
|
const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
|
|
138
138
|
const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
|
|
139
|
+
const QA_DEPLOY_SRC = crewHome + "/current/lib/qa-deploy.mjs";
|
|
140
|
+
const QA_DEPLOY = RUN_LIB + "/qa-deploy.mjs";
|
|
141
|
+
const SERVE_ARTIFACT_SRC = crewHome + "/current/lib/serve-artifact.js";
|
|
142
|
+
const SERVE_ARTIFACT = RUN_LIB + "/serve-artifact.js";
|
|
143
|
+
const QA_DB_SRC = crewHome + "/current/lib/qa-db.js";
|
|
144
|
+
const QA_DB = RUN_LIB + "/qa-db.js";
|
|
139
145
|
// See docs/decisions/qa-reproduce.md#pin-basenames: the pin step materializes the required scripts.
|
|
140
|
-
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
|
|
146
|
+
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB, QA_DEPLOY, SERVE_ARTIFACT, QA_DB].map(function (p) { return p.split("/").pop(); });
|
|
141
147
|
|
|
142
148
|
// Project config — passed by dispatcher, falls back to dashboard defaults
|
|
143
149
|
const projectConfig = inputs.project_config || {};
|
|
@@ -275,7 +281,7 @@ function attemptKey(base, reworkCount) {
|
|
|
275
281
|
function pinLifecycle(key) {
|
|
276
282
|
return agent(
|
|
277
283
|
"Snapshot lifecycle scripts for version pinning.\n" +
|
|
278
|
-
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
284
|
+
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && cp " + QA_DEPLOY_SRC + " " + QA_DEPLOY + " && cp " + SERVE_ARTIFACT_SRC + " " + SERVE_ARTIFACT + " && cp " + QA_DB_SRC + " " + QA_DB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
279
285
|
"Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
|
|
280
286
|
{ key: key, label: "Pinning lifecycle scripts",
|
|
281
287
|
schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
|
|
@@ -476,7 +476,12 @@ for (var pi = 0; pi < projects.length; pi++) {
|
|
|
476
476
|
// The user-facing surface for experiential QA routing (artifact |
|
|
477
477
|
// terminal | null=unclassified). Carried alongside deploy_type — it is a
|
|
478
478
|
// separate axis, not a redeclaration of the deployment target.
|
|
479
|
-
environment_type: proj.environment_type || null
|
|
479
|
+
environment_type: proj.environment_type || null,
|
|
480
|
+
// QA deploy (replaces the issue #3 version gate): versioned_build opts the
|
|
481
|
+
// project into the post-Integrate QA deploy + pre-QA ensure-deployed;
|
|
482
|
+
// version_file is retained for compatibility but no longer consumed.
|
|
483
|
+
versioned_build: !!proj.versioned_build,
|
|
484
|
+
version_file: proj.version_file || null
|
|
480
485
|
};
|
|
481
486
|
}
|
|
482
487
|
|
package/workflows/standard.js
CHANGED
|
@@ -80,8 +80,14 @@ const CLASSIFY_SURFACE_SRC = crewHome + "/current/lib/classify-surface.js";
|
|
|
80
80
|
const CLASSIFY_SURFACE = RUN_LIB + "/classify-surface.js";
|
|
81
81
|
const NOTE_VOCAB_SRC = crewHome + "/current/lib/publish-note-vocabulary.js";
|
|
82
82
|
const NOTE_VOCAB = RUN_LIB + "/publish-note-vocabulary.js";
|
|
83
|
+
const QA_DEPLOY_SRC = crewHome + "/current/lib/qa-deploy.mjs";
|
|
84
|
+
const QA_DEPLOY = RUN_LIB + "/qa-deploy.mjs";
|
|
85
|
+
const SERVE_ARTIFACT_SRC = crewHome + "/current/lib/serve-artifact.js";
|
|
86
|
+
const SERVE_ARTIFACT = RUN_LIB + "/serve-artifact.js";
|
|
87
|
+
const QA_DB_SRC = crewHome + "/current/lib/qa-db.js";
|
|
88
|
+
const QA_DB = RUN_LIB + "/qa-db.js";
|
|
83
89
|
// See docs/decisions/qa-reproduce.md#pin-basenames: the pin step materializes the required scripts.
|
|
84
|
-
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB].map(function (p) { return p.split("/").pop(); });
|
|
90
|
+
const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, CREW_API_PINNED, SCHEMA_SQL_PINNED, COMPUTE_DIFF, CLASSIFY_SURFACE, NOTE_VOCAB, QA_DEPLOY, SERVE_ARTIFACT, QA_DB].map(function (p) { return p.split("/").pop(); });
|
|
85
91
|
|
|
86
92
|
// Project config — passed by dispatcher, falls back to dashboard defaults
|
|
87
93
|
const projectConfig = inputs.project_config || {};
|
|
@@ -273,7 +279,7 @@ function attemptKey(base, reworkCount) {
|
|
|
273
279
|
function pinLifecycle(key) {
|
|
274
280
|
return agent(
|
|
275
281
|
"Snapshot lifecycle scripts for version pinning.\n" +
|
|
276
|
-
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
282
|
+
"Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + CREW_API_SRC + " " + CREW_API_PINNED + " && cp " + SCHEMA_SQL_SRC + " " + SCHEMA_SQL_PINNED + " && cp " + COMPUTE_DIFF_SRC + " " + COMPUTE_DIFF + " && cp " + CLASSIFY_SURFACE_SRC + " " + CLASSIFY_SURFACE + " && cp " + NOTE_VOCAB_SRC + " " + NOTE_VOCAB + " && cp " + QA_DEPLOY_SRC + " " + QA_DEPLOY + " && cp " + SERVE_ARTIFACT_SRC + " " + SERVE_ARTIFACT + " && cp " + QA_DB_SRC + " " + QA_DB + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " && ls -1 " + RUN_LIB + "\n" +
|
|
277
283
|
"Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
|
|
278
284
|
{ key: key, label: "Pinning lifecycle scripts",
|
|
279
285
|
schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
|
|
@@ -571,6 +577,38 @@ function decideIntegrateRetry(o) {
|
|
|
571
577
|
if (rec.malformed) return "park";
|
|
572
578
|
return ancestor ? "skip-to-publish" : "proceed";
|
|
573
579
|
}
|
|
580
|
+
// QA deploy result parser (replaces the version gate, issue #3): the ferry
|
|
581
|
+
// returns qa-deploy.mjs's stdout verbatim; the marker line is the only
|
|
582
|
+
// machine-read signal. Pure — fails closed on anything unrecognized.
|
|
583
|
+
// Returns { ok, hash } or { ok:false, reason }.
|
|
584
|
+
function parseDeployResult(output) {
|
|
585
|
+
var m = /^DEPLOY_OK ([0-9a-f]{7})$/m.exec(String(output || "").trim());
|
|
586
|
+
if (m) return { ok: true, hash: m[1] };
|
|
587
|
+
var f = /^DEPLOY_FAIL ([a-z-]+):(.{1,200})$/m.exec(String(output || "").trim());
|
|
588
|
+
if (f) return { ok: false, reason: f[1] + ": " + f[2].trim() };
|
|
589
|
+
return { ok: false, reason: "unrecognized deploy output" };
|
|
590
|
+
}
|
|
591
|
+
// QA environment dir — pipeline-owned, under the crew home. The web artifact
|
|
592
|
+
// directory (~/workspace/ts-spaces/<slug>/) is never built by hand (its own
|
|
593
|
+
// AGENTS.md forbids it); this separate dir is cloned, synced, built, and
|
|
594
|
+
// validated by qa-deploy.mjs. Named by project id (deploy_slug is an
|
|
595
|
+
// artifact-system concern; the QA env is ours).
|
|
596
|
+
var QA_ENV_DIR = crewHome + "/qa-envs/" + LAUNCH_PROJECT_ID;
|
|
597
|
+
// Run the deploy ferry: a thin mechanical agent that runs one exact pinned
|
|
598
|
+
// command and returns stdout verbatim. No identity, no judgment — the
|
|
599
|
+
// module self-records the full evidence via the Crew API; the ferry carries
|
|
600
|
+
// only the marker line.
|
|
601
|
+
function runDeployFerry(mode) {
|
|
602
|
+
var cmd = "node " + QA_DEPLOY + " --repo " + REPO_PATH + " --qa-dir " + QA_ENV_DIR +
|
|
603
|
+
" --task " + taskId + " --crew-home " + crewHome + " --crew-api " + CREW_API_PINNED +
|
|
604
|
+
" --serve-artifact " + SERVE_ARTIFACT + (mode === "--ensure" ? " --ensure" : "") + " 2>&1";
|
|
605
|
+
return agent(
|
|
606
|
+
"Run in shell and return the stdout verbatim:\n" + cmd + "\n" +
|
|
607
|
+
"Return JSON { \"output\": \"<the command's full stdout, trimmed>\" } and nothing else.",
|
|
608
|
+
{ key: "qa-deploy-" + mode.replace(/-/g, "") + "-" + taskId, label: "Deploying QA environment",
|
|
609
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
610
|
+
);
|
|
611
|
+
}
|
|
574
612
|
// On a dispatcher retry resumed at Integrate, the run-local releaseDecision
|
|
575
613
|
// is null (Build doesn't re-run). Hydrate it from the merge record so the
|
|
576
614
|
// npm Publish gate doesn't park a publishable merge. Pure — pinned by
|
|
@@ -1298,8 +1336,28 @@ while (i < STEPS.length) {
|
|
|
1298
1336
|
// experiential-loop guard (a PASS with missing experiential evidence is
|
|
1299
1337
|
// never terminal).
|
|
1300
1338
|
var qaExperiential = false;
|
|
1339
|
+
var qaBundleHash = null;
|
|
1301
1340
|
if (step.name === "QA") {
|
|
1302
1341
|
qaExperiential = (await resolveExperiential()) === "yes" && SURFACE_CLASSIFIED;
|
|
1342
|
+
if (qaExperiential && SURFACE_ARTIFACT && projectConfig.versioned_build === true) {
|
|
1343
|
+
// ensure-deployed: idempotent precondition. Fast path when the bundle
|
|
1344
|
+
// is already current. Failed deploy = operational failure (retryable),
|
|
1345
|
+
// never a park. The module self-records the bundle hash to the event log.
|
|
1346
|
+
var ensureOut = "";
|
|
1347
|
+
try {
|
|
1348
|
+
var ensureResult = await runDeployFerry("--ensure");
|
|
1349
|
+
ensureOut = (ensureResult.output || "").trim();
|
|
1350
|
+
} catch (e) {
|
|
1351
|
+
ensureOut = "";
|
|
1352
|
+
}
|
|
1353
|
+
var ensureParsed = parseDeployResult(ensureOut);
|
|
1354
|
+
if (!ensureParsed.ok) {
|
|
1355
|
+
log("QA ensure-deployed failed for task " + taskId + ": " + ensureParsed.reason + " — returning failed for dispatcher retry");
|
|
1356
|
+
return { status: "failed", task_id: taskId, reason: "QA ensure-deployed failed: " + ensureParsed.reason };
|
|
1357
|
+
}
|
|
1358
|
+
qaBundleHash = ensureParsed.hash;
|
|
1359
|
+
log("QA ensure-deployed ok for task " + taskId + ": bundle " + qaBundleHash);
|
|
1360
|
+
}
|
|
1303
1361
|
}
|
|
1304
1362
|
|
|
1305
1363
|
// Step-specific instructions
|
|
@@ -2373,7 +2431,25 @@ while (i < STEPS.length) {
|
|
|
2373
2431
|
// never a park. No automatic FAIL override: if the QA agent still
|
|
2374
2432
|
// reports FAIL, it stands — finding attribution informs follow-up
|
|
2375
2433
|
// filing only.
|
|
2376
|
-
if (step.name === "QA") {
|
|
2434
|
+
if (step.name === "QA" && qaBundleHash !== null) {
|
|
2435
|
+
// Closeout: the bundle Hazel tested must match the ensure-deployed hash
|
|
2436
|
+
// from QA start. Mismatch/unreadable = operational failure, never a park.
|
|
2437
|
+
var closeHash = "";
|
|
2438
|
+
try {
|
|
2439
|
+
var closeResult = await agent(
|
|
2440
|
+
"Run: node " + QA_DEPLOY + " --hash-only --qa-dir " + QA_ENV_DIR + " 2>&1\n" +
|
|
2441
|
+
"Return JSON { \"output\": \"<stdout, trimmed>\" } and nothing else.",
|
|
2442
|
+
{ key: "qa-closeout-hash-" + taskId, label: "Reading QA bundle hash",
|
|
2443
|
+
schema: { type: "object", properties: { output: { type: "string" } }, required: ["output"] } }
|
|
2444
|
+
);
|
|
2445
|
+
var hm = /BUNDLE_HASH ([0-9a-f]{40})/.exec(String(closeResult.output || ""));
|
|
2446
|
+
closeHash = hm ? hm[1].slice(0, 7) : "";
|
|
2447
|
+
} catch (e) {}
|
|
2448
|
+
if (closeHash !== qaBundleHash) {
|
|
2449
|
+
log("QA closeout hash mismatch for task " + taskId + " — marking failed for retry");
|
|
2450
|
+
stepResult.summary = (stepResult.summary || "") + "\nqa_closeout: FAILED — bundle hash " + (closeHash ? "changed during QA" : "unreadable");
|
|
2451
|
+
passed = false;
|
|
2452
|
+
}
|
|
2377
2453
|
var contentFindings = extractContentFindings(workerText);
|
|
2378
2454
|
if (!contentFindings.ok) {
|
|
2379
2455
|
// Unknown attribution fails closed: a missing or malformed
|
|
@@ -2503,6 +2579,29 @@ while (i < STEPS.length) {
|
|
|
2503
2579
|
}
|
|
2504
2580
|
if (/^VERIFIED:/m.test(integrateVerifyOut)) {
|
|
2505
2581
|
log("Integrate verified for task " + taskId + ": task branch tip is an ancestor of the integration target");
|
|
2582
|
+
// QA deploy (replaces the version gate): after a verified merge, ensure
|
|
2583
|
+
// the QA environment serves a bundle built from merged source. Runs for
|
|
2584
|
+
// versioned_build projects on a classified artifact surface; experiential
|
|
2585
|
+
// tasks are the ones Hazel serves the bundle to. A failed deploy is an
|
|
2586
|
+
// operational failure (retryable under the dispatcher's cap), never a park.
|
|
2587
|
+
if (projectConfig.versioned_build === true && SURFACE_ARTIFACT) {
|
|
2588
|
+
var deployOut = "";
|
|
2589
|
+
try {
|
|
2590
|
+
var deployResult = await runDeployFerry("");
|
|
2591
|
+
deployOut = (deployResult.output || "").trim();
|
|
2592
|
+
} catch (e) {
|
|
2593
|
+
deployOut = "";
|
|
2594
|
+
}
|
|
2595
|
+
var deployParsed = parseDeployResult(deployOut);
|
|
2596
|
+
if (deployParsed.ok) {
|
|
2597
|
+
log("QA deploy ok for task " + taskId + ": bundle " + deployParsed.hash);
|
|
2598
|
+
stepResult.summary = (stepResult.summary || "") + "\ndeploy: ok " + deployParsed.hash;
|
|
2599
|
+
} else {
|
|
2600
|
+
log("QA deploy failed for task " + taskId + ": " + deployParsed.reason + " — marking failed for retry");
|
|
2601
|
+
stepResult.summary = (stepResult.summary || "") + "\ndeploy: FAILED — " + deployParsed.reason;
|
|
2602
|
+
passed = false;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2506
2605
|
} else {
|
|
2507
2606
|
var integrateVerifyReason = integrateVerifyOut
|
|
2508
2607
|
? integrateVerifyOut.split("\n")[0].slice(0, 200)
|