toolroll 0.9.71 → 0.9.73

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 (131) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/agent-fence.d.ts +10 -2
  3. package/dist/agent-fence.js +47 -9
  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/browser/workspace.js +28 -28
  9. package/dist/builder.js +48 -6
  10. package/dist/chat-actions.js +13 -8
  11. package/dist/chat-decide.d.ts +47 -1
  12. package/dist/chat-decide.js +186 -5
  13. package/dist/check-access.js +2 -2
  14. package/dist/check-report.d.ts +109 -0
  15. package/dist/check-report.js +205 -0
  16. package/dist/claim.js +1 -1
  17. package/dist/coding-provider.js +4 -2
  18. package/dist/commit-extract.d.ts +111 -0
  19. package/dist/commit-extract.js +514 -0
  20. package/dist/contracts/console-api.d.ts +4 -4
  21. package/dist/contracts/console-api.js +5 -4
  22. package/dist/contracts/flow.d.ts +16 -0
  23. package/dist/contracts/flow.js +3 -0
  24. package/dist/contracts/release-check.d.ts +335 -0
  25. package/dist/contracts/release-check.js +159 -0
  26. package/dist/contracts/release-terms.d.ts +66 -0
  27. package/dist/contracts/release-terms.js +74 -0
  28. package/dist/contracts/verification-receipt.d.ts +42 -8
  29. package/dist/contracts/verification-receipt.js +24 -7
  30. package/dist/contracts/verification-view.d.ts +1 -0
  31. package/dist/deploy-gate.d.ts +17 -0
  32. package/dist/deploy-gate.js +68 -0
  33. package/dist/dispatch.js +3 -10
  34. package/dist/dispose.js +4 -1
  35. package/dist/evidence-pack.d.ts +1 -1
  36. package/dist/evidence-pack.js +20 -2
  37. package/dist/evidence.d.ts +1 -0
  38. package/dist/evidence.js +2 -1
  39. package/dist/exec.d.ts +1 -1
  40. package/dist/exec.js +7 -3
  41. package/dist/flow-engine.js +5 -1
  42. package/dist/flow-gallery-ui.js +4 -2
  43. package/dist/flow-gallery.d.ts +3 -0
  44. package/dist/flow-gallery.js +6 -2
  45. package/dist/flow-pull-request.d.ts +5 -2
  46. package/dist/flow-pull-request.js +14 -5
  47. package/dist/flow-schedule.js +25 -6
  48. package/dist/flow-triggers.d.ts +8 -0
  49. package/dist/flow-triggers.js +15 -0
  50. package/dist/flows-cli.js +13 -3
  51. package/dist/flows-ui.js +7 -1
  52. package/dist/guides.js +10 -1
  53. package/dist/invoke.d.ts +17 -6
  54. package/dist/invoke.js +48 -18
  55. package/dist/kits-ui.js +4 -2
  56. package/dist/kits.d.ts +6 -4
  57. package/dist/kits.js +9 -11
  58. package/dist/lead-status.js +5 -2
  59. package/dist/live-terminal.d.ts +10 -6
  60. package/dist/live-terminal.js +55 -14
  61. package/dist/mcp-connect.d.ts +10 -0
  62. package/dist/mcp-connect.js +2 -0
  63. package/dist/nightly-check.d.ts +104 -0
  64. package/dist/nightly-check.js +362 -0
  65. package/dist/operate.js +39 -2
  66. package/dist/person-proof.d.ts +2 -0
  67. package/dist/person-proof.js +14 -0
  68. package/dist/plan-auto.js +4 -4
  69. package/dist/planner-source.d.ts +1 -1
  70. package/dist/project-delete-ui.js +1 -1
  71. package/dist/project-tools.js +3 -1
  72. package/dist/proof.js +2 -0
  73. package/dist/protected-storage.d.ts +67 -0
  74. package/dist/protected-storage.js +398 -0
  75. package/dist/publish.js +156 -144
  76. package/dist/pull-request-flow.d.ts +2 -0
  77. package/dist/pull-request-flow.js +24 -2
  78. package/dist/release-adapters.d.ts +16 -1
  79. package/dist/release-adapters.js +292 -29
  80. package/dist/release-authority.d.ts +118 -0
  81. package/dist/release-authority.js +135 -0
  82. package/dist/release-card.d.ts +76 -0
  83. package/dist/release-card.js +252 -0
  84. package/dist/release-carry.d.ts +236 -0
  85. package/dist/release-carry.js +664 -0
  86. package/dist/release-cli.d.ts +3 -0
  87. package/dist/release-cli.js +158 -6
  88. package/dist/release-coverage.js +3 -0
  89. package/dist/release-git.d.ts +43 -0
  90. package/dist/release-git.js +106 -0
  91. package/dist/release-recheck.d.ts +146 -0
  92. package/dist/release-recheck.js +657 -0
  93. package/dist/release-view.d.ts +34 -0
  94. package/dist/release-view.js +27 -0
  95. package/dist/release.d.ts +139 -3
  96. package/dist/release.js +641 -250
  97. package/dist/remote-main.d.ts +2 -0
  98. package/dist/remote-main.js +1 -1
  99. package/dist/result-completion.d.ts +6 -0
  100. package/dist/result-completion.js +15 -1
  101. package/dist/result-follow-ups.js +29 -1
  102. package/dist/sandbox-runtime.d.ts +16 -5
  103. package/dist/sandbox-runtime.js +19 -6
  104. package/dist/scope.d.ts +122 -5
  105. package/dist/scope.js +236 -7
  106. package/dist/server/flows.js +40 -10
  107. package/dist/server/render-settings.js +1 -1
  108. package/dist/server/render-tasks.d.ts +8 -0
  109. package/dist/server/render-tasks.js +50 -9
  110. package/dist/server/route-table.js +1 -0
  111. package/dist/server/settings.js +26 -5
  112. package/dist/server/task-views.js +4 -0
  113. package/dist/server/tasks.js +12 -0
  114. package/dist/setup-guide.d.ts +2 -2
  115. package/dist/store.d.ts +230 -5
  116. package/dist/store.js +733 -37
  117. package/dist/subagent-memory.d.ts +1 -0
  118. package/dist/subagent-memory.js +1 -1
  119. package/dist/subagent-tools.d.ts +22 -6
  120. package/dist/subagent-tools.js +44 -8
  121. package/dist/subagent-work.d.ts +2 -0
  122. package/dist/subagent-work.js +5 -2
  123. package/dist/subagents-ui.js +6 -4
  124. package/dist/task-outcome-cli.js +7 -3
  125. package/dist/telegram.d.ts +6 -1
  126. package/dist/telegram.js +58 -6
  127. package/dist/toolroll-update.js +2 -2
  128. package/dist/verification-evidence.d.ts +25 -1
  129. package/dist/verification-evidence.js +57 -2
  130. package/dist/work-index.js +3 -6
  131. 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,29 @@ 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";
