toolroll 0.9.85 → 0.9.87

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 (220) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/agents-board.d.ts +12 -0
  5. package/dist/agents-board.js +26 -0
  6. package/dist/approval-rules-ui.d.ts +4 -1
  7. package/dist/approval-rules-ui.js +31 -15
  8. package/dist/assignment-ui.js +1 -1
  9. package/dist/assignment.js +4 -3
  10. package/dist/backup-ui.d.ts +5 -4
  11. package/dist/backup-ui.js +65 -32
  12. package/dist/browser/terminal.css +1 -1
  13. package/dist/browser/terminal.js +57 -54
  14. package/dist/browser/workspace.css +2 -2
  15. package/dist/browser/workspace.js +35 -68
  16. package/dist/browser-crew.js +1 -1
  17. package/dist/browser-workspace.d.ts +363 -177
  18. package/dist/browser-workspace.js +10 -9
  19. package/dist/chat-actions.d.ts +11 -1
  20. package/dist/chat-actions.js +53 -1
  21. package/dist/chat-channel.js +1 -0
  22. package/dist/chat-controls.d.ts +1 -1
  23. package/dist/chat-controls.js +1 -1
  24. package/dist/chat-quiet.js +3 -2
  25. package/dist/chat-settings-page.d.ts +29 -9
  26. package/dist/chat-settings-page.js +59 -28
  27. package/dist/check-levels-ui.d.ts +2 -1
  28. package/dist/check-levels-ui.js +67 -58
  29. package/dist/claim.d.ts +3 -0
  30. package/dist/claim.js +4 -1
  31. package/dist/contracts/chat-actions.d.ts +9 -0
  32. package/dist/contracts/chat-actions.js +3 -0
  33. package/dist/contracts/cli.d.ts +43 -0
  34. package/dist/contracts/cli.js +4 -0
  35. package/dist/contracts/console-api.d.ts +4 -1
  36. package/dist/contracts/console-api.js +4 -1
  37. package/dist/contracts/flow-propose.d.ts +4 -1
  38. package/dist/contracts/flow-propose.js +1 -1
  39. package/dist/contracts/flow.d.ts +41 -0
  40. package/dist/contracts/flow.js +4 -2
  41. package/dist/contracts/lead-tools.d.ts +21 -0
  42. package/dist/contracts/lead-tools.js +2 -0
  43. package/dist/contracts/store-json.d.ts +2 -0
  44. package/dist/contracts/trigger-payloads.d.ts +20 -0
  45. package/dist/contracts/trigger-payloads.js +4 -0
  46. package/dist/coordinator.js +1 -0
  47. package/dist/credentials-ui.d.ts +5 -3
  48. package/dist/credentials-ui.js +58 -31
  49. package/dist/deploy-gate.js +4 -0
  50. package/dist/design/kit.css +685 -0
  51. package/dist/design/kit.d.ts +208 -0
  52. package/dist/design/kit.js +177 -0
  53. package/dist/design/tokens.css +19 -1
  54. package/dist/discord-settings.js +8 -4
  55. package/dist/dispatch.d.ts +3 -0
  56. package/dist/dispatch.js +4 -1
  57. package/dist/export-ui.d.ts +6 -4
  58. package/dist/export-ui.js +18 -8
  59. package/dist/flow-browser.d.ts +103 -0
  60. package/dist/flow-browser.js +204 -0
  61. package/dist/flow-changed.d.ts +14 -0
  62. package/dist/flow-changed.js +53 -0
  63. package/dist/flow-code.d.ts +5 -1
  64. package/dist/flow-code.js +4 -1
  65. package/dist/flow-engine.js +16 -0
  66. package/dist/flow-gallery-ui.d.ts +41 -4
  67. package/dist/flow-gallery-ui.js +95 -63
  68. package/dist/flow-live.js +9 -1
  69. package/dist/flow-pull-request.js +15 -7
  70. package/dist/flow-share.js +3 -1
  71. package/dist/flow-starters-ui.d.ts +3 -9
  72. package/dist/flow-starters-ui.js +12 -23
  73. package/dist/flow-steps.d.ts +9 -1
  74. package/dist/flow-steps.js +55 -5
  75. package/dist/flow-triggers.js +55 -0
  76. package/dist/flow-unattended-words.d.ts +20 -0
  77. package/dist/flow-unattended-words.js +24 -0
  78. package/dist/flow-unattended.d.ts +9 -5
  79. package/dist/flow-unattended.js +45 -23
  80. package/dist/flows-cli.d.ts +19 -0
  81. package/dist/flows-cli.js +54 -5
  82. package/dist/flows-ui.d.ts +12 -6
  83. package/dist/flows-ui.js +152 -32
  84. package/dist/flows.js +5 -2
  85. package/dist/integrations-ui.d.ts +6 -4
  86. package/dist/integrations-ui.js +34 -40
  87. package/dist/integrations.d.ts +2 -2
  88. package/dist/integrations.js +4 -4
  89. package/dist/kits-ui.d.ts +8 -10
  90. package/dist/kits-ui.js +29 -33
  91. package/dist/kits.js +1 -1
  92. package/dist/knowledge-ui.js +76 -19
  93. package/dist/lead-tools.js +35 -6
  94. package/dist/lead-voice.js +16 -5
  95. package/dist/ledger-view.d.ts +2 -0
  96. package/dist/ledger-view.js +41 -38
  97. package/dist/models-ui.d.ts +8 -3
  98. package/dist/models-ui.js +82 -59
  99. package/dist/monitoring-ui.d.ts +4 -2
  100. package/dist/monitoring-ui.js +44 -13
  101. package/dist/needs-you.d.ts +1 -1
  102. package/dist/needs-you.js +8 -0
  103. package/dist/operate-remote-arguments.js +1 -0
  104. package/dist/operate-remote.js +1 -1
  105. package/dist/operate.d.ts +1 -1
  106. package/dist/operate.js +249 -6
  107. package/dist/passwordless.d.ts +1 -1
  108. package/dist/passwordless.js +1 -1
  109. package/dist/people-audit-ui.d.ts +2 -1
  110. package/dist/people-audit-ui.js +25 -23
  111. package/dist/policy-ui.d.ts +3 -2
  112. package/dist/policy-ui.js +27 -21
  113. package/dist/policy.d.ts +3 -0
  114. package/dist/policy.js +14 -1
  115. package/dist/project-delete-ui.d.ts +4 -2
  116. package/dist/project-delete-ui.js +135 -126
  117. package/dist/project-delete.js +7 -3
  118. package/dist/project-knowledge.d.ts +10 -10
  119. package/dist/publish.d.ts +11 -0
  120. package/dist/publish.js +16 -3
  121. package/dist/pull-request-flow.js +35 -4
  122. package/dist/pull-request-ui.d.ts +2 -1
  123. package/dist/pull-request-ui.js +39 -28
  124. package/dist/recipe-ui.d.ts +4 -2
  125. package/dist/recipe-ui.js +14 -4
  126. package/dist/release-adapters.d.ts +3 -0
  127. package/dist/release-adapters.js +22 -2
  128. package/dist/release-cli.js +81 -9
  129. package/dist/release.d.ts +21 -0
  130. package/dist/release.js +40 -4
  131. package/dist/releases.js +1 -1
  132. package/dist/request-budget-ui.d.ts +4 -2
  133. package/dist/request-budget-ui.js +30 -19
  134. package/dist/result-follow-ups.js +4 -0
  135. package/dist/retention-ui.d.ts +4 -3
  136. package/dist/retention-ui.js +15 -12
  137. package/dist/scope.d.ts +8 -0
  138. package/dist/scope.js +13 -0
  139. package/dist/serve.d.ts +0 -1
  140. package/dist/serve.js +51 -7
  141. package/dist/server/agents-computers.d.ts +9 -0
  142. package/dist/server/agents-computers.js +67 -0
  143. package/dist/server/chat.js +20 -18
  144. package/dist/server/chrome.d.ts +1 -1
  145. package/dist/server/chrome.js +19 -9
  146. package/dist/server/console.css +31 -2
  147. package/dist/server/flows.js +79 -36
  148. package/dist/server/home.d.ts +35 -0
  149. package/dist/server/home.js +104 -0
  150. package/dist/server/http.d.ts +3 -0
  151. package/dist/server/http.js +5 -0
  152. package/dist/server/kit-standalone.d.ts +22 -0
  153. package/dist/server/kit-standalone.js +69 -0
  154. package/dist/server/kit.d.ts +405 -0
  155. package/dist/server/kit.js +361 -0
  156. package/dist/server/pages.d.ts +2 -1
  157. package/dist/server/pages.js +213 -174
  158. package/dist/server/people-tokens.js +26 -85
  159. package/dist/server/render-history.d.ts +51 -0
  160. package/dist/server/render-history.js +228 -0
  161. package/dist/server/render-new-task.d.ts +29 -0
  162. package/dist/server/render-new-task.js +96 -0
  163. package/dist/server/render-next.d.ts +63 -0
  164. package/dist/server/render-next.js +109 -0
  165. package/dist/server/render-pages.d.ts +35 -190
  166. package/dist/server/render-pages.js +154 -563
  167. package/dist/server/render-people.d.ts +24 -3
  168. package/dist/server/render-people.js +93 -72
  169. package/dist/server/render-projects.d.ts +103 -0
  170. package/dist/server/render-projects.js +247 -0
  171. package/dist/server/render-results.d.ts +5 -24
  172. package/dist/server/render-results.js +48 -185
  173. package/dist/server/render-settings.d.ts +5 -74
  174. package/dist/server/render-settings.js +102 -493
  175. package/dist/server/render-tasks.d.ts +43 -5
  176. package/dist/server/render-tasks.js +131 -91
  177. package/dist/server/route-table.js +31 -5
  178. package/dist/server/settings.js +276 -255
  179. package/dist/server/task-views.d.ts +8 -0
  180. package/dist/server/task-views.js +90 -2
  181. package/dist/server/tasks.d.ts +18 -0
  182. package/dist/server/tasks.js +148 -41
  183. package/dist/server/terminals.js +1 -1
  184. package/dist/server/welcome.d.ts +22 -0
  185. package/dist/server/welcome.js +69 -0
  186. package/dist/settings-groups-ui.d.ts +279 -0
  187. package/dist/settings-groups-ui.js +615 -0
  188. package/dist/settings-kit.d.ts +42 -6
  189. package/dist/settings-kit.js +69 -7
  190. package/dist/settings-proofs.js +1 -0
  191. package/dist/setup-guide.d.ts +2 -2
  192. package/dist/skills-ui.js +59 -7
  193. package/dist/slack-settings.js +12 -5
  194. package/dist/spend-ui.d.ts +45 -26
  195. package/dist/spend-ui.js +124 -56
  196. package/dist/sso-ui.d.ts +5 -2
  197. package/dist/sso-ui.js +63 -31
  198. package/dist/storage-ui.d.ts +3 -2
  199. package/dist/storage-ui.js +56 -50
  200. package/dist/store.d.ts +182 -5
  201. package/dist/store.js +512 -38
  202. package/dist/surface.js +23 -3
  203. package/dist/task-archive.d.ts +10 -0
  204. package/dist/task-archive.js +16 -0
  205. package/dist/task-control.d.ts +1 -1
  206. package/dist/teams-settings.d.ts +2 -1
  207. package/dist/teams-settings.js +24 -41
  208. package/dist/telegram-settings.d.ts +0 -4
  209. package/dist/telegram-settings.js +52 -25
  210. package/dist/tidy.d.ts +130 -0
  211. package/dist/tidy.js +503 -0
  212. package/dist/toolroll-update-ui.d.ts +8 -4
  213. package/dist/toolroll-update-ui.js +55 -45
  214. package/dist/tools-ui.d.ts +5 -3
  215. package/dist/tools-ui.js +107 -42
  216. package/dist/work-index.d.ts +24 -0
  217. package/dist/work-index.js +56 -30
  218. package/dist/workspace-ui.d.ts +1 -1
  219. package/dist/workspace-ui.js +8 -4
  220. package/package.json +1 -1
