opencode-ruby-upgrader 0.1.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/E2E_EVIDENCE.md +186 -0
- package/LICENSE +21 -0
- package/PRIVACY.md +9 -0
- package/README.md +118 -0
- package/RELEASE_CHECKLIST.md +17 -0
- package/RELEASE_NOTES.md +41 -0
- package/RELEASING.md +9 -0
- package/SECURITY.md +11 -0
- package/agents/ruby-upgrade.md +91 -0
- package/bin/opencode-ruby-upgrader.mjs +121 -0
- package/package.json +28 -0
- package/src/commit-hop.js +234 -0
- package/src/controller.js +273 -0
- package/src/dashboard-page.js +16 -0
- package/src/dashboard.js +67 -0
- package/src/index.js +112 -0
- package/src/inventory.js +30 -0
- package/src/preflight.js +69 -0
- package/src/provenance.js +21 -0
- package/src/run-state.js +200 -0
- package/src/supply-chain.js +16 -0
- package/src/target-runtime.js +165 -0
- package/src/test-evidence.js +9 -0
- package/src/validation-executor.js +45 -0
package/src/run-state.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const SECRET_VALUE = /(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:xox[baprs]-|npm_|glpat-)[A-Za-z0-9_-]{16,})/g;
|
|
7
|
+
const SECRET_KEY = /(?:password|secret|token|api[_-]?key|credential|authorization)/i;
|
|
8
|
+
const URL_CREDENTIALS = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@]*@/ig;
|
|
9
|
+
const URL_SECRET_QUERY = /([?&](?:password|secret|token|api[_-]?key|credential|authorization)=)[^&#\s]*/ig;
|
|
10
|
+
const statuses = new Set(["in_progress", "paused", "blocked", "complete"]);
|
|
11
|
+
const phases = new Set(["initialized", "inventory_complete", "research_complete", "hop_validated", "committed", "paused", "blocked", "complete"]);
|
|
12
|
+
const reportName = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
|
13
|
+
|
|
14
|
+
export class RunStateError extends Error {
|
|
15
|
+
constructor(message, code) { super(message); this.code = code; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function redact(value, key = "") {
|
|
19
|
+
if (/^(?:root|worktreeRoot)$/i.test(key)) return "[LOCAL PATH]";
|
|
20
|
+
if (SECRET_KEY.test(key)) return "[REDACTED]";
|
|
21
|
+
if (typeof value === "string") return redactSourceUrl(value).replace(SECRET_VALUE, "[REDACTED]");
|
|
22
|
+
if (Array.isArray(value)) return value.map((item) => redact(item));
|
|
23
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, redact(item, name)]));
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function redactSourceUrl(value) {
|
|
28
|
+
return typeof value === "string" ? value.replace(URL_CREDENTIALS, "$1[REDACTED]@").replace(URL_SECRET_QUERY, "$1[REDACTED]") : value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validVersion(value) { return typeof value === "string" && /^\d+\.\d+(?:\.(?:\d+|x))?$/.test(value); }
|
|
32
|
+
function validRailsVersion(value) { return typeof value === "string" && /^\d+(?:\.\d+)+$/.test(value); }
|
|
33
|
+
function validCitation(value) { return value && typeof value.title === "string" && /^https:\/\//.test(value.url ?? ""); }
|
|
34
|
+
function safeFiles(files) { return Array.isArray(files) && files.every((file) => typeof file === "string" && !path.isAbsolute(file) && !file.includes("..")); }
|
|
35
|
+
function validAppUpdateReview(review) { return review && review.command === "bin/rails app:update" && typeof review.receiptId === "string" && !Number.isNaN(Date.parse(review.executedAt ?? "")) && !Number.isNaN(Date.parse(review.reviewedAt ?? "")) && validFingerprint(review.worktree) && ["no_changes", "changes_applied", "changes_deferred"].includes(review.outcome) && safeFiles(review.files) && typeof review.summary === "string" && Boolean(review.summary); }
|
|
36
|
+
function validFingerprint(fingerprint) { return fingerprint && fingerprint.algorithm === "sha256" && /^[a-f0-9]{40}$/i.test(fingerprint.headSha ?? "") && /^[a-f0-9]{64}$/i.test(fingerprint.diffSha256 ?? ""); }
|
|
37
|
+
function validReceipt(receipt) { return receipt && receipt.receiptVersion === 1 && typeof receipt.id === "string" && /^[a-f0-9-]{36}$/i.test(receipt.id) && ["test", "rails_app_update"].includes(receipt.kind) && typeof receipt.commandId === "string" && Array.isArray(receipt.argv) && !Number.isNaN(Date.parse(receipt.startedAt ?? "")) && !Number.isNaN(Date.parse(receipt.finishedAt ?? "")) && typeof receipt.durationMs === "number" && (typeof receipt.exitCode === "number" || receipt.exitCode === null) && typeof receipt.timedOut === "boolean" && /^[a-f0-9]{64}$/i.test(receipt.output?.redactedSha256 ?? "") && Number.isSafeInteger(receipt.output?.bytes) && receipt.output.bytes >= 0 && receipt.output.summary === undefined && validFingerprint(receipt.worktree); }
|
|
38
|
+
function receiptErrors(iteration, index, rails, required, errors) {
|
|
39
|
+
const receipts = iteration?.validationReceipts;
|
|
40
|
+
if (!required && receipts === undefined) return;
|
|
41
|
+
if (!Array.isArray(receipts) || !receipts.length || receipts.some((receipt) => !validReceipt(receipt))) errors.push(`${rails ? "Rails " : ""}iteration ${index + 1} requires valid executed validation receipts.`);
|
|
42
|
+
if (iteration?.tests?.passed === true && !receipts?.some((receipt) => receipt.kind === "test" && receipt.exitCode === 0 && receipt.testEvidence?.passed === true)) errors.push(`${rails ? "Rails " : ""}iteration ${index + 1} requires a passing executed test receipt.`);
|
|
43
|
+
if (rails && !receipts?.some((receipt) => receipt.kind === "rails_app_update" && receipt.exitCode === 0)) errors.push(`Rails iteration ${index + 1} requires a successful executed app:update receipt.`);
|
|
44
|
+
if (rails) {
|
|
45
|
+
const update = receipts?.[0];
|
|
46
|
+
const test = receipts?.at(-1);
|
|
47
|
+
const review = iteration?.appUpdateReview;
|
|
48
|
+
if (receipts?.length !== 2 || update?.kind !== "rails_app_update" || update.exitCode !== 0 || test?.kind !== "test" || test.exitCode !== 0 || test.testEvidence?.passed !== true || !review || review.receiptId !== update.id || review.executedAt !== update.startedAt || review.worktree.headSha !== update.worktree.headSha || review.worktree.diffSha256 !== update.worktree.diffSha256 || Date.parse(update.finishedAt) > Date.parse(review.reviewedAt) || Date.parse(review.reviewedAt) > Date.parse(test.startedAt)) errors.push(`Rails iteration ${index + 1} must execute app:update, review its receipt and diff, then run final passing tests.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateRailsBridge(run, errors) {
|
|
53
|
+
for (const key of ["runId", "title", "startedAt", "targetRails", "targetRailsPinnedAt", "phase", "status", "bridge"]) if (!run[key]) errors.push(`Rails bridge report requires ${key}.`);
|
|
54
|
+
if (!validRailsVersion(run.targetRails)) errors.push("targetRails must be a Rails version.");
|
|
55
|
+
if (typeof run.runId !== "string" || !/^[a-f0-9-]{36}$/i.test(run.runId ?? "")) errors.push("runId must be a UUID.");
|
|
56
|
+
if (run.startedAt && Number.isNaN(Date.parse(run.startedAt))) errors.push("startedAt must be ISO-8601.");
|
|
57
|
+
if (!statuses.has(run.status) || !phases.has(run.phase)) errors.push("Rails bridge has invalid status or phase.");
|
|
58
|
+
if (run.lockNonce !== undefined && (typeof run.lockNonce !== "string" || !/^[a-f0-9-]{36}$/i.test(run.lockNonce))) errors.push("lockNonce must be a UUID.");
|
|
59
|
+
if (run.targetRailsPinnedAt && Number.isNaN(Date.parse(run.targetRailsPinnedAt))) errors.push("targetRailsPinnedAt must be ISO-8601.");
|
|
60
|
+
if (!run.bridge || typeof run.bridge !== "object" || typeof run.bridge.rubyReportPath !== "string" || !/^[a-f0-9-]{36}$/i.test(run.bridge.rubyRunId ?? "") || !validVersion(run.bridge.rubyFrom) || !validVersion(run.bridge.rubyTo) || !validRailsVersion(run.bridge.railsFrom) || !validRailsVersion(run.bridge.railsTo)) errors.push("Rails bridge report has invalid Ruby-run linkage.");
|
|
61
|
+
if (!Array.isArray(run.research?.ladder) || run.research.ladder.some((version) => !validRailsVersion(version))) errors.push("Rails bridge research.ladder must contain Rails versions.");
|
|
62
|
+
if (!Array.isArray(run.research?.citations) || run.research.citations.some((citation) => !validCitation(citation))) errors.push("research.citations must contain HTTPS citations.");
|
|
63
|
+
if (!Array.isArray(run.iterations)) errors.push("iterations must be an array.");
|
|
64
|
+
if (run.pendingAppUpdate !== undefined && (!validReceipt(run.pendingAppUpdate) || run.pendingAppUpdate.kind !== "rails_app_update" || run.pendingAppUpdate.exitCode !== 0 || !["research_complete", "committed"].includes(run.phase))) errors.push("Rails bridge pending app:update receipt is invalid.");
|
|
65
|
+
for (const [index, iteration] of (run.iterations ?? []).entries()) {
|
|
66
|
+
if (!validRailsVersion(iteration?.from) || !validRailsVersion(iteration?.to) || iteration?.status !== "complete") errors.push(`Rails iteration ${index + 1} is invalid.`);
|
|
67
|
+
if (!safeFiles(iteration?.files) || iteration?.tests?.passed !== true || !iteration.tests?.command || !iteration.tests?.smoke || !Array.isArray(iteration?.citations) || !iteration.citations.length || !Array.isArray(iteration?.fixes) || iteration.fixes.some((fix) => !safeFiles(fix.files) || !fix.explanation)) errors.push(`Rails iteration ${index + 1} lacks required evidence.`);
|
|
68
|
+
if (!validAppUpdateReview(iteration?.appUpdateReview)) errors.push(`Rails iteration ${index + 1} requires reviewed bin/rails app:update evidence.`);
|
|
69
|
+
if (iteration?.appUpdateReview?.outcome === "changes_applied" && iteration.appUpdateReview.files.some((file) => !iteration.files.includes(file))) errors.push(`Rails iteration ${index + 1} must declare applied app:update files.`);
|
|
70
|
+
if (iteration?.checkpointSha !== undefined && !/^[a-f0-9]{40}$/i.test(iteration.checkpointSha)) errors.push(`Rails iteration ${index + 1} checkpointSha must be a Git SHA.`);
|
|
71
|
+
receiptErrors(iteration, index, true, run.validationReceiptsRequired === true, errors);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validateRun(run) {
|
|
76
|
+
const errors = [];
|
|
77
|
+
if (!run || typeof run !== "object" || Array.isArray(run)) return { valid: false, errors: ["Report must be a JSON object."] };
|
|
78
|
+
if (run.schemaVersion !== 2) errors.push("Report schemaVersion must be 2.");
|
|
79
|
+
if (run.validationReceiptsRequired !== undefined && run.validationReceiptsRequired !== true) errors.push("validationReceiptsRequired must be true when present.");
|
|
80
|
+
if (run.reportType === "rails_bridge") { validateRailsBridge(run, errors); return { valid: errors.length === 0, errors }; }
|
|
81
|
+
if (run.reportType !== undefined && run.reportType !== "ruby") errors.push("Unknown reportType.");
|
|
82
|
+
for (const key of ["runId", "title", "startedAt", "targetRuby", "targetPinnedAt", "phase", "status"]) if (!run[key]) errors.push(`Report requires ${key}.`);
|
|
83
|
+
if (run.lockNonce !== undefined && (typeof run.lockNonce !== "string" || !/^[a-f0-9-]{36}$/i.test(run.lockNonce))) errors.push("lockNonce must be a UUID.");
|
|
84
|
+
if (typeof run.runId !== "string" || !/^[a-f0-9-]{36}$/i.test(run.runId ?? "")) errors.push("runId must be a UUID.");
|
|
85
|
+
if (run.startedAt && Number.isNaN(Date.parse(run.startedAt))) errors.push("startedAt must be ISO-8601.");
|
|
86
|
+
if (!validVersion(run.targetRuby)) errors.push("targetRuby must be a Ruby version.");
|
|
87
|
+
if (run.targetPinnedAt && Number.isNaN(Date.parse(run.targetPinnedAt))) errors.push("targetPinnedAt must be ISO-8601.");
|
|
88
|
+
if (!statuses.has(run.status)) errors.push("Invalid run status.");
|
|
89
|
+
if (!phases.has(run.phase)) errors.push("Invalid run phase.");
|
|
90
|
+
if (!Array.isArray(run.iterations)) errors.push("iterations must be an array.");
|
|
91
|
+
if (!Array.isArray(run.research?.ladder) || run.research.ladder.some((version) => !validVersion(version))) errors.push("research.ladder must be Ruby versions.");
|
|
92
|
+
if (!Array.isArray(run.research?.citations) || run.research.citations.some((citation) => !validCitation(citation))) errors.push("research.citations must contain HTTPS citations.");
|
|
93
|
+
if (!Array.isArray(run.riskDecisions)) errors.push("riskDecisions must be an array.");
|
|
94
|
+
if (run.requiredRisks !== undefined && (!Array.isArray(run.requiredRisks) || run.requiredRisks.some((risk) => typeof risk !== "string"))) errors.push("requiredRisks must be string identifiers.");
|
|
95
|
+
if (run.frameworkBridge !== undefined && run.frameworkBridge !== null) {
|
|
96
|
+
const bridge = run.frameworkBridge;
|
|
97
|
+
if (!bridge || typeof bridge !== "object" || bridge.status !== "approved") errors.push("frameworkBridge must be an approved compatibility decision.");
|
|
98
|
+
if (!validVersion(bridge?.rubyFrom) || !validVersion(bridge?.rubyTo) || !validRailsVersion(bridge?.railsFrom) || !validRailsVersion(bridge?.railsTo)) errors.push("frameworkBridge requires Ruby and Rails from/to versions.");
|
|
99
|
+
if (typeof bridge?.rationale !== "string" || !bridge.rationale) errors.push("frameworkBridge requires a rationale.");
|
|
100
|
+
if (!Array.isArray(bridge?.citations) || !bridge.citations.length || bridge.citations.some((citation) => !validCitation(citation))) errors.push("frameworkBridge requires HTTPS citations.");
|
|
101
|
+
}
|
|
102
|
+
for (const [index, iteration] of (run.iterations ?? []).entries()) {
|
|
103
|
+
if (!validVersion(iteration?.from) || !validVersion(iteration?.to)) errors.push(`Iteration ${index + 1} requires Ruby from/to versions.`);
|
|
104
|
+
if (iteration?.status !== "complete") errors.push(`Iteration ${index + 1} must be complete.`);
|
|
105
|
+
if (!safeFiles(iteration?.files)) errors.push(`Iteration ${index + 1} files must be safe relative paths.`);
|
|
106
|
+
if (iteration?.tests?.passed !== true || typeof iteration.tests.command !== "string" || !iteration.tests.command) errors.push(`Iteration ${index + 1} requires passing test evidence.`);
|
|
107
|
+
if (typeof iteration.tests.smoke !== "string" || !iteration.tests.smoke) errors.push(`Iteration ${index + 1} requires smoke evidence.`);
|
|
108
|
+
if (!Array.isArray(iteration.citations) || iteration.citations.length === 0 || iteration.citations.some((citation) => !validCitation(citation))) errors.push(`Iteration ${index + 1} requires HTTPS citations.`);
|
|
109
|
+
if (!Array.isArray(iteration.fixes) || iteration.fixes.some((fix) => !Array.isArray(fix.files) || typeof fix.explanation !== "string" || !fix.explanation)) errors.push(`Iteration ${index + 1} requires explained fixes.`);
|
|
110
|
+
if (iteration.dependencyReview && (iteration.dependencyReview.completed !== true || typeof iteration.dependencyReview.compatibility !== "string" || typeof iteration.dependencyReview.licenses !== "string")) errors.push(`Iteration ${index + 1} dependency review is incomplete.`);
|
|
111
|
+
if (iteration.checkpointSha !== undefined && !/^[a-f0-9]{40}$/i.test(iteration.checkpointSha)) errors.push(`Iteration ${index + 1} checkpointSha must be a Git SHA.`);
|
|
112
|
+
receiptErrors(iteration, index, false, run.validationReceiptsRequired === true, errors);
|
|
113
|
+
}
|
|
114
|
+
return { valid: errors.length === 0, errors };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function canonicalRoot(root) { return fs.realpathSync(root); }
|
|
118
|
+
function runsDirectory(root, create = false) {
|
|
119
|
+
const canonical = canonicalRoot(root);
|
|
120
|
+
let current = canonical;
|
|
121
|
+
for (const component of [".ruby-upgrades", "runs"]) {
|
|
122
|
+
current = path.join(current, component);
|
|
123
|
+
if (!fs.existsSync(current)) {
|
|
124
|
+
if (!create) return current;
|
|
125
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
126
|
+
}
|
|
127
|
+
const stat = fs.lstatSync(current);
|
|
128
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new RunStateError("Upgrade evidence directories must be real directories inside the worktree.", "unsafe-report-path");
|
|
129
|
+
}
|
|
130
|
+
return current;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function reportPath(root, relativePath, create = false) {
|
|
134
|
+
if (typeof relativePath !== "string" || relativePath !== path.join(".ruby-upgrades", "runs", path.basename(relativePath)) || !reportName.test(path.basename(relativePath))) throw new RunStateError("Report must be a generated JSON basename under .ruby-upgrades/runs/.", "invalid-report-path");
|
|
135
|
+
const directory = runsDirectory(root, create);
|
|
136
|
+
const file = path.join(directory, path.basename(relativePath));
|
|
137
|
+
if (fs.existsSync(file)) {
|
|
138
|
+
const stat = fs.lstatSync(file);
|
|
139
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new RunStateError("Report must be a regular file inside the worktree.", "unsafe-report-path");
|
|
140
|
+
}
|
|
141
|
+
return file;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function readRun(root, relativePath) {
|
|
145
|
+
try {
|
|
146
|
+
const run = JSON.parse(fs.readFileSync(reportPath(root, relativePath), "utf8"));
|
|
147
|
+
const validation = validateRun(run);
|
|
148
|
+
if (!validation.valid) throw new RunStateError(validation.errors.join(" "), "invalid-report");
|
|
149
|
+
return redact(run);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof RunStateError) throw error;
|
|
152
|
+
throw new RunStateError("The upgrade report is missing or invalid JSON.", "invalid-report");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function writeRun(root, relativePath, run) {
|
|
157
|
+
const validation = validateRun(run);
|
|
158
|
+
if (!validation.valid) throw new RunStateError(validation.errors.join(" "), "invalid-report");
|
|
159
|
+
const file = reportPath(root, relativePath, true);
|
|
160
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
161
|
+
fs.writeFileSync(temporary, `${JSON.stringify(redact(run), null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
162
|
+
fs.renameSync(temporary, file);
|
|
163
|
+
const markdown = file.replace(/\.json$/, ".md");
|
|
164
|
+
if (fs.existsSync(markdown) && (!fs.lstatSync(markdown).isFile() || fs.lstatSync(markdown).isSymbolicLink())) throw new RunStateError("Markdown evidence must be a regular file inside the worktree.", "unsafe-report-path");
|
|
165
|
+
const safe = redact(run);
|
|
166
|
+
const target = safe.reportType === "rails_bridge" ? `- **Target Rails:** ${safe.targetRails}` : `- **Target Ruby:** ${safe.targetRuby}`;
|
|
167
|
+
const markdownBody = `# ${safe.title}\n\n- **Status:** ${safe.status}\n- **Phase:** ${safe.phase}\n${target}\n- **Started:** ${safe.startedAt}\n\n## Durable evidence\n\n\`\`\`json\n${JSON.stringify(safe, null, 2)}\n\`\`\`\n`;
|
|
168
|
+
const markdownTemporary = path.join(path.dirname(markdown), `.${path.basename(markdown)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
169
|
+
fs.writeFileSync(markdownTemporary, markdownBody, { mode: 0o600, flag: "wx" });
|
|
170
|
+
fs.renameSync(markdownTemporary, markdown);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function lockPath(root) {
|
|
174
|
+
try { return execFileSync("git", ["rev-parse", "--git-path", "opencode-ruby-upgrade.lock"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
175
|
+
catch { return path.join(runsDirectory(root, true), ".run.lock"); }
|
|
176
|
+
}
|
|
177
|
+
export function acquireRunLock(root, relativePath) {
|
|
178
|
+
reportPath(root, relativePath, true);
|
|
179
|
+
const file = lockPath(root);
|
|
180
|
+
const payload = { pid: process.pid, nonce: crypto.randomUUID(), report: relativePath, acquiredAt: new Date().toISOString() };
|
|
181
|
+
try { fs.writeFileSync(file, `${JSON.stringify(payload)}\n`, { flag: "wx", mode: 0o600 }); return payload; }
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code !== "EEXIST") throw error;
|
|
184
|
+
let current = {}; try { current = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* explicit recovery only */ }
|
|
185
|
+
throw new RunStateError(`An upgrade lock exists for ${current.report ?? "an unknown run"}. Inspect it, then explicitly release it if the prior session is gone.`, "run-locked");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function assertRunLock(root, relativePath, expectedNonce) {
|
|
189
|
+
try {
|
|
190
|
+
const current = JSON.parse(fs.readFileSync(lockPath(root), "utf8"));
|
|
191
|
+
if (current.report !== relativePath || !current.nonce || (expectedNonce && current.nonce !== expectedNonce)) throw new RunStateError("Another upgrade run owns the worktree lock.", "lock-owner-mismatch");
|
|
192
|
+
return current;
|
|
193
|
+
} catch (error) { if (error instanceof RunStateError) throw error; throw new RunStateError("No active run lock exists. Resume the recorded run before continuing.", "run-lock-missing"); }
|
|
194
|
+
}
|
|
195
|
+
export function releaseRunLock(root, relativePath, expectedNonce) {
|
|
196
|
+
const file = lockPath(root); const current = assertRunLock(root, relativePath, expectedNonce);
|
|
197
|
+
const latest = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
198
|
+
if (latest.nonce !== current.nonce) throw new RunStateError("Lock changed while releasing; refusing to remove it.", "lock-raced");
|
|
199
|
+
fs.rmSync(file);
|
|
200
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { redactSourceUrl } from "./run-state.js";
|
|
4
|
+
|
|
5
|
+
export function inspectSupplyChain(root = process.cwd()) {
|
|
6
|
+
const lockfile = path.join(root, "Gemfile.lock");
|
|
7
|
+
const gemfile = path.join(root, "Gemfile");
|
|
8
|
+
if (!fs.existsSync(lockfile) && !fs.existsSync(gemfile)) return { lockfile: null, sources: [], privateSources: [], dependencyCount: 0, vulnerabilityStatus: "unavailable", licenseStatus: "unavailable" };
|
|
9
|
+
const contents = fs.existsSync(lockfile) ? fs.readFileSync(lockfile, "utf8") : "";
|
|
10
|
+
const gemfileContents = fs.existsSync(gemfile) ? fs.readFileSync(gemfile, "utf8") : "";
|
|
11
|
+
const sources = [...contents.matchAll(/^\s*remote:\s*(.+)$/gm)].map((match) => redactSourceUrl(match[1].trim()));
|
|
12
|
+
const declaredSources = [...gemfileContents.matchAll(/(?:git|github|path)\s*:\s*["']([^"']+)["']/g), ...gemfileContents.matchAll(/\bsource\s*["']([^"']+)["']/g)].map((match) => redactSourceUrl(match[1]));
|
|
13
|
+
const privateSources = [...sources, ...declaredSources].filter((source) => !/^https:\/\/rubygems\.org\/?$/i.test(source));
|
|
14
|
+
const dependencyCount = (contents.match(/^ [\w.-]+ \(/gm) ?? []).length;
|
|
15
|
+
return { lockfile: fs.existsSync(lockfile) ? "Gemfile.lock" : null, sources, declaredSources, privateSources, dependencyCount, vulnerabilityStatus: "requires_bundle_audit_review", licenseStatus: "requires_bundle_license_review" };
|
|
16
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { RunStateError, readRun } from "./run-state.js";
|
|
6
|
+
|
|
7
|
+
const RUNTIME_FILE = "runtime.json";
|
|
8
|
+
const LABEL = "io.opencode-ruby-upgrader.run-id";
|
|
9
|
+
const rubyVersion = /^\d+\.\d+\.\d+$/;
|
|
10
|
+
const NODE_INSTALL = "set -eu; . /etc/os-release; codename=${VERSION_CODENAME:-stretch}; printf '%s\\n' \"deb http://deb.debian.org/debian ${codename} main\" > /etc/apt/sources.list; printf '%s\\n' 'Acquire::Check-Valid-Until \"false\";' > /etc/apt/apt.conf.d/99archive; if ! apt-get update; then printf '%s\\n' \"deb http://archive.debian.org/debian ${codename} main\" > /etc/apt/sources.list; apt-get update; fi; apt-get install -y --no-install-recommends nodejs; if [ ! -x /usr/bin/node ]; then ln -sf /usr/bin/nodejs /usr/local/bin/node; fi; node --version";
|
|
11
|
+
|
|
12
|
+
function canonicalRoot(root) { return fs.realpathSync(root); }
|
|
13
|
+
function upgradesDirectory(root, create = false) {
|
|
14
|
+
const directory = path.join(canonicalRoot(root), ".ruby-upgrades");
|
|
15
|
+
if (!fs.existsSync(directory) && create) fs.mkdirSync(directory, { mode: 0o700 });
|
|
16
|
+
if (fs.existsSync(directory)) {
|
|
17
|
+
const stat = fs.lstatSync(directory);
|
|
18
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(".ruby-upgrades must be a real directory inside the worktree.");
|
|
19
|
+
}
|
|
20
|
+
return directory;
|
|
21
|
+
}
|
|
22
|
+
function runtimePath(root, create = false) {
|
|
23
|
+
const file = path.join(upgradesDirectory(root, create), RUNTIME_FILE);
|
|
24
|
+
if (fs.existsSync(file)) {
|
|
25
|
+
const stat = fs.lstatSync(file);
|
|
26
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Target runtime metadata must be a regular file.");
|
|
27
|
+
}
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
function docker(spawn, args) {
|
|
31
|
+
const result = spawn("docker", args, { shell: false, encoding: "utf8", maxBuffer: 256 * 1024 });
|
|
32
|
+
if (result.error) throw new Error(`Docker command failed: ${result.error.message}`);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
function waitForPostgres(spawn, runtime) {
|
|
36
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
37
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
38
|
+
if (docker(spawn, ["exec", runtime.postgresContainer, "pg_isready", "-U", "postgres", "-d", "ruby_upgrade_test"]).status === 0) return;
|
|
39
|
+
Atomics.wait(sleeper, 0, 0, 1000);
|
|
40
|
+
}
|
|
41
|
+
throw new Error("Target PostgreSQL did not become ready within 30 seconds. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
42
|
+
}
|
|
43
|
+
function preparationResult(result) {
|
|
44
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
45
|
+
return { sha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) };
|
|
46
|
+
}
|
|
47
|
+
function bootstrap(spawn, runtime, rails) {
|
|
48
|
+
const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? ["Rails test database initialization"] : []), "Ruby version attestation"];
|
|
49
|
+
const commands = [
|
|
50
|
+
["exec", runtime.appContainer, "sh", "-c", NODE_INSTALL],
|
|
51
|
+
["exec", runtime.appContainer, "gem", "install", "bundler", "-v", "2.4.22", "--no-document"],
|
|
52
|
+
["exec", runtime.appContainer, "bundle", "_2.4.22_", "install"],
|
|
53
|
+
...(rails ? [["exec", runtime.appContainer, "bundle", "exec", "rake", "db:create"]] : []),
|
|
54
|
+
["exec", runtime.appContainer, "ruby", "--version"]
|
|
55
|
+
];
|
|
56
|
+
const results = commands.map((args, index) => {
|
|
57
|
+
const result = docker(spawn, args);
|
|
58
|
+
if (result.status !== 0) throw new Error(`Target runtime bootstrap failed during ${labels[index]}. Rerun prepare-target-runtime --ruby <x.y.z>.`);
|
|
59
|
+
return result;
|
|
60
|
+
});
|
|
61
|
+
const rubyOutput = `${results.at(-1).stdout ?? ""}${results.at(-1).stderr ?? ""}`;
|
|
62
|
+
if (!new RegExp(`^ruby ${runtime.ruby.replaceAll(".", "\\.")}(?:p\\d+|\\s|$)`).test(rubyOutput.trim())) throw new Error("Target runtime did not execute the requested Ruby version.");
|
|
63
|
+
return {
|
|
64
|
+
node: preparationResult(results[0]),
|
|
65
|
+
bundler: preparationResult(results[1]),
|
|
66
|
+
bundleInstall: preparationResult(results[2]),
|
|
67
|
+
...(rails ? { databaseCreate: preparationResult(results[3]) } : {}),
|
|
68
|
+
rubyVersion: preparationResult(results.at(-1))
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function railsProject(root) {
|
|
72
|
+
const gemfile = path.join(root, "Gemfile");
|
|
73
|
+
return fs.existsSync(gemfile) && /^\s*gem\s+["']rails["']/m.test(fs.readFileSync(gemfile, "utf8"));
|
|
74
|
+
}
|
|
75
|
+
function inspect(spawn, name, message) {
|
|
76
|
+
const result = docker(spawn, ["inspect", name]);
|
|
77
|
+
if (result.status !== 0) throw new Error(message);
|
|
78
|
+
try { const parsed = JSON.parse(result.stdout); return parsed[0]; } catch { throw new Error("Docker inspection returned invalid JSON."); }
|
|
79
|
+
}
|
|
80
|
+
function activeRun(root) {
|
|
81
|
+
const directory = path.join(upgradesDirectory(root), "runs");
|
|
82
|
+
if (!fs.existsSync(directory)) throw new RunStateError("No active or paused upgrade run exists. Start or resume a run first.", "no-active-run");
|
|
83
|
+
const candidates = fs.readdirSync(directory, { withFileTypes: true })
|
|
84
|
+
.filter((entry) => entry.isFile() && /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/.test(entry.name))
|
|
85
|
+
.map((entry) => `.ruby-upgrades/runs/${entry.name}`)
|
|
86
|
+
.map((reportPath) => ({ reportPath, run: readRun(root, reportPath) }))
|
|
87
|
+
.filter(({ run }) => ["in_progress", "paused"].includes(run.status));
|
|
88
|
+
if (candidates.length !== 1) throw new RunStateError(candidates.length ? "Multiple active or paused upgrade runs exist. Resolve them before preparing a target runtime." : "No active or paused upgrade run exists. Start or resume a run first.", candidates.length ? "ambiguous-active-run" : "no-active-run");
|
|
89
|
+
return candidates[0];
|
|
90
|
+
}
|
|
91
|
+
function selectedRun(root, reportPath) {
|
|
92
|
+
if (!reportPath) return activeRun(root);
|
|
93
|
+
const run = readRun(root, reportPath);
|
|
94
|
+
if (!["in_progress", "paused"].includes(run.status)) throw new RunStateError("Target runtime preparation requires an active or paused upgrade run.", "run-not-resumable");
|
|
95
|
+
return { reportPath, run };
|
|
96
|
+
}
|
|
97
|
+
function names(runId) {
|
|
98
|
+
const prefix = `ruby-upgrader-${runId}`;
|
|
99
|
+
return { appContainer: `${prefix}-app`, postgresContainer: `${prefix}-postgres`, network: `${prefix}-network` };
|
|
100
|
+
}
|
|
101
|
+
function writeRuntime(root, runtime) {
|
|
102
|
+
const file = runtimePath(root, true);
|
|
103
|
+
const temporary = path.join(path.dirname(file), `.${RUNTIME_FILE}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
104
|
+
fs.writeFileSync(temporary, `${JSON.stringify(runtime, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
105
|
+
fs.renameSync(temporary, file);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readTargetRuntime(root = process.cwd()) {
|
|
109
|
+
const file = runtimePath(root);
|
|
110
|
+
if (!fs.existsSync(file)) return undefined;
|
|
111
|
+
try {
|
|
112
|
+
const runtime = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
113
|
+
if (runtime?.version !== 2 || typeof runtime.runId !== "string" || !rubyVersion.test(runtime.ruby ?? "") || !/^sha256:[a-f0-9]{64}$/i.test(runtime.resolvedImageId ?? "") || !runtime.preparation || ![runtime.appContainer, runtime.postgresContainer, runtime.network].every((name) => /^[a-z0-9][a-z0-9-]{0,127}$/.test(name ?? ""))) throw new Error();
|
|
114
|
+
return runtime;
|
|
115
|
+
} catch { throw new Error("Target runtime metadata is invalid. Rerun prepare-target-runtime --ruby <x.y.z>."); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, spawn = spawnSync }) {
|
|
119
|
+
if (!rubyVersion.test(ruby ?? "")) throw new Error("--ruby must be an exact numeric Ruby version such as 3.4.1.");
|
|
120
|
+
const canonical = canonicalRoot(root);
|
|
121
|
+
const selected = selectedRun(canonical, reportPath);
|
|
122
|
+
const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, ...names(selected.run.runId) };
|
|
123
|
+
const existing = readTargetRuntime(canonical);
|
|
124
|
+
if (existing && existing.runId !== runtime.runId) {
|
|
125
|
+
const prior = readRun(canonical, existing.reportPath);
|
|
126
|
+
if (!["blocked", "complete"].includes(prior.status)) throw new Error("Target runtime metadata belongs to another resumable run. Pause and resolve that run before preparing this one.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const network = docker(spawn, ["network", "inspect", runtime.network]);
|
|
130
|
+
if (network.status !== 0 && docker(spawn, ["network", "create", "--label", `${LABEL}=${selected.run.runId}`, runtime.network]).status !== 0) throw new Error("Could not create the isolated target-runtime Docker network.");
|
|
131
|
+
const postgres = docker(spawn, ["inspect", runtime.postgresContainer]);
|
|
132
|
+
if (postgres.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.postgresContainer, "--label", `${LABEL}=${selected.run.runId}`, "--network", runtime.network, "--env", "POSTGRES_HOST_AUTH_METHOD=trust", "--env", "POSTGRES_DB=ruby_upgrade_test", "postgres:16-alpine"]).status !== 0) throw new Error("Could not start the isolated target-runtime PostgreSQL container.");
|
|
133
|
+
waitForPostgres(spawn, runtime);
|
|
134
|
+
let app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
135
|
+
if (app.status === 0 && existing && existing.ruby !== ruby) {
|
|
136
|
+
let container;
|
|
137
|
+
try { [container] = JSON.parse(app.stdout); } catch { throw new Error("Docker validation container inspection returned invalid JSON."); }
|
|
138
|
+
if (container?.Config?.Labels?.[LABEL] !== selected.run.runId) throw new Error("Refusing to replace an app container not labelled for this run.");
|
|
139
|
+
if (docker(spawn, ["rm", "--force", runtime.appContainer]).status !== 0) throw new Error("Could not replace the prior target Ruby app container.");
|
|
140
|
+
app = docker(spawn, ["inspect", runtime.appContainer]);
|
|
141
|
+
}
|
|
142
|
+
if (app.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.appContainer, "--label", `${LABEL}=${selected.run.runId}`, "--network", runtime.network, "--mount", `type=bind,src=${canonical},dst=/app`, "--workdir", "/app", "--env", "RAILS_ENV=test", "--env", `DATABASE_URL=postgresql://postgres@${runtime.postgresContainer}:5432/ruby_upgrade_test`, `ruby:${ruby}`, "sleep", "infinity"]).status !== 0) throw new Error("Could not start the target Ruby app container.");
|
|
143
|
+
const prepared = bootstrap(spawn, runtime, railsProject(canonical));
|
|
144
|
+
const appInspection = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
145
|
+
if (!/^sha256:[a-f0-9]{64}$/i.test(appInspection?.Image ?? "")) throw new Error("Target Ruby image ID is unavailable.");
|
|
146
|
+
runtime.resolvedImageId = appInspection.Image;
|
|
147
|
+
runtime.preparation = prepared;
|
|
148
|
+
validateTargetRuntime({ root: canonical, runtime, spawn });
|
|
149
|
+
writeRuntime(canonical, runtime);
|
|
150
|
+
return runtime;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function validateTargetRuntime({ root = process.cwd(), runtime = readTargetRuntime(root), spawn = spawnSync }) {
|
|
154
|
+
if (!runtime) return undefined;
|
|
155
|
+
const canonical = canonicalRoot(root);
|
|
156
|
+
const app = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
157
|
+
const postgres = inspect(spawn, runtime.postgresContainer, "Target PostgreSQL container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
158
|
+
const env = Object.fromEntries((app?.Config?.Env ?? []).map((entry) => { const index = entry.indexOf("="); return [entry.slice(0, index), entry.slice(index + 1)]; }));
|
|
159
|
+
const mountedRoot = app?.Mounts?.some((mount) => mount.Type === "bind" && mount.Source === canonical && mount.Destination === "/app");
|
|
160
|
+
const appNetwork = app?.NetworkSettings?.Networks?.[runtime.network];
|
|
161
|
+
const postgresNetwork = postgres?.NetworkSettings?.Networks?.[runtime.network];
|
|
162
|
+
const valid = app?.State?.Running && postgres?.State?.Running && app?.Config?.Labels?.[LABEL] === runtime.runId && postgres?.Config?.Labels?.[LABEL] === runtime.runId && app?.Config?.Image === `ruby:${runtime.ruby}` && app?.Image === runtime.resolvedImageId && app?.Config?.WorkingDir === "/app" && mountedRoot && appNetwork && postgresNetwork && env.RAILS_ENV === "test" && env.DATABASE_URL === `postgresql://postgres@${runtime.postgresContainer}:5432/ruby_upgrade_test`;
|
|
163
|
+
if (!valid) throw new Error("Target runtime no longer matches its prepared run. Rerun prepare-target-runtime --ruby <x.y.z>.");
|
|
164
|
+
return { name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby };
|
|
165
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function parseTestEvidence(output, command, durationSeconds = null) {
|
|
2
|
+
const rspec = output.match(/(\d+) examples?,\s*(\d+) failures?/i);
|
|
3
|
+
const minitest = output.match(/(\d+) runs?,\s*(\d+) assertions?,\s*(\d+) failures?,\s*(\d+) errors?/i);
|
|
4
|
+
const elapsed = output.match(/Finished in\s+([\d.]+)\s+seconds/i);
|
|
5
|
+
const duration = durationSeconds ?? (elapsed ? Number(elapsed[1]) : null);
|
|
6
|
+
if (rspec) return { command, count: Number(rspec[1]), passed: Number(rspec[2]) === 0, failures: Number(rspec[2]), durationSeconds: duration, coveragePercent: null };
|
|
7
|
+
if (minitest) return { command, count: Number(minitest[1]), passed: Number(minitest[3]) + Number(minitest[4]) === 0, failures: Number(minitest[3]) + Number(minitest[4]), assertions: Number(minitest[2]), durationSeconds: duration, coveragePercent: null };
|
|
8
|
+
return { command, count: null, passed: null, failures: null, durationSeconds: duration, coveragePercent: null, parseStatus: "unrecognized" };
|
|
9
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { redact } from "./run-state.js";
|
|
4
|
+
import { parseTestEvidence } from "./test-evidence.js";
|
|
5
|
+
import { worktreeFingerprint } from "./provenance.js";
|
|
6
|
+
import { readTargetRuntime, validateTargetRuntime } from "./target-runtime.js";
|
|
7
|
+
|
|
8
|
+
const railsAppUpdateSkipExisting = [
|
|
9
|
+
'require "./config/application"',
|
|
10
|
+
'require "rails/generators"',
|
|
11
|
+
'require "rails/generators/rails/app/app_generator"',
|
|
12
|
+
'generator = Rails::Generators::AppGenerator.new(["rails"], { api: !!Rails.application.config.api_only, update: true }, destination_root: Rails.root, behavior: :skip)',
|
|
13
|
+
'File.exist?(Rails.root.join("config", "application.rb")) ? generator.send(:app_const) : generator.send(:valid_const?)',
|
|
14
|
+
'[:create_boot_file, :update_config_files, :create_bin_files, :display_upgrade_guide_info].each { |method| generator.send(method) }',
|
|
15
|
+
].join("; ");
|
|
16
|
+
|
|
17
|
+
const commands = Object.freeze({
|
|
18
|
+
"bundle-rspec": { argv: ["bundle", "exec", "rspec"], kind: "test", expected: "bundle exec rspec" },
|
|
19
|
+
"docker-bundle-rspec": { argv: ["bundle", "exec", "rspec"], kind: "test", expected: "bundle exec rspec", docker: true },
|
|
20
|
+
"bundle-rails-test": { argv: ["bundle", "exec", "rails", "test"], kind: "test", expected: "bundle exec rails test" },
|
|
21
|
+
"bundle-rake-test": { argv: ["bundle", "exec", "rake", "test"], kind: "test", expected: "bundle exec rake test" },
|
|
22
|
+
"bin-rails-test": { argv: ["bin/rails", "test"], kind: "test", expected: "bin/rails test" },
|
|
23
|
+
"rails-app-update": { argv: ["bundle", "exec", "ruby", "-e", railsAppUpdateSkipExisting], kind: "rails_app_update", expected: "bin/rails app:update", docker: true }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function dockerContainer(root, spawn) {
|
|
27
|
+
const runtime = readTargetRuntime(root);
|
|
28
|
+
if (!runtime) throw new Error("Prepare the isolated target runtime with prepare-target-runtime --ruby <x.y.z> before Docker validation.");
|
|
29
|
+
return validateTargetRuntime({ root, runtime, spawn });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function executeValidation({ root = process.cwd(), inventory, commandId, timeoutMs = 600000, spawn = spawnSync }) {
|
|
33
|
+
const definition = commands[commandId];
|
|
34
|
+
if (!definition) throw new Error("Unknown validation command ID.");
|
|
35
|
+
if (definition.kind === "rails_app_update" ? inventory.framework !== "rails" : !inventory.recommendedCommands.includes(definition.expected)) throw new Error("Validation command is not supported by this project's detected adapter.");
|
|
36
|
+
const environment = definition.docker ? { type: "docker", ...dockerContainer(root, spawn) } : undefined;
|
|
37
|
+
const argv = definition.docker ? ["docker", "exec", "--env", "DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true", environment.name, ...definition.argv] : definition.argv;
|
|
38
|
+
const startedAt = new Date().toISOString(); const started = Date.now();
|
|
39
|
+
const result = spawn(argv[0], argv.slice(1), { cwd: root, shell: false, encoding: "utf8", timeout: timeoutMs, maxBuffer: 256 * 1024 });
|
|
40
|
+
const output = redact(`${result.stdout ?? ""}${result.stderr ?? ""}`).slice(0, 8192);
|
|
41
|
+
const durationMs = Date.now() - started; const timedOut = result.error?.code === "ETIMEDOUT";
|
|
42
|
+
const receipt = { receiptVersion: 1, id: crypto.randomUUID(), kind: definition.kind, commandId, argv, ...(environment ? { environment } : {}), startedAt, finishedAt: new Date().toISOString(), durationMs, exitCode: typeof result.status === "number" ? result.status : null, timedOut, output: { redactedSha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) }, worktree: worktreeFingerprint(root) };
|
|
43
|
+
if (definition.kind === "test") receipt.testEvidence = parseTestEvidence(output, definition.expected, durationMs / 1000);
|
|
44
|
+
return receipt;
|
|
45
|
+
}
|