27
+ import { checkoutIncludes } from "./teardown-git.js";
28
+ import { RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
29
+ export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
21
30
  /** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
22
31
  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) => {
32
+ 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
33
  const failure = error;
25
34
  const code = failure === null ? 0 : failure.code === "ENOENT" ? 127 : failure.killed === true ? 124 : typeof failure.code === "number" ? failure.code : 1;
26
35
  done({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
27
36
  });
37
+ // `input`: written on the child's stdin, then closed (a secret that must never ride in argv).
38
+ child.stdin?.on("error", () => { });
39
+ if (options.input !== undefined)
40
+ child.stdin?.end(options.input);
41
+ else
42
+ child.stdin?.end();
28
43
  });
29
44
  /** Without NODE_OPTIONS, which a test runner or debugger may have left for this process. */
30
45
  const cleanEnv = (extra = {}) => {
@@ -107,6 +122,7 @@ export function gateStateOf(answer, accounts = []) {
107
122
  approval: approvedAt === null || approvedBy === null || approvedDigest === null ? null : {
108
123
  by: approvedBy, at: approvedAt, digest: approvedDigest, basis: textOr(scope["approvalBasis"]),
109
124
  role: textOr(account?.["role"]), active: account?.["revokedAt"] === null,
125
+ ...personAnswerOf(answer["personApproval"]),
110
126
  },
111
127
  result: resultRun === null || textOr(result["digest"]) === null ? null : {
112
128
  run: resultRun, head: textOr(result["head"]), receipt: String(result["digest"]), worktree: textOr(worktree),
@@ -149,6 +165,48 @@ export function stageGate(stage, owner) {
149
165
  const id = textOr(journal["id"]), phase = textOr(journal["phase"]);
150
166
  return id !== null && phase !== null && BEFORE_SWAP.includes(phase) && owner() === id ? { id, phase } : null;
151
167
  }
168
+ /** The recorded repository for a gate checkout, from the plane's database; throws in plain words when there is none. */
169
+ export function recordedReleaseRepo(database, checkout) {
170
+ const real = (path) => { try {
171
+ return realpathSync(path);
172
+ }
173
+ catch {
174
+ return null;
175
+ } };
176
+ const at = real(checkout);
177
+ if (at === null)
178
+ throw new Error(`${checkout} isn't there.`);
179
+ if (!existsSync(database))
180
+ throw new Error("The plane's database isn't here, so the release's repository can't be read from its records.");
181
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
182
+ let project = null, remote = null;
183
+ try {
184
+ const store = new Store(db);
185
+ project = store.listProjects().map(one => one.path).find(path => real(path) === at) ?? null;
186
+ remote = project === null ? null : store.projectRemote(project);
187
+ }
188
+ finally {
189
+ db.close();
190
+ }
191
+ if (project === null)
192
+ throw new Error(`${checkout} isn't one of the projects Toolroll records, so it can't be released from here.`);
193
+ if (remote === null)
194
+ throw new Error(`${project} has no recorded remote. Record it with \`toolroll repos add ${project} --remote <url>\` first.`);
195
+ const parsed = releaseRemote(remote);
196
+ if (parsed === null)
197
+ throw new Error(`${project}'s recorded remote (${remote}) isn't a GitHub repository over https or ssh.`);
198
+ const named = join(at, ".git");
199
+ let gitDir;
200
+ try {
201
+ if (!lstatSync(named).isDirectory())
202
+ throw new Error("not a folder");
203
+ gitDir = realpathSync(named);
204
+ }
205
+ catch {
206
+ throw new Error(`${project}'s Git directory isn't a plain folder, so the release won't read it.`);
207
+ }
208
+ return { checkout: at, gitDir, remote, ...parsed };
209
+ }
152
210
  /** How many times, and how far apart, a merged branch's deletion is looked for after the push lost a race. */
153
211
  export const BRANCH_GONE_LOOKS = 5;
154
212
  export const BRANCH_GONE_PAUSE_MS = 2_000;
@@ -158,9 +216,11 @@ export function releaseAdapters(options) {
158
216
  const { checkout } = options;
159
217
  const tap = options.tap ?? "ap9000/homebrew-toolroll";
160
218
  const formula = options.formula ?? "Formula/toolroll.rb";
161
- const git = (...args) => run("git", ["-C", checkout, ...args]);
162
219
  const pause = options.sleep ?? ((ms) => new Promise(done => setTimeout(done, ms)));
163
- const gh = (...args) => run("gh", args);
220
+ const tools = options.tools ?? releaseTools();
221
+ const gitEnv = releaseGitEnv();
222
+ // Never a PATH lookup or the checkout's Git config: gh by its absolute path, from a neutral folder, naming the repo.
223
+ const gh = (...args) => run(tools.gh, args, { cwd: "/", env: gitEnv });
164
224
  const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
165
225
  const toolroll = async (...args) => {
166
226
  const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
@@ -173,6 +233,29 @@ export function releaseAdapters(options) {
173
233
  };
174
234
  const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
175
235
  const database = options.database ?? databasePath(process.env, homedir());
236
+ let recorded = options.releaseRepo ?? null;
237
+ /** The recorded repository, read once (chat-management.md §0a.2). */
238
+ const releaseRepo = () => recorded ??= recordedReleaseRepo(database, checkout);
239
+ /**
240
+ * The coordinator's one way to run Git (§0a.2): on the recorded Git directory, hardened, with an allowlisted
241
+ * environment and the absolute Git. Before every command the checkout's own config is read with includes off and
242
+ * as plain files; one that includes a file, names a command or redirects a remote stops the release, naming it.
243
+ */
244
+ const git = async (...args) => {
245
+ const repo = releaseRepo();
246
+ const stop = (why) => new Error(`Toolroll won't run Git in ${repo.checkout} for a release: ${why}. Remove it from the project's Git config, then rerun.`);
247
+ if (options.releaseRepo === undefined && checkoutIncludes(repo.checkout))
248
+ throw stop("its Git config includes another file, or couldn't be read as a plain file");
249
+ const listing = await run(tools.git, [`--git-dir=${repo.gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env: gitEnv });
250
+ if (listing.code !== 0)
251
+ throw stop("its Git config couldn't be read");
252
+ const setting = releaseConfigProblem(listing.stdout);
253
+ if (setting !== null)
254
+ throw stop(`its Git config sets ${setting}, which a release doesn't run with (it could run a command, or change where Git connects or what it sends)`);
255
+ return run(tools.git, [...releaseGitArgs(repo, tools), ...args], { cwd: repo.checkout, env: gitEnv });
256
+ };
257
+ /** The remote every fetch, look and push names: the recorded URL, defined only on the command line. */
258
+ const remoteUrl = () => RELEASE_REMOTE;
176
259
  const liveOwner = () => {
177
260
  if (!existsSync(database))
178
261
  return null;
@@ -190,24 +273,102 @@ export function releaseAdapters(options) {
190
273
  const gate = stageGate(stage, liveOwner);
191
274
  return gate === null ? { ok: false, message } : { ok: false, message, gate };
192
275
  };
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
- });
276
+ /** The deployment gate in this runtime (deploy-gate.ts): a person's approval, the passing check, the exact completed
277
+ * result. Its answer names the approved commit and the project it belongs to, from the plane's own records. */
278
+ const proveCandidate = (runId) => {
279
+ if (!existsSync(database))
280
+ return { ok: false, message: "The plane's database isn't here, so the candidate can't be proven." };
281
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
282
+ try {
283
+ return deploymentGate(new Store(db), { runId, evidenceRoot: join(dirname(database), "evidence"), now: (options.now ?? (() => new Date()))() });
284
+ }
285
+ catch (error) {
286
+ return { ok: false, message: `The candidate can't be proven: ${error.message}` };
287
+ }
288
+ finally {
289
+ db.close();
290
+ }
291
+ };
292
+ /** The run's recorded commit and project, for recovery (which needs no approval). */
293
+ const recordedCommit = (runId) => {
294
+ if (!existsSync(database))
295
+ return null;
296
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
297
+ try {
298
+ const store = new Store(db), run = store.getRun(runId), repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
299
+ return run?.headRevision != null && repo != null ? { head: run.headRevision, repo } : null;
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ finally {
305
+ db.close();
306
+ }
307
+ };
308
+ /**
309
+ * The approved commit, written by this runtime into a fresh folder it owns (commit-extract.ts): every regular file of
310
+ * that full SHA, read byte for byte from the PROJECT's own Git directory as Toolroll records it (realpath, a real
311
+ * directory — never the candidate worktree's `.git` pointer or anything in that worktree), with no filter, attribute,
312
+ * replacement object, lazy fetch, hook or outside config, and no symlink or submodule. The deployment script runs
313
+ * from here, so nothing changed, flagged, swapped or untracked in the worktree, nor its Git pointer, changes what runs.
314
+ */
315
+ const extractApproved = async (repo, head) => {
316
+ let gitDir;
317
+ try {
318
+ const named = join(realpathSync(repo), ".git");
319
+ if (!lstatSync(named).isDirectory())
320
+ return { ok: false, message: `${repo}'s Git directory isn't a plain directory, so the approved commit can't be read from it.` };
321
+ gitDir = realpathSync(named);
322
+ }
323
+ catch {
324
+ return { ok: false, message: `${repo} has no Git directory to read the approved commit from.` };
325
+ }
326
+ const dir = realpathSync(mkdtempSync(join(resolved(tmpdir()), "toolroll-deployer-")));
327
+ const extracted = extractCommit(gitDir, head, dir);
328
+ if (!extracted.ok) {
329
+ rmSync(dir, { recursive: true, force: true });
330
+ return { ok: false, message: `The approved commit couldn't be prepared: ${extracted.message}` };
331
+ }
332
+ return { ok: true, dir, gitDir };
333
+ };
334
+ /** The approved commit's own deployment script, from the pristine extraction; the worktree is only its --source. */
335
+ const deployScript = async (worktree, stage, runId, commit, extra, input) => {
336
+ const extracted = await extractApproved(commit.repo, commit.head);
337
+ if (!extracted.ok)
338
+ return { code: 1, stdout: "", stderr: extracted.message };
339
+ try {
340
+ // The script node runs is the extraction's own regular file, by its real path inside the folder.
341
+ let script;
342
+ try {
343
+ script = realpathSync(join(extracted.dir, "scripts", "deploy-browser.mjs"));
344
+ if (!script.startsWith(extracted.dir + "/") || !lstatSync(script).isFile())
345
+ throw new Error("outside");
346
+ }
347
+ catch {
348
+ return { code: 1, stdout: "", stderr: "The approved commit has no deployment script of its own." };
349
+ }
350
+ return await run(process.execPath, [script, "--run", String(runId), "--stage", stage, "--source", worktree,
351
+ "--head", commit.head, "--git-dir", extracted.gitDir, ...extra], {
352
+ cwd: extracted.dir, env: cleanEnv({ TMPDIR: resolved(process.env["TMPDIR"] ?? "/tmp") }), timeoutMs: (options.deployMinutes ?? 30) * 60_000, ...(input === undefined ? {} : { input }),
353
+ });
354
+ }
355
+ finally {
356
+ rmSync(extracted.dir, { recursive: true, force: true });
357
+ }
358
+ };
359
+ const release = storeReleaseAdapter(() => openStore(database), store => store.close(), options.now ?? (() => new Date()));
196
360
  return {
361
+ ...(existsSync(database) ? { release } : {}),
197
362
  now: options.now ?? (() => new Date()),
198
363
  sleep: options.sleep ?? sleep,
199
364
  say: options.say ?? (line => process.stdout.write(`${line}\n`)),
200
365
  repo: {
201
- async github() {
202
- const slug = githubSlug(await must(git("remote", "get-url", "origin"), "git remote get-url origin"));
203
- if (slug === null)
204
- throw new Error(`${checkout}'s origin is not a GitHub repository`);
205
- return slug;
206
- },
366
+ // The recorded owner/name, never the checkout's origin.
367
+ async github() { return releaseRepo().slug; },
207
368
  async sync(branch) {
208
- await must(git("fetch", "--quiet", "origin", "+refs/heads/main:refs/remotes/origin/main"), "git fetch origin main");
369
+ await must(git("fetch", "--quiet", "--no-tags", remoteUrl(), "+refs/heads/main:refs/remotes/origin/main"), "git fetch main");
209
370
  // A merged branch may be gone from GitHub; its commit is already here.
210
- await git("fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
371
+ await git("fetch", "--quiet", "--no-tags", remoteUrl(), `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
211
372
  const current = (await git("symbolic-ref", "--quiet", "--short", "HEAD")).stdout.trim();
212
373
  if (current === "main") {
213
374
  await must(git("merge", "--ff-only", "--quiet", "origin/main"), "git merge --ff-only origin/main");
@@ -221,17 +382,17 @@ export function releaseAdapters(options) {
221
382
  throw new Error(`${checkout}'s local main has commits origin/main doesn't; the gate diffs from it, so fix it first`);
222
383
  },
223
384
  async remoteHead(branch) {
224
- const out = await must(git("ls-remote", "origin", `refs/heads/${branch}`), "git ls-remote");
385
+ const out = await must(git("ls-remote", remoteUrl(), `refs/heads/${branch}`), "git ls-remote");
225
386
  return out.trim().split(/\s+/)[0] || null;
226
387
  },
227
388
  async deleteBranch(branch, head) {
228
389
  const ref = `refs/heads/${branch}`;
229
- const current = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
390
+ const current = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
230
391
  if (current === null)
231
392
  return;
232
393
  if (current !== head)
233
394
  throw new Error(`${branch} moved after merging; its new head was not deleted.`);
234
- const pushed = await git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`);
395
+ const pushed = await git("push", "--no-verify", `--force-with-lease=${ref}:${head}`, remoteUrl(), `:${ref}`);
235
396
  if (pushed.code === 0)
236
397
  return;
237
398
  // GitHub may delete a merged branch itself between the look and the push; gone is what was wanted. Its refs can
@@ -239,7 +400,7 @@ export function releaseAdapters(options) {
239
400
  for (let look = 0; look < BRANCH_GONE_LOOKS; look++) {
240
401
  if (look > 0)
241
402
  await pause(BRANCH_GONE_PAUSE_MS);
242
- const after = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
403
+ const after = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
243
404
  if (after === null)
244
405
  return;
245
406
  }
@@ -278,7 +439,7 @@ export function releaseAdapters(options) {
278
439
  const added = await toolroll("task", "add", input.title, "--id", input.taskId, "--repo", input.checkout, "--checks", "full");
279
440
  if (added["ok"] !== true)
280
441
  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);
442
+ 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
443
  if (scoped["ok"] !== true)
283
444
  throw new Error(`task scope: ${refused(scoped)}`);
284
445
  const digest = gateStateOf(await toolroll("task", "show", input.taskId)).scopeDigest;
@@ -308,10 +469,17 @@ export function releaseAdapters(options) {
308
469
  db.close();
309
470
  }
310
471
  },
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"]);
472
+ async deploy({ run: runId, worktree, stage, operation }) {
473
+ // Approval policy §1.7: this runtime proves the candidate FIRST — a person approved it, its exact result passed
474
+ // the approved check and is complete, and the checkout is exactly that commit — before anything from the
475
+ // candidate's checkout runs. The candidate's own script never decides its own deployment.
476
+ const proven = proveCandidate(runId);
477
+ if (!proven.ok)
478
+ return stopped(stage, proven.message);
479
+ // The approved commit's own deployment, extracted by this runtime: drain, backup, rehearsal, swap, health. Its
480
+ // last JSON line names the runtime it installed; the CLI names are moved to it by the release's link step.
481
+ // The operation the coordinator reserved, adopted by the deploy run with its secret on stdin (never in argv).
482
+ const result = await deployScript(worktree, stage, runId, proven, ["--yes", ...(operation === undefined ? [] : ["--operation", operation.id])], operation?.secret);
315
483
  const runtime = deployedRuntime(result.stdout);
316
484
  if (result.code !== 0 || runtime === null)
317
485
  return stopped(stage, tail(result));
@@ -325,7 +493,11 @@ export function releaseAdapters(options) {
325
493
  const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
326
494
  if (running.length > 0)
327
495
  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"]);
496
+ // Recovery needs no approval, but it runs only the recorded commit's own script, extracted by this runtime.
497
+ const recorded = recordedCommit(runId);
498
+ if (recorded === null)
499
+ return stopped(stage, `Run ${runId} names no commit to recover with.`);
500
+ const result = await deployScript(worktree, stage, runId, recorded, ["--phase", "recover"]);
329
501
  if (result.code === 0)
330
502
  return { ok: true };
331
503
  // Killed while preparing, before it paused anything: nothing to undo (its journal is kept).
@@ -339,6 +511,34 @@ export function releaseAdapters(options) {
339
511
  return { ok: true };
340
512
  return stopped(stage, `${tail(result)} (the deployment journal is at ${String(journal["phase"] ?? "an unreadable phase")}; ${join(stage, "deployment.json")})`);
341
513
  },
514
+ async deploymentIdle(stage) {
515
+ // Whatever the stage: no deployment is running, and no update pause is held.
516
+ if ((options.processes ?? processes)().some(one => one.command.includes("deploy-browser.mjs")))
517
+ return false;
518
+ if (liveOwner() !== null)
519
+ return false;
520
+ if (stage === null)
521
+ return false;
522
+ const journalFile = join(stage, "deployment.json");
523
+ // Never prepared: staging touched neither the service nor the update pause.
524
+ if (!existsSync(journalFile))
525
+ return true;
526
+ // Its journal lock is free (nothing holds the journal), and it stopped where nothing changed, or finished.
527
+ try {
528
+ acquireDeploymentLock(journalFile, 0).release();
529
+ }
530
+ catch {
531
+ return false;
532
+ }
533
+ let phase;
534
+ try {
535
+ phase = object(JSON.parse(readFileSync(journalFile, "utf8")))["phase"];
536
+ }
537
+ catch {
538
+ return false;
539
+ }
540
+ return phase === "preparing" || phase === "released" || phase === "deployed";
541
+ },
342
542
  async cliLinks() { return findCliLinks(); },
343
543
  async switchLinks(links, bin, version) {
344
544
  const answers = (path) => spawnSync(path, ["--version"], { encoding: "utf8", env: cleanEnv(), timeout: 30_000 }).stdout?.trim() === version;
@@ -361,8 +561,9 @@ export function releaseAdapters(options) {
361
561
  // Oldest first, so a rerun's attempt is the last one listed for its name.
362
562
  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
563
  },
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}`));
564
+ async merge(repo, pr, head, message) {
565
+ // The squash commit's message lists every commit that landed after the approval (release-once.md).
566
+ 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
567
  if (typeof merged["sha"] !== "string")
367
568
  throw new Error(`GitHub did not merge #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
368
569
  return merged["sha"];
@@ -510,3 +711,65 @@ export function bumpFormula(text, url, sha256) {
510
711
  throw new Error("the formula has no url and sha256 lines to update");
511
712
  return text.replace(/^(\s*url ")[^"]*(")/m, `$1${url}$2`).replace(/^(\s*sha256 ")[0-9a-f]{64}(")/m, `$1${sha256}$2`);
512
713
  }
714
+ /** The plane's person-approval answer from `task show --json`, read strictly: anything malformed is no person. */
715
+ function personAnswerOf(value) {
716
+ if (value === undefined)
717
+ return {};
718
+ const read = object(value);
719
+ if (read["ok"] === true && typeof read["method"] === "string")
720
+ return { person: { ok: true, method: read["method"] } };
721
+ return { person: { ok: false, why: typeof read["why"] === "string" ? read["why"] : "the plane's answer about who approved it can't be read" } };
722
+ }
723
+ /**
724
+ * The release's authority record through a store (release-once.md): what `toolroll release` reads and writes for its
725
+ * lease, its carries and its operations. `open` hands a store for each call and `done` lets it go.
726
+ */
727
+ export function storeReleaseAdapter(open, done, now, runner) {
728
+ const withStore = (body) => { const store = open(); try {
729
+ return body(store);
730
+ }
731
+ finally {
732
+ done(store);
733
+ } };
734
+ return {
735
+ async authority(taskId) {
736
+ return withStore(store => {
737
+ const found = releaseAuthorityOfTask(store, taskId);
738
+ if (found === null)
739
+ return null;
740
+ const a = found.authority;
741
+ return { id: a.id, generation: a.generation, state: a.state, tipSha: a.tipSha, tipTask: a.tipTask, version: a.version, pr: a.pr,
742
+ landed: releaseCarries(store, a.id).flatMap(carry => carry.commits) };
743
+ });
744
+ },
745
+ async live(taskId, action) {
746
+ 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 }; });
747
+ },
748
+ async lease(authority, owner) { return withStore(store => takeReleaseLease(store, authority, owner, now())); },
749
+ async renew(authority, owner, epoch) { return withStore(store => renewReleaseLease(store, authority, owner, epoch, now())); },
750
+ async carry(authority, to, requestId, epoch) {
751
+ const store = open();
752
+ try {
753
+ const result = await proveCarry(store, { authority, to, requestId, epoch, now: now(), ...(runner === undefined ? {} : { runner }) });
754
+ return result.ok ? { ok: true, taskId: result.taskId } : { ok: false, message: result.message };
755
+ }
756
+ finally {
757
+ done(store);
758
+ }
759
+ },
760
+ async reserve(input) {
761
+ return withStore(store => {
762
+ const secret = randomBytes(32).toString("hex");
763
+ const result = reserveRelease(store, { ...input, operationId: randomUUID(), now: now(), secret });
764
+ return result.ok ? { ok: true, operation: result.operation.id, secret } : result;
765
+ });
766
+ },
767
+ async start(operation, epoch) { return withStore(store => runReleaseOperation(store, operation, epoch, now())); },
768
+ async finish(operation, outcome, facts, epoch) { return withStore(store => finishReleaseOperation(store, operation, outcome, facts, now(), epoch)); },
769
+ async needsApproval(authority, reason, epoch) { return withStore(store => requireFreshReleaseApproval(store, authority, reason, epoch, now())); },
770
+ async operations(authority) {
771
+ 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,
772
+ direct: op.direct, issuerGone: releaseIssuerGone(op), stage: op.stage })));
773
+ },
774
+ };
775
+ }
@@ -0,0 +1,118 @@
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
+ /** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
102
+ * off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
103
+ export declare const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
104
+ export declare function carriesUnsandboxed(store: Store, repo: string): boolean;
105
+ /** How far a check reaches, narrowest first. */
106
+ export declare const CHECK_ACCESS_WIDTH: Readonly<Record<string, number>>;
107
+ export declare function belongsToRelease(store: Store, taskId: string): boolean;
108
+ export declare function releaseCarries(store: Store, authority: number): ReleaseCarry[];
109
+ export declare function releaseCarryTo(store: Store, authority: number, toSha: string): ReleaseCarry | null;
110
+ export declare function releaseOperations(store: Store, authority: number): ReleaseOperation[];
111
+ export declare function releaseOperation(store: Store, id: string): ReleaseOperation | null;
112
+ /** Operations nobody has settled yet: until each is reconciled, nothing new starts. */
113
+ export declare function outstandingReleaseOperations(store: Store, authority: number): ReleaseOperation[];
114
+ /**
115
+ * Why a person's seal of these release terms can't open a new generation now, in words, or null. A live generation is
116
+ * superseded by the new one — but never while one of its operations is unsettled, and never once its merge started.
117
+ */
118
+ export declare function releaseSealProblem(store: Store, terms: ReleaseTerms): string | null;