@@ -14,7 +14,7 @@
14
14
  * read must show the PR open, on the accepted commit, with checks passing; GitHub's head-commit match guards
15
15
  * the race; the branch is deleted; the merge commit and who merged are recorded in the ledger.
16
16
  */
17
- import { createHash } from "node:crypto";
17
+ import { createHash, randomUUID } from "node:crypto";
18
18
  import { run as execRun } from "./exec.js";
19
19
  import { bodyHashOf, publicationBody, publicationGrantOf } from "./publish.js";
20
20
  import { failingChecks, summarizeChecks } from "./pulls.js";
@@ -23,7 +23,7 @@ import { requestResultChanges } from "./result-actions.js";
23
23
  import { revisionSourceOf } from "./result-review.js";
24
24
  import { authenticateApprover, personProof } from "./scope.js";
25
25
  import { BRANCH_PREFIX, headWithin } from "./names.js";
26
- import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, releaseTaskRef } from "./store.js";
26
+ import { NIGHTLY_CHECK_ONLY, RELEASE_MERGES_ONLY, nightlyTaskRef, refArchived, releaseTaskRef } from "./store.js";
27
27
  import { belongsToRelease } from "./release-authority.js";
28
28
  import { autoReleaseSealed } from "./release-auto.js";
29
29
  import { fullCheckGate, requestFollowUpChecks } from "./result-follow-ups.js";
