toolroll 0.9.71 → 0.9.72
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/CHANGELOG.md +79 -0
- package/dist/agent-fence.d.ts +5 -2
- package/dist/agent-fence.js +5 -2
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.js +48 -6
- package/dist/chat-decide.js +17 -2
- package/dist/check-access.js +2 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +204 -0
- package/dist/claim.js +1 -1
- package/dist/commit-extract.d.ts +94 -0
- package/dist/commit-extract.js +436 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +60 -0
- package/dist/contracts/release-terms.js +70 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/flow-engine.js +5 -1
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flows-ui.js +7 -1
- package/dist/invoke.d.ts +17 -6
- package/dist/invoke.js +48 -18
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/project-delete-ui.js +1 -1
- package/dist/proof.js +2 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +8 -0
- package/dist/release-adapters.js +212 -15
- package/dist/release-authority.d.ts +112 -0
- package/dist/release-authority.js +126 -0
- package/dist/release-carry.d.ts +233 -0
- package/dist/release-carry.js +645 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +124 -5
- package/dist/release-coverage.js +3 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +131 -1
- package/dist/release.js +562 -244
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +16 -5
- package/dist/sandbox-runtime.js +19 -6
- package/dist/scope.d.ts +115 -5
- package/dist/scope.js +219 -7
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/store.d.ts +227 -5
- package/dist/store.js +727 -37
- package/dist/task-outcome-cli.js +7 -3
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
package/dist/release-adapters.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { execFile, spawnSync } from "node:child_process";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
9
|
+
import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
11
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { parse as parseYaml } from "yaml";
|
|
@@ -17,14 +17,26 @@ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
|
17
17
|
import { activeUpdateWork } from "./desktop-update-gate.js";
|
|
18
18
|
import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
|
|
19
19
|
import { findCliLinks, switchCliLinks } from "./release-links.js";
|
|
20
|
-
import { databasePath } from "./store.js";
|
|
20
|
+
import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, releaseIssuerGone, takeReleaseLease, } from "./store.js";
|
|
21
|
+
import { acquireDeploymentLock } from "./deploy-lock.js";
|
|
22
|
+
import { liveReleaseOf } from "./scope.js";
|
|
23
|
+
import { proveCarry } from "./release-carry.js";
|
|
24
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
25
|
+
import { deploymentGate } from "./deploy-gate.js";
|
|
26
|
+
import { extractCommit } from "./commit-extract.js";
|
|
21
27
|
/** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
|
|
22
28
|
export const exec = (command, args, options = {}) => new Promise(done => {
|
|
23
|
-
execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
29
|
+
const child = execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
24
30
|
const failure = error;
|
|
25
31
|
const code = failure === null ? 0 : failure.code === "ENOENT" ? 127 : failure.killed === true ? 124 : typeof failure.code === "number" ? failure.code : 1;
|
|
26
32
|
done({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
|
|
27
33
|
});
|
|
34
|
+
// `input`: written on the child's stdin, then closed (a secret that must never ride in argv).
|
|
35
|
+
child.stdin?.on("error", () => { });
|
|
36
|
+
if (options.input !== undefined)
|
|
37
|
+
child.stdin?.end(options.input);
|
|
38
|
+
else
|
|
39
|
+
child.stdin?.end();
|
|
28
40
|
});
|
|
29
41
|
/** Without NODE_OPTIONS, which a test runner or debugger may have left for this process. */
|
|
30
42
|
const cleanEnv = (extra = {}) => {
|
|
@@ -107,6 +119,7 @@ export function gateStateOf(answer, accounts = []) {
|
|
|
107
119
|
approval: approvedAt === null || approvedBy === null || approvedDigest === null ? null : {
|
|
108
120
|
by: approvedBy, at: approvedAt, digest: approvedDigest, basis: textOr(scope["approvalBasis"]),
|
|
109
121
|
role: textOr(account?.["role"]), active: account?.["revokedAt"] === null,
|
|
122
|
+
...personAnswerOf(answer["personApproval"]),
|
|
110
123
|
},
|
|
111
124
|
result: resultRun === null || textOr(result["digest"]) === null ? null : {
|
|
112
125
|
run: resultRun, head: textOr(result["head"]), receipt: String(result["digest"]), worktree: textOr(worktree),
|
|
@@ -190,10 +203,92 @@ export function releaseAdapters(options) {
|
|
|
190
203
|
const gate = stageGate(stage, liveOwner);
|
|
191
204
|
return gate === null ? { ok: false, message } : { ok: false, message, gate };
|
|
192
205
|
};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
206
|
+
/** The deployment gate in this runtime (deploy-gate.ts): a person's approval, the passing check, the exact completed
|
|
207
|
+
* result. Its answer names the approved commit and the project it belongs to, from the plane's own records. */
|
|
208
|
+
const proveCandidate = (runId) => {
|
|
209
|
+
if (!existsSync(database))
|
|
210
|
+
return { ok: false, message: "The plane's database isn't here, so the candidate can't be proven." };
|
|
211
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
212
|
+
try {
|
|
213
|
+
return deploymentGate(new Store(db), { runId, evidenceRoot: join(dirname(database), "evidence"), now: (options.now ?? (() => new Date()))() });
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
return { ok: false, message: `The candidate can't be proven: ${error.message}` };
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
db.close();
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
/** The run's recorded commit and project, for recovery (which needs no approval). */
|
|
223
|
+
const recordedCommit = (runId) => {
|
|
224
|
+
if (!existsSync(database))
|
|
225
|
+
return null;
|
|
226
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
227
|
+
try {
|
|
228
|
+
const store = new Store(db), run = store.getRun(runId), repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
|
|
229
|
+
return run?.headRevision != null && repo != null ? { head: run.headRevision, repo } : null;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
db.close();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* The approved commit, written by this runtime into a fresh folder it owns (commit-extract.ts): every regular file of
|
|
240
|
+
* that full SHA, read byte for byte from the PROJECT's own Git directory as Toolroll records it (realpath, a real
|
|
241
|
+
* directory — never the candidate worktree's `.git` pointer or anything in that worktree), with no filter, attribute,
|
|
242
|
+
* replacement object, lazy fetch, hook or outside config, and no symlink or submodule. The deployment script runs
|
|
243
|
+
* from here, so nothing changed, flagged, swapped or untracked in the worktree, nor its Git pointer, changes what runs.
|
|
244
|
+
*/
|
|
245
|
+
const extractApproved = async (repo, head) => {
|
|
246
|
+
let gitDir;
|
|
247
|
+
try {
|
|
248
|
+
const named = join(realpathSync(repo), ".git");
|
|
249
|
+
if (!lstatSync(named).isDirectory())
|
|
250
|
+
return { ok: false, message: `${repo}'s Git directory isn't a plain directory, so the approved commit can't be read from it.` };
|
|
251
|
+
gitDir = realpathSync(named);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return { ok: false, message: `${repo} has no Git directory to read the approved commit from.` };
|
|
255
|
+
}
|
|
256
|
+
const dir = realpathSync(mkdtempSync(join(resolved(tmpdir()), "toolroll-deployer-")));
|
|
257
|
+
const extracted = extractCommit(gitDir, head, dir);
|
|
258
|
+
if (!extracted.ok) {
|
|
259
|
+
rmSync(dir, { recursive: true, force: true });
|
|
260
|
+
return { ok: false, message: `The approved commit couldn't be prepared: ${extracted.message}` };
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, dir, gitDir };
|
|
263
|
+
};
|
|
264
|
+
/** The approved commit's own deployment script, from the pristine extraction; the worktree is only its --source. */
|
|
265
|
+
const deployScript = async (worktree, stage, runId, commit, extra, input) => {
|
|
266
|
+
const extracted = await extractApproved(commit.repo, commit.head);
|
|
267
|
+
if (!extracted.ok)
|
|
268
|
+
return { code: 1, stdout: "", stderr: extracted.message };
|
|
269
|
+
try {
|
|
270
|
+
// The script node runs is the extraction's own regular file, by its real path inside the folder.
|
|
271
|
+
let script;
|
|
272
|
+
try {
|
|
273
|
+
script = realpathSync(join(extracted.dir, "scripts", "deploy-browser.mjs"));
|
|
274
|
+
if (!script.startsWith(extracted.dir + "/") || !lstatSync(script).isFile())
|
|
275
|
+
throw new Error("outside");
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return { code: 1, stdout: "", stderr: "The approved commit has no deployment script of its own." };
|
|
279
|
+
}
|
|
280
|
+
return await run(process.execPath, [script, "--run", String(runId), "--stage", stage, "--source", worktree,
|
|
281
|
+
"--head", commit.head, "--git-dir", extracted.gitDir, ...extra], {
|
|
282
|
+
cwd: extracted.dir, env: cleanEnv({ TMPDIR: resolved(process.env["TMPDIR"] ?? "/tmp") }), timeoutMs: (options.deployMinutes ?? 30) * 60_000, ...(input === undefined ? {} : { input }),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
rmSync(extracted.dir, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()));
|
|
196
290
|
return {
|
|
291
|
+
...(existsSync(database) ? { release } : {}),
|
|
197
292
|
now: options.now ?? (() => new Date()),
|
|
198
293
|
sleep: options.sleep ?? sleep,
|
|
199
294
|
say: options.say ?? (line => process.stdout.write(`${line}\n`)),
|
|
@@ -278,7 +373,7 @@ export function releaseAdapters(options) {
|
|
|
278
373
|
const added = await toolroll("task", "add", input.title, "--id", input.taskId, "--repo", input.checkout, "--checks", "full");
|
|
279
374
|
if (added["ok"] !== true)
|
|
280
375
|
throw new Error(`task add: ${refused(added)}`);
|
|
281
|
-
const scoped = await toolroll("task", "scope", input.taskId, "--candidate", input.candidate, "--goal", input.goal, "--acceptance", input.acceptance);
|
|
376
|
+
const scoped = await toolroll("task", "scope", input.taskId, "--candidate", input.candidate, "--goal", input.goal, "--acceptance", input.acceptance, ...(input.release === undefined ? [] : ["--release", `${input.release.version}:${input.release.pr}:${input.release.branch}`, ...(input.release.noCarry ? ["--no-carry"] : [])]));
|
|
282
377
|
if (scoped["ok"] !== true)
|
|
283
378
|
throw new Error(`task scope: ${refused(scoped)}`);
|
|
284
379
|
const digest = gateStateOf(await toolroll("task", "show", input.taskId)).scopeDigest;
|
|
@@ -308,10 +403,17 @@ export function releaseAdapters(options) {
|
|
|
308
403
|
db.close();
|
|
309
404
|
}
|
|
310
405
|
},
|
|
311
|
-
async deploy({ run: runId, worktree, stage }) {
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
406
|
+
async deploy({ run: runId, worktree, stage, operation }) {
|
|
407
|
+
// Approval policy §1.7: this runtime proves the candidate FIRST — a person approved it, its exact result passed
|
|
408
|
+
// the approved check and is complete, and the checkout is exactly that commit — before anything from the
|
|
409
|
+
// candidate's checkout runs. The candidate's own script never decides its own deployment.
|
|
410
|
+
const proven = proveCandidate(runId);
|
|
411
|
+
if (!proven.ok)
|
|
412
|
+
return stopped(stage, proven.message);
|
|
413
|
+
// The approved commit's own deployment, extracted by this runtime: drain, backup, rehearsal, swap, health. Its
|
|
414
|
+
// last JSON line names the runtime it installed; the CLI names are moved to it by the release's link step.
|
|
415
|
+
// The operation the coordinator reserved, adopted by the deploy run with its secret on stdin (never in argv).
|
|
416
|
+
const result = await deployScript(worktree, stage, runId, proven, ["--yes", ...(operation === undefined ? [] : ["--operation", operation.id])], operation?.secret);
|
|
315
417
|
const runtime = deployedRuntime(result.stdout);
|
|
316
418
|
if (result.code !== 0 || runtime === null)
|
|
317
419
|
return stopped(stage, tail(result));
|
|
@@ -325,7 +427,11 @@ export function releaseAdapters(options) {
|
|
|
325
427
|
const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
|
|
326
428
|
if (running.length > 0)
|
|
327
429
|
return { ok: false, message: `the deployment is still running (process ${running.map(one => one.pid).join(", ")}); let it finish, then rerun` };
|
|
328
|
-
|
|
430
|
+
// Recovery needs no approval, but it runs only the recorded commit's own script, extracted by this runtime.
|
|
431
|
+
const recorded = recordedCommit(runId);
|
|
432
|
+
if (recorded === null)
|
|
433
|
+
return stopped(stage, `Run ${runId} names no commit to recover with.`);
|
|
434
|
+
const result = await deployScript(worktree, stage, runId, recorded, ["--phase", "recover"]);
|
|
329
435
|
if (result.code === 0)
|
|
330
436
|
return { ok: true };
|
|
331
437
|
// Killed while preparing, before it paused anything: nothing to undo (its journal is kept).
|
|
@@ -339,6 +445,34 @@ export function releaseAdapters(options) {
|
|
|
339
445
|
return { ok: true };
|
|
340
446
|
return stopped(stage, `${tail(result)} (the deployment journal is at ${String(journal["phase"] ?? "an unreadable phase")}; ${join(stage, "deployment.json")})`);
|
|
341
447
|
},
|
|
448
|
+
async deploymentIdle(stage) {
|
|
449
|
+
// Whatever the stage: no deployment is running, and no update pause is held.
|
|
450
|
+
if ((options.processes ?? processes)().some(one => one.command.includes("deploy-browser.mjs")))
|
|
451
|
+
return false;
|
|
452
|
+
if (liveOwner() !== null)
|
|
453
|
+
return false;
|
|
454
|
+
if (stage === null)
|
|
455
|
+
return false;
|
|
456
|
+
const journalFile = join(stage, "deployment.json");
|
|
457
|
+
// Never prepared: staging touched neither the service nor the update pause.
|
|
458
|
+
if (!existsSync(journalFile))
|
|
459
|
+
return true;
|
|
460
|
+
// Its journal lock is free (nothing holds the journal), and it stopped where nothing changed, or finished.
|
|
461
|
+
try {
|
|
462
|
+
acquireDeploymentLock(journalFile, 0).release();
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
let phase;
|
|
468
|
+
try {
|
|
469
|
+
phase = object(JSON.parse(readFileSync(journalFile, "utf8")))["phase"];
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
return phase === "preparing" || phase === "released" || phase === "deployed";
|
|
475
|
+
},
|
|
342
476
|
async cliLinks() { return findCliLinks(); },
|
|
343
477
|
async switchLinks(links, bin, version) {
|
|
344
478
|
const answers = (path) => spawnSync(path, ["--version"], { encoding: "utf8", env: cleanEnv(), timeout: 30_000 }).stdout?.trim() === version;
|
|
@@ -361,8 +495,9 @@ export function releaseAdapters(options) {
|
|
|
361
495
|
// Oldest first, so a rerun's attempt is the last one listed for its name.
|
|
362
496
|
return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]), app: textOr(one["app"]) }));
|
|
363
497
|
},
|
|
364
|
-
async merge(repo, pr, head) {
|
|
365
|
-
|
|
498
|
+
async merge(repo, pr, head, message) {
|
|
499
|
+
// The squash commit's message lists every commit that landed after the approval (release-once.md).
|
|
500
|
+
const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`, ...(message === undefined ? [] : ["-f", `commit_message=${message}`])));
|
|
366
501
|
if (typeof merged["sha"] !== "string")
|
|
367
502
|
throw new Error(`GitHub did not merge #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
|
|
368
503
|
return merged["sha"];
|
|
@@ -510,3 +645,65 @@ export function bumpFormula(text, url, sha256) {
|
|
|
510
645
|
throw new Error("the formula has no url and sha256 lines to update");
|
|
511
646
|
return text.replace(/^(\s*url ")[^"]*(")/m, `$1${url}$2`).replace(/^(\s*sha256 ")[0-9a-f]{64}(")/m, `$1${sha256}$2`);
|
|
512
647
|
}
|
|
648
|
+
/** The plane's person-approval answer from `task show --json`, read strictly: anything malformed is no person. */
|
|
649
|
+
function personAnswerOf(value) {
|
|
650
|
+
if (value === undefined)
|
|
651
|
+
return {};
|
|
652
|
+
const read = object(value);
|
|
653
|
+
if (read["ok"] === true && typeof read["method"] === "string")
|
|
654
|
+
return { person: { ok: true, method: read["method"] } };
|
|
655
|
+
return { person: { ok: false, why: typeof read["why"] === "string" ? read["why"] : "the plane's answer about who approved it can't be read" } };
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The release's authority record through a store (release-once.md): what `toolroll release` reads and writes for its
|
|
659
|
+
* lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
|
|
660
|
+
*/
|
|
661
|
+
export function storeReleaseAdapter(open, done, now, runner) {
|
|
662
|
+
const withStore = (body) => { const store = open(); try {
|
|
663
|
+
return body(store);
|
|
664
|
+
}
|
|
665
|
+
finally {
|
|
666
|
+
done(store);
|
|
667
|
+
} };
|
|
668
|
+
return {
|
|
669
|
+
async authority(taskId) {
|
|
670
|
+
return withStore(store => {
|
|
671
|
+
const found = releaseAuthorityOfTask(store, taskId);
|
|
672
|
+
if (found === null)
|
|
673
|
+
return null;
|
|
674
|
+
const a = found.authority;
|
|
675
|
+
return { id: a.id, generation: a.generation, state: a.state, tipSha: a.tipSha, tipTask: a.tipTask, version: a.version, pr: a.pr,
|
|
676
|
+
landed: releaseCarries(store, a.id).flatMap(carry => carry.commits) };
|
|
677
|
+
});
|
|
678
|
+
},
|
|
679
|
+
async live(taskId, action) {
|
|
680
|
+
return withStore(store => { const live = liveReleaseOf(store, taskId, action, now()); return live.applies ? { applies: true, ok: live.ok, why: live.ok ? null : live.why } : { applies: false, ok: false, why: null }; });
|
|
681
|
+
},
|
|
682
|
+
async lease(authority, owner) { return withStore(store => takeReleaseLease(store, authority, owner, now())); },
|
|
683
|
+
async renew(authority, owner, epoch) { return withStore(store => renewReleaseLease(store, authority, owner, epoch, now())); },
|
|
684
|
+
async carry(authority, to, requestId, epoch) {
|
|
685
|
+
const store = open();
|
|
686
|
+
try {
|
|
687
|
+
const result = await proveCarry(store, { authority, to, requestId, epoch, now: now(), ...(runner === undefined ? {} : { runner }) });
|
|
688
|
+
return result.ok ? { ok: true, taskId: result.taskId } : { ok: false, message: result.message };
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
done(store);
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
async reserve(input) {
|
|
695
|
+
return withStore(store => {
|
|
696
|
+
const secret = randomBytes(32).toString("hex");
|
|
697
|
+
const result = reserveRelease(store, { ...input, operationId: randomUUID(), now: now(), secret });
|
|
698
|
+
return result.ok ? { ok: true, operation: result.operation.id, secret } : result;
|
|
699
|
+
});
|
|
700
|
+
},
|
|
701
|
+
async start(operation, epoch) { return withStore(store => runReleaseOperation(store, operation, epoch, now())); },
|
|
702
|
+
async finish(operation, outcome, facts, epoch) { return withStore(store => finishReleaseOperation(store, operation, outcome, facts, now(), epoch)); },
|
|
703
|
+
async needsApproval(authority, reason, epoch) { return withStore(store => requireFreshReleaseApproval(store, authority, reason, epoch, now())); },
|
|
704
|
+
async operations(authority) {
|
|
705
|
+
return withStore(store => releaseOperations(store, authority).map(op => ({ id: op.id, kind: op.kind, state: op.state, candidate: op.candidate, externalRef: op.externalRef, reservedAt: op.reservedAt,
|
|
706
|
+
direct: op.direct, issuerGone: releaseIssuerGone(op), stage: op.stage })));
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release authority record, read (docs/plans/release-once.md, schema v135): one generation of a release a person
|
|
3
|
+
* approved, its carried commits and its deploy and merge operations. Kept apart from store.ts so the one live-release
|
|
4
|
+
* reader (scope.ts liveReleaseOf) can read it without a module cycle; every write is in store.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { type ReleasePolicy, type ReleaseTerms } from "./contracts/release-terms.js";
|
|
7
|
+
import type { Store } from "./store.js";
|
|
8
|
+
export type ReleaseState = "open" | "deploying" | "merge-reserved" | "merged" | "revoked" | "expired" | "superseded";
|
|
9
|
+
export declare const LIVE_RELEASE_STATES: readonly ReleaseState[];
|
|
10
|
+
/** One generation of a release a person approved: the single source of truth for it. */
|
|
11
|
+
export type ReleaseAuthority = {
|
|
12
|
+
id: number;
|
|
13
|
+
repo: string;
|
|
14
|
+
generation: number;
|
|
15
|
+
previous: number | null;
|
|
16
|
+
version: string;
|
|
17
|
+
pr: number;
|
|
18
|
+
branch: string;
|
|
19
|
+
base: string;
|
|
20
|
+
anchorTask: string;
|
|
21
|
+
anchorDigest: string;
|
|
22
|
+
approvedSha: string;
|
|
23
|
+
approvedTree: string;
|
|
24
|
+
artifactDigest: string | null;
|
|
25
|
+
tipSha: string;
|
|
26
|
+
tipTask: string;
|
|
27
|
+
policyJson: string;
|
|
28
|
+
policy: ReleasePolicy | null;
|
|
29
|
+
policyDigest: string;
|
|
30
|
+
verifyCommand: number;
|
|
31
|
+
verifyDigest: string;
|
|
32
|
+
accessDigest: string;
|
|
33
|
+
state: ReleaseState;
|
|
34
|
+
rev: number;
|
|
35
|
+
leaseOwner: string | null;
|
|
36
|
+
leaseEpoch: number;
|
|
37
|
+
leaseExpires: string | null;
|
|
38
|
+
expiresAt: string;
|
|
39
|
+
createdAt: string;
|
|
40
|
+
endedAt: string | null;
|
|
41
|
+
endedBy: string | null;
|
|
42
|
+
};
|
|
43
|
+
/** One later commit carried under an authority. */
|
|
44
|
+
export type ReleaseCarry = {
|
|
45
|
+
id: number;
|
|
46
|
+
authority: number;
|
|
47
|
+
taskId: string;
|
|
48
|
+
scopeDigest: string;
|
|
49
|
+
requestId: string;
|
|
50
|
+
fromSha: string;
|
|
51
|
+
toSha: string;
|
|
52
|
+
toTree: string;
|
|
53
|
+
artifactDigest: string;
|
|
54
|
+
commits: {
|
|
55
|
+
sha: string;
|
|
56
|
+
files: {
|
|
57
|
+
path: string;
|
|
58
|
+
added: number;
|
|
59
|
+
deleted: number;
|
|
60
|
+
}[];
|
|
61
|
+
}[];
|
|
62
|
+
changedLines: number;
|
|
63
|
+
files: number;
|
|
64
|
+
churn: number;
|
|
65
|
+
runtime: string;
|
|
66
|
+
provedAt: string;
|
|
67
|
+
};
|
|
68
|
+
/** A deploy or merge that can't be undone, fenced by the lease epoch that reserved it. */
|
|
69
|
+
export type ReleaseOperation = {
|
|
70
|
+
id: string;
|
|
71
|
+
authority: number;
|
|
72
|
+
kind: "deploy" | "merge";
|
|
73
|
+
taskId: string;
|
|
74
|
+
candidate: string;
|
|
75
|
+
epoch: number;
|
|
76
|
+
state: "reserved" | "running" | "succeeded" | "failed" | "unknown";
|
|
77
|
+
externalRef: string | null;
|
|
78
|
+
reservedAt: string;
|
|
79
|
+
finishedAt: string | null;
|
|
80
|
+
detail: string | null;
|
|
81
|
+
/** Reserved by a person's own deploy run, with no coordinator holding the release. */
|
|
82
|
+
direct: boolean;
|
|
83
|
+
/** The process that reserved, then issued, it (store.ts currentReleaseIssuer); null when none was recorded. */
|
|
84
|
+
issuer: {
|
|
85
|
+
pid: number;
|
|
86
|
+
started: string | null;
|
|
87
|
+
at: string;
|
|
88
|
+
} | null;
|
|
89
|
+
/** A direct deploy's staging folder. */
|
|
90
|
+
stage: string | null;
|
|
91
|
+
};
|
|
92
|
+
export declare function releaseAuthority(store: Store, id: number): ReleaseAuthority | null;
|
|
93
|
+
/** The newest generation of a release, whatever its state. */
|
|
94
|
+
export declare function newestReleaseGeneration(store: Store, repo: string, version: string, pr: number): ReleaseAuthority | null;
|
|
95
|
+
/** The release authority a task belongs to — as an anchor (any generation) or as a carried commit — or null. */
|
|
96
|
+
export declare function releaseAuthorityOfTask(store: Store, taskId: string): {
|
|
97
|
+
authority: ReleaseAuthority;
|
|
98
|
+
carry: ReleaseCarry | null;
|
|
99
|
+
} | null;
|
|
100
|
+
/** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
|
|
101
|
+
export declare function belongsToRelease(store: Store, taskId: string): boolean;
|
|
102
|
+
export declare function releaseCarries(store: Store, authority: number): ReleaseCarry[];
|
|
103
|
+
export declare function releaseCarryTo(store: Store, authority: number, toSha: string): ReleaseCarry | null;
|
|
104
|
+
export declare function releaseOperations(store: Store, authority: number): ReleaseOperation[];
|
|
105
|
+
export declare function releaseOperation(store: Store, id: string): ReleaseOperation | null;
|
|
106
|
+
/** Operations nobody has settled yet: until each is reconciled, nothing new starts. */
|
|
107
|
+
export declare function outstandingReleaseOperations(store: Store, authority: number): ReleaseOperation[];
|
|
108
|
+
/**
|
|
109
|
+
* Why a person's seal of these release terms can't open a new generation now, in words, or null. A live generation is
|
|
110
|
+
* superseded by the new one — but never while one of its operations is unsettled, and never once its merge started.
|
|
111
|
+
*/
|
|
112
|
+
export declare function releaseSealProblem(store: Store, terms: ReleaseTerms): string | null;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release authority record, read (docs/plans/release-once.md, schema v135): one generation of a release a person
|
|
3
|
+
* approved, its carried commits and its deploy and merge operations. Kept apart from store.ts so the one live-release
|
|
4
|
+
* reader (scope.ts liveReleaseOf) can read it without a module cycle; every write is in store.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { readReleasePolicy } from "./contracts/release-terms.js";
|
|
7
|
+
export const LIVE_RELEASE_STATES = ["open", "deploying", "merge-reserved"];
|
|
8
|
+
function readReleaseAuthority(row) {
|
|
9
|
+
const text = (key) => row[key] == null ? null : String(row[key]);
|
|
10
|
+
let policy = null;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = readReleasePolicyStrict(String(row["policy_json"]));
|
|
13
|
+
policy = parsed;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
policy = null;
|
|
17
|
+
}
|
|
18
|
+
const state = String(row["state"]);
|
|
19
|
+
return {
|
|
20
|
+
id: Number(row["id"]), repo: String(row["repo"]), generation: Number(row["generation"]), previous: row["previous"] == null ? null : Number(row["previous"]),
|
|
21
|
+
version: String(row["version"]), pr: Number(row["pr"]), branch: String(row["branch"]), base: String(row["base"]),
|
|
22
|
+
anchorTask: String(row["anchor_task"]), anchorDigest: String(row["anchor_digest"]), approvedSha: String(row["approved_sha"]), approvedTree: String(row["approved_tree"]),
|
|
23
|
+
artifactDigest: text("artifact_digest"), tipSha: String(row["tip_sha"]), tipTask: String(row["tip_task"]), policyJson: String(row["policy_json"]), policy,
|
|
24
|
+
policyDigest: String(row["policy_digest"]), verifyCommand: Number(row["verify_command"]), verifyDigest: String(row["verify_digest"]), accessDigest: String(row["access_digest"]),
|
|
25
|
+
state: ["open", "deploying", "merge-reserved", "merged", "revoked", "expired", "superseded"].includes(state) ? state : "revoked",
|
|
26
|
+
rev: Number(row["rev"]), leaseOwner: text("lease_owner"), leaseEpoch: Number(row["lease_epoch"]), leaseExpires: text("lease_expires"),
|
|
27
|
+
expiresAt: String(row["expires_at"]), createdAt: String(row["created_at"]), endedAt: text("ended_at"), endedBy: text("ended_by"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** A sealed policy read strictly; null when this runtime doesn't know it (and so refuses to carry under it). */
|
|
31
|
+
function readReleasePolicyStrict(json) {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(json);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const read = readReleasePolicy(parsed);
|
|
40
|
+
return read.ok && JSON.stringify(read.value) === json ? read.value : null;
|
|
41
|
+
}
|
|
42
|
+
function readReleaseCarry(row) {
|
|
43
|
+
let commits = [];
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(String(row["commits_json"]));
|
|
46
|
+
if (Array.isArray(parsed))
|
|
47
|
+
commits = parsed;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
commits = [];
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
id: Number(row["id"]), authority: Number(row["authority"]), taskId: String(row["task_id"]), scopeDigest: String(row["scope_digest"]), requestId: String(row["request_id"]),
|
|
54
|
+
fromSha: String(row["from_sha"]), toSha: String(row["to_sha"]), toTree: String(row["to_tree"]), artifactDigest: String(row["artifact_digest"]), commits,
|
|
55
|
+
changedLines: Number(row["changed_lines"]), files: Number(row["files"]), churn: Number(row["churn"]), runtime: String(row["runtime"]), provedAt: String(row["proved_at"]),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function readReleaseOperation(row) {
|
|
59
|
+
const text = (key) => row[key] == null ? null : String(row[key]);
|
|
60
|
+
return {
|
|
61
|
+
id: String(row["id"]), authority: Number(row["authority"]), kind: row["kind"] === "merge" ? "merge" : "deploy", taskId: String(row["task_id"]), candidate: String(row["candidate"]),
|
|
62
|
+
epoch: Number(row["epoch"]), state: String(row["state"]), externalRef: text("external_ref"), reservedAt: String(row["reserved_at"]),
|
|
63
|
+
finishedAt: text("finished_at"), detail: text("detail"), direct: Number(row["direct"] ?? 0) === 1,
|
|
64
|
+
issuer: row["issuer_pid"] == null || row["issued_at"] == null ? null : { pid: Number(row["issuer_pid"]), started: text("issuer_started"), at: String(row["issued_at"]) },
|
|
65
|
+
stage: text("stage"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function releaseAuthority(store, id) {
|
|
69
|
+
const row = store.handle.prepare("SELECT * FROM release_authority WHERE id = ?").get(id);
|
|
70
|
+
return row === undefined ? null : readReleaseAuthority(row);
|
|
71
|
+
}
|
|
72
|
+
/** The newest generation of a release, whatever its state. */
|
|
73
|
+
export function newestReleaseGeneration(store, repo, version, pr) {
|
|
74
|
+
const row = store.handle.prepare("SELECT * FROM release_authority WHERE repo = ? AND version = ? AND pr = ? ORDER BY generation DESC LIMIT 1").get(repo, version, pr);
|
|
75
|
+
return row === undefined ? null : readReleaseAuthority(row);
|
|
76
|
+
}
|
|
77
|
+
/** The release authority a task belongs to — as an anchor (any generation) or as a carried commit — or null. */
|
|
78
|
+
export function releaseAuthorityOfTask(store, taskId) {
|
|
79
|
+
const carryRow = store.handle.prepare("SELECT * FROM release_carry WHERE task_id = ?").get(taskId);
|
|
80
|
+
if (carryRow !== undefined) {
|
|
81
|
+
const carry = readReleaseCarry(carryRow);
|
|
82
|
+
const authority = releaseAuthority(store, carry.authority);
|
|
83
|
+
return authority === null ? null : { authority, carry };
|
|
84
|
+
}
|
|
85
|
+
const row = store.handle.prepare("SELECT * FROM release_authority WHERE anchor_task = ? ORDER BY id DESC LIMIT 1").get(taskId);
|
|
86
|
+
return row === undefined ? null : { authority: readReleaseAuthority(row), carry: null };
|
|
87
|
+
}
|
|
88
|
+
/** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
|
|
89
|
+
export function belongsToRelease(store, taskId) {
|
|
90
|
+
return store.handle.prepare("SELECT 1 AS hit FROM release_authority WHERE anchor_task = ? UNION ALL SELECT 1 FROM release_carry WHERE task_id = ? LIMIT 1").get(taskId, taskId) !== undefined;
|
|
91
|
+
}
|
|
92
|
+
export function releaseCarries(store, authority) {
|
|
93
|
+
return store.handle.prepare("SELECT * FROM release_carry WHERE authority = ? ORDER BY id").all(authority).map(readReleaseCarry);
|
|
94
|
+
}
|
|
95
|
+
export function releaseCarryTo(store, authority, toSha) {
|
|
96
|
+
const row = store.handle.prepare("SELECT * FROM release_carry WHERE authority = ? AND to_sha = ?").get(authority, toSha);
|
|
97
|
+
return row === undefined ? null : readReleaseCarry(row);
|
|
98
|
+
}
|
|
99
|
+
export function releaseOperations(store, authority) {
|
|
100
|
+
return store.handle.prepare("SELECT * FROM release_operation WHERE authority = ? ORDER BY reserved_at, id").all(authority).map(readReleaseOperation);
|
|
101
|
+
}
|
|
102
|
+
export function releaseOperation(store, id) {
|
|
103
|
+
const row = store.handle.prepare("SELECT * FROM release_operation WHERE id = ?").get(id);
|
|
104
|
+
return row === undefined ? null : readReleaseOperation(row);
|
|
105
|
+
}
|
|
106
|
+
/** Operations nobody has settled yet: until each is reconciled, nothing new starts. */
|
|
107
|
+
export function outstandingReleaseOperations(store, authority) {
|
|
108
|
+
return releaseOperations(store, authority).filter(one => one.state === "reserved" || one.state === "running" || one.state === "unknown");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Why a person's seal of these release terms can't open a new generation now, in words, or null. A live generation is
|
|
112
|
+
* superseded by the new one — but never while one of its operations is unsettled, and never once its merge started.
|
|
113
|
+
*/
|
|
114
|
+
export function releaseSealProblem(store, terms) {
|
|
115
|
+
const live = newestReleaseGeneration(store, terms.repo, terms.packageVersion, terms.pr);
|
|
116
|
+
if (live === null)
|
|
117
|
+
return null;
|
|
118
|
+
if (live.state === "merged")
|
|
119
|
+
return `Release ${terms.packageVersion} from pull request #${terms.pr} was already merged.`;
|
|
120
|
+
if (live.state === "merge-reserved")
|
|
121
|
+
return `The merge of release ${terms.packageVersion} already started; it can't be approved again.`;
|
|
122
|
+
if (LIVE_RELEASE_STATES.includes(live.state) && outstandingReleaseOperations(store, live.id).length > 0) {
|
|
123
|
+
return `Release ${terms.packageVersion} has a deploy that hasn't been settled yet. Run \`toolroll release ${live.branch}\` to finish or recover it first.`;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|