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.
Files changed (93) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/agent-fence.d.ts +5 -2
  3. package/dist/agent-fence.js +5 -2
  4. package/dist/approval-authority.d.ts +20 -0
  5. package/dist/approval-authority.js +67 -0
  6. package/dist/assignment.js +26 -4
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/builder.js +48 -6
  9. package/dist/chat-decide.js +17 -2
  10. package/dist/check-access.js +2 -2
  11. package/dist/check-report.d.ts +109 -0
  12. package/dist/check-report.js +204 -0
  13. package/dist/claim.js +1 -1
  14. package/dist/commit-extract.d.ts +94 -0
  15. package/dist/commit-extract.js +436 -0
  16. package/dist/contracts/console-api.d.ts +1 -1
  17. package/dist/contracts/console-api.js +1 -0
  18. package/dist/contracts/flow.d.ts +16 -0
  19. package/dist/contracts/flow.js +3 -0
  20. package/dist/contracts/release-check.d.ts +335 -0
  21. package/dist/contracts/release-check.js +159 -0
  22. package/dist/contracts/release-terms.d.ts +60 -0
  23. package/dist/contracts/release-terms.js +70 -0
  24. package/dist/contracts/verification-receipt.d.ts +42 -8
  25. package/dist/contracts/verification-receipt.js +24 -7
  26. package/dist/contracts/verification-view.d.ts +1 -0
  27. package/dist/deploy-gate.d.ts +17 -0
  28. package/dist/deploy-gate.js +68 -0
  29. package/dist/dispatch.js +3 -10
  30. package/dist/dispose.js +4 -1
  31. package/dist/evidence-pack.d.ts +1 -1
  32. package/dist/evidence-pack.js +20 -2
  33. package/dist/evidence.d.ts +1 -0
  34. package/dist/evidence.js +2 -1
  35. package/dist/flow-engine.js +5 -1
  36. package/dist/flow-pull-request.d.ts +5 -2
  37. package/dist/flow-pull-request.js +14 -5
  38. package/dist/flow-schedule.js +25 -6
  39. package/dist/flows-ui.js +7 -1
  40. package/dist/invoke.d.ts +17 -6
  41. package/dist/invoke.js +48 -18
  42. package/dist/lead-status.js +5 -2
  43. package/dist/live-terminal.d.ts +10 -6
  44. package/dist/live-terminal.js +55 -14
  45. package/dist/nightly-check.d.ts +104 -0
  46. package/dist/nightly-check.js +362 -0
  47. package/dist/operate.js +39 -2
  48. package/dist/person-proof.d.ts +2 -0
  49. package/dist/person-proof.js +14 -0
  50. package/dist/plan-auto.js +4 -4
  51. package/dist/planner-source.d.ts +1 -1
  52. package/dist/project-delete-ui.js +1 -1
  53. package/dist/proof.js +2 -0
  54. package/dist/publish.js +156 -144
  55. package/dist/pull-request-flow.d.ts +2 -0
  56. package/dist/pull-request-flow.js +24 -2
  57. package/dist/release-adapters.d.ts +8 -0
  58. package/dist/release-adapters.js +212 -15
  59. package/dist/release-authority.d.ts +112 -0
  60. package/dist/release-authority.js +126 -0
  61. package/dist/release-carry.d.ts +233 -0
  62. package/dist/release-carry.js +645 -0
  63. package/dist/release-cli.d.ts +3 -0
  64. package/dist/release-cli.js +124 -5
  65. package/dist/release-coverage.js +3 -0
  66. package/dist/release-recheck.d.ts +146 -0
  67. package/dist/release-recheck.js +657 -0
  68. package/dist/release-view.d.ts +34 -0
  69. package/dist/release-view.js +27 -0
  70. package/dist/release.d.ts +131 -1
  71. package/dist/release.js +562 -244
  72. package/dist/remote-main.d.ts +2 -0
  73. package/dist/remote-main.js +1 -1
  74. package/dist/result-completion.d.ts +6 -0
  75. package/dist/result-completion.js +15 -1
  76. package/dist/result-follow-ups.js +29 -1
  77. package/dist/sandbox-runtime.d.ts +16 -5
  78. package/dist/sandbox-runtime.js +19 -6
  79. package/dist/scope.d.ts +115 -5
  80. package/dist/scope.js +219 -7
  81. package/dist/server/render-tasks.d.ts +8 -0
  82. package/dist/server/render-tasks.js +50 -9
  83. package/dist/server/route-table.js +1 -0
  84. package/dist/server/task-views.js +4 -0
  85. package/dist/server/tasks.js +12 -0
  86. package/dist/store.d.ts +227 -5
  87. package/dist/store.js +727 -37
  88. package/dist/task-outcome-cli.js +7 -3
  89. package/dist/toolroll-update.js +2 -2
  90. package/dist/verification-evidence.d.ts +25 -1
  91. package/dist/verification-evidence.js +57 -2
  92. package/dist/work-index.js +3 -6
  93. package/package.json +1 -1
@@ -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
- const deployScript = (worktree, stage, runId, extra) => run(process.execPath, ["scripts/deploy-browser.mjs", "--run", String(runId), "--stage", stage, ...extra], {
194
- cwd: worktree, env: cleanEnv({ TMPDIR: resolved(process.env["TMPDIR"] ?? "/tmp") }), timeoutMs: (options.deployMinutes ?? 30) * 60_000,
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
- // The candidate's own deployment, from its own checkout: drain, backup, rehearsal, swap, health. Its last JSON
313
- // line names the runtime it installed; the CLI names are moved to it by the release's link step.
314
- const result = await deployScript(worktree, stage, runId, ["--yes"]);
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
- const result = await deployScript(worktree, stage, runId, ["--phase", "recover"]);
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
- const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
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
+ }