@@ -292,6 +292,9 @@ export async function followPullRequests(store, seen, options) {
292
292
  continue;
293
293
  const pr = publication.prNumber;
294
294
  const taskId = taskIdOf(store, publication);
295
+ // A put-away task (v145) gets no automatic revision, merge or ready notice: restoring it resumes following.
296
+ if (refArchived(store, publication.taskRef))
297
+ continue;
295
298
  if (one.remoteState === "MERGED") {
296
299
  updateFollow(store, publication.id, { merge_commit: one.mergeCommit ?? "", merged_by: "GitHub", merged_at: clock().toISOString() }, clock());
297
300
  continue;
@@ -397,6 +400,8 @@ export async function mergePullRequest(store, input) {
397
400
  // The nightly check of main (release-once.md, part 3) is a check only: it never merges.
398
401
  const mergedRun = store.getRun(input.runId);
399
402
  const mergedTask = mergedRun === null ? null : store.refById(mergedRun.taskRef)?.externalId ?? null;
403
+ if (mergedRun !== null && refArchived(store, mergedRun.taskRef))
404
+ return refuse("archived", "This task is put away. Restore it before merging.");
400
405
  if (mergedTask !== null && store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(mergedTask) !== undefined)
401
406
  return refuse("check-only", "The nightly check of main is only a check; it never merges.");
402
407
  // A release's task merges only through `toolroll release` (release-once.md), which proves the live release, reserves
@@ -470,8 +475,34 @@ export async function mergePullRequest(store, input) {
470
475
  return fail("moved", "The pull request has commits that weren't part of the completed result, so it won't be merged from here.");
471
476
  if (before.checks !== "passing")
472
477
  return fail("checks", before.checks === "failing" ? "Checks are failing, so it can't merge." : before.checks === "running" ? "Checks are still running. Merge once they pass." : "No checks have reported yet. Merge once they pass.");
473
- const merged = await exec("gh", ["pr", "merge", pr, "--repo", publication.githubRepo, `--${method}`, "--match-head-commit", publication.headSha, "--delete-branch"], { timeoutMs: EXEC_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" } });
474
- const after = await view();
478
+ // The merge is reserved with the put-away check in one write (v145): a put away that committed first refuses it, and
479
+ // one that comes after waits for GitHub's answer (the reservation counts as work going on).
480
+ // One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
481
+ const token = randomUUID();
482
+ const reserved = store.transact(() => {
483
+ if (refArchived(store, publication.taskRef))
484
+ return "archived";
485
+ if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
486
+ return "deleting";
487
+ if (store.handle.prepare("SELECT 1 FROM merge_in_flight WHERE publication = ? AND at > ?").get(publication.id, new Date(clock().getTime() - 15 * 60_000).toISOString()) !== undefined)
488
+ return "merging";
489
+ store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
490
+ return "ok";
491
+ });
492
+ if (reserved === "archived")
493
+ return refuse("archived", "This task is put away. Restore it before merging.");
494
+ if (reserved === "deleting")
495
+ return refuse("deleting", "This task's project is being deleted, so nothing merges.");
496
+ if (reserved === "merging")
497
+ return refuse("merging", "This pull request is already being merged. Wait for that to finish.");
498
+ let merged, after;
499
+ try {
500
+ merged = await exec("gh", ["pr", "merge", pr, "--repo", publication.githubRepo, `--${method}`, "--match-head-commit", publication.headSha, "--delete-branch"], { timeoutMs: EXEC_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" } });
501
+ after = await view();
502
+ }
503
+ finally {
504
+ store.handle.prepare("DELETE FROM merge_in_flight WHERE publication = ? AND token = ?").run(publication.id, token);
505
+ }
475
506
  if (after?.state === "MERGED") {
476
507
  record(after.commit, input.by);
477
508
  return { ok: true, commit: after.commit };
@@ -21,4 +21,5 @@ export type PullRequestSettingsView = {
21
21
  problem: string | null;
22
22
  };
23
23
  export declare function pullRequestSettingsHtml(view: PullRequestSettingsView): Html;
24
- export declare const PULL_REQUEST_SETTINGS_CSS = "\n .pr-settings { display: grid; gap: .6rem; max-width: 40rem; }\n .pr-settings p, .pr-settings h2 { margin: 0; }\n .pr-settings label { display: grid; gap: .25rem; }\n .pr-settings label input[type=password], .pr-settings select { min-height: 44px; box-sizing: border-box; width: 100%; }\n .pr-settings .pr-check { display: flex; align-items: center; gap: .5rem; min-height: 44px; }\n .pr-settings button { min-height: 44px; justify-self: start; }\n .pr-terms { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }\n @media (max-width: 480px) { .pr-settings button { justify-self: stretch; } }\n";
24
+ /** Kept for the console's style bundle (server/chrome.ts); the page itself is kit markup. */
25
+ export declare const PULL_REQUEST_SETTINGS_CSS = "";
@@ -1,44 +1,55 @@
1
1
  /** Settings → Projects → Pull requests: one project's pull-request setup, in plain words. Off shows what setup
2
2
  * found (or what to fix) and one Turn on; on shows where pull requests go and how they merge. Every change takes
3
3
  * the password; turning on restates the exact repository and branch that were checked. */
4
- import { html, postForm, reauth } from "./html.js";
4
+ import { html, joinHtml, postForm } from "./html.js";
5
+ import { acts, btn, disclose, formRow, infoLine, note, notice, select, settingsSection, switchInput } from "./server/kit.js";
6
+ import { kitSectionForm } from "./settings-kit.js";
5
7
  const METHOD_WORDS = { squash: "Squash and merge", merge: "Merge commit", rebase: "Rebase and merge" };
8
+ const mono = (text) => html `<span class="k-mono">${text}</span>`;
6
9
  export function pullRequestSettingsHtml(view) {
7
- const post = (act, body, hidden = {}) => postForm("/settings/pull-requests", body, { attrs: { class: "card pr-settings" }, hidden: { repo: view.repo, act, ...hidden } });
8
- const password = html `${reauth(html `<label>Password<input type="password" name="password" autocomplete="current-password" required></label>`)}`;
9
- const notes = [
10
- view.said === null ? "" : html `<p class="notice" role="status">${view.said}</p>`,
11
- view.problem === null ? "" : html `<p class="problem" role="alert">${view.problem}</p>`,
12
- ];
13
- const head = html `<p class="meta">${view.name} · <span class="mono">${view.repo}</span></p>${notes}`;
10
+ const head = joinHtml([
11
+ view.problem === null ? "" : notice(view.problem),
12
+ view.said === null ? "" : infoLine(view.said),
13
+ note(html `${view.name} · ${mono(view.repo)}`),
14
+ ]);
14
15
  const publishing = view.publishing;
15
16
  if (publishing.on) {
16
- const status = html `<section class="card pr-settings"><p><strong>On</strong> · <span class="mono">${publishing.githubRepo}</span> into <span class="mono">${publishing.base}</span></p><p class="meta">${publishing.legacy ? "Every finished build opens a pull request (set up with publish grant)." : "Complete offers “Complete and open a pull request”."} Set up by ${publishing.grantedBy}.</p></section>`;
17
+ const status = settingsSection({ id: "pull-requests", title: "Pull requests", state: " · On",
18
+ lead: html `${mono(publishing.githubRepo)} into ${mono(publishing.base)}`,
19
+ body: note(`${publishing.legacy ? "Every finished build opens a pull request (set up with publish grant)." : "Complete offers “Complete and open a pull request”."} Set up by ${publishing.grantedBy}.`) });
17
20
  if (!view.canChange)
18
- return html `${head}${status}`;
19
- const methods = ["squash", "merge", "rebase"].map(method => html `<option value="${method}"${publishing.mergeMethod === method ? html ` selected` : ""}>${METHOD_WORDS[method]}${method === "squash" ? " (default)" : ""}</option>`);
20
- return html `${head}${status}${post("settings", html `<h2>Merging</h2><label>Merge method<select name="method">${methods}</select></label><label class="row pr-check"><input type="checkbox" name="when-green" value="1"${publishing.mergeWhenGreen ? html ` checked` : ""}> Merge when checks pass</label><p class="meta">Off by default. When on, a pull request merges by itself once its checks pass on the completed commit. Branches are deleted after merging.</p>${password}<button type="submit">Save</button>`)}<details class="settings-more"><summary>Turn off pull requests</summary>${post("off", html `<p class="meta">Nothing more is pushed. Open pull requests stay on GitHub.</p>${password}<button type="submit" class="danger">Turn off</button>`)}</details>`;
21
+ return joinHtml([head, status]);
22
+ const merging = settingsSection({ id: "merging", title: "Merging",
23
+ body: kitSectionForm({ proof: "pull-requests", action: "/settings/pull-requests", id: "merging-form", gate: true, hidden: { repo: view.repo, act: "settings" },
24
+ body: joinHtml([
25
+ formRow("Merge method", null, select({ id: "pr-method", name: "method", label: "Merge method", current: publishing.mergeMethod,
26
+ items: ["squash", "merge", "rebase"].map(method => [method, `${METHOD_WORDS[method]}${method === "squash" ? " (default)" : ""}`]) })),
27
+ formRow("Merge when checks pass", "Off by default. When on, a pull request merges by itself once its checks pass on the completed commit. Branches are deleted after merging.", switchInput({ id: "pr-when-green", name: "when-green", label: publishing.mergeWhenGreen ? "On" : "Off", on: publishing.mergeWhenGreen, attrs: { value: "1", "aria-label": "Merge when checks pass" } }), "inline"),
28
+ ]) }) });
29
+ const off = disclose("Turn off pull requests", kitSectionForm({ proof: "pull-requests", action: "/settings/pull-requests", id: "pr-off-form", hidden: { repo: view.repo, act: "off" },
30
+ save: { label: "Turn off", variant: "danger" }, confirm: "Turn off pull requests", body: note("Nothing more is pushed. Open pull requests stay on GitHub.") }));
31
+ return joinHtml([head, status, merging, off]);
21
32
  }
22
33
  const check = view.check;
23
34
  if (check === null)
24
- return html `${head}<section class="card pr-settings"><p><strong>Off</strong></p><p class="meta">An approver can turn on pull requests.</p></section>`;
35
+ return joinHtml([head, settingsSection({ id: "pull-requests", title: "Pull requests", state: " · Off", body: note("An approver can turn on pull requests.") })]);
25
36
  // The checks ask git and GitHub, so they run when asked, never on opening the page (docs/plans/get-side-effects.md).
26
- const checkButton = (label) => postForm("/settings/pull-requests/check", html `<button type="submit"${label === "Check again" ? html ` class="secondary"` : ""}>${label}</button>`, { hidden: { repo: view.repo } });
27
- if (check === "unchecked")
28
- return html `${head}<section class="card pr-settings"><p><strong>Off</strong></p><p class="meta">Check this project's GitHub setup to see what turning on would do.</p>${checkButton("Check setup")}</section>`;
37
+ const checkButton = (label, variant) => postForm("/settings/pull-requests/check", acts(btn(label, { sm: true, type: "submit", variant })), { hidden: { repo: view.repo } });
38
+ if (check === "unchecked") {
39
+ return joinHtml([head, settingsSection({ id: "pull-requests", title: "Pull requests", state: " · Off",
40
+ lead: "Check this project's GitHub setup to see what turning on would do.", body: checkButton("Check setup", "primary") })]);
41
+ }
29
42
  if (!check.ok) {
30
- return html `${head}<section class="card pr-settings"><p><strong>Can’t turn on yet</strong></p><p>${check.message}</p>${checkButton("Check again")}</section>`;
43
+ return joinHtml([head, settingsSection({ id: "pull-requests", title: "Pull requests", state: " · Can’t turn on yet",
44
+ body: joinHtml([notice(check.message), checkButton("Check again", "outline")]) })]);
31
45
  }
32
46
  const plan = check.plan;
33
- return html `${head}${post("on", html `<p><strong>Ready to turn on</strong></p><ul class="pr-terms"><li>Pull requests open on <span class="mono">${plan.githubRepo}</span> into <span class="mono">${plan.base}</span>${plan.account === null ? "" : `, as ${plan.account}`}.</li><li>Complete offers “Complete and open a pull request”, from the exact completed commit. A flow's Pull request zone can open one too.</li><li>Merging squashes and deletes the branch. A person merges with their password; a flow merges only when its zone says so, after a person approved the card.</li></ul>${password}<button type="submit">Turn on pull requests</button>`, { github: plan.githubRepo, base: plan.base })}`;
47
+ return joinHtml([head, settingsSection({ id: "pull-requests", title: "Pull requests", state: " · Ready to turn on",
48
+ body: kitSectionForm({ proof: "pull-requests-on", action: "/settings/pull-requests", id: "pr-on-form", hidden: { repo: view.repo, act: "on", github: plan.githubRepo, base: plan.base },
49
+ save: { label: "Turn on pull requests" }, confirm: "Turn on pull requests",
50
+ body: html `<ul class="k-note"><li>Pull requests open on ${mono(plan.githubRepo)} into ${mono(plan.base)}${plan.account === null ? "" : `, as ${plan.account}`}.</li>\
51
+ <li>Complete offers “Complete and open a pull request”, from the exact completed commit. A flow's Pull request zone can open one too.</li>\
52
+ <li>Merging squashes and deletes the branch. A person merges with their password; a flow merges only when its zone says so, after a person approved the card.</li></ul>` }) })]);
34
53
  }
35
- export const PULL_REQUEST_SETTINGS_CSS = `
36
- .pr-settings { display: grid; gap: .6rem; max-width: 40rem; }
37
- .pr-settings p, .pr-settings h2 { margin: 0; }
38
- .pr-settings label { display: grid; gap: .25rem; }
39
- .pr-settings label input[type=password], .pr-settings select { min-height: 44px; box-sizing: border-box; width: 100%; }
40
- .pr-settings .pr-check { display: flex; align-items: center; gap: .5rem; min-height: 44px; }
41
- .pr-settings button { min-height: 44px; justify-self: start; }
42
- .pr-terms { margin: 0; padding-left: 1.1rem; display: grid; gap: .25rem; }
43
- @media (max-width: 480px) { .pr-settings button { justify-self: stretch; } }
44
- `;
54
+ /** Kept for the console's style bundle (server/chrome.ts); the page itself is kit markup. */
55
+ export const PULL_REQUEST_SETTINGS_CSS = "";
@@ -1,7 +1,9 @@
1
1
  import { type Recipe, type RecipeDocument, type WorkflowPreview } from "./recipes.js";
2
2
  import { type Html } from "./html.js";
3
3
  export declare function recipeFromForm(body: URLSearchParams): RecipeDocument;
4
- export declare function recipeLibraryHtml(starters: Recipe[], saved: Recipe[], project: string | null, revision: number, recent: {
4
+ /** "One-time work" (New flow): the open project's recipes and the starters as rows, each with one "Use this"; then
5
+ * Create a recipe, importing a shared one, and the work recipes made lately. */
6
+ export declare function recipeRowsHtml(starters: Recipe[], saved: Recipe[], project: string | null, projectName: string | null, revision: number, recent: {
5
7
  name: string;
6
8
  href: string;
7
9
  state: string;
@@ -16,4 +18,4 @@ export declare function recipeDefinitionPreviewHtml(preview: WorkflowPreview, re
16
18
  export declare function recipeRunHtml(recipe: Recipe, repo: string, revision: number, problem?: string | null, values?: URLSearchParams, saved?: boolean): Html;
17
19
  export declare function recipeAnswersFromForm(body: URLSearchParams): Map<string, string>;
18
20
  export declare function recipeScript(): string;
19
- export declare const RECIPE_CSS = "\n.recipe-heading { margin: .5rem 0 1.75rem; max-width: 46rem; }\n.recipe-heading h1 { font-size: clamp(1.65rem,3vw,2.5rem); letter-spacing:-.035em; margin:.35rem 0 .6rem; }\n.recipe-kicker { font-size:.7rem; text-transform:uppercase; letter-spacing:.12em; color:var(--muted-foreground); }\n.recipe-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.8rem; margin:1rem 0 2rem; }\n.recipe-card { border:1px solid var(--border); border-radius:var(--radius); padding:1.1rem; background:var(--card); display:flex; flex-direction:column; gap:.5rem; }\n.recipe-card h3 { font-size:1.05rem; margin:0; letter-spacing:-.015em; }\n.recipe-card p { color:var(--muted-foreground); margin:.2rem 0; font-size:.85rem; line-height:1.5; }\n.recipe-card-link { margin-top:auto; padding-top:.75rem; display:flex; justify-content:space-between; }\n.recipe-editor { max-width:52rem; }.recipe-editor h2 { margin-top:0; }\n.recipe-criterion { border:1px solid var(--border); border-radius:calc(var(--radius) - 3px); padding:.8rem; margin:.8rem 0; }\n.recipe-evidence { display:flex; flex-wrap:wrap; gap:.5rem 1rem; }.recipe-evidence label { display:flex; align-items:center; gap:.4rem; font-size:.8rem; }.recipe-evidence input { width:auto; margin:0; }\n.recipe-options { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }\n.recipe-preview-grid { display:grid; grid-template-columns:minmax(0,1.7fr) minmax(16rem,1fr); gap:1rem; }\n.recipe-steps { padding-left:1.4rem; }.recipe-steps li { padding:.4rem 0 .6rem .3rem; }.recipe-steps p { margin:.3rem 0 0; font-size:.85rem; color:var(--muted-foreground); }\n.recipe-launch { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }.recipe-launch form { margin:0 0 .6rem; }\n.recipe-history a { display:flex; justify-content:space-between; gap:1rem; padding:.6rem 0; border-bottom:1px solid var(--border); }.recipe-history span { color:var(--muted-foreground); }\n.recipe-error { border-color:var(--destructive); }\n.recipe-card[hidden] { display:none; }.recipe-search { max-width:28rem; }.recipe-question { border:1px solid var(--border); border-radius:calc(var(--radius) - 3px); margin:1rem 0; padding:.8rem; min-width:0; }.recipe-run { max-width:42rem; }.recipe-run button { margin-top:1rem; }\n@media(max-width:1000px){.recipe-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }}\n@media(max-width:760px){.recipe-grid,.recipe-options,.recipe-preview-grid,.recipe-launch { grid-template-columns:1fr; }.recipe-heading { margin-bottom:1rem; }.recipe-history a { flex-direction:column; gap:.2rem; }}\n";
21
+ export declare const RECIPE_CSS = "\n.fl-recipe-more { display:grid; gap:12px; padding-top:12px; }.fl-recipe-more .k-disclose[open] > form, .fl-recipe-more .k-disclose[open] > div { margin-top:12px; }.fl-recipe-import { display:grid; gap:12px; margin:0; }\n.fl-recipe-recent { display:grid; }.fl-recipe-recent a { display:flex; justify-content:space-between; gap:12px; min-height:40px; align-items:center; border-bottom:1px solid var(--so-line); color:var(--so-ink); text-decoration:none; font-size:13px; }\n.recipe-heading { margin: .5rem 0 1.75rem; max-width: 46rem; }\n.recipe-heading h1 { font-size: clamp(1.65rem,3vw,2.5rem); letter-spacing:-.035em; margin:.35rem 0 .6rem; }\n.recipe-kicker { font-size:.7rem; text-transform:uppercase; letter-spacing:.12em; color:var(--muted-foreground); }\n.recipe-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.8rem; margin:1rem 0 2rem; }\n.recipe-card { border:1px solid var(--border); border-radius:var(--radius); padding:1.1rem; background:var(--card); display:flex; flex-direction:column; gap:.5rem; }\n.recipe-card h3 { font-size:1.05rem; margin:0; letter-spacing:-.015em; }\n.recipe-card p { color:var(--muted-foreground); margin:.2rem 0; font-size:.85rem; line-height:1.5; }\n.recipe-card-link { margin-top:auto; padding-top:.75rem; display:flex; justify-content:space-between; }\n.recipe-editor { max-width:52rem; }.recipe-editor h2 { margin-top:0; }\n.recipe-criterion { border:1px solid var(--border); border-radius:calc(var(--radius) - 3px); padding:.8rem; margin:.8rem 0; }\n.recipe-evidence { display:flex; flex-wrap:wrap; gap:.5rem 1rem; }.recipe-evidence label { display:flex; align-items:center; gap:.4rem; font-size:.8rem; }.recipe-evidence input { width:auto; margin:0; }\n.recipe-options { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }\n.recipe-preview-grid { display:grid; grid-template-columns:minmax(0,1.7fr) minmax(16rem,1fr); gap:1rem; }\n.recipe-steps { padding-left:1.4rem; }.recipe-steps li { padding:.4rem 0 .6rem .3rem; }.recipe-steps p { margin:.3rem 0 0; font-size:.85rem; color:var(--muted-foreground); }\n.recipe-launch { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }.recipe-launch form { margin:0 0 .6rem; }\n.recipe-history a { display:flex; justify-content:space-between; gap:1rem; padding:.6rem 0; border-bottom:1px solid var(--border); }.recipe-history span { color:var(--muted-foreground); }\n.recipe-error { border-color:var(--destructive); }\n.recipe-card[hidden] { display:none; }.recipe-search { max-width:28rem; }.recipe-question { border:1px solid var(--border); border-radius:calc(var(--radius) - 3px); margin:1rem 0; padding:.8rem; min-width:0; }.recipe-run { max-width:42rem; }.recipe-run button { margin-top:1rem; }\n@media(max-width:1000px){.recipe-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }}\n@media(max-width:760px){.recipe-grid,.recipe-options,.recipe-preview-grid,.recipe-launch { grid-template-columns:1fr; }.recipe-heading { margin-bottom:1rem; }.recipe-history a { flex-direction:column; gap:.2rem; }}\n";
package/dist/recipe-ui.js CHANGED
@@ -3,6 +3,8 @@ import { composerSchedule, scheduleEditorHtml, scheduleEditorScript } from "./ta
3
3
  import { parseRecipe, workflowSteps, recipeScheduleWords, RecipeError, RECIPE_INPUT_LIMIT } from "./recipes.js";
4
4
  import { ACCEPTANCE_LIMITS } from "./scope.js";
5
5
  import { html, joinHtml, postForm, replaceMarkup } from "./html.js";
6
+ import { templateRow } from "./flow-gallery-ui.js";
7
+ import { acts, btn, count, disclose, field, group, note, textarea } from "./server/kit.js";
6
8
  const hidden = (name, value) => html `<input type="hidden" name="${name}" value="${value}">`;
7
9
  const evidence = [["check", "Passing tests or commands"], ["changed-path", "Changed files"], ["screenshot", "Screenshot"], ["manual-review", "Reviewer assessment"]];
8
10
  export function recipeFromForm(body) {
@@ -53,10 +55,16 @@ export function recipeFromForm(body) {
53
55
  return parseRecipe({ format: "standing-orders-recipe", version: inputs.length ? 2 : 1, ...(inputs.length ? { inputs } : {}), name: body.get("name") ?? "", description: body.get("description") ?? "", goal: body.get("goal") ?? "", outOfScope: body.get("not") || null,
54
56
  touches: (body.get("touches") ?? "").split(/[\n,]/).map(one => one.trim()).filter(Boolean), acceptance, planning: body.get("planning") ?? "auto", deliverable: body.get("deliverable") ?? "branch", schedule: schedule.schedule, costCeilingUsd: ceiling === "" ? null : Number(ceiling) });
55
57
  }
56
- export function recipeLibraryHtml(starters, saved, project, revision, recent) {
57
- const cards = (recipes, reusable = false) => html `<div class="recipe-grid"${reusable && html ` data-saved-recipes`}>${recipes.map(recipe => html `<article class="recipe-card" data-recipe-card data-recipe-search="${recipe.document.name + " " + recipe.document.description}"><p class="recipe-kicker">${recipe.document.deliverable === "report" ? "Report" : recipe.document.schedule === null ? "One-time work" : "Recurring care"}${recipe.document.version === 2 ? ` · ${recipe.document.inputs.length} question${recipe.document.inputs.length === 1 ? "" : "s"}` : ""}</p><h3>${recipe.document.name}</h3><p>${recipe.document.description}</p><a class="recipe-card-link" href="/recipes/${reusable ? "run" : "start"}?recipe=${recipe.id}&revision=${recipe.revision}">${reusable ? "Use recipe" : "Customize recipe"} <span aria-hidden="true">→</span></a>${reusable && html `<a class="meta" href="/recipes/start?recipe=${recipe.id}&revision=${recipe.revision}">Edit a copy</a>`}</article>`)}</div>`;
58
- const savedSection = html `<h2>Your project’s recipes</h2>${saved.length >= 100 && html `<p class="meta">Showing 100 recipes, with recently used recipes first.</p>`}${saved.length ? html `<p class="hint">Your usual work, ready to go. Recently used recipes come first.</p><label class="recipe-search">Find a recipe<input type="search" data-recipe-search-input placeholder="Search your recipes…"></label>${cards(saved, true)}<p data-recipe-empty hidden>No recipes match. Try another search.</p>` : html `<p class="hint">Save work you do often. Subagents with project access can use the same recipe.</p>`}`;
59
- return html `<div class="recipe-heading"><p class="recipe-kicker">Workflows</p><h1>Good work, on repeat.</h1><p class="hint">Save the work you do often. Answer what changes this time. Kick it off.</p><p><a href="/recipes/new" class="new-task">Create a recipe</a> <a href="/flows">Manage scheduled work →</a></p></div>${project === null && html `<div class="card"><strong>Choose a project to get started.</strong><p>Browse recipes now; <a href="/projects">open a project</a> to customize, save, or run one.</p></div>`}${saved.length > 0 && savedSection}<h2>Start here</h2>${cards(starters)}${saved.length === 0 && savedSection}${recent.length > 0 && html `<h2>Recent workflows</h2><div class="card recipe-history">${recent.map(one => html `<a href="${one.href}"><strong>${one.name}</strong><span>${one.state} →</span></a>`)}</div>`}${project !== null && html `<details class="card"><summary>Import a shared recipe</summary><p class="hint">Paste exported recipe JSON. You’ll review it in this project before creating any work.</p>${postForm("/recipes/import", html `<label>Recipe JSON<textarea name="document" rows="7" required maxlength="32768" spellcheck="false"></textarea></label><button type="submit">Review imported recipe</button>`, { projectRevision: String(revision), hidden: { repo: project } })}</details>`}`;
58
+ /** "One-time work" (New flow): the open project's recipes and the starters as rows, each with one "Use this"; then
59
+ * Create a recipe, importing a shared one, and the work recipes made lately. */
60
+ export function recipeRowsHtml(starters, saved, project, projectName, revision, recent) {
61
+ const kind = (recipe) => `${recipe.document.deliverable === "report" ? "Report" : "Code change"} · ${recipe.document.schedule === null ? "runs once as a task" : "repeats on a schedule"}${recipe.document.version === 2 ? ` · ${recipe.document.inputs.length} question${recipe.document.inputs.length === 1 ? "" : "s"}` : ""}`;
62
+ const rowOf = (recipe, reusable) => templateRow({ name: recipe.document.name, promise: recipe.document.description, line: kind(recipe), attrs: html ` data-recipe-card data-recipe="${recipe.id}"`,
63
+ action: project === null ? "" : btn("Use this", { sm: true, href: `/recipes/${reusable ? "run" : "start"}?recipe=${recipe.id}&revision=${recipe.revision}`, attrs: { "aria-label": `Use ${recipe.document.name}` } }) });
64
+ const tools = project === null ? note("Open a project to use, save or import a recipe.")
65
+ : html `<p class="k-acts fl-recipe-acts">${btn("Create a recipe", { sm: true, href: "/recipes/new" })}</p>${disclose("Import a shared recipe", postForm("/recipes/import", html `${field({ id: "fl-recipe-json", label: "Recipe JSON", hint: "Paste exported recipe JSON. You review it in this project before any work is made.", control: textarea({ id: "fl-recipe-json", name: "document", rows: 6, attrs: { required: true, maxlength: 32768, spellcheck: "false" } }) })}${acts(btn("Review imported recipe", { type: "submit" }))}`, { projectRevision: String(revision), hidden: { repo: project }, attrs: { class: "fl-recipe-import" } }))}`;
66
+ const recentRows = recent.length === 0 ? "" : disclose(`Made from recipes lately (${recent.length})`, html `<div class="fl-recipe-recent">${recent.map(one => html `<a href="${one.href}"><span>${one.name}</span><span class="k-note">${one.state}</span></a>`)}</div>`);
67
+ return html `${group(projectName === null ? "One-time work" : `One-time work in ${projectName}`, count(saved.length + starters.length))}<div data-group="recipes">${saved.map(one => rowOf(one, true))}${starters.map(one => rowOf(one, false))}</div><div class="fl-recipe-more">${tools}${recentRows}</div>`;
60
68
  }
61
69
  function criterionHtml(index, criterion, values) {
62
70
  const prefix = `criterion-${index}-`;
@@ -137,6 +145,8 @@ export function recipeScript() {
137
145
  })();`;
138
146
  }
139
147
  export const RECIPE_CSS = `
148
+ .fl-recipe-more { display:grid; gap:12px; padding-top:12px; }.fl-recipe-more .k-disclose[open] > form, .fl-recipe-more .k-disclose[open] > div { margin-top:12px; }.fl-recipe-import { display:grid; gap:12px; margin:0; }
149
+ .fl-recipe-recent { display:grid; }.fl-recipe-recent a { display:flex; justify-content:space-between; gap:12px; min-height:40px; align-items:center; border-bottom:1px solid var(--so-line); color:var(--so-ink); text-decoration:none; font-size:13px; }
140
150
  .recipe-heading { margin: .5rem 0 1.75rem; max-width: 46rem; }
141
151
  .recipe-heading h1 { font-size: clamp(1.65rem,3vw,2.5rem); letter-spacing:-.035em; margin:.35rem 0 .6rem; }
142
152
  .recipe-kicker { font-size:.7rem; text-transform:uppercase; letter-spacing:.12em; color:var(--muted-foreground); }
@@ -189,3 +189,6 @@ export declare class ProtectionUnreadable extends Error {
189
189
  }
190
190
  /** Whether main's protection holds, as a yes/no (release-auto.ts sealAutoGreen): null when it can't be read. */
191
191
  export declare function readUpToDateRequired(run: Exec, gh: string, repo: string, branch?: string): Promise<boolean | null>;
192
+ /** The release's step record (store.ts release_step, v146) through a store opened for each entry: a failure to open or
193
+ * write is swallowed, so recording never stops or changes the release. */
194
+ export declare function releaseProgressRecorder(open: () => Store, done: (store: Store) => void): NonNullable<ReleaseAdapters["progress"]>;
@@ -19,7 +19,7 @@ import { PROTECTION_MINIMUM_CHECKS, PROTECTION_REPORT_ONLY_CHECKS } from "./rele
19
19
  import { activeUpdateWork } from "./desktop-update-gate.js";
20
20
  import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
21
21
  import { findCliLinks, switchCliLinks } from "./release-links.js";
22
- import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, releaseIssuerGone, takeReleaseLease, } from "./store.js";
22
+ import { databasePath, finishReleaseOperation, requireFreshReleaseApproval, openStore, releaseAuthorityOfTask, releaseCarries, releaseOperations, renewReleaseLease, reserveRelease, runReleaseOperation, Store, recordReleaseStep, releaseIssuerGone, takeReleaseLease, } from "./store.js";
23
23
  import { acquireDeploymentLock } from "./deploy-lock.js";
24
24
  import { autoReleaseApprovalOf, liveReleaseOf } from "./scope.js";
25
25
  import { recheckReleaseTask, releaseCheckStanding } from "./check-failure.js";
@@ -429,7 +429,7 @@ export function releaseAdapters(options) {
429
429
  // A boundary's one bounded read of GitHub's nightly: plain exec, never the release's long retries (20 s in all).
430
430
  githubReads(run, ciGh));
431
431
  return {
432
- ...(existsSync(database) ? { release } : {}),
432
+ ...(existsSync(database) ? { release, progress: releaseProgressRecorder(() => openStore(database), store => store.close()) } : {}),
433
433
  now: options.now ?? (() => new Date()),
434
434
  sleep: options.sleep ?? sleep,
435
435
  say,
@@ -1471,3 +1471,23 @@ function mainTriggered(run) {
1471
1471
  return bases.length > 0 && bases.every(base => base === "main");
1472
1472
  return false;
1473
1473
  }
1474
+ /** The release's step record (store.ts release_step, v146) through a store opened for each entry: a failure to open or
1475
+ * write is swallowed, so recording never stops or changes the release. */
1476
+ export function releaseProgressRecorder(open, done) {
1477
+ return entry => {
1478
+ let store = null;
1479
+ try {
1480
+ store = open();
1481
+ recordReleaseStep(store, entry);
1482
+ }
1483
+ catch { /* best effort: the release goes on */ }
1484
+ finally {
1485
+ if (store !== null) {
1486
+ try {
1487
+ done(store);
1488
+ }
1489
+ catch { /* best effort */ }
1490
+ }
1491
+ }
1492
+ };
1493
+ }
@@ -16,7 +16,8 @@ import { RELEASE_LIMITS, runRelease } from "./release.js";
16
16
  import { processes, releaseAdapters } from "./release-adapters.js";
17
17
  import { journals, releaseGate } from "./release-gate.js";
18
18
  import { rescueDeployment } from "./deploy-rescue.js";
19
- import { databasePath, openStore, revokeRelease } from "./store.js";
19
+ import { databasePath, elapsedSeconds, latestReleaseStepTask, openStore, releaseProgressFor, revokeRelease } from "./store.js";
20
+ import { dur } from "./design/kit.js";
20
21
  import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
21
22
  import { authenticateApprover, machineActing, personProof, proveCredential } from "./scope.js";
22
23
  import { allowAutoPublish, setReleaseApproval } from "./release-auto.js";
@@ -35,6 +36,12 @@ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itse
35
36
 
36
37
  Run the same command to resume after a stop. Interrupted deployments recover their saved stage before retrying.
37
38
 
39
+ A branch whose name is one of this command's own words (status, nightly, carry…) is named with --branch <name>
40
+ or after --: \`toolroll release --branch status\`.
41
+
42
+ toolroll release status [<task>] — the steps of a release (the newest one recorded, or the release task named): each
43
+ step's state and how long it took, and where it stopped
44
+
38
45
  toolroll release --release-gate <id> [--yes] — lift the update pause a deployment left before its swap
39
46
 
40
47
  Shows why it is safe first: the pause is that deployment's, its journal stopped before the swap, and the
@@ -98,7 +105,7 @@ A runner's lease is returned with \`toolroll worker release <lease>\`.`;
98
105
  /** A runner lease id (claim.ts mints randomUUID()). */
99
106
  export const LEASE_SHAPED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
100
107
  export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
101
- const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule", "--stage", "--lane-retire"]);
108
+ const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate", "--to", "--request", "--as", "--token", "--schedule", "--stage", "--lane-retire", "--branch"]);
102
109
  const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes", "--no-carry", "--start", "--recover-deploy", "--github"]);
103
110
  export async function runReleaseCommand(args, write, deps = {}) {
104
111
  // The flag, not a secret's value that happens to read "--json".
@@ -112,9 +119,23 @@ export async function runReleaseCommand(args, write, deps = {}) {
112
119
  const positional = [];
113
120
  const limits = {};
114
121
  let stage, laneRetire, laneAlias;
122
+ // `--branch <name>` (or `-- <name>`) names the branch outright, so a branch called "status", "nightly" or "carry" is
123
+ // still a branch and never one of this command's own words.
124
+ let branchFlag, literal = false, branchFlags = 0;
125
+ // Only what follows `--` is literal: it never changes how the words before it read.
126
+ const literalArgs = [];
115
127
  let repo, tap, gate, to, request, as, token, scheduleFlag;
116
128
  for (let i = 0; i < args.length; i += 1) {
117
129
  const raw = args[i];
130
+ // After `--`, every argument is a branch name, never a flag or a subcommand.
131
+ if (raw === "--" && !literal) {
132
+ literal = true;
133
+ continue;
134
+ }
135
+ if (literal) {
136
+ literalArgs.push(raw);
137
+ continue;
138
+ }
118
139
  if (BARE_FLAGS.has(raw))
119
140
  continue;
120
141
  // `--lane-alias <lane-id> <new name>`: two values, neither a flag of this command.
@@ -157,6 +178,10 @@ export async function runReleaseCommand(args, write, deps = {}) {
157
178
  stage = value;
158
179
  else if (arg === "--lane-retire")
159
180
  laneRetire = value;
181
+ else if (arg === "--branch") {
182
+ branchFlag = value;
183
+ branchFlags += 1;
184
+ }
160
185
  else {
161
186
  const match = /^([a-z]+)=([0-9]+)$/.exec(value);
162
187
  if (match === null || !(match[1] in RELEASE_LIMITS) || Number(match[2]) < 1)
@@ -174,12 +199,12 @@ export async function runReleaseCommand(args, write, deps = {}) {
174
199
  return 0;
175
200
  }
176
201
  if (gate !== undefined) {
177
- if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new"].includes(arg)))
202
+ if (positional.length > 0 || branchFlag !== undefined || literalArgs.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new"].includes(arg)))
178
203
  return refuse("usage", "--release-gate <id> takes no branch or release options.", 2);
179
204
  return runGateRelease(gate, bare.has("--yes"), write, json, deps.gateWorld ?? installedGateWorld());
180
205
  }
181
206
  if (bare.has("--recover-deploy")) {
182
- if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new", "--release-gate"].includes(arg.split("=")[0])))
207
+ if (positional.length > 0 || branchFlag !== undefined || literalArgs.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new", "--release-gate"].includes(arg.split("=")[0])))
183
208
  return refuse("usage", "--recover-deploy takes only --stage <dir>, --yes and --json.", 2);
184
209
  return runRescue(stage, bare.has("--yes"), write, json, deps.rescueWorld ?? installedRescueWorld(deps.cwd ?? process.cwd()));
185
210
  }
@@ -194,15 +219,29 @@ export async function runReleaseCommand(args, write, deps = {}) {
194
219
  return refuse("usage", "Use one of --github, --lane-alias or --lane-retire, without --schedule or --start.", 2);
195
220
  return runGithubNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, alias: laneAlias, retire: laneRetire }, write, json, deps);
196
221
  }
197
- if (positional[0] === "nightly" && positional.length === 1)
222
+ const named = branchFlag !== undefined || literalArgs.length > 0;
223
+ // One branch, named one way: never a guess between two.
224
+ if (branchFlags > 1 || literalArgs.length > 1 || (branchFlag !== undefined && (positional.length > 0 || literalArgs.length > 0)) || (literalArgs.length > 0 && positional.length > 0)) {
225
+ return refuse("usage", "Name the branch once: `toolroll release <branch>`, `--branch <name>` or `-- <name>`, never two of them.", 2);
226
+ }
227
+ if (branchFlag !== undefined && branchFlag === "")
228
+ return refuse("usage", "--branch takes a branch name.", 2);
229
+ // `release status [<task>]`: the steps recorded for a release. A branch named "status" is released with
230
+ // `--branch status` (or `-- status`).
231
+ if (!named && positional[0] === "status" && positional.length <= 2) {
232
+ if (args.some(arg => VALUE_FLAGS.has(arg.split("=")[0]) || ["--new", "--no-carry", "--start"].includes(arg)))
233
+ return refuse("usage", "`toolroll release status [<task>]` takes only --json.", 2);
234
+ return runStatusCommand(positional[1], write, json, deps);
235
+ }
236
+ if (!named && positional[0] === "nightly" && positional.length === 1)
198
237
  return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: bare.has("--start") }, write, json, deps);
199
- if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
238
+ if (!named && ["carry", "revoke", "ask-every-commit", "unsandboxed-carry", "auto-approve", "publish-ok"].includes(positional[0] ?? "") && positional.length === 2) {
200
239
  return runAuthorityCommand(positional[0], positional[1], { to, request, as, token, repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo) }, write, json, deps);
201
240
  }
202
- if (positional.length !== 1)
241
+ if (!named && positional.length !== 1)
203
242
  return refuse("usage", `Which branch? \`toolroll release <branch>\`.\n${RELEASE_HELP}`, 2);
204
- const branch = positional[0];
205
- if (LEASE_SHAPED.test(branch)) {
243
+ const branch = branchFlag ?? literalArgs[0] ?? positional[0];
244
+ if (!named && LEASE_SHAPED.test(branch)) {
206
245
  const releaseOptions = args.filter(arg => VALUE_FLAGS.has(arg) || arg === "--new");
207
246
  if (releaseOptions.length > 0)
208
247
  return refuse("ambiguous", `${branch} looks like a lease id, but ${releaseOptions[0]} is a release option. Return a lease with \`toolroll worker release <lease>\`; a branch shaped like a lease id can't be released.`, 2);
@@ -519,3 +558,36 @@ async function runGithubNightlyCommand(flags, write, json, deps) {
519
558
  store.close();
520
559
  }
521
560
  }
561
+ /** `toolroll release status [<task>]` (read-only): the release's recorded steps (store.ts release_step, v146). */
562
+ async function runStatusCommand(task, write, json, deps) {
563
+ const command = "release status";
564
+ const refuse = (reason, message, code) => { write(json ? checkedEnvelopeJson({ ok: false, command, reason, message }) : message); return code; };
565
+ const database = deps.database ?? databasePath(process.env, homedir());
566
+ if (!existsSync(database))
567
+ return refuse("no-plane", "This machine has no Toolroll database.", 1);
568
+ const store = openStore(database);
569
+ try {
570
+ const now = (deps.now ?? (() => new Date()))();
571
+ const taskId = task ?? latestReleaseStepTask(store);
572
+ if (taskId === null)
573
+ return refuse("no-release", "No release has recorded its steps yet.", 1);
574
+ const progress = releaseProgressFor(store, taskId);
575
+ if (progress === null)
576
+ return refuse("no-release", `No release steps are recorded for ${taskId}.`, 1);
577
+ const steps = progress.steps.map(one => ({ ...one, seconds: elapsedSeconds(one.startedAt, one.endedAt, now) }));
578
+ if (json) {
579
+ write(checkedEnvelopeJson({ ok: true, command, task: taskId, release: { ...progress, steps } }));
580
+ return 0;
581
+ }
582
+ const word = { running: "running", waiting: "waiting", done: "done", failed: "failed" };
583
+ write(`Release ${progress.version} (${progress.tag})${progress.pr === null ? "" : ` · pull request #${progress.pr}`} · task ${taskId}`);
584
+ for (const one of steps)
585
+ write(` ${one.step.padEnd(9)} ${word[one.state].padEnd(8)} ${one.seconds === null ? "–" : dur(one.seconds)}${one.detail === null ? "" : ` ${one.detail}`}`);
586
+ if (progress.stopped !== null)
587
+ write(`Stopped at ${progress.stopped.step}: ${progress.stopped.message}`);
588
+ return 0;
589
+ }
590
+ finally {
591
+ store.close();
592
+ }
593
+ }
package/dist/release.d.ts CHANGED
@@ -48,6 +48,13 @@ export declare const REQUIRED_PR_CHECKS_WORKFLOW: Readonly<Record<typeof REQUIRE
48
48
  export declare const PROTECTION_REPORT_ONLY_CHECKS: readonly string[];
49
49
  /** The checks main's protection must require at minimum: every required check that isn't report-only. */
50
50
  export declare const PROTECTION_MINIMUM_CHECKS: readonly string[];
51
+ /**
52
+ * Pull-request checks a release never waits for or fails on, by exact name and the workflow file that defines them:
53
+ * youiyoux's UI drift check (src/design/KIT.md), which fails a pull request that adds design drift but says nothing
54
+ * about whether a build is safe to release. Only a GitHub Actions run of that name from that workflow is left out; the
55
+ * same name from anywhere else is judged like any other check. Never a required check.
56
+ */
57
+ export declare const ADVISORY_PR_CHECKS: Readonly<Record<string, string>>;
51
58
  /**
52
59
  * Main's protection as the plane's gh reads it (release-adapters.ts readMainProtection): `effective` only when one layer
53
60
  * (classic protection or one active ruleset) requires up-to-date branches, binds every PROTECTION_MINIMUM_CHECKS check to GitHub Actions,
@@ -393,6 +400,18 @@ export type AutoReleaseAdapter = {
393
400
  export type ReleaseAdapters = {
394
401
  /** One approval per release (release-once.md); absent on a plane that can't carry. */
395
402
  release?: ReleaseAuthorityAdapter;
403
+ /** Where each step's start, end and outcome are recorded (store.ts release_step, v146) for the Release view and
404
+ * `toolroll release status`. Best effort: a failure here never stops or changes the release. */
405
+ progress?: (entry: {
406
+ taskId: string;
407
+ tag: string;
408
+ version: string;
409
+ pr: number | null;
410
+ step: ReleaseStep;
411
+ state: "running" | "done" | "failed" | "waiting";
412
+ at: Date;
413
+ detail?: string | null;
414
+ }) => void;
396
415
  now(): Date;
397
416
  sleep(ms: number): Promise<void>;
398
417
  say(line: string): void;
@@ -731,4 +750,6 @@ export declare function checkFailedWords(input: {
731
750
  }): string;
732
751
  /** Whether GitHub's workflow-run `path` names exactly `file` (it may carry the ref it ran from, after an `@`). */
733
752
  export declare function workflowIs(path: string | null | undefined, file: string): boolean;
753
+ /** How a person names this branch to `toolroll release` again. */
754
+ export declare function releaseBranchArg(branch: string): string;
734
755
  export {};