toolroll 0.9.92 → 0.9.93

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 (65) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assignment-status.d.ts +1 -1
  3. package/dist/assignment-ui.js +2 -2
  4. package/dist/assignment.js +11 -3
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +30 -30
  7. package/dist/browser-crew.js +2 -2
  8. package/dist/browser-workspace.d.ts +3 -5
  9. package/dist/builder.js +21 -2
  10. package/dist/check-facts.d.ts +124 -0
  11. package/dist/check-facts.js +272 -0
  12. package/dist/check-report.d.ts +5 -0
  13. package/dist/check-report.js +4 -3
  14. package/dist/cli.js +5 -0
  15. package/dist/contracts/console-api.d.ts +2 -2
  16. package/dist/contracts/console-api.js +2 -2
  17. package/dist/contracts/release-check.d.ts +25 -1
  18. package/dist/contracts/release-check.js +19 -0
  19. package/dist/desktop-update.d.ts +2 -2
  20. package/dist/desktop-update.js +13 -9
  21. package/dist/eval-hooks.d.ts +55 -0
  22. package/dist/eval-hooks.js +227 -4
  23. package/dist/flaky.d.ts +17 -0
  24. package/dist/flaky.js +9 -0
  25. package/dist/needs-you.d.ts +7 -7
  26. package/dist/needs-you.js +12 -10
  27. package/dist/operate.js +138 -18
  28. package/dist/project-delete.js +5 -1
  29. package/dist/project-knowledge.d.ts +6 -6
  30. package/dist/push.d.ts +10 -3
  31. package/dist/push.js +22 -6
  32. package/dist/release-adapters.js +11 -1
  33. package/dist/release-approval.d.ts +191 -0
  34. package/dist/release-approval.js +382 -0
  35. package/dist/release-auto.js +13 -4
  36. package/dist/release-card.d.ts +73 -4
  37. package/dist/release-card.js +168 -23
  38. package/dist/release-cli.js +9 -1
  39. package/dist/release.d.ts +30 -0
  40. package/dist/release.js +42 -3
  41. package/dist/result-acts.d.ts +3 -3
  42. package/dist/result-acts.js +1 -1
  43. package/dist/server/guards.js +1 -0
  44. package/dist/server/pages-assets.js +1 -1
  45. package/dist/server/render-release.d.ts +17 -0
  46. package/dist/server/render-release.js +84 -0
  47. package/dist/server/render-results.d.ts +0 -2
  48. package/dist/server/render-results.js +4 -6
  49. package/dist/server/render-tasks.js +3 -3
  50. package/dist/server/route-table.d.ts +1 -1
  51. package/dist/server/route-table.js +7 -1
  52. package/dist/server/tasks.d.ts +15 -0
  53. package/dist/server/tasks.js +102 -20
  54. package/dist/setup-guide.d.ts +2 -2
  55. package/dist/stop-proof.d.ts +67 -0
  56. package/dist/stop-proof.js +293 -0
  57. package/dist/store.d.ts +70 -31
  58. package/dist/store.js +335 -95
  59. package/dist/surface.js +1 -1
  60. package/dist/task-outcome-cli.js +19 -0
  61. package/dist/toolroll-update-ui.js +2 -2
  62. package/dist/toolroll-update.js +1 -0
  63. package/dist/work-index.js +7 -9
  64. package/dist/work-summary.d.ts +2 -2
  65. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -33,6 +33,7 @@ import { personProof } from "./person-proof.js";
33
33
  import { RemoteMains } from "./remote-main.js";
34
34
  import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
35
35
  import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
36
+ import { proveFinishedRunsStopped, proveRunStopped } from "./stop-proof.js";
36
37
  import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
37
38
  import { checkPolicy, parseList, policyParts } from "./policy.js";
38
39
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, subagentNames, usd as spendUsd } from "./spend.js";
@@ -63,6 +64,8 @@ import { sendSubagentSummaries } from "./subagent-admin.js";
63
64
  import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
64
65
  import { refreshConnections } from "./mcp-connect.js";
65
66
  import { releaseCheckPurposeFor, releaseTermsFor } from "./release-carry.js";
67
+ import { approveReleaseCard, journalMatchesPlane, releaseApprovalCard, releaseCardLines, releaseTaskOf, waitingJournals } from "./release-approval.js";
68
+ import { journalFile } from "./release.js";
66
69
  import { refreshTrustedMains, settleReleaseChecks } from "./nightly-check.js";
67
70
  import { githubNightlyRepos, readGithubNightlies } from "./nightly-github.js";
68
71
  import { exec as releaseExec, githubReads } from "./release-adapters.js";
