toolroll 0.9.65 → 0.9.67

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 (49) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -2
  3. package/dist/agent-fence.d.ts +69 -2
  4. package/dist/agent-fence.js +291 -11
  5. package/dist/agentconfig.js +4 -1
  6. package/dist/batch-checks.js +3 -2
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
  8. package/dist/browser/terminal.js +134 -23
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/builder.d.ts +4 -0
  11. package/dist/builder.js +54 -12
  12. package/dist/contracts/console-api.d.ts +1 -0
  13. package/dist/contracts/console-api.js +1 -0
  14. package/dist/invoke.d.ts +18 -0
  15. package/dist/invoke.js +137 -14
  16. package/dist/live-terminal.d.ts +24 -13
  17. package/dist/live-terminal.js +79 -42
  18. package/dist/operate.d.ts +2 -0
  19. package/dist/operate.js +60 -2
  20. package/dist/project-delete-ui.d.ts +6 -0
  21. package/dist/project-delete-ui.js +20 -1
  22. package/dist/release-adapters.d.ts +6 -0
  23. package/dist/release-adapters.js +70 -3
  24. package/dist/release.d.ts +7 -1
  25. package/dist/release.js +20 -7
  26. package/dist/result-follow-ups.js +3 -2
  27. package/dist/scope.d.ts +13 -0
  28. package/dist/scope.js +10 -5
  29. package/dist/serve.js +1 -0
  30. package/dist/server/chrome.d.ts +10 -1
  31. package/dist/server/chrome.js +45 -4
  32. package/dist/server/console.css +55 -4
  33. package/dist/server/render-pages.d.ts +28 -3
  34. package/dist/server/render-pages.js +78 -9
  35. package/dist/server/render-tasks.js +2 -1
  36. package/dist/server/route-table.js +1 -0
  37. package/dist/server/settings.js +21 -0
  38. package/dist/server/tasks.js +7 -4
  39. package/dist/store.d.ts +11 -1
  40. package/dist/store.js +33 -2
  41. package/dist/teardown-git.d.ts +22 -0
  42. package/dist/teardown-git.js +136 -2
  43. package/dist/tool-broker.d.ts +12 -0
  44. package/dist/tool-broker.js +66 -2
  45. package/dist/unfenced-test-support.d.ts +2 -0
  46. package/dist/unfenced-test-support.js +11 -0
  47. package/dist/writes-mode.d.ts +9 -0
  48. package/dist/writes-mode.js +8 -0
  49. package/package.json +5 -1
@@ -19,8 +19,6 @@ export const MAX_LIVE_TERMINALS = 8;
19
19
  export const MAX_KEPT_TERMINALS = 16;
20
20
  /** At most this many pages attached to one session (a desktop, a phone, a spare tab or two). */
21
21
  export const MAX_VIEWERS = 4;
22
- /** Raw output kept per session for a page that attaches later. */
23
- export const SCROLLBACK_BYTES = 1024 * 1024;
24
22
  /** After SIGHUP, the terminal's processes have this long before SIGKILL. */
25
23
  export const CLOSE_GRACE_MS = 2_000;
26
24
  /** Typed input not yet read by the program, per terminal: past this, more is refused until it reads. */
@@ -70,37 +68,45 @@ export function loadPtySpawn() {
70
68
  return null;
71
69
  }
72
70
  }
