toolroll 0.9.54 → 0.9.55

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 (199) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-polish.d.ts +5 -4
  19. package/dist/chat-polish.js +9 -10
  20. package/dist/check-levels-ui.d.ts +2 -1
  21. package/dist/check-levels-ui.js +26 -53
  22. package/dist/cli.d.ts +3 -1
  23. package/dist/cli.js +9 -3
  24. package/dist/coding-shipping-ui.d.ts +2 -1
  25. package/dist/coding-shipping-ui.js +6 -6
  26. package/dist/coding-ui.d.ts +3 -1
  27. package/dist/coding-ui.js +30 -29
  28. package/dist/contracts/cli.d.ts +1 -3
  29. package/dist/contracts/console-api.d.ts +1 -1
  30. package/dist/contracts/console-api.js +6 -5
  31. package/dist/contracts/health.d.ts +1 -3
  32. package/dist/control-ui.d.ts +5 -4
  33. package/dist/control-ui.js +9 -34
  34. package/dist/credentials-ui.d.ts +5 -4
  35. package/dist/credentials-ui.js +23 -24
  36. package/dist/demo-chat.d.ts +3 -3
  37. package/dist/demo-chat.js +29 -76
  38. package/dist/discord-settings.d.ts +3 -2
  39. package/dist/discord-settings.js +17 -42
  40. package/dist/envelope.js +10 -0
  41. package/dist/evidence-pack.d.ts +2 -1
  42. package/dist/evidence-pack.js +45 -49
  43. package/dist/export-ui.d.ts +7 -2
  44. package/dist/export-ui.js +4 -10
  45. package/dist/flow-gallery-ui.d.ts +4 -3
  46. package/dist/flow-gallery-ui.js +28 -43
  47. package/dist/flow-starters-ui.d.ts +2 -1
  48. package/dist/flow-starters-ui.js +12 -15
  49. package/dist/flow-triggers.d.ts +1 -0
  50. package/dist/flow-triggers.js +16 -14
  51. package/dist/flows-ui.d.ts +4 -3
  52. package/dist/flows-ui.js +19 -30
  53. package/dist/flows.js +2 -1
  54. package/dist/guides.js +108 -3
  55. package/dist/html.d.ts +76 -0
  56. package/dist/html.js +173 -0
  57. package/dist/integrations-ui.d.ts +2 -1
  58. package/dist/integrations-ui.js +19 -23
  59. package/dist/kits-ui.d.ts +5 -4
  60. package/dist/kits-ui.js +18 -20
  61. package/dist/knowledge-ui.d.ts +6 -5
  62. package/dist/knowledge-ui.js +26 -25
  63. package/dist/lead-context.d.ts +11 -1
  64. package/dist/lead-context.js +6 -8
  65. package/dist/ledger-view.d.ts +2 -1
  66. package/dist/ledger-view.js +27 -29
  67. package/dist/limits-ui.d.ts +2 -1
  68. package/dist/limits-ui.js +7 -7
  69. package/dist/live-bus.d.ts +50 -8
  70. package/dist/live-bus.js +133 -50
  71. package/dist/mcp-oauth.js +35 -36
  72. package/dist/models-ui.d.ts +2 -1
  73. package/dist/models-ui.js +25 -35
  74. package/dist/monitoring-ui.d.ts +4 -3
  75. package/dist/monitoring-ui.js +13 -26
  76. package/dist/openrouter-models.d.ts +2 -1
  77. package/dist/openrouter-models.js +5 -9
  78. package/dist/operate.js +11 -7
  79. package/dist/people-audit-ui.d.ts +2 -1
  80. package/dist/people-audit-ui.js +13 -13
  81. package/dist/policy-ui.d.ts +3 -2
  82. package/dist/policy-ui.js +17 -23
  83. package/dist/project-delete-ui.d.ts +4 -3
  84. package/dist/project-delete-ui.js +15 -27
  85. package/dist/pull-request-ui.d.ts +2 -1
  86. package/dist/pull-request-ui.js +17 -32
  87. package/dist/recipe-ui.d.ts +8 -7
  88. package/dist/recipe-ui.js +39 -32
  89. package/dist/release-adapters.d.ts +60 -0
  90. package/dist/release-adapters.js +435 -0
  91. package/dist/release-cli.d.ts +24 -0
  92. package/dist/release-cli.js +181 -0
  93. package/dist/release-gate.d.ts +80 -0
  94. package/dist/release-gate.js +150 -0
  95. package/dist/release-links.d.ts +29 -0
  96. package/dist/release-links.js +102 -0
  97. package/dist/release.d.ts +331 -0
  98. package/dist/release.js +550 -0
  99. package/dist/reply-shape.d.ts +2 -1
  100. package/dist/reply-shape.js +5 -5
  101. package/dist/repository-context-ui.d.ts +2 -1
  102. package/dist/repository-context-ui.js +2 -7
  103. package/dist/request-budget-ui.d.ts +2 -1
  104. package/dist/request-budget-ui.js +16 -16
  105. package/dist/result-review.d.ts +2 -3
  106. package/dist/result-review.js +3 -9
  107. package/dist/retention-ui.d.ts +7 -1
  108. package/dist/retention-ui.js +7 -11
  109. package/dist/serve.d.ts +11 -2
  110. package/dist/serve.js +69 -58
  111. package/dist/server/chat.d.ts +3 -6
  112. package/dist/server/chat.js +969 -1006
  113. package/dist/server/chrome.d.ts +291 -0
  114. package/dist/server/chrome.js +747 -0
  115. package/dist/server/console.css +2819 -0
  116. package/dist/server/flows.d.ts +2 -6
  117. package/dist/server/flows.js +840 -766
  118. package/dist/server/guards.d.ts +1 -1
  119. package/dist/server/guards.js +5 -2
  120. package/dist/server/handler-context.d.ts +1 -1
  121. package/dist/server/handler-context.js +1 -1
  122. package/dist/server/handler-registry.d.ts +8 -6
  123. package/dist/server/handler-registry.js +14 -2
  124. package/dist/server/http.d.ts +274 -0
  125. package/dist/server/http.js +239 -0
  126. package/dist/server/live.d.ts +34 -0
  127. package/dist/server/live.js +309 -0
  128. package/dist/server/pages.d.ts +2 -6
  129. package/dist/server/pages.js +808 -789
  130. package/dist/server/people-tokens.d.ts +1 -6
  131. package/dist/server/people-tokens.js +580 -572
  132. package/dist/server/remote.d.ts +4 -7
  133. package/dist/server/remote.js +37 -70
  134. package/dist/server/render-chat.d.ts +402 -0
  135. package/dist/server/render-chat.js +927 -0
  136. package/dist/server/render-pages.d.ts +535 -0
  137. package/dist/server/render-pages.js +1277 -0
  138. package/dist/server/render-people.d.ts +32 -0
  139. package/dist/server/render-people.js +121 -0
  140. package/dist/server/render-results.d.ts +744 -0
  141. package/dist/server/render-results.js +2237 -0
  142. package/dist/server/render-settings.d.ts +147 -0
  143. package/dist/server/render-settings.js +577 -0
  144. package/dist/server/render-tasks.d.ts +632 -0
  145. package/dist/server/render-tasks.js +2345 -0
  146. package/dist/server/request-context.d.ts +46 -0
  147. package/dist/server/request-context.js +17 -0
  148. package/dist/server/route-table.d.ts +25 -3
  149. package/dist/server/route-table.js +163 -75
  150. package/dist/server/runtime.d.ts +15 -7
  151. package/dist/server/runtime.js +7 -1
  152. package/dist/server/session.d.ts +138 -0
  153. package/dist/server/session.js +94 -0
  154. package/dist/server/settings.d.ts +1 -5
  155. package/dist/server/settings.js +2181 -2117
  156. package/dist/server/task-views.d.ts +11 -4
  157. package/dist/server/task-views.js +26 -22
  158. package/dist/server/tasks.d.ts +9 -9
  159. package/dist/server/tasks.js +1486 -1418
  160. package/dist/server-telemetry.d.ts +4 -3
  161. package/dist/server-telemetry.js +4 -8
  162. package/dist/skills-ui.d.ts +4 -3
  163. package/dist/skills-ui.js +14 -17
  164. package/dist/skills.d.ts +1 -1
  165. package/dist/skills.js +2 -1
  166. package/dist/slack-settings.d.ts +3 -2
  167. package/dist/slack-settings.js +17 -42
  168. package/dist/spend-ui.d.ts +2 -1
  169. package/dist/spend-ui.js +9 -32
  170. package/dist/sso-ui.d.ts +3 -2
  171. package/dist/sso-ui.js +26 -27
  172. package/dist/storage-ui.d.ts +2 -1
  173. package/dist/storage-ui.js +14 -33
  174. package/dist/surface.js +14 -3
  175. package/dist/task-composer.d.ts +2 -1
  176. package/dist/task-composer.js +7 -7
  177. package/dist/task-status.d.ts +4 -3
  178. package/dist/task-status.js +15 -16
  179. package/dist/team-http.d.ts +5 -9
  180. package/dist/team-http.js +5 -63
  181. package/dist/team-ui.d.ts +2 -1
  182. package/dist/team-ui.js +2 -5
  183. package/dist/teammates-ui.d.ts +5 -4
  184. package/dist/teammates-ui.js +61 -90
  185. package/dist/teams-settings.d.ts +3 -2
  186. package/dist/teams-settings.js +20 -31
  187. package/dist/telegram-settings.d.ts +3 -2
  188. package/dist/telegram-settings.js +14 -25
  189. package/dist/toolroll-update-ui.d.ts +3 -2
  190. package/dist/toolroll-update-ui.js +35 -39
  191. package/dist/tools-ui.d.ts +5 -2
  192. package/dist/tools-ui.js +31 -31
  193. package/dist/when-html.d.ts +11 -3
  194. package/dist/when-html.js +5 -6
  195. package/dist/workspace-ui.d.ts +4 -2
  196. package/dist/workspace-ui.js +10 -10
  197. package/package.json +1 -1
  198. package/dist/server/shared.d.ts +0 -3151
  199. package/dist/server/shared.js +0 -12001
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `toolroll release <branch>` (release.ts): the command line around the release coordinator.
3
+ *
4
+ * `release <lease>` used to return a runner's lease; that is `toolroll worker release <lease>` now. The old form still
5
+ * works for a value shaped like a lease id (a UUID), with a one-line warning, until the next minor version. A
6
+ * UUID-shaped value given with release options is refused rather than guessed at.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { createRequire } from "node:module";
12
+ // Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
13
+ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
14
+ import { checkedEnvelopeJson } from "./contracts/cli.js";
15
+ import { RELEASE_LIMITS, runRelease } from "./release.js";
16
+ import { processes, releaseAdapters } from "./release-adapters.js";
17
+ import { releaseGate } from "./release-gate.js";
18
+ import { databasePath } from "./store.js";
19
+ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
20
+
21
+ Requires an active approver's scope approval (the only human step) and a passing Full check on the exact commit.
22
+ Completes that result, drains, backs up, deploys and relinks both CLI names to that commit. After PR checks pass,
23
+ squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
24
+ publication; Homebrew waits for its checks to register and all pass before merging.
25
+
26
+ Run the same command to resume after a stop. Interrupted deployments recover their saved stage before retrying.
27
+
28
+ toolroll release --release-gate <id> [--yes] — lift the update pause a deployment left before its swap
29
+
30
+ Shows why it is safe first: the pause is that deployment's, its journal stopped before the swap, and the
31
+ database, service definition and running service are all still the previous build's. --yes lifts that one pause.
32
+ Any missing or contradicting evidence refuses and leaves the pause in place.
33
+
34
+ Options
35
+ --repo <path> the gate checkout the release check runs in (default: this checkout)
36
+ --new release a new commit on the branch, setting the earlier unfinished release aside
37
+ --limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
38
+ --tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
39
+ --release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
40
+ --yes with --release-gate: lift the pause once it is proved safe
41
+ --json answer with one machine envelope
42
+
43
+ A runner's lease is returned with \`toolroll worker release <lease>\`.`;
44
+ /** A runner lease id (claim.ts mints randomUUID()). */
45
+ 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;
46
+ export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
47
+ const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate"]);
48
+ const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes"]);
49
+ export async function runReleaseCommand(args, write, deps = {}) {
50
+ const json = args.includes("--json");
51
+ const refuse = (reason, message, code) => {
52
+ write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason, message }) : message);
53
+ return code;
54
+ };
55
+ const positional = [];
56
+ const limits = {};
57
+ let repo, tap, gate;
58
+ for (let i = 0; i < args.length; i += 1) {
59
+ const arg = args[i];
60
+ if (BARE_FLAGS.has(arg))
61
+ continue;
62
+ if (VALUE_FLAGS.has(arg)) {
63
+ const value = args[i + 1];
64
+ if (value === undefined || value.startsWith("--"))
65
+ return refuse("usage", `${arg} takes a value.`, 2);
66
+ i += 1;
67
+ if (arg === "--repo")
68
+ repo = value;
69
+ else if (arg === "--tap")
70
+ tap = value;
71
+ else if (arg === "--release-gate")
72
+ gate = value;
73
+ else {
74
+ const match = /^([a-z]+)=([0-9]+)$/.exec(value);
75
+ if (match === null || !(match[1] in RELEASE_LIMITS) || Number(match[2]) < 1)
76
+ return refuse("usage", `--limit takes <step>=<minutes> for one of: ${Object.keys(RELEASE_LIMITS).join(", ")}.`, 2);
77
+ limits[match[1]] = Number(match[2]);
78
+ }
79
+ continue;
80
+ }
81
+ if (arg.startsWith("--"))
82
+ return refuse("usage", `Unknown option ${arg}.\n${RELEASE_HELP}`, 2);
83
+ positional.push(arg);
84
+ }
85
+ if (args.includes("--help")) {
86
+ write(RELEASE_HELP);
87
+ return 0;
88
+ }
89
+ if (gate !== undefined) {
90
+ if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new"].includes(arg)))
91
+ return refuse("usage", "--release-gate <id> takes no branch or release options.", 2);
92
+ return runGateRelease(gate, args.includes("--yes"), write, json, deps.gateWorld ?? installedGateWorld());
93
+ }
94
+ if (args.includes("--yes"))
95
+ return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
96
+ if (positional.length !== 1)
97
+ return refuse("usage", `Which branch? \`toolroll release <branch>\`.\n${RELEASE_HELP}`, 2);
98
+ const branch = positional[0];
99
+ if (LEASE_SHAPED.test(branch)) {
100
+ const releaseOptions = args.filter(arg => VALUE_FLAGS.has(arg) || arg === "--new");
101
+ if (releaseOptions.length > 0)
102
+ 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);
103
+ (deps.warn ?? (line => process.stderr.write(`${line}\n`)))(LEASE_DEPRECATION);
104
+ if (deps.releaseLease === undefined)
105
+ return refuse("usage", "This build can't return leases from here: use `toolroll worker release <lease>`.", 2);
106
+ return deps.releaseLease(args);
107
+ }
108
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith("-") || branch.includes(".."))
109
+ return refuse("usage", `${branch} is not a branch name this command takes.`, 2);
110
+ const checkout = resolve(deps.cwd ?? process.cwd(), repo ?? ".");
111
+ const stateDir = deps.stateDir ?? dirname(databasePath(process.env, homedir()));
112
+ const lines = [];
113
+ const say = (line) => { if (json)
114
+ lines.push(line);
115
+ else
116
+ write(line); };
117
+ const adapters = deps.adapters === undefined
118
+ ? releaseAdapters({ checkout, say, ...(tap === undefined ? {} : { tap }), ...(limits["deploy"] === undefined ? {} : { deployMinutes: limits["deploy"] }) })
119
+ : { ...deps.adapters, say };
120
+ const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: args.includes("--new") }, adapters);
121
+ return report(outcome, write, json, lines);
122
+ }
123
+ function report(outcome, write, json, lines) {
124
+ const release = outcome.journal === null ? null : {
125
+ branch: outcome.journal.branch, candidate: outcome.journal.candidate.sha, tree: outcome.journal.candidate.tree, version: outcome.journal.packageVersion,
126
+ tag: outcome.journal.tag, pullRequest: outcome.journal.pr, task: outcome.journal.task.id, step: outcome.journal.step,
127
+ merge: outcome.journal.merge?.sha ?? null, runtime: outcome.journal.deploy?.runtime ?? null,
128
+ };
129
+ if (outcome.ok) {
130
+ if (json)
131
+ write(checkedEnvelopeJson({ ok: true, command: "release", release, journal: outcome.file, said: lines }));
132
+ return 0;
133
+ }
134
+ if (json) {
135
+ write(checkedEnvelopeJson({ ok: false, command: "release", reason: outcome.reason, message: outcome.message, step: outcome.step, release, journal: outcome.file, resume: outcome.resume, said: lines }));
136
+ }
137
+ else {
138
+ write(`✗ Stopped at ${outcome.step}: ${outcome.message}`);
139
+ write(` Continue from there: ${outcome.resume}`);
140
+ }
141
+ // A usage slip or refusal before anything began is 2; a stop along the way is 3 ("no", not "broken"), as elsewhere.
142
+ return outcome.reason === "usage" ? 2 : 3;
143
+ }
144
+ /** The installation's live database, LaunchAgents and processes, for --release-gate. */
145
+ function installedGateWorld() {
146
+ const database = databasePath(process.env, homedir());
147
+ const agents = join(homedir(), "Library", "LaunchAgents");
148
+ return {
149
+ database, stateDir: dirname(database), now: () => new Date(), processes,
150
+ open: () => {
151
+ const db = new (sqlite().DatabaseSync)(database);
152
+ db.exec("PRAGMA busy_timeout=5000");
153
+ return db;
154
+ },
155
+ servicePlists: () => ["com.toolroll.browser", "com.standing-orders.browser"].map(label => join(agents, `${label}.plist`))
156
+ .filter(path => existsSync(path)).map(path => ({ path, text: readFileSync(path, "utf8") })),
157
+ };
158
+ }
159
+ function runGateRelease(id, yes, write, json, world) {
160
+ let outcome;
161
+ try {
162
+ outcome = releaseGate(id, world, yes);
163
+ }
164
+ catch (error) {
165
+ outcome = { ok: false, reason: "error", message: `${error.message} The update pause was left as it is.` };
166
+ }
167
+ if (!outcome.ok) {
168
+ write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason: outcome.reason, message: outcome.message }) : `✗ ${outcome.message}`);
169
+ return outcome.reason === "usage" ? 2 : 3;
170
+ }
171
+ if (json) {
172
+ write(checkedEnvelopeJson({ ok: true, command: "release", gate: { id: outcome.id, released: outcome.released, stage: outcome.stage, proof: outcome.proof } }));
173
+ return 0;
174
+ }
175
+ write(outcome.released ? `✓ Lifted update pause ${outcome.id}: new work can start again.` : `Update pause ${outcome.id} was left before its deployment's swap:`);
176
+ for (const line of outcome.proof)
177
+ write(` • ${line}`);
178
+ if (!outcome.released)
179
+ write(`Lift it: toolroll release --release-gate ${outcome.id} --yes`);
180
+ return 0;
181
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
3
+ * left behind when it stopped before the swap and its own recovery could not tell. The typical case is a deployment
4
+ * killed between installing the pause and journaling that it had: the journal still says `preparing`, so
5
+ * `--phase recover` leaves the pause alone, and new work stays paused.
6
+ *
7
+ * The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
8
+ * evidence agrees that the service was never swapped:
9
+ *
10
+ * - the live database's update pause is owned by exactly this id
11
+ * - exactly one intact deployment journal names this id and this database, at a phase before the swap, with
12
+ * nothing recorded that only the swap writes (stopping, migration, the new service), and no new service definition
13
+ * staged
14
+ * - the live database is still at the schema that journal recorded
15
+ * - the service definition still names the runtime the journal started from (and is the one it saved), never the
16
+ * staged one
17
+ * - that runtime's service is running, nothing runs from the staged runtime, and no deployment is still running
18
+ *
19
+ * Anything missing, damaged or contradicting refuses and leaves the pause as it is. Without --yes it only shows the
20
+ * proof; with --yes it lifts that one pause (the owner is checked again inside the same write) and journals it.
21
+ */
22
+ import type { Database } from "./store.js";
23
+ /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
24
+ export declare const BEFORE_SWAP: readonly string[];
25
+ export type GateReleaseWorld = {
26
+ /** The live database file. */
27
+ database: string;
28
+ /** The installation's state folder: deployments stage under staged-upgrades/ there. */
29
+ stateDir: string;
30
+ /** The live database, opened without migrating it. */
31
+ open(): Database;
32
+ /** The installed browser service definitions (LaunchAgents), path and text. */
33
+ servicePlists(): {
34
+ path: string;
35
+ text: string;
36
+ }[];
37
+ /** Every process on this machine: pid and full command line. */
38
+ processes(): {
39
+ pid: number;
40
+ command: string;
41
+ }[];
42
+ now(): Date;
43
+ };
44
+ type Journal = Record<string, unknown> & {
45
+ id: string;
46
+ phase: string;
47
+ database: string;
48
+ schema: number;
49
+ priorRuntime: string;
50
+ nextRuntime: string;
51
+ };
52
+ export type GateProof = {
53
+ ok: true;
54
+ id: string;
55
+ stage: string;
56
+ journal: Journal;
57
+ proof: string[];
58
+ } | {
59
+ ok: false;
60
+ reason: string;
61
+ message: string;
62
+ };
63
+ /** The owner id written into the live pause's triggers, when there is one pause and it names one id. */
64
+ export declare function gateOwner(db: Database): string | null;
65
+ /** Whether the pause owned by `id` was left by a deployment that never began its swap, and the evidence that says so. */
66
+ export declare function proveBeforeSwap(id: string, world: GateReleaseWorld): GateProof;
67
+ export type GateReleaseOutcome = {
68
+ ok: true;
69
+ released: boolean;
70
+ id: string;
71
+ stage: string;
72
+ proof: string[];
73
+ } | {
74
+ ok: false;
75
+ reason: string;
76
+ message: string;
77
+ };
78
+ /** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
79
+ export declare function releaseGate(id: string, world: GateReleaseWorld, yes: boolean): GateReleaseOutcome;
80
+ export {};
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `toolroll release --release-gate <id>`: lift one update pause that a browser deployment (scripts/deploy-browser.mjs)
3
+ * left behind when it stopped before the swap and its own recovery could not tell. The typical case is a deployment
4
+ * killed between installing the pause and journaling that it had: the journal still says `preparing`, so
5
+ * `--phase recover` leaves the pause alone, and new work stays paused.
6
+ *
7
+ * The id alone proves nothing, and neither does the journal's phase. The pause is lifted only when every piece of
8
+ * evidence agrees that the service was never swapped:
9
+ *
10
+ * - the live database's update pause is owned by exactly this id
11
+ * - exactly one intact deployment journal names this id and this database, at a phase before the swap, with
12
+ * nothing recorded that only the swap writes (stopping, migration, the new service), and no new service definition
13
+ * staged
14
+ * - the live database is still at the schema that journal recorded
15
+ * - the service definition still names the runtime the journal started from (and is the one it saved), never the
16
+ * staged one
17
+ * - that runtime's service is running, nothing runs from the staged runtime, and no deployment is still running
18
+ *
19
+ * Anything missing, damaged or contradicting refuses and leaves the pause as it is. Without --yes it only shows the
20
+ * proof; with --yes it lifts that one pause (the owner is checked again inside the same write) and journals it.
21
+ */
22
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { durableJson } from "./desktop-update.js";
25
+ import { removeUpdateGate, updateGateOwned } from "./desktop-update-gate.js";
26
+ /** The journal phases a deployment passes before it stops the old service (deploy-phases.mjs). */
27
+ export const BEFORE_SWAP = Object.freeze(["preparing", "admission-paused", "frozen", "backup-verified", "rehearsed"]);
28
+ /** Journal fields that only the swap and later phases write. */
29
+ const SWAP_FIELDS = ["stoppingService", "codingBackupBeforeSwap", "migration", "newService", "deployedAt"];
30
+ const GATE_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
31
+ const refuse = (reason, message) => ({ ok: false, reason, message: `${message} The update pause was left as it is.` });
32
+ /** The owner id written into the live pause's triggers, when there is one pause and it names one id. */
33
+ export function gateOwner(db) {
34
+ const rows = db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name GLOB 'so_desktop_update_*'").all();
35
+ const ids = new Set(rows.map(row => /\[([a-f0-9-]{36})\]'\)/.exec(String(row["sql"]))?.[1] ?? "?"));
36
+ return ids.size === 1 ? [...ids][0] : null;
37
+ }
38
+ /** Every deployment journal under staged-upgrades/: the stage it is in and what it holds, or why it can't be read. */
39
+ function journals(stateDir) {
40
+ const root = join(stateDir, "staged-upgrades");
41
+ let stages = [];
42
+ try {
43
+ stages = readdirSync(root, { withFileTypes: true }).filter(one => one.isDirectory()).map(one => join(root, one.name));
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ return stages.filter(stage => existsSync(join(stage, "deployment.json"))).map(stage => {
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(join(stage, "deployment.json"), "utf8"));
51
+ return { stage, journal: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null };
52
+ }
53
+ catch {
54
+ return { stage, journal: null };
55
+ }
56
+ });
57
+ }
58
+ /** Whether the pause owned by `id` was left by a deployment that never began its swap, and the evidence that says so. */
59
+ export function proveBeforeSwap(id, world) {
60
+ if (!GATE_ID.test(id))
61
+ return refuse("usage", `${id} is not an update id.`);
62
+ const proof = [];
63
+ let owner, owned, schema;
64
+ const db = world.open();
65
+ try {
66
+ owner = gateOwner(db);
67
+ owned = updateGateOwned(db, id);
68
+ schema = Number(db.prepare("SELECT version FROM schema_version").get()?.["version"]);
69
+ }
70
+ finally {
71
+ db.close();
72
+ }
73
+ if (owner === null)
74
+ return refuse("not-paused", "No single update pause is held on the live database.");
75
+ if (owner !== id || !owned)
76
+ return refuse("not-owner", `The live update pause belongs to ${owner}, not ${id}.`);
77
+ proof.push(`The live database's update pause belongs to ${id}.`);
78
+ const all = journals(world.stateDir);
79
+ const mine = all.filter(one => one.journal?.["id"] === id);
80
+ if (mine.length > 1)
81
+ return refuse("conflict", `${mine.length} deployment journals name ${id}: ${mine.map(one => one.stage).join(", ")}.`);
82
+ if (mine.length === 0) {
83
+ const damaged = all.filter(one => one.journal === null);
84
+ return refuse(damaged.length > 0 ? "damaged" : "no-journal", damaged.length > 0
85
+ ? `No readable deployment journal names ${id}, and ${damaged.map(one => join(one.stage, "deployment.json")).join(", ")} can't be read; it may be that deployment's.`
86
+ : `No deployment journal under ${join(world.stateDir, "staged-upgrades")} names ${id}; this pause was not left by a browser deployment.`);
87
+ }
88
+ const { stage, journal: raw } = mine[0];
89
+ const r = raw;
90
+ if (typeof r.phase !== "string" || typeof r.priorRuntime !== "string" || typeof r.nextRuntime !== "string" || typeof r.schema !== "number" || typeof r.database !== "string") {
91
+ return refuse("damaged", `${join(stage, "deployment.json")} is missing its phase, runtimes, schema or database.`);
92
+ }
93
+ if (r.database !== world.database)
94
+ return refuse("conflict", `That deployment ran on ${r.database}, not ${world.database}.`);
95
+ if (!BEFORE_SWAP.includes(r.phase))
96
+ return refuse("after-swap", `That deployment is at ${r.phase}, past the point where it stops the service. Recover it with \`node scripts/deploy-browser.mjs --stage ${stage} --phase recover\` from its checkout.`);
97
+ const swapped = SWAP_FIELDS.filter(field => r[field] !== undefined && r[field] !== null);
98
+ if (swapped.length > 0)
99
+ return refuse("conflict", `The journal says ${r.phase} but records ${swapped.join(", ")}, which only the swap writes.`);
100
+ if (existsSync(join(stage, "browser.next.plist")))
101
+ return refuse("conflict", `The journal says ${r.phase} but a new service definition was staged (${join(stage, "browser.next.plist")}).`);
102
+ proof.push(`Deployment journal ${join(stage, "deployment.json")} stopped at ${r.phase}, before the swap, and records nothing the swap writes.`);
103
+ if (!Number.isInteger(schema) || schema !== r.schema)
104
+ return refuse("conflict", `The live database is at schema ${schema}; the deployment started at ${r.schema}.`);
105
+ proof.push(`The live database is still at schema ${schema}.`);
106
+ const plists = world.servicePlists();
107
+ if (plists.length !== 1)
108
+ return refuse("conflict", plists.length === 0 ? "No browser service definition is installed." : `More than one browser service definition is installed: ${plists.map(one => one.path).join(", ")}.`);
109
+ const plist = plists[0];
110
+ if (!plist.text.includes(`<string>${r.priorRuntime}/cli.js</string>`) || plist.text.includes(r.nextRuntime))
111
+ return refuse("conflict", `${plist.path} does not name the runtime the deployment started from (${r.priorRuntime}).`);
112
+ const saved = join(stage, "browser.saved.plist");
113
+ if (existsSync(saved) && readFileSync(saved, "utf8") !== plist.text)
114
+ return refuse("conflict", `${plist.path} differs from the definition the deployment saved (${saved}).`);
115
+ proof.push(`${plist.path} still names ${r.priorRuntime}${existsSync(saved) ? " and matches the saved copy" : ""}.`);
116
+ const processes = world.processes();
117
+ const deploying = processes.filter(one => one.command.includes("deploy-browser.mjs"));
118
+ if (deploying.length > 0)
119
+ return refuse("deploying", `A deployment is still running (process ${deploying.map(one => one.pid).join(", ")}); let it finish or recover.`);
120
+ const staged = processes.filter(one => one.command.includes(r.nextRuntime));
121
+ if (staged.length > 0)
122
+ return refuse("after-swap", `Process ${staged.map(one => one.pid).join(", ")} runs from the staged runtime ${r.nextRuntime}.`);
123
+ const supervisor = processes.find(one => one.command.includes(`${r.priorRuntime}/controller-service.js`));
124
+ const worker = processes.find(one => one.command.includes(`${r.priorRuntime}/cli.js up --db ${world.database}`));
125
+ if (supervisor === undefined || worker === undefined)
126
+ return refuse("conflict", `The service is not running from ${r.priorRuntime}, so it can't be shown that the swap never began.`);
127
+ proof.push(`The service runs from ${r.priorRuntime} (process ${supervisor.pid}); nothing runs from the staged runtime, and no deployment is running.`);
128
+ return { ok: true, id, stage, journal: r, proof };
129
+ }
130
+ /** Prove, then (with `yes`) lift exactly that pause and journal the release in the deployment's own journal. */
131
+ export function releaseGate(id, world, yes) {
132
+ const proved = proveBeforeSwap(id, world);
133
+ if (!proved.ok)
134
+ return proved;
135
+ if (!yes)
136
+ return { ok: true, released: false, id, stage: proved.stage, proof: proved.proof };
137
+ const db = world.open();
138
+ // removeUpdateGate checks the owner again inside its own write: a pause that changed hands is not touched.
139
+ try {
140
+ removeUpdateGate(db, id);
141
+ }
142
+ finally {
143
+ db.close();
144
+ }
145
+ durableJson(join(proved.stage, "deployment.json"), {
146
+ ...proved.journal, phase: "released", updatedAt: world.now().toISOString(),
147
+ releasedBeforeSwap: { at: world.now().toISOString(), by: "toolroll release --release-gate", from: proved.journal.phase, proof: proved.proof },
148
+ });
149
+ return { ok: true, released: true, id, stage: proved.stage, proof: proved.proof };
150
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Both CLI names, moved to a deployed runtime (release.ts, step `link`): `toolroll` and `standing-orders` are pointed
3
+ * at `<runtime>/bin.js`, each link replaced whole (a new link renamed over the old one). Each name must then resolve to
4
+ * that file and answer with the released version, or every link goes back to where it pointed before.
5
+ *
6
+ * The coordinator saves what `findCliLinks` saw before switching, so a release killed halfway through relinking still
7
+ * knows where to put each name back. A name on PATH that is a file rather than a link is someone else's and never
8
+ * replaced.
9
+ */
10
+ export declare const CLI_NAMES: readonly ["toolroll", "standing-orders"];
11
+ /** One name on PATH: where its link is and what it pointed at before the release (null: not there yet). */
12
+ export type CliLink = {
13
+ name: string;
14
+ path: string;
15
+ before: string | null;
16
+ foreign?: true;
17
+ };
18
+ /** Where each name is on PATH. A missing name is made beside the other one; none on PATH at all is an empty list. */
19
+ export declare function findCliLinks(path?: string, names?: readonly string[]): CliLink[];
20
+ /** Point `link` at `target`, atomically: a new link beside it, renamed over it. */
21
+ export declare function pointLink(link: string, target: string): void;
22
+ /**
23
+ * Switch every link to `bin`. `answers(path)` says whether that name now answers as the released build. Any failure
24
+ * puts every link back where `links` says it was, and throws. Switching a link already at `bin` changes nothing, so a
25
+ * resumed relink repeats safely.
26
+ */
27
+ export declare function switchCliLinks(links: readonly CliLink[], bin: string, answers: (path: string) => boolean): CliLink[];
28
+ /** Each link where it pointed before; a link that wasn't there before is removed. */
29
+ export declare function restoreCliLinks(links: readonly CliLink[]): void;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Both CLI names, moved to a deployed runtime (release.ts, step `link`): `toolroll` and `standing-orders` are pointed
3
+ * at `<runtime>/bin.js`, each link replaced whole (a new link renamed over the old one). Each name must then resolve to
4
+ * that file and answer with the released version, or every link goes back to where it pointed before.
5
+ *
6
+ * The coordinator saves what `findCliLinks` saw before switching, so a release killed halfway through relinking still
7
+ * knows where to put each name back. A name on PATH that is a file rather than a link is someone else's and never
8
+ * replaced.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { lstatSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync } from "node:fs";
12
+ import { delimiter, dirname, join, resolve } from "node:path";
13
+ export const CLI_NAMES = Object.freeze(["toolroll", "standing-orders"]);
14
+ /** Where each name is on PATH. A missing name is made beside the other one; none on PATH at all is an empty list. */
15
+ export function findCliLinks(path = process.env["PATH"] ?? "", names = CLI_NAMES) {
16
+ const found = names.map((name) => {
17
+ for (const dir of path.split(delimiter).filter(Boolean)) {
18
+ const at = join(dir, name);
19
+ let entry;
20
+ try {
21
+ entry = lstatSync(at);
22
+ }
23
+ catch {
24
+ continue;
25
+ }
26
+ if (!entry.isSymbolicLink())
27
+ return { name, path: at, before: null, foreign: true };
28
+ return { name, path: at, before: resolve(dirname(at), readlinkSync(at)) };
29
+ }
30
+ return { name, path: null, before: null };
31
+ });
32
+ const home = found.find(one => one.path !== null && one.foreign === undefined);
33
+ if (home === undefined)
34
+ return found.filter((one) => one.path !== null);
35
+ return found.map(one => ({ ...one, path: one.path ?? join(dirname(home.path), one.name) }));
36
+ }
37
+ /** Point `link` at `target`, atomically: a new link beside it, renamed over it. */
38
+ export function pointLink(link, target) {
39
+ const temp = `${link}.${randomUUID().slice(0, 8)}.link`;
40
+ symlinkSync(target, temp);
41
+ try {
42
+ renameSync(temp, link);
43
+ }
44
+ catch (error) {
45
+ rmSync(temp, { force: true });
46
+ throw error;
47
+ }
48
+ }
49
+ const target = (link) => { try {
50
+ return resolve(dirname(link), readlinkSync(link));
51
+ }
52
+ catch {
53
+ return null;
54
+ } };
55
+ const real = (path) => { try {
56
+ return realpathSync(path);
57
+ }
58
+ catch {
59
+ return null;
60
+ } };
61
+ /**
62
+ * Switch every link to `bin`. `answers(path)` says whether that name now answers as the released build. Any failure
63
+ * puts every link back where `links` says it was, and throws. Switching a link already at `bin` changes nothing, so a
64
+ * resumed relink repeats safely.
65
+ */
66
+ export function switchCliLinks(links, bin, answers) {
67
+ const foreign = links.find(one => one.foreign === true);
68
+ if (foreign !== undefined)
69
+ throw new Error(`${foreign.path} is not a link; it was left as it is`);
70
+ if (links.length < CLI_NAMES.length)
71
+ throw new Error(`found ${links.length === 0 ? "no CLI name" : `only ${links.map(one => one.name).join(", ")}`} on PATH; link both with \`toolroll link\` first`);
72
+ const wanted = real(bin);
73
+ if (wanted === null)
74
+ throw new Error(`${bin} does not exist`);
75
+ try {
76
+ for (const link of links)
77
+ if (target(link.path) !== bin)
78
+ pointLink(link.path, bin);
79
+ for (const link of links) {
80
+ if (real(link.path) !== wanted)
81
+ throw new Error(`${link.path} does not resolve to ${bin}`);
82
+ if (!answers(link.path))
83
+ throw new Error(`${link.path} does not answer as the released build`);
84
+ }
85
+ return links.map(({ name, path, before }) => ({ name, path, before }));
86
+ }
87
+ catch (error) {
88
+ restoreCliLinks(links);
89
+ throw error;
90
+ }
91
+ }
92
+ /** Each link where it pointed before; a link that wasn't there before is removed. */
93
+ export function restoreCliLinks(links) {
94
+ for (const link of links) {
95
+ if (link.foreign === true)
96
+ continue;
97
+ if (link.before === null)
98
+ rmSync(link.path, { force: true });
99
+ else if (target(link.path) !== link.before)
100
+ pointLink(link.path, link.before);
101
+ }
102
+ }