@@ -1215,6 +1218,9 @@ async function dispatch(command, positional, flags, context) {
1215
1218
  switch (command) {
1216
1219
  case "status":
1217
1220
  return statusCommand(positional, flags, context);
1221
+ // `toolroll release approve` (cli.ts routes only this verb here; every other `release` form is release-cli.ts).
1222
+ case "release":
1223
+ return releaseApproveCommand(positional, flags, context);
1218
1224
  case "evals":
1219
1225
  return evalsCommand(positional, flags, context);
1220
1226
  case "ready":
@@ -3576,6 +3582,9 @@ async function reconcileCommand(flags, context) {
3576
3582
  // A witness reserved for a spawn that never made a process (a crash or a
3577
3583
  // thrown spawn) settles once its run's process groups are proven gone.
3578
3584
  store.settleUnspawnedWitnesses(clock());
3585
+ // What no record proves stopped, Toolroll proves itself from this computer's processes (stop-proof.ts): nobody is
3586
+ // asked to confirm a build stopped, so a release waiting on its check moves on by itself.
3587
+ await proveFinishedRunsStopped(store, clock);
3579
3588
  store.settleQuiescentStops(clock());
3580
3589
  // An update whose updater ended while letting work finish must not leave new work paused.
3581
3590
  try {
@@ -10790,43 +10799,47 @@ async function resumeTaskCommand(positional, flags, context) {
10790
10799
  ]);
10791
10800
  }
10792
10801
  /**
10793
- * `toolroll run settle <id> --why "<text>" --as <you> --token <t>`: an
10794
- * approver's last resort for a finished run whose process witnesses can be
10795
- * proven neither exited nor alive (a spawn interrupted before its pid was
10796
- * written). Refuses while anything of the run is alive; the reason is kept
10797
- * in the ledger against the approver's name. Signals nothing.
10802
+ * `toolroll run settle <id> --as <you> --token <t>`: have Toolroll check again whether a finished run stopped. Nobody
10803
+ * vouches that a build stopped (a person can't see whether a process exited): this runs Toolroll's own look at this
10804
+ * computer's processes (stop-proof.ts), the one the worker makes on its own, and settles the run when none of them is
10805
+ * the run's. A leftover proven the run's is stopped; anything else is left alone. `--why` is still accepted from older
10806
+ * scripts and ignored.
10798
10807
  */
10799
10808
  async function runCommand(positional, flags, context) {
10800
10809
  const { store, write, json, clock } = context;
10801
10810
  const [action, id, ...extra] = positional;
10802
10811
  const command = "run settle";
10803
- const usage = "`toolroll run settle <run-id> --why \"<reason>\" --as <you> --token <t>`";
10812
+ const usage = "`toolroll run settle <run-id>`";
10804
10813
  if (action !== "settle")
10805
10814
  return fail(write, json, "run", "usage", usage, EXIT.usage);
10806
10815
  const runId = Number(id ?? "");
10807
- const why = text(flags, "why")?.trim();
10808
10816
  if (id === undefined || extra.length > 0 || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(runId))
10809
10817
  return fail(write, json, command, "usage", usage, EXIT.usage);
10810
- if (why === undefined || why === "" || why.length > 300 || /[\x00-\x1f\x7f]/.test(why))
10811
- return fail(write, json, command, "usage", "say why in one line with --why (at most 300 characters) — it is kept in the ledger", EXIT.usage);
10812
10818
  const run = store.getRun(runId);
10813
10819
  if (run === null)
10814
10820
  return fail(write, json, command, "unknown-run", `no run #${runId}`, EXIT.refused);
10815
10821
  const acting = await askCredentials(flags, context);
10816
10822
  if (acting === null)
10817
- return fail(write, json, command, "usage", "settling takes `--as <you> --token <t>` — it is an approver's act and is recorded against your name", EXIT.usage);
10823
+ return fail(write, json, command, "usage", "checking a run again takes `--as <you> --token <t>` (or your sign-in from `toolroll up`)", EXIT.usage);
10818
10824
  const authenticated = authenticateApprover(store, acting.name, acting.token);
10819
10825
  const repo = store.refById(run.taskRef)?.repo ?? null;
10820
10826
  if (!authenticated.ok || !store.accountCanAccess(acting.name, repo))
10821
10827
  return fail(write, json, command, "not-an-approver", "that is not an approver for this run's project, or the token does not match", EXIT.refused);
10822
- const settled = store.settleRunWitnessesByApprover({ runId, by: acting.name, why }, clock());
10823
- if (!settled.ok)
10824
- return fail(write, json, command, settled.reason, settled.detail, EXIT.refused, { run: runId });
10825
- return succeed(write, json, command, { run: runId, witnesses: settled.witnesses, repeated: settled.repeated }, () => [
10826
- settled.repeated
10827
- ? `Run #${runId} has no unsettled process witness; nothing changed.`
10828
- : `Settled run #${runId}: ${settled.witnesses.length} process witness${settled.witnesses.length === 1 ? "" : "es"} recorded as ended, with your reason in the ledger.`,
10829
- ]);
10828
+ if (run.outcome === null)
10829
+ return fail(write, json, command, "still-running", `run #${runId} is still open`, EXIT.refused, { run: runId });
10830
+ const proved = await proveRunStopped(store, runId, clock);
10831
+ const stopped = store.stopQuiescenceFact(runId);
10832
+ if (stopped === null) {
10833
+ return succeed(write, json, command, { run: runId, outcome: proved.outcome === "skipped" ? "already-stopped" : "stopped", stoppedProcesses: proved.stopped ?? 0 }, () => [
10834
+ proved.outcome === "skipped" ? `Run #${runId} had already stopped; nothing changed.`
10835
+ : `Run #${runId} stopped: Toolroll checked this computer's processes and none of them is the run's${proved.stopped ? ` (it stopped ${proved.stopped} the run left running)` : ""}. The check is in the ledger.`,
10836
+ ]);
10837
+ }
10838
+ const reason = proved.outcome === "blocked" ? "unproven" : stopped.kind === "alive" ? "alive" : "waiting";
10839
+ const words = proved.outcome === "blocked" ? `Run #${proved.run} ${proved.words}.`
10840
+ : stopped.kind === "alive" ? `Something of run #${stopped.run} is still running; Toolroll stops what is proven the run's on its own.`
10841
+ : `Toolroll is still making sure run #${stopped.run} stopped. This clears on its own.`;
10842
+ return fail(write, json, command, reason, words, EXIT.refused, { run: runId });
10830
10843
  }
10831
10844
  // ---- shared ---------------------------------------------------------------
10832
10845
  /**
@@ -13466,3 +13479,110 @@ function memoryWriteVerb(positional) {
13466
13479
  return MEMORY_WRITE_VERBS.has(positional[0] ?? "");
13467
13480
  }
13468
13481
  const MEMORY_WRITE_REFUSAL = "Project memory is changed by a person with their own sign-in, not the lead. The lead proposes decisions in chat for you to confirm.";
13482
+ /**
13483
+ * `toolroll release approve [<branch>] [--repo <path>]` (docs/plans/trusted-release-reports.md, slice A, A8): approve
13484
+ * the release waiting for a person from this terminal, without copying a task id or a digest. The release journals are
13485
+ * discovery only, read strictly; the plane's sealed release identity decides which release this is. The card prints
13486
+ * control-free; the person is asked through `askCredentials` (or its remembered login, under its existing conditions);
13487
+ * the card is built again AFTER that prompt and anything changed refuses; then the shared card wrapper hands the
13488
+ * snapshot's scope digest to the unchanged approve(). `--json` never prompts.
13489
+ */
13490
+ async function releaseApproveCommand(positional, flags, context) {
13491
+ const { store, write, json } = context;
13492
+ const command = "release approve";
13493
+ const usage = "`toolroll release approve [<branch>] [--repo <path>]` (with --json: --as <you> --token <password> --digest <card digest>)";
13494
+ const branchFlag = text(flags, "branch");
13495
+ if (positional[0] !== "approve" || positional.length > 2 || (branchFlag !== undefined && positional.length > 1))
13496
+ return fail(write, json, command, "usage", usage, EXIT.usage);
13497
+ const branch = branchFlag ?? positional[1];
13498
+ if (branch !== undefined && (!/^[A-Za-z0-9._/-]{1,255}$/.test(branch) || branch.startsWith("-") || branch.includes("..")))
13499
+ return fail(write, json, command, "usage", `${branch} is not a branch name this command takes.`, EXIT.usage);
13500
+ const repoFlag = text(flags, "repo");
13501
+ const repo = repoFlag === undefined ? null : resolve(repoFlag);
13502
+ const stateDir = dirname(context.databaseFile);
13503
+ const { journals, unreadable } = waitingJournals(stateDir, name => journalFile(stateDir, name));
13504
+ // The exact branch asked for, never another that shares its file name (feature/foo and feature_foo).
13505
+ const named = branch === undefined ? journals : journals.filter(one => one.branch === branch);
13506
+ if (branch !== undefined && named.length === 0 && unreadable.includes(basename(journalFile(stateDir, branch)))) {
13507
+ return fail(write, json, command, "unreadable", `${branch}'s release journal can't be read as one; nothing was approved. Run \`toolroll release ${branch}\` again.`, EXIT.refused);
13508
+ }
13509
+ const matches = [], refusals = [];
13510
+ for (const one of named) {
13511
+ if (releaseTaskOf(store, one.taskId) !== null && approvalOf(store.getScope(one.taskId)).approved)
13512
+ continue;
13513
+ const proved = journalMatchesPlane(store, one, repo);
13514
+ if (proved.ok)
13515
+ matches.push(one);
13516
+ else
13517
+ refusals.push(proved.why);
13518
+ }
13519
+ if (matches.length === 0) {
13520
+ const why = refusals.length > 0 ? `${refusals[0]}; nothing was approved` : branch === undefined ? "No release is waiting for your approval" : `No release of ${branch} is waiting for your approval`;
13521
+ return fail(write, json, command, refusals.length > 0 ? "mismatch" : "none", `${why}.`, EXIT.refused);
13522
+ }
13523
+ if (matches.length > 1) {
13524
+ return fail(write, json, command, "ambiguous", `More than one release is waiting: ${matches.map(one => `${one.branch} (${one.version})`).join(", ")}. Name the branch: \`toolroll release approve <branch>\`.`, EXIT.refused, { releases: matches.map(one => ({ branch: one.branch, version: one.version, task: one.taskId })) });
13525
+ }
13526
+ const chosen = matches[0];
13527
+ const built = releaseApprovalCard(store, chosen.taskId);
13528
+ if (!built.ok)
13529
+ return fail(write, json, command, "none", built.why, EXIT.refused);
13530
+ const shown = built.card;
13531
+ const cardData = { task: shown.taskId, version: shown.seal.identity.packageVersion, branch: shown.seal.identity.branch, candidate: shown.seal.identity.approvedSha, digest: shown.digest, ci: shown.ci?.state ?? null };
13532
+ if (shown.approved !== null)
13533
+ return fail(write, json, command, "approved", `Release ${cardData.version} is already approved by ${shown.approved.by}.`, EXIT.refused, { release: cardData });
13534
+ if (shown.refusal !== null) {
13535
+ if (!json)
13536
+ for (const line of releaseCardLines(shown))
13537
+ write(line);
13538
+ return fail(write, json, command, "refused", `Can't approve now: ${shown.refusal}`, EXIT.refused, { release: cardData });
13539
+ }
13540
+ let name, token;
13541
+ if (json) {
13542
+ const given = credentialsFrom(flags, context);
13543
+ if (given.name === undefined || given.token === undefined || given.name === "" || given.token === "")
13544
+ return fail(write, json, command, "credentials", "Approving a release needs who is agreeing: --as <you> --token <password>. Nothing was approved.", EXIT.usage, { release: cardData });
13545
+ if (text(flags, "digest") !== shown.digest)
13546
+ return fail(write, json, command, "unconfirmed", "Name the card you read: --digest <card digest>. Nothing was approved.", EXIT.refused, { release: cardData });
13547
+ name = given.name;
13548
+ token = given.token;
13549
+ }
13550
+ else {
13551
+ for (const line of releaseCardLines(shown))
13552
+ write(line);
13553
+ write("");
13554
+ if (interactive()) {
13555
+ if (!await confirm(`Approve release ${cardData.version}?`)) {
13556
+ write("Nothing approved.");
13557
+ return EXIT.refused;
13558
+ }
13559
+ }
13560
+ else if (!flags.has("yes") || text(flags, "digest") !== shown.digest) {
13561
+ write(`Nothing has been approved. Agree to exactly this card with: toolroll release approve ${chosen.branch} --yes --digest ${shown.digest} --as <you> --token <your password>`);
13562
+ return EXIT.refused;
13563
+ }
13564
+ const acting = await askCredentials(flags, context);
13565
+ if (acting === null)
13566
+ return fail(write, json, command, "credentials", "Approving a release needs who is agreeing. Nothing was approved.", EXIT.usage);
13567
+ name = acting.name;
13568
+ token = acting.token;
13569
+ }
13570
+ // After the prompt: the card again, from the plane. A change refuses; it is never approved in place of what was shown.
13571
+ const again = releaseApprovalCard(store, chosen.taskId);
13572
+ if (!again.ok || again.card.digest !== shown.digest)
13573
+ return fail(write, json, command, "changed", "This release changed while you were being asked; nothing was approved. Run the command again to read it.", EXIT.refused);
13574
+ const now = context.clock();
13575
+ const answer = approveReleaseCard(store, { taskId: chosen.taskId, cardDigest: shown.digest, approveNow: scopeDigest => approve(store, chosen.taskId, name, now, scopeDigest, token, mutationFrom(flags, now)) });
13576
+ if (!answer.ok) {
13577
+ if (answer.reason !== "approve")
13578
+ return fail(write, json, command, answer.reason, `${answer.message} Nothing was approved.`, EXIT.refused);
13579
+ const refused = answer.result;
13580
+ if (refused.reason === "policy")
13581
+ return fail(write, json, command, "policy", `${chosen.taskId}: ${refused.message}`, EXIT.refused);
13582
+ return fail(write, json, command, refused.reason, describeApproveFailure(refused.reason, chosen.taskId), EXIT.refused);
13583
+ }
13584
+ return succeed(write, json, command, { release: cardData }, () => [
13585
+ `Approved release ${cardData.version} (${cardData.candidate.slice(0, 7)}). The release check runs next; it deploys, merges and publishes once GitHub CI and the check pass.`,
13586
+ ...(shown.stopped === null ? [] : [`The release isn't running: run \`toolroll release ${chosen.branch}\` to go on.`]),
13587
+ ]);
13588
+ }
@@ -15,7 +15,7 @@
15
15
  import { existsSync, lstatSync, rmSync } from "node:fs";
16
16
  import { isAbsolute, relative, resolve } from "node:path";
17
17
  import { teardownGit, refusal } from "./teardown-git.js";
18
- import { BUILT_IN, forgetProjectModels } from "./store.js";
18
+ import { BUILT_IN, forgetProjectModels, V149_REPO_TABLES } from "./store.js";
19
19
  import { BRANCH_PREFIXES, isOwnBranch } from "./names.js";
20
20
  /** The tables whose history is the audit record itself: never touched. */
21
21
  const KEPT = new Set(["action_ledger", "ledger_seal", "ledger_checkpoint"]);
@@ -318,6 +318,10 @@ function deleteRows(store, repo, d, now) {
318
318
  // Settings, knowledge and the project itself.
319
319
  del("operating_mode_event", "mode IN (SELECT id FROM operating_mode WHERE repo = ?)", repo);
320
320
  del("decision_change", "decision IN (SELECT id FROM project_decision WHERE repo = ?)", repo);
321
+ // Its learning-loops rows (v149), by repo; a check's results, a note's tallies and a merge of its decisions go with
322
+ // the rows they hang off (the deleted runs, notes and decisions), below.
323
+ for (const table of V149_REPO_TABLES)
324
+ del(table, "repo = ?", repo);
321
325
  del("memory_sighting", "gap IN (SELECT id FROM memory_gap WHERE repo = ?) OR session IN (SELECT id FROM memory_session WHERE repo = ?)", repo, repo);
322
326
  for (const [table, column] of SETTINGS)
323
327
  del(table, `${column} = ?`, repo);
@@ -22,18 +22,18 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
- history?: never;
25
+ content?: never;
26
26
  revision: number;
27
27
  stale: boolean;
28
28
  notice: string;
29
29
  decision?: never;
30
30
  title?: never;
31
- content?: never;
31
+ history?: never;
32
32
  instructions?: never;
33
33
  references?: never;
34
34
  decisions?: never;
35
35
  } | {
36
- history?: never;
36
+ content?: never;
37
37
  stale?: never;
38
38
  decision: {
39
39
  repo: string;
@@ -51,27 +51,27 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
51
51
  };
52
52
  notice: string;
53
53
  title?: never;
54
- content?: never;
55
54
  revision?: never;
55
+ history?: never;
56
56
  instructions?: never;
57
57
  references?: never;
58
58
  decisions?: never;
59
59
  } | {
60
- history?: never;
61
60
  stale?: never;
62
61
  decision?: never;
63
62
  revision: number;
64
63
  title: string;
65
64
  content: string;
66
65
  notice: string;
66
+ history?: never;
67
67
  instructions?: never;
68
68
  references?: never;
69
69
  decisions?: never;
70
70
  } | {
71
+ content?: never;
71
72
  stale?: never;
72
73
  decision?: never;
73
74
  title?: never;
74
- content?: never;
75
75
  revision: number;
76
76
  history: {
77
77
  revision: number;
package/dist/push.d.ts CHANGED
@@ -61,14 +61,21 @@ export declare function validatePushEndpoint(raw: string): {
61
61
  ok: false;
62
62
  problem: string;
63
63
  };
64
+ /** A push's class: a stored class, or `release`, which is never stored: it is derived at send time from the plane's own
65
+ * state (release-approval.ts releasePushOf), never from a title, a task id or a link. */
66
+ export type PushClass = NonNullable<Notification["pushClass"]> | "release";
64
67
  /** Fixed words, except for one validated machine-only suite/count line. */
65
- export declare const PUSH_WORDS: Record<NonNullable<Notification["pushClass"]>, {
68
+ export declare const PUSH_WORDS: Record<PushClass, {
66
69
  title: string;
67
70
  body: string;
68
71
  }>;
69
72
  /** Static words and machine-minted numbers only — re-proved at send time. */
70
- export declare function safePushLink(pushClass: NonNullable<Notification["pushClass"]>, link: string | null): string;
71
- export declare function buildPushPayload(notification: Notification, waiting?: number | null): string;
73
+ export declare function safePushLink(pushClass: PushClass, link: string | null): string;
74
+ /** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
75
+ * row's stored class and link, revalidated. */
76
+ export declare function buildPushPayload(notification: Notification, waiting?: number | null, release?: {
77
+ link: string;
78
+ } | null): string;
72
79
  export type PushFetch = (url: string, init: Record<string, unknown>) => Promise<{
73
80
  status: number;
74
81
  headers: {
package/dist/push.js CHANGED
@@ -20,6 +20,7 @@ import { closeSync, constants as fsConstants, fsyncSync, openSync, readFileSync,
20
20
  import { join } from "node:path";
21
21
  import { createCipheriv, createECDH, createPrivateKey, hkdfSync, randomBytes, createHash, sign as cryptoSign } from "node:crypto";
22
22
  import { isCheckProgressLine } from "./check-progress.js";
23
+ import { RELEASE_PUSH_WORDS, releasePushOf, releaseReadyObsolete } from "./release-approval.js";
23
24
  export const VAPID_FILE = "vapid-keys.json";
24
25
  /** RFC 8291 §4: rs 4096 leaves room for the record's delimiter and tag. */
25
26
  const RECORD_SIZE = 4096;
@@ -193,7 +194,6 @@ export function validatePushEndpoint(raw) {
193
194
  return { ok: false, problem: "that host is not a known push service — extending the list is a code change, deliberately" };
194
195
  return { ok: true, url };
195
196
  }
196
- // ---- the payload ------------------------------------------------------------
197
197
  /** Fixed words, except for one validated machine-only suite/count line. */
198
198
  export const PUSH_WORDS = {
199
199
  decision: { title: "Toolroll", body: "a decision needs you" },
@@ -201,28 +201,37 @@ export const PUSH_WORDS = {
201
201
  merge: { title: "Toolroll", body: "a pull request needs a person" },
202
202
  attention: { title: "Toolroll", body: "the plane needs attention" },
203
203
  progress: { title: "Toolroll", body: "checks in progress" },
204
+ release: { title: "Toolroll", body: RELEASE_PUSH_WORDS },
204
205
  };
205
- const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/];
206
+ // `/approve/<n>`: a release's opaque locator (a task ref number: no version, commit or branch), resolved by the console.
207
+ const RELEASE_LINK = /^\/approve\/[0-9]{1,15}$/;
208
+ const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/, RELEASE_LINK];
206
209
  const CLASS_FALLBACK = {
207
210
  decision: "/next",
208
211
  pick: "/next",
209
212
  merge: "/review",
210
213
  attention: "/next",
211
214
  progress: "/next",
215
+ release: "/next",
212
216
  };
213
217
  /** Static words and machine-minted numbers only — re-proved at send time. */
214
218
  export function safePushLink(pushClass, link) {
219
+ // The release locator rides only the derived `release` class: a stored link of that shape on any other row is not one.
220
+ if (link !== null && RELEASE_LINK.test(link))
221
+ return pushClass === "release" ? link : CLASS_FALLBACK[pushClass];
215
222
  if (link !== null && LINK_SHAPES.some(shape => shape.test(link)))
216
223
  return link;
217
224
  return CLASS_FALLBACK[pushClass];
218
225
  }
219
- export function buildPushPayload(notification, waiting = null) {
220
- const pushClass = notification.pushClass;
226
+ /** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
227
+ * row's stored class and link, revalidated. */
228
+ export function buildPushPayload(notification, waiting = null, release = null) {
229
+ const pushClass = release !== null ? "release" : notification.pushClass;
221
230
  const words = PUSH_WORDS[pushClass];
222
231
  return JSON.stringify({
223
232
  title: words.title,
224
233
  body: pushClass === "progress" && isCheckProgressLine(notification.body) ? notification.body : words.body,
225
- url: safePushLink(pushClass, notification.link),
234
+ url: safePushLink(pushClass, release !== null ? release.link : notification.link),
226
235
  // The opaque tag collapses a crash-duplicate on the lock screen.
227
236
  tag: pushClass === "progress" && notification.run !== null ? `so-run-${notification.run}` : `so-${notification.id}`,
228
237
  // The app-icon badge (Phase 2E, v2 S4): the server-computed
@@ -268,6 +277,13 @@ export async function pushPass(store, options) {
268
277
  if (fenced === null)
269
278
  continue; // resolved, retired, rotated, or lost — nothing to send
270
279
  const subscription = fenced.subscriptionRow;
280
+ // A release's "ready for your approval" that no longer stands (approved, superseded, put away) is resolved, never
281
+ // sent; one that stands is sent as `release`, its class and link derived from the plane's state now.
282
+ if (releaseReadyObsolete(store, fenced.notificationRow)) {
283
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(clock().toISOString(), fenced.notificationRow.id);
284
+ continue;
285
+ }
286
+ const release = releasePushOf(store, fenced.notificationRow);
271
287
  const endpoint = validatePushEndpoint(subscription.endpoint);
272
288
  if (!endpoint.ok) {
273
289
  store.retirePushSubscription(subscription.id, "invalid-endpoint", clock());
@@ -280,7 +296,7 @@ export async function pushPass(store, options) {
280
296
  // The badge count rides every push: the same inbox classifier the
281
297
  // console's own sidebar badge reads, unscoped (a phone has no
282
298
  // project pane), computed at send time.
283
- buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count));
299
+ buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count, release));
284
300
  }
285
301
  catch {
286
302
  // Off-curve or malformed subscriber material: the device, not us.
@@ -31,6 +31,8 @@ import { randomBytes, randomUUID } from "node:crypto";
31
31
  import { deploymentGate } from "./deploy-gate.js";
32
32
  import { failedBeforeTests } from "./github-jobs.js";
33
33
  import { mainHistoryOf, nightlyBoundary } from "./nightly-github.js";
34
+ import { noteReleaseReady, noteReleaseStopped, recordReleaseCi } from "./release-approval.js";
35
+ import { recordVerifiedRelease } from "./release-card.js";
34
36
  export { failedBeforeTests } from "./github-jobs.js";
35
37
  import { extractCommit } from "./commit-extract.js";
36
38
  import { teardownGit, refusal as gitRefusal } from "./teardown-git.js";
@@ -529,7 +531,7 @@ export function releaseAdapters(options) {
529
531
  },
530
532
  async complete(taskId, receipt) {
531
533
  const answer = await toolroll("task", "complete", taskId, "--digest", receipt);
532
- return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer) };
534
+ return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer), ...(typeof answer["reason"] === "string" ? { reason: answer["reason"] } : {}) };
533
535
  },
534
536
  async busy() {
535
537
  const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
@@ -971,6 +973,14 @@ nightlyReads) {
971
973
  } };
972
974
  return {
973
975
  ...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
976
+ // One-tap release approval (trusted-release-reports.md, slice A): ids and the coordinator's own readings only; the
977
+ // plane decides from its own records whether anything is written.
978
+ ready: {
979
+ async asked(taskId) { withStore(store => { noteReleaseReady(store, { taskId, now: now() }); }); },
980
+ async ci(input) { withStore(store => { recordReleaseCi(store, { ...input, now: now() }); }); },
981
+ async stopped(input) { withStore(store => { noteReleaseStopped(store, { ...input, now: now() }); }); },
982
+ async verified(input) { withStore(store => { recordVerifiedRelease(store, { ...input, now: now() }); }); },
983
+ },
974
984
  // After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
975
985
  ...(auto === undefined ? {} : {
976
986
  async recheck(taskId) {
@@ -0,0 +1,191 @@
1
+ import { type PullRequestReader, type ReleaseCard, type ReleaseSeal, type TermLine } from "./release-card.js";
2
+ import { type ApproveResult, type Scope } from "./scope.js";
3
+ import { type Notification, type Store } from "./store.js";
4
+ /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
5
+ export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
6
+ /** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
7
+ export type ReleaseCiRun = {
8
+ id: number | null;
9
+ name: string;
10
+ status: string;
11
+ conclusion: string | null;
12
+ };
13
+ export type ReleaseCiSnapshot = {
14
+ candidate: string;
15
+ at: string;
16
+ state: "passed" | "running" | "failed";
17
+ failed: string[];
18
+ runs: ReleaseCiRun[];
19
+ };
20
+ /**
21
+ * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
22
+ * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
23
+ * it. The coordinator is Toolroll's own runtime reading GitHub through the plane's recorded repository.
24
+ */
25
+ export declare function recordReleaseCi(store: Store, input: {
26
+ taskId: string;
27
+ candidate: string;
28
+ state: ReleaseCiSnapshot["state"];
29
+ failed: readonly string[];
30
+ runs: readonly {
31
+ id?: number | null;
32
+ name: string;
33
+ status: string;
34
+ conclusion: string | null;
35
+ }[];
36
+ now: Date;
37
+ }): boolean;
38
+ /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
39
+ export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
40
+ /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
41
+ * in progress read alike (neither is finished); the time it was read is not bound. */
42
+ export declare function ciBinding(ci: ReleaseCiSnapshot | null): unknown;
43
+ /** GitHub CI in one line, as the sheet and the CLI say it. */
44
+ export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
45
+ export type ReleaseApprovalCard = {
46
+ taskId: string;
47
+ taskRef: number;
48
+ repo: string;
49
+ scope: Scope;
50
+ seal: ReleaseSeal;
51
+ /** The chat release card (release-card.ts): the change list and its own digest. */
52
+ card: ReleaseCard;
53
+ /** Every signed term, one line each (release-card.ts approvalTermsOf). */
54
+ terms: TermLine[];
55
+ ci: ReleaseCiSnapshot | null;
56
+ /** Why it can't be approved now, in words; null when it can (or already is). */
57
+ refusal: string | null;
58
+ approved: {
59
+ by: string;
60
+ at: string;
61
+ } | null;
62
+ /** The release coordinator stopped (its last step failed): approving needs `toolroll release` run again. */
63
+ stopped: {
64
+ step: string;
65
+ message: string;
66
+ } | null;
67
+ /** Binds everything above that a person is shown and can change under them. */
68
+ digest: string;
69
+ };
70
+ /** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
71
+ * task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
72
+ export declare function releaseTaskOf(store: Store, taskId: string): {
73
+ ref: {
74
+ id: number;
75
+ repo: string;
76
+ };
77
+ scope: Scope;
78
+ seal: ReleaseSeal;
79
+ } | null;
80
+ /** The authoritative card for a release task waiting for its person, or why there is none. Reads only. */
81
+ export declare function releaseApprovalCard(store: Store, taskId: string, options?: {
82
+ read?: PullRequestReader;
83
+ }): {
84
+ ok: true;
85
+ card: ReleaseApprovalCard;
86
+ } | {
87
+ ok: false;
88
+ why: string;
89
+ };
90
+ /** What approving does, in the sheet's and the CLI's words: the consequence, the check's access, and whether later
91
+ * commits carry. */
92
+ export declare function releaseConsequence(card: ReleaseApprovalCard): string[];
93
+ export type ReleaseCardRefusal = {
94
+ ok: false;
95
+ reason: "not-release" | "changed" | "approved" | "refused";
96
+ message: string;
97
+ } | {
98
+ ok: false;
99
+ reason: "approve";
100
+ result: Exclude<ApproveResult, {
101
+ ok: true;
102
+ }>;
103
+ };
104
+ /**
105
+ * The one card-approval wrapper (Codex round 2, finding 2): rebuild the authoritative card and compare the digest the
106
+ * person saw; then, in one transaction, re-read the store's parts (the scope digest, the CI reading, the refusal state)
107
+ * and refuse on any change; then hand THAT snapshot's scope digest to `approveNow`, the caller's unchanged
108
+ * transactional `approve()`, which proves the person and seals. Nothing here proves anyone.
109
+ */
110
+ export declare function approveReleaseCard(store: Store, input: {
111
+ taskId: string;
112
+ cardDigest: string;
113
+ read?: PullRequestReader;
114
+ approveNow: (scopeDigest: string) => ApproveResult;
115
+ }): {
116
+ ok: true;
117
+ scope: Scope;
118
+ } | ReleaseCardRefusal;
119
+ export declare const RELEASE_READY_KIND = "release-ready";
120
+ export declare const RELEASE_STOPPED_KIND = "release-stopped";
121
+ /** The one push phrase for a release waiting on its person (push.ts PUSH_WORDS.release). */
122
+ export declare const RELEASE_PUSH_WORDS = "A release is ready for your approval";
123
+ /** The sheet's own address for a task (the in-app link; a push carries the opaque `/approve/<task ref>` instead). */
124
+ export declare const releaseSheetHref: (taskId: string) => string;
125
+ /**
126
+ * The coordinator asked a person (on entering the approval step, a fresh filing or a resumed one): one notification per
127
+ * task and stable scope digest, so a crash between filing and asking is healed by the next run and a repeat says nothing
128
+ * new. Every older `release-ready` row of the project (another task or digest) is resolved first. Nothing when the task
129
+ * isn't a release waiting for a person.
130
+ */
131
+ export declare function noteReleaseReady(store: Store, input: {
132
+ taskId: string;
133
+ now: Date;
134
+ }): boolean;
135
+ /** Resolve a task's `release-ready` rows (it was approved, or the release stopped): a push not sent yet never is. */
136
+ export declare function retireReleaseReady(store: Store, taskRef: number, now: Date): number;
137
+ /**
138
+ * The push class `release` and its link, derived at send time from the plane's own state, never from a title, a task id,
139
+ * the stored link or the stored class: the row is a `release-ready` row for exactly its task ref and a scope digest, and
140
+ * that task is a release scope (terms or a release check's purpose, its candidate the approved commit), not put away,
141
+ * not approved, at that same digest. Null otherwise.
142
+ */
143
+ export declare function releasePushOf(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): {
144
+ link: string;
145
+ } | null;
146
+ /** A `release-ready` row that no longer stands (the push pass resolves it instead of sending it). */
147
+ export declare function releaseReadyObsolete(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): boolean;
148
+ /**
149
+ * A person's release stopped (CI failed, the check failed, the deploy, the merge, the tag or publication failed, or it
150
+ * stopped waiting): one attention notification per task, step and reason, with the stop's words in the app (the push
151
+ * says only the fixed attention phrase). Its `release-ready` row is resolved, except while it only stopped waiting
152
+ * for the approval (the sheet still approves it then).
153
+ */
154
+ export declare function noteReleaseStopped(store: Store, input: {
155
+ taskId: string;
156
+ step: string;
157
+ reason: string;
158
+ message: string;
159
+ now: Date;
160
+ }): boolean;
161
+ /** The card as the CLI prints it, every line control-free (src/terminal-text.ts). */
162
+ export declare function releaseCardLines(card: ReleaseApprovalCard): string[];
163
+ /** One release journal as discovery reads it: strictly, every field it relies on in its exact shape. */
164
+ export type WaitingJournal = {
165
+ file: string;
166
+ branch: string;
167
+ checkout: string;
168
+ taskId: string;
169
+ scopeDigest: string;
170
+ candidate: string;
171
+ version: string;
172
+ pr: number;
173
+ };
174
+ /** A journal's own fields, strictly; null when any is missing or not its exact shape. */
175
+ export declare function readWaitingJournal(file: string, raw: unknown, expectedFile: (branch: string) => string): WaitingJournal | null;
176
+ /**
177
+ * Whether a journal (discovery only) names exactly the release the plane sealed: the task is a release scope whose
178
+ * sealed identity has this exact branch, version, pull request and candidate at this scope digest, and the journal's
179
+ * checkout, the task's project and the sealed repository are one real folder (`repo` too, when given). Why not, in words.
180
+ */
181
+ export declare function journalMatchesPlane(store: Store, journal: WaitingJournal, repo: string | null): {
182
+ ok: true;
183
+ } | {
184
+ ok: false;
185
+ why: string;
186
+ };
187
+ /** Every release journal in the state folder, read strictly; unreadable ones are counted, never guessed at. */
188
+ export declare function waitingJournals(stateDir: string, expectedFile: (branch: string) => string): {
189
+ journals: WaitingJournal[];
190
+ unreadable: string[];
191
+ };