73
- /** Raw output, oldest dropped first, never more than `cap` bytes. */
74
- export class Scrollback {
75
- cap;
76
- chunks = [];
77
- size = 0;
78
- constructor(cap = SCROLLBACK_BYTES) {
79
- this.cap = cap;
80
- }
81
- push(chunk) {
82
- if (chunk.length >= this.cap) {
83
- this.chunks = [chunk.subarray(chunk.length - this.cap)];
84
- this.size = this.cap;
85
- return;
86
- }
87
- this.chunks.push(chunk);
88
- this.size += chunk.length;
89
- while (this.size > this.cap) {
90
- const first = this.chunks[0];
91
- const over = this.size - this.cap;
92
- if (first.length <= over) {
93
- this.chunks.shift();
94
- this.size -= first.length;
95
- }
96
- else {
97
- this.chunks[0] = first.subarray(over);
98
- this.size -= over;
99
- }
71
+ /** How many lines above the screen a terminal keeps for a page that attaches later. */
72
+ export const SCREEN_SCROLLBACK_LINES = 5000;
73
+ const require = createRequire(import.meta.url);
74
+ /**
75
+ * The terminal as a page would see it now: every byte the program writes is parsed by a headless xterm here, so a page
76
+ * that attaches later gets the screen, its scrollback and its modes (the alternate screen of a full-screen program, the
77
+ * cursor, colours, bracketed paste, mouse reporting) — never a replay of raw output cut at an arbitrary byte, which
78
+ * could split an escape sequence or redraw a full-screen program garbled.
79
+ */
80
+ export class ScreenMirror {
81
+ lines;
82
+ terminal;
83
+ serializer;
84
+ constructor(cols, rows, lines = SCREEN_SCROLLBACK_LINES) {
85
+ this.lines = lines;
86
+ const { Terminal } = require("@xterm/headless");
87
+ const { SerializeAddon } = require("@xterm/addon-serialize");
88
+ this.terminal = new Terminal({ cols, rows, scrollback: lines, allowProposedApi: true });
89
+ this.serializer = new SerializeAddon();
90
+ this.terminal.loadAddon(this.serializer);
91
+ }
92
+ write(data) { this.terminal.write(data); }
93
+ resize(cols, rows) { try {
94
+ this.terminal.resize(cols, rows);
95
+ }
96
+ catch { /* sizes are clamped before this */ } }
97
+ /** The screen as of this call (after everything written before it), handed to `done` once parsed. */
98
+ snapshot(done) {
99
+ this.terminal.write("", () => { let screen = ""; try {
100
+ screen = this.serializer.serialize({ scrollback: this.lines });
100
101
  }
102
+ catch {
103
+ screen = "";
104
+ } done(screen); });
105
+ }
106
+ dispose() { try {
107
+ this.terminal.dispose();
101
108
  }
102
- bytes() { return Buffer.concat(this.chunks, this.size); }
103
- get length() { return this.size; }
109
+ catch { /* already */ } }
104
110
  }
105
111
  const NO_LEDGER = { reserve() { }, running() { }, release() { }, forget() { } };
106
112
  export class TerminalError extends Error {
@@ -237,7 +243,7 @@ export class TerminalHost {
237
243
  }
238
244
  let resolveExited;
239
245
  const session = { id, owner, generation, repo, program, branch, worktree, startedAt: this.clock().toISOString(), endedAt: null, exit: null, endedBy: null, cleanup: null,
240
- pty, leader: pty.pid, tty: pty.tty, known: new Map(), exitedAt: null, worktreeReal: realpathSync(worktree), startedMs: Date.now(), residentsOnly: false, residentsSeen: new Map(), cols, rows, scrollback: new Scrollback(this.options.scrollbackBytes), viewers: new Set(), closing: null,
246
+ pty, leader: pty.pid, tty: pty.tty, known: new Map(), exitedAt: null, worktreeReal: realpathSync(worktree), startedMs: Date.now(), residentsOnly: false, residentsSeen: new Map(), cols, rows, screen: new ScreenMirror(cols, rows, this.options.scrollbackLines), viewers: new Set(), closing: null,
241
247
  exited: new Promise(resolve => { resolveExited = resolve; }) };
242
248
  this.sessions.set(id, session);
243
249
  this.ledger.running(id);
@@ -246,7 +252,7 @@ export class TerminalHost {
246
252
  pty.onData(data => {
247
253
  this.noteSoon();
248
254
  const chunk = Buffer.from(data, "utf8");
249
- session.scrollback.push(chunk);
255
+ session.screen.write(data);
250
256
  for (const viewer of session.viewers) {
251
257
  try {
252
258
  viewer.output(chunk);
@@ -280,21 +286,50 @@ export class TerminalHost {
280
286
  this.ledger.release(id, null);
281
287
  }
282
288
  }
283
- /** Attach a page: it gets the scrollback, then `hello`, then live output. Returns its detach. */
289
+ /**
290
+ * Attach a page: it gets the screen as it is now (ScreenMirror), then `hello`, then live output — what the program
291
+ * writes meanwhile is held and sent after the screen, in order. Returns its detach.
292
+ */
284
293
  attach(id, viewer) {
285
294
  const session = this.sessions.get(id);
286
295
  if (session === undefined)
287
296
  throw new TerminalError("No such terminal.");
288
297
  if (session.viewers.size >= MAX_VIEWERS)
289
298
  throw new TerminalError(`This terminal is open in ${MAX_VIEWERS} places already. Close one of them.`);
290
- const replay = session.scrollback.bytes();
291
- if (replay.length > 0)
292
- viewer.output(replay);
293
- viewer.control({ t: "hello", cols: session.cols, rows: session.rows, program: session.program, ended: session.endedAt !== null });
294
- if (session.exit !== null)
295
- viewer.control({ t: "exit", code: session.exit.code, signal: session.exit.signal });
296
- session.viewers.add(viewer);
297
- return () => { session.viewers.delete(viewer); };
299
+ const held = [];
300
+ let live = false;
301
+ const gate = {
302
+ output: data => { if (live)
303
+ viewer.output(data);
304
+ else
305
+ held.push({ output: data }); },
306
+ control: message => { if (live)
307
+ viewer.control(message);
308
+ else
309
+ held.push({ control: message }); },
310
+ };
311
+ session.viewers.add(gate);
312
+ session.screen.snapshot(screen => {
313
+ if (!session.viewers.has(gate))
314
+ return;
315
+ try {
316
+ if (screen !== "")
317
+ viewer.output(Buffer.from(screen, "utf8"));
318
+ viewer.control({ t: "hello", cols: session.cols, rows: session.rows, program: session.program, ended: session.endedAt !== null });
319
+ if (session.exit !== null && !held.some(one => "control" in one && one.control.t === "exit"))
320
+ viewer.control({ t: "exit", code: session.exit.code, signal: session.exit.signal });
321
+ for (const one of held) {
322
+ if ("output" in one)
323
+ viewer.output(one.output);
324
+ else
325
+ viewer.control(one.control);
326
+ }
327
+ }
328
+ catch { /* the viewer's own close handles it */ }
329
+ held.length = 0;
330
+ live = true;
331
+ });
332
+ return () => { session.viewers.delete(gate); };
298
333
  }
299
334
  /** How many pages are attached now. */
300
335
  viewerCount(id) { return this.sessions.get(id)?.viewers.size ?? 0; }
@@ -319,6 +354,7 @@ export class TerminalHost {
319
354
  session.pty.resize(session.cols, session.rows);
320
355
  }
321
356
  catch { /* it exited between */ }
357
+ session.screen.resize(session.cols, session.rows);
322
358
  }
323
359
  noteTimer = null;
324
360
  /** Activity (output, typing) usually means a command started: note processes shortly after, at most every 250 ms. */
@@ -487,6 +523,7 @@ export class TerminalHost {
487
523
  catch { /* gone */ }
488
524
  }
489
525
  session.viewers.clear();
526
+ session.screen.dispose();
490
527
  const result = await (this.options.git ?? teardownGit())(session.repo, ["worktree", "remove", "--force", "--", session.worktree]);
491
528
  this.options.record?.("remove", this.summary(session));
492
529
  if (result.code !== 0 && existsSync(session.worktree)) {
package/dist/operate.d.ts CHANGED
@@ -162,3 +162,5 @@ export declare function readUpConsoleUrl(dir: string): string | null;
162
162
  * missing credentials stay a usage refusal, exactly as before.
163
163
  */
164
164
  export declare const PROPOSALS_ACTIONS: readonly ["list", "confirm", "dismiss"];
165
+ /** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
166
+ export declare function ownValueFlags(command: string, argv: readonly string[]): ReadonlySet<string> | undefined;
package/dist/operate.js CHANGED
@@ -111,6 +111,7 @@ import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, taskBranches } from
111
111
  import { claimActor, currentActor, withActor } from "./actor.js";
112
112
  import { parseLeadToken } from "./api-tokens.js";
113
113
  import { MAX_NETWORK_HOSTS, normalizeHost } from "./egress.js";
114
+ import { isWritesMode, writesWords } from "./writes-mode.js";
114
115
  import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
115
116
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
116
117
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
@@ -260,6 +261,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
260
261
  toolroll project network --repo <p> [--allow h,…] [--remove h,…] [--open|--allowlist] [--local any|none]
261
262
  what the project's builds may reach on the network; an approver changes it
262
263
  (new scopes seal it; approved ones keep theirs)
264
+ toolroll project writes --repo <p> [workspace|anywhere]
265
+ where the project's builds may write: only their checkout, caches and temp
266
+ (workspace, the default), or anywhere outside Toolroll's fenced folders;
267
+ an approver changes it (new scopes seal it; approved ones keep theirs)
263
268
  toolroll config unconfined-agents [allow|refuse]
264
269
  whether builds run where this machine can't confine them (instance operator)
265
270
  toolroll project concurrency [<n>] --repo <p>
@@ -688,6 +693,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
688
693
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
689
694
  "self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
690
695
  "html", "csv", "alerts-only",
696
+ // project network: what its builds may reach (--open | --allowlist).
697
+ "open", "allowlist",
691
698
  // v105: the full export.
692
699
  "zip",
693
700
  // Settings → Integrations: the last checks, without checking again.
@@ -709,6 +716,9 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
709
716
  // task steer: pause the running Claude build, then send the note.
710
717
  "now",
711
718
  ]);
719
+ /** `project network` reads --remove <hosts> and --local any|none as values, where every other verb's --remove and
720
+ * --local are switches. */
721
+ const PROJECT_NETWORK_VALUE_FLAGS = new Set(["remove", "local"]);
712
722
  export function parseOperateArgs(argv, ownValues = new Set()) {
713
723
  const positional = [];
714
724
  const flags = new Map();
@@ -767,7 +777,7 @@ export async function runOperate(command, argv, write, options = {}) {
767
777
  return withPasswordSource(CLI_PASSWORD_SOURCE, () => rememberedLogin.run({ login: null }, () => runOperateLocal(command, argv, write, options)));
768
778
  }
769
779
  async function runOperateLocal(command, argv, write, options = {}) {
770
- const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
780
+ const parsed = parseOperateArgs(argv, ownValueFlags(command, argv));
771
781
  // A parse error precedes the flags map, so JSON mode is read from the raw
772
782
  // argv — the envelope contract holds even for the earliest refusal.
773
783
  if ("error" in parsed)
@@ -979,7 +989,7 @@ export async function runOperateAs(argv, opts) {
979
989
  const scope = REMOTE_SCOPES.get(row.invocation);
980
990
  if (row.remote !== "yes" || scope === undefined)
981
991
  return refuse("not-remote", REMOTE_MESSAGES.no);
982
- const parsed = parseOperateArgs(rest, root === "flows" ? FLOWS_VALUE_FLAGS : undefined);
992
+ const parsed = parseOperateArgs(rest, ownValueFlags(root, rest));
983
993
  if ("error" in parsed)
984
994
  return refuse("usage", parsed.error);
985
995
  // The parser's positionals must spell the same row (a value flag can't hide a subcommand).
@@ -1159,6 +1169,8 @@ async function dispatch(command, positional, flags, context) {
1159
1169
  return projectConcurrencyCommand(positional, flags, context);
1160
1170
  if (positional[0] === "network")
1161
1171
  return projectNetworkCommand(positional, flags, context);
1172
+ if (positional[0] === "writes")
1173
+ return projectWritesCommand(positional, flags, context);
1162
1174
  if (positional[0] === "checks")
1163
1175
  return projectChecksCommand(positional, flags, context);
1164
1176
  if (positional[0] === "demo")
@@ -10970,6 +10982,43 @@ async function projectNetworkCommand(positional, flags, context) {
10970
10982
  "Scopes written from now on use this; scopes already approved keep the network they were approved with.",
10971
10983
  ]);
10972
10984
  }
10985
+ /**
10986
+ * `project writes --repo <p> [workspace|anywhere]`: where the project's builds may write (writes-mode.ts). `workspace`:
10987
+ * only their checkout and its Git data, their caches, their temp folder and the provider state their CLI needs;
10988
+ * `anywhere`: anywhere outside Toolroll's fenced folders. New scopes seal it; approved ones keep theirs. Changing it is
10989
+ * an approver's act with a password typed now, kept in the ledger as before → after.
10990
+ */
10991
+ async function projectWritesCommand(positional, flags, context) {
10992
+ const command = "project writes";
10993
+ const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
10994
+ for (const name of flags.keys())
10995
+ if (!allowed.has(name))
10996
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project writes option.`, EXIT.usage);
10997
+ const repoFlag = text(flags, "repo");
10998
+ const wanted = positional[1];
10999
+ if (positional.length > 2 || repoFlag === undefined || (wanted !== undefined && !isWritesMode(wanted))) {
11000
+ return fail(context.write, context.json, command, "usage", "Use project writes --repo <project path> [workspace | anywhere].", EXIT.usage);
11001
+ }
11002
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
11003
+ const known = [...new Set([...context.store.knownRepos(), ...context.store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
11004
+ const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
11005
+ if (repo === undefined)
11006
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
11007
+ const words = (one) => [`${repo}: builds write ${writesWords(one)}.`];
11008
+ const current = context.store.projectWrites(repo);
11009
+ if (wanted === undefined)
11010
+ return succeed(context.write, context.json, command, { repo, writes: current }, () => words(current));
11011
+ const acting = await askCredentials(flags, context);
11012
+ const verified = acting === null ? null : credentialApprover(context.store, acting.name, acting.token, repo);
11013
+ if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
11014
+ return fail(context.write, context.json, command, "refused", "An approver for this project changes where its builds may write, with their password: pass --as and --token.", EXIT.refused);
11015
+ }
11016
+ const changed = context.store.setProjectWrites(repo, wanted, acting.name, context.clock());
11017
+ return succeed(context.write, context.json, command, { repo, before: changed.before, writes: changed.after }, () => [
11018
+ ...words(changed.after),
11019
+ "Scopes written from now on use this; scopes already approved keep what they were approved with.",
11020
+ ]);
11021
+ }
10973
11022
  /** `config unconfined-agents [allow|refuse]`: whether a build may run on a machine that can't confine it at the OS
10974
11023
  * (no macOS sandbox, no working bubblewrap). Refused unless an instance operator allows it, with a password. */
10975
11024
  async function unconfinedAgentsCommand(positional, flags, context) {
@@ -11876,3 +11925,12 @@ function databaseFailure(write, json, command, file, error) {
11876
11925
  return fail(write, json, command, "database", describe(error), EXIT.failed);
11877
11926
  return fail(write, json, command, "database-busy", `The database ${file} stayed busy for ${CONCURRENT_WRITER_WAIT_MS / 1_000} seconds. Wait for the other Toolroll process to finish, then try again.`, EXIT.failed);
11878
11927
  }
11928
+ // Last in the file, so the password tripwire's function positions (password-freshness.test.ts) stay as reviewed.
11929
+ /** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
11930
+ export function ownValueFlags(command, argv) {
11931
+ if (command === "flows")
11932
+ return FLOWS_VALUE_FLAGS;
11933
+ if (command === "project" && argv.find(arg => !arg.startsWith("-")) === "network")
11934
+ return PROJECT_NETWORK_VALUE_FLAGS;
11935
+ return undefined;
11936
+ }
@@ -5,6 +5,7 @@
5
5
  * sign-in with the identity provider). The repository itself stays.
6
6
  */
7
7
  import type { ProjectNetwork } from "./egress.js";
8
+ import type { WritesMode } from "./writes-mode.js";
8
9
  import { type Html } from "./html.js";
9
10
  import { type ProjectHoldings } from "./project-delete.js";
10
11
  export declare const PROJECT_DELETE_CSS: string;
@@ -44,6 +45,11 @@ export type ProjectSettingsView = {
44
45
  setting: ProjectNetwork;
45
46
  canChange: boolean;
46
47
  };
48
+ /** Where its builds may write (writes-mode.ts), and whether this person may change it. */
49
+ writes?: {
50
+ setting: WritesMode;
51
+ canChange: boolean;
52
+ };
47
53
  };
48
54
  /** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
49
55
  export declare function projectSettingsHtml(view: ProjectSettingsView, notice: {
@@ -14,6 +14,25 @@ export const PROJECT_DELETE_CSS = `.project-settings,.project-delete{max-width:7
14
14
  `.project-settings .build-access ul{margin:0 0 4px;padding-left:20px;font:var(--so-typography-body);color:var(--so-ink)}.project-settings .build-access li{margin:2px 0}.project-settings .build-access li .meta{color:var(--so-muted)}` +
15
15
  `.project-settings .build-access a.manage-tools{display:inline-flex;align-items:center;justify-content:center;min-height:44px;margin-top:4px;padding:0 14px;white-space:nowrap;background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line);border-radius:var(--so-radius-md);font:var(--so-typography-label);text-decoration:none}` +
16
16
  `.project-settings .build-access a.manage-tools:hover{background:var(--so-raised)}.project-settings .build-access a.manage-tools:focus-visible,.project-settings .build-access summary:focus-visible{outline:2px solid var(--so-ink);outline-offset:2px}`;
17
+ /** Where builds may write, said plainly; an approver changes it with their password. */
18
+ function writesHtml(view) {
19
+ const writes = view.writes;
20
+ if (writes === undefined)
21
+ return "";
22
+ const said = writes.setting === "workspace"
23
+ ? html `<p>Builds write <strong>only their own checkout</strong>, its caches and its temp folder. Your shell profile, login items, agent settings and the repository's Git config and hooks stay as they are.</p>`
24
+ : html `<p>Builds may write <strong>anywhere</strong> outside Toolroll's own folders, your credentials and other projects.</p>`;
25
+ if (!writes.canChange)
26
+ return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<p class="meta">An approver for this project can change this.</p></section>`;
27
+ const option = (value, label, detail) => html `<label class="check"><input type="radio" name="writes" value="${value}"${writes.setting === value ? html ` checked` : ""}> ${label}</label><p class="meta">${detail}</p>`;
28
+ const form = postForm("/settings/project/writes", html `
29
+ ${option("workspace", "Only their workspace (recommended)", "The checkout and its Git data, the project's own package caches, the build's temp folder, and what its agent needs to keep its session.")}
30
+ ${option("anywhere", "Anywhere outside Toolroll's fenced folders", "For a project whose tools must write elsewhere in your home folder or the shared temp folder. Credentials, Toolroll's own folders and other projects stay fenced.")}
31
+ <p class="meta">Tasks scoped from now on use this; tasks already approved keep what they were approved with.</p>
32
+ ${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}
33
+ <button type="submit">Save</button>`, { hidden: { repo: view.repo } });
34
+ return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<details><summary>Change</summary>${form}</details></section>`;
35
+ }
17
36
  /** Network: what the project's builds may reach, said plainly; an approver changes it with their password. */
18
37
  function networkHtml(view) {
19
38
  const network = view.network;
@@ -92,7 +111,7 @@ export function projectSettingsHtml(view, notice) {
92
111
  const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
93
112
  ? html `<p class="path">${view.remoteUrl}</p>`
94
113
  : html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add &lt;project&gt; --remote &lt;url&gt;</p></details></section>`;
95
- const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${liveViewHtml(view)}`;
114
+ const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${liveViewHtml(view)}`;
96
115
  if (!view.canDelete)
97
116
  return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
98
117
  const body = view.running.length > 0
@@ -55,6 +55,12 @@ export type AdapterOptions = {
55
55
  sleep?: (ms: number) => Promise<void>;
56
56
  };
57
57
  export declare function releaseAdapters(options: AdapterOptions): ReleaseAdapters;
58
+ /**
59
+ * Whether a workflow's triggers (its top-level `on:` as a name, a list or a map) include a change to the repository.
60
+ * A file that doesn't parse, or whose triggers aren't one of those shapes, counts as running, so the release waits
61
+ * rather than skipping checks it owes.
62
+ */
63
+ export declare function workflowRunsOnChange(text: string): boolean;
58
64
  /** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
59
65
  export declare function bumpFormula(text: string, url: string, sha256: string): string;
60
66
  export {};
@@ -10,6 +10,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { basename, delimiter, dirname, join } from "node:path";
12
12
  import { createRequire } from "node:module";
13
+ import { parse as parseYaml } from "yaml";
13
14
  // Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
14
15
  const sqlite = () => createRequire(import.meta.url)("node:sqlite");
15
16
  import { activeUpdateWork } from "./desktop-update-gate.js";
@@ -225,7 +226,14 @@ export function releaseAdapters(options) {
225
226
  return;
226
227
  if (current !== head)
227
228
  throw new Error(`${branch} moved after merging; its new head was not deleted.`);
228
- await must(git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`), `deleting merged branch ${branch}`);
229
+ const pushed = await git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`);
230
+ if (pushed.code === 0)
231
+ return;
232
+ // GitHub may delete a merged branch itself between the look and the push; gone is what was wanted.
233
+ const after = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
234
+ if (after === null)
235
+ return;
236
+ throw new Error(`deleting merged branch ${branch} failed: ${pushed.stderr.trim() || `exit ${pushed.code}`}`);
229
237
  },
230
238
  async tree(sha) { return (await must(git("rev-parse", `${sha}^{tree}`), `git rev-parse ${sha.slice(0, 7)}^{tree}`)).trim(); },
231
239
  async fileAt(sha, path) {
@@ -338,10 +346,10 @@ export function releaseAdapters(options) {
338
346
  return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
339
347
  },
340
348
  async checks(repo, sha) {
341
- const out = await must(gh("api", "--paginate", `repos/${repo}/commits/${sha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion}"), "gh api check-runs");
349
+ const out = await must(gh("api", "--paginate", `repos/${repo}/commits/${sha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app: .app.slug}"), "gh api check-runs");
342
350
  const runs = out.split("\n").filter(Boolean).map(line => object(JSON.parse(line)));
343
351
  // Oldest first, so a rerun's attempt is the last one listed for its name.
344
- return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]) }));
352
+ return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]), app: textOr(one["app"]) }));
345
353
  },
346
354
  async merge(repo, pr, head) {
347
355
  const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
@@ -379,6 +387,37 @@ export function releaseAdapters(options) {
379
387
  },
380
388
  homebrew: {
381
389
  repo: tap,
390
+ async checked(head) {
391
+ // Only a workflow that runs on pull requests (or pushes) puts checks on the tap's change; apps that open a
392
+ // check suite and never run it don't count. Workflows are read as the change has them and as the tap's default
393
+ // branch has them (pull_request_target runs from there); a file this can't read counts as checking.
394
+ for (const ref of [head, await defaultBranch()]) {
395
+ const listed = await gh("api", `repos/${tap}/contents/.github/workflows?ref=${encodeURIComponent(ref)}`, "--jq", ".[] | select(.type == \"file\") | .path");
396
+ if (listed.code !== 0) {
397
+ if (/Not Found|HTTP 404/.test(listed.stderr + listed.stdout))
398
+ continue;
399
+ throw new Error(`listing ${tap}'s workflows at ${ref}: ${listed.stderr.trim()}`);
400
+ }
401
+ for (const path of listed.stdout.split("\n").filter(Boolean)) {
402
+ if (!/\.ya?ml$/.test(path))
403
+ continue;
404
+ const file = await gh("api", `repos/${tap}/contents/${path}?ref=${encodeURIComponent(ref)}`, "--jq", ".content");
405
+ if (file.code !== 0)
406
+ return true;
407
+ if (workflowRunsOnChange(Buffer.from(file.stdout.replace(/\\n|\s/g, ""), "base64").toString("utf8")))
408
+ return true;
409
+ }
410
+ }
411
+ return false;
412
+ },
413
+ async statuses(head) {
414
+ // The older commit-status API, which some CI reports through instead of checks; newest first, so reversed.
415
+ const out = await must(gh("api", "--paginate", `repos/${tap}/commits/${head}/statuses?per_page=100`, "--jq", ".[] | {context, state}"), "gh api statuses");
416
+ return out.split("\n").filter(Boolean).map(line => object(JSON.parse(line))).reverse().map((one) => {
417
+ const state = String(one["state"]);
418
+ return { name: `status: ${String(one["context"])}`, status: state === "pending" ? "in_progress" : "completed", conclusion: state === "pending" ? null : state === "success" ? "success" : "failure" };
419
+ });
420
+ },
382
421
  async state(version) {
383
422
  const branch = await defaultBranch();
384
423
  const current = await formulaAt(branch);
@@ -427,6 +466,34 @@ export function releaseAdapters(options) {
427
466
  }
428
467
  }
429
468
  const brewBranch = (version) => `toolroll-${version}`;
469
+ /** The events that put checks on a change. */
470
+ const CHANGE_EVENTS = new Set(["pull_request", "pull_request_target", "push", "merge_group"]);
471
+ /**
472
+ * Whether a workflow's triggers (its top-level `on:` as a name, a list or a map) include a change to the repository.
473
+ * A file that doesn't parse, or whose triggers aren't one of those shapes, counts as running, so the release waits
474
+ * rather than skipping checks it owes.
475
+ */
476
+ export function workflowRunsOnChange(text) {
477
+ let parsed;
478
+ try {
479
+ parsed = parseYaml(text);
480
+ }
481
+ catch {
482
+ return true;
483
+ }
484
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
485
+ return true;
486
+ const workflow = parsed;
487
+ // YAML 1.1 readers take a bare `on` for true; either key names the triggers.
488
+ const on = "on" in workflow ? workflow["on"] : workflow["true"];
489
+ const events = typeof on === "string" ? [on]
490
+ : Array.isArray(on) ? on
491
+ : on !== null && typeof on === "object" ? Object.keys(on)
492
+ : null;
493
+ if (events === null || !events.every(one => typeof one === "string"))
494
+ return true;
495
+ return events.some(one => CHANGE_EVENTS.has(one));
496
+ }
430
497
  /** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
431
498
  export function bumpFormula(text, url, sha256) {
432
499
  if (!/^\s*url "[^"]*"/m.test(text) || !/^\s*sha256 "[0-9a-f]{64}"/m.test(text))
package/dist/release.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * skipped or failed never merges. merge
18
18
  * squash-merges with the head pinned to the gated commit, tree proves main's new commit has exactly the gated tree,
19
19
  * and only then tag pushes the version tag, whose macOS matrix must pass before npm publication (publish.yml). publish
20
- * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it.
20
+ * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it (after its checks, when the tap runs any).
21
21
  *
22
22
  * A private journal (mode 0600, written whole then renamed) records what each step is about to do before it does
23
23
  * anything outside this machine, and what it saw after. A stop — a time limit, a failure, a crash, Ctrl-C — leaves the
@@ -38,10 +38,12 @@ export type ReleaseStep = typeof RELEASE_STEPS[number];
38
38
  type WaitingStep = "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
39
39
  /** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
40
40
  export declare const RELEASE_LIMITS: Readonly<Record<WaitingStep, number>>;
41
+ /** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known. */
41
42
  export type CheckRun = {
42
43
  name: string;
43
44
  status: string;
44
45
  conclusion: string | null;
46
+ app?: string | null;
45
47
  };
46
48
  export type PullRequest = {
47
49
  number: number;
@@ -195,6 +197,10 @@ export type ReleaseAdapters = {
195
197
  };
196
198
  homebrew: {
197
199
  repo: string;
200
+ /** Whether any of the tap's workflows, at the change or its base, runs on a change; if none does, no checks are owed. */
201
+ checked(head: string): Promise<boolean>;
202
+ /** The change's commit statuses, as check runs oldest first. */
203
+ statuses(head: string): Promise<CheckRun[]>;
198
204
  state(version: string): Promise<HomebrewState>;
199
205
  propose(version: string): Promise<{
200
206
  pr: number;
package/dist/release.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * skipped or failed never merges. merge
18
18
  * squash-merges with the head pinned to the gated commit, tree proves main's new commit has exactly the gated tree,
19
19
  * and only then tag pushes the version tag, whose macOS matrix must pass before npm publication (publish.yml). publish
20
- * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it.
20
+ * waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it (after its checks, when the tap runs any).
21
21
  *
22
22
  * A private journal (mode 0600, written whole then renamed) records what each step is about to do before it does
23
23
  * anything outside this machine, and what it saw after. A stop — a time limit, a failure, a crash, Ctrl-C — leaves the
@@ -454,15 +454,28 @@ export async function runRelease(options, adapters) {
454
454
  const proposed = state;
455
455
  j.homebrew = { pr: proposed.pr, head: proposed.head, merged: false };
456
456
  save();
457
+ // Whatever checks do register are always judged; an empty set passes only for a tap whose workflows never run
458
+ // on a change, and only when it's still empty a poll later.
459
+ const checked = await brew.checked(proposed.head);
460
+ let emptyLooks = 0;
457
461
  await waitFor("homebrew", `the Homebrew change's checks (${brew.repo} #${proposed.pr})`, async () => {
458
- const runs = await adapters.github.checks(brew.repo, proposed.head);
462
+ // Check runs and commit statuses are judged apart, so neither stands in for the other or hides its failure.
463
+ // Two apps may name a check alike; each app's attempts stand for themselves.
464
+ const runs = (await adapters.github.checks(brew.repo, proposed.head)).map(run => ({ ...run, name: `${run.app ?? "unknown app"}: ${run.name}` }));
465
+ const statuses = await brew.statuses(proposed.head);
466
+ const verdicts = [runs, statuses].filter(one => one.length > 0).map(one => checksVerdict(one, [...new Set(one.map(run => run.name))]));
467
+ const failed = verdicts.flatMap(one => one.state === "failed" ? one.failed : []);
468
+ if (failed.length > 0)
469
+ throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${failed.join(", ")}.`);
470
+ if (verdicts.some(one => one.state === "pending"))
471
+ return null;
459
472
  // GitHub registers checks asynchronously; an empty set says nothing about success.
460
- if (runs.length === 0)
473
+ // A workflow is owed: only its own run counts, not another app's green check.
474
+ if (checked && !runs.some(run => run.app === "github-actions"))
461
475
  return null;
462
- const verdict = checksVerdict(runs, [...new Set(runs.map(one => one.name))]);
463
- if (verdict.state === "failed")
464
- throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${verdict.failed.join(", ")}.`);
465
- return verdict.state === "passed" ? true : null;
476
+ if (runs.length === 0 && statuses.length === 0)
477
+ return ++emptyLooks >= 2 ? true : null;
478
+ return true;
466
479
  });
467
480
  await brew.merge(proposed.pr, proposed.head);
468
481
  }
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Add tests files a small task, unapproved like any other filing, to write
11
11
  * tests for that change. */
12
+ import { fencedCheckoutCommands } from "./invoke.js";
12
13
  import { mkdtempSync, rmSync } from "node:fs";
13
14
  import { tmpdir } from "node:os";
14
15
  import { join } from "node:path";
@@ -201,7 +202,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
201
202
  let ready = true;
202
203
  if (setup !== null) {
203
204
  const shell = approvedCommandShell(setup.command);
204
- const prepared = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
205
+ const prepared = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
205
206
  note("Approved project setup", setup.command, prepared);
206
207
  if (prepared.code !== 0 || prepared.timedOut || prepared.notFound) {
207
208
  ready = false;
@@ -216,7 +217,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
216
217
  }
217
218
  else {
218
219
  const shell = approvedCommandShell(live.command);
219
- const ran = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
220
+ const ran = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
220
221
  note(`${CHECK_LEVEL_WORDS[target.level]} check`, live.command, ran);
221
222
  result = ran.timedOut ? { ran: false, failure: "The check ran out of time." } : ran.notFound ? { ran: false, failure: "The check couldn't start." } : { ran: true, exitCode: ran.code };
222
223
  }