toolroll 0.9.94 → 0.9.95
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.
- package/CHANGELOG.md +12 -1
- package/dist/agent-fence.d.ts +6 -0
- package/dist/agent-fence.js +2 -2
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +2 -0
- package/dist/attest.d.ts +3 -0
- package/dist/attest.js +10 -0
- package/dist/browser/terminal.js +1 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +24 -24
- package/dist/browser-crew.js +2 -0
- package/dist/browser-workspace.d.ts +5 -0
- package/dist/build-review.js +2 -0
- package/dist/builder.d.ts +7 -2
- package/dist/builder.js +183 -79
- package/dist/card-catalog.d.ts +209 -0
- package/dist/card-catalog.js +133 -0
- package/dist/card-cli.d.ts +43 -0
- package/dist/card-cli.js +44 -0
- package/dist/card-render.d.ts +26 -0
- package/dist/card-render.js +223 -0
- package/dist/card-sources.d.ts +68 -0
- package/dist/card-sources.js +234 -0
- package/dist/chat-actions.d.ts +95 -8
- package/dist/chat-actions.js +449 -45
- package/dist/chat-approval.d.ts +13 -0
- package/dist/chat-approval.js +39 -9
- package/dist/chat-card-part.d.ts +11 -0
- package/dist/chat-card-part.js +18 -0
- package/dist/chat-card.d.ts +46 -0
- package/dist/chat-card.js +99 -0
- package/dist/chat-channel.d.ts +2 -0
- package/dist/chat-channel.js +26 -14
- package/dist/chat-controls.d.ts +4 -0
- package/dist/chat-controls.js +4 -0
- package/dist/chat-decide.d.ts +3 -14
- package/dist/chat-decide.js +43 -54
- package/dist/chat-delivery.js +13 -4
- package/dist/chat-flow.d.ts +0 -27
- package/dist/chat-flow.js +31 -2
- package/dist/chat-quiet.js +1 -1
- package/dist/chat-task-actions.d.ts +3 -2
- package/dist/chat-task-actions.js +19 -5
- package/dist/chat-terms.d.ts +14 -2
- package/dist/chat-terms.js +35 -0
- package/dist/check-failure.d.ts +2 -0
- package/dist/check-failure.js +34 -2
- package/dist/check-repair.d.ts +47 -0
- package/dist/check-repair.js +240 -0
- package/dist/claim.d.ts +6 -0
- package/dist/claim.js +13 -2
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +2 -0
- package/dist/cli.js +4 -0
- package/dist/coding-provider.d.ts +1 -1
- package/dist/coding-provider.js +9 -5
- package/dist/coding-workspace.d.ts +1 -1
- package/dist/coding-workspace.js +4 -4
- package/dist/contracts/chat-actions.d.ts +83 -0
- package/dist/contracts/chat-actions.js +18 -0
- package/dist/contracts/chat-card.d.ts +91 -0
- package/dist/contracts/chat-card.js +54 -0
- package/dist/contracts/chat-content.d.ts +191 -0
- package/dist/contracts/chat-content.js +4 -0
- package/dist/contracts/console-api.d.ts +2 -0
- package/dist/contracts/console-api.js +2 -0
- package/dist/contracts/flow-propose.d.ts +8 -1
- package/dist/contracts/flow-propose.js +3 -1
- package/dist/contracts/flow-send.d.ts +126 -0
- package/dist/contracts/flow-send.js +3 -0
- package/dist/contracts/lead-tools.d.ts +173 -0
- package/dist/contracts/lead-tools.js +15 -0
- package/dist/contracts/store-json.d.ts +16 -0
- package/dist/discord-chat.js +14 -0
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.js +7 -1
- package/dist/dispose.d.ts +19 -0
- package/dist/dispose.js +47 -7
- package/dist/eval-oracle.js +4 -1
- package/dist/evidence-pair.d.ts +53 -0
- package/dist/evidence-pair.js +128 -0
- package/dist/exhaustion.d.ts +17 -4
- package/dist/exhaustion.js +44 -4
- package/dist/export.js +1 -1
- package/dist/failing-first.d.ts +31 -0
- package/dist/failing-first.js +44 -0
- package/dist/fallback-schema.d.ts +8 -0
- package/dist/fallback-schema.js +40 -0
- package/dist/fallback.d.ts +138 -0
- package/dist/fallback.js +605 -0
- package/dist/findings.d.ts +224 -0
- package/dist/findings.js +449 -0
- package/dist/fleet-schema.d.ts +17 -0
- package/dist/fleet-schema.js +135 -0
- package/dist/flow-browser.js +2 -2
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +9 -5
- package/dist/flow-result-card.d.ts +22 -0
- package/dist/flow-result-card.js +55 -0
- package/dist/flow-send.js +12 -9
- package/dist/flow-steps.js +1 -1
- package/dist/flow-triggers.js +1 -1
- package/dist/flow-unattended.d.ts +26 -0
- package/dist/flow-unattended.js +12 -3
- package/dist/flows-ui.js +10 -2
- package/dist/follow-through-brief.d.ts +12 -0
- package/dist/follow-through-brief.js +31 -0
- package/dist/follow-through-cli.d.ts +23 -0
- package/dist/follow-through-cli.js +153 -0
- package/dist/git-incident.d.ts +16 -0
- package/dist/git-incident.js +69 -6
- package/dist/handover-note.d.ts +33 -0
- package/dist/handover-note.js +106 -0
- package/dist/invoke.d.ts +20 -1
- package/dist/invoke.js +108 -44
- package/dist/lead-cli.js +26 -24
- package/dist/lead-contract.js +1 -0
- package/dist/lead-doors.d.ts +4 -0
- package/dist/lead-doors.js +2 -2
- package/dist/lead-progress.js +2 -0
- package/dist/lead-tools.js +111 -32
- package/dist/lead.js +1 -1
- package/dist/local-owner.js +5 -0
- package/dist/modes.d.ts +6 -6
- package/dist/modes.js +3 -1
- package/dist/needs-you.d.ts +5 -2
- package/dist/needs-you.js +14 -2
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +165 -14
- package/dist/plan-login.d.ts +14 -0
- package/dist/plan-login.js +58 -0
- package/dist/planner.d.ts +1 -1
- package/dist/planner.js +2 -1
- package/dist/project-delete-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +18 -1
- package/dist/project-delete.js +6 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -4
- package/dist/project-tools.js +23 -7
- package/dist/protected-storage-look.d.ts +1 -0
- package/dist/protected-storage-look.js +13 -0
- package/dist/protected-storage.d.ts +70 -39
- package/dist/protected-storage.js +196 -147
- package/dist/provider-capture.d.ts +34 -0
- package/dist/provider-capture.js +53 -0
- package/dist/release-carry.js +10 -1
- package/dist/release.js +9 -1
- package/dist/scope.d.ts +22 -3
- package/dist/scope.js +71 -20
- package/dist/scout.d.ts +1 -1
- package/dist/scout.js +2 -1
- package/dist/serve.js +2 -0
- package/dist/server/chat.js +2 -2
- package/dist/server/console.css +50 -0
- package/dist/server/render-chat.js +14 -1
- package/dist/server/render-follow-through.d.ts +11 -0
- package/dist/server/render-follow-through.js +68 -0
- package/dist/server/render-results.js +1 -1
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +24 -5
- package/dist/server/route-table.js +3 -0
- package/dist/server/settings.js +37 -12
- package/dist/server/task-views.js +5 -0
- package/dist/server/tasks.js +76 -0
- package/dist/settings-groups-ui.d.ts +1 -0
- package/dist/settings-groups-ui.js +3 -1
- package/dist/settings-kit.js +3 -2
- package/dist/settings-proofs.js +3 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/slack-chat.js +14 -1
- package/dist/steer-mailbox.d.ts +8 -1
- package/dist/steer-mailbox.js +164 -6
- package/dist/store.d.ts +11 -4
- package/dist/store.js +93 -30
- package/dist/surface.js +24 -4
- package/dist/task-control.d.ts +2 -1
- package/dist/task-status.js +5 -0
- package/dist/teardown-git.d.ts +2 -0
- package/dist/teardown-git.js +42 -16
- package/dist/telegram-lead.js +23 -6
- package/dist/telegram.js +12 -1
- package/dist/toolroll-update.js +2 -0
- package/dist/work-index.js +22 -7
- package/dist/work-summary.d.ts +1 -1
- package/dist/work-summary.js +5 -2
- package/dist/workspace-ui.d.ts +3 -1
- package/dist/workspace-ui.js +10 -1
- package/dist/worktree.js +23 -1
- package/package.json +2 -1
|
@@ -15,14 +15,18 @@
|
|
|
15
15
|
* OP_CONFIG_DIR, BITWARDENCLI_APPDATA_DIR, and the profiles Firefox's (and Zen's) profiles.ini places elsewhere.
|
|
16
16
|
* Storage Toolroll doesn't know about (a browser started with its own --user-data-dir, a portable app) isn't covered.
|
|
17
17
|
*
|
|
18
|
+
* Each launch's check is its own and finishes before the launch (prepareProtectedStorage): on a Mac it runs in a child
|
|
19
|
+
* process, so a privacy prompt never holds the thread that serves the console, and a check that can't finish refuses
|
|
20
|
+
* the launch in words that say which program macOS may need to allow. No earlier walk stands in for it.
|
|
21
|
+
*
|
|
18
22
|
* The chat and vault variables (SLACK_*, TELEGRAM_*, DISCORD_*, TEAMS_*, OP_*, BW_*, BITWARDENCLI_*) are kept out of
|
|
19
23
|
* every launch's environment (`withoutProtectedEnv`, exec.ts).
|
|
20
24
|
*/
|
|
21
|
-
import {
|
|
22
|
-
import { closeSync, existsSync, fstatSync, lstatSync, openSync, constants as fsConstants, readSync, readdirSync, readlinkSync, realpathSync
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import { closeSync, existsSync, fstatSync, lstatSync, openSync, constants as fsConstants, readSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
23
27
|
import { homedir } from "node:os";
|
|
24
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
25
|
-
import {
|
|
28
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
26
30
|
const AS = "Library/Application Support";
|
|
27
31
|
const CONTAINERS = "Library/Containers";
|
|
28
32
|
const GROUPS = "Library/Group Containers";
|
|
@@ -127,28 +131,38 @@ export function protectedStorageNames(home = homedir(), env = process.env, platf
|
|
|
127
131
|
add(profile);
|
|
128
132
|
return [...names].sort();
|
|
129
133
|
}
|
|
130
|
-
/** The absolute `Path=` entries of a profiles.ini in `root`, read only as the plain file it is, bounded.
|
|
134
|
+
/** The absolute `Path=` entries of a profiles.ini in `root`, read only as the plain file it is, bounded. On a Mac this
|
|
135
|
+
* runs only inside the storage check's child process (prepareProtectedStorage), where an open that waits on macOS
|
|
136
|
+
* holds up nothing else. */
|
|
131
137
|
function profilesElsewhere(root) {
|
|
132
|
-
const
|
|
138
|
+
const text = profilesIniHere(join(root, "profiles.ini"));
|
|
139
|
+
return (text ?? "").split(/\r?\n/)
|
|
140
|
+
.map(line => /^\s*Path\s*=\s*(.+?)\s*$/i.exec(line)?.[1] ?? null)
|
|
141
|
+
.filter((path) => path !== null && isAbsolute(path));
|
|
142
|
+
}
|
|
143
|
+
function profilesIniHere(file) {
|
|
144
|
+
try {
|
|
145
|
+
lstatSync(file);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
133
150
|
let fd;
|
|
134
151
|
try {
|
|
135
152
|
fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
136
153
|
}
|
|
137
154
|
catch {
|
|
138
|
-
return
|
|
155
|
+
return null;
|
|
139
156
|
}
|
|
140
157
|
try {
|
|
141
158
|
const held = fstatSync(fd);
|
|
142
159
|
if (!held.isFile())
|
|
143
|
-
return
|
|
160
|
+
return null;
|
|
144
161
|
const buffer = Buffer.alloc(Math.min(held.size, 64 * 1024));
|
|
145
|
-
|
|
146
|
-
return buffer.subarray(0, read).toString("utf8").split(/\r?\n/)
|
|
147
|
-
.map(line => /^\s*Path\s*=\s*(.+?)\s*$/i.exec(line)?.[1] ?? null)
|
|
148
|
-
.filter((path) => path !== null && isAbsolute(path));
|
|
162
|
+
return buffer.subarray(0, readSync(fd, buffer, 0, buffer.length, 0)).toString("utf8");
|
|
149
163
|
}
|
|
150
164
|
catch {
|
|
151
|
-
return
|
|
165
|
+
return null;
|
|
152
166
|
}
|
|
153
167
|
finally {
|
|
154
168
|
closeSync(fd);
|
|
@@ -156,26 +170,24 @@ function profilesElsewhere(root) {
|
|
|
156
170
|
}
|
|
157
171
|
const unwalkedSaid = new Set();
|
|
158
172
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
173
|
+
* The storage check, run right here: every root by name and by real path, every link inside a present root that leads
|
|
174
|
+
* outward (where it leads, by real path and as spelled), and the overrides. `unfinished` names each present root whose
|
|
175
|
+
* walk met an error other than "not there" or "not allowed": its links aren't all known. A link macOS makes in a
|
|
176
|
+
* sandboxed app's container to the same place under the home folder (its Desktop, Downloads, Fonts…) is the system's
|
|
177
|
+
* own mirror of the person's folders, not the app's data, and isn't followed (systemMirror).
|
|
163
178
|
*
|
|
164
|
-
* On a Mac
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* (`containers`, containersListable) shows they can be listed without waiting. A look that fails any other way refuses
|
|
168
|
-
* the launch (it throws). When macOS is waiting on the person, the containers stay denied by name and real path, and
|
|
169
|
-
* the outward links the last complete walk found (kept in the plane's own state folder, linkCacheFile) stay denied too;
|
|
170
|
-
* with none kept, only the roots are denied, and that is said once.
|
|
179
|
+
* On a Mac a listing (or an open) inside another app's data can wait until a person answers a privacy prompt, so there
|
|
180
|
+
* this runs only in the check's own child process (prepareProtectedStorage), never on a thread that serves anything.
|
|
181
|
+
* `at` is told each root before its walk starts, so a check that runs out of time can name the app it was waiting on.
|
|
171
182
|
*/
|
|
172
|
-
export function
|
|
183
|
+
export function protectedStorageHere(home = homedir(), env = process.env, platform = process.platform, say = () => { }, list = path => readdirSync(path, { withFileTypes: true }), at = () => { }) {
|
|
173
184
|
const base = real(home);
|
|
185
|
+
const names = protectedStorageNames(base, env, platform);
|
|
174
186
|
const out = new Set();
|
|
175
187
|
const add = (path) => { if (!tooWide(path, base))
|
|
176
188
|
out.add(path); };
|
|
177
|
-
const
|
|
178
|
-
for (const name of
|
|
189
|
+
const unfinished = [];
|
|
190
|
+
for (const name of names) {
|
|
179
191
|
add(name);
|
|
180
192
|
try {
|
|
181
193
|
lstatSync(name);
|
|
@@ -185,36 +197,18 @@ export function protectedStorageDenies(home = homedir(), env = process.env, plat
|
|
|
185
197
|
}
|
|
186
198
|
const root = real(name);
|
|
187
199
|
add(root);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// Throws when the look itself failed: the launch is refused, never fenced with less.
|
|
192
|
-
const listable = held.length === 0 || containers(held);
|
|
193
|
-
const cache = held.length === 0 ? null : linkCacheFile(base, env);
|
|
194
|
-
if (!listable) {
|
|
195
|
-
const kept = cache === null ? null : readLinkCache(cache, base);
|
|
196
|
-
for (const target of kept ?? [])
|
|
200
|
+
at(root);
|
|
201
|
+
const walked = { complete: true };
|
|
202
|
+
for (const target of outwardLinks(name, root, base, say, list, walked))
|
|
197
203
|
add(target);
|
|
198
|
-
if (!
|
|
199
|
-
|
|
200
|
-
say(`toolroll: fence: macOS didn't let Toolroll look inside other apps' containers without asking you; they stay denied, ${kept === null ? "but links inside them weren't checked" : "with the links found inside them when they were last checked"}. Allow Toolroll's service to access data from other apps (Privacy & Security) to check them now.`);
|
|
201
|
-
}
|
|
204
|
+
if (!walked.complete && !unfinished.includes(root))
|
|
205
|
+
unfinished.push(root);
|
|
202
206
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
for (const target of outwardLinks(name, root, base, say, list, container ? walked : undefined)) {
|
|
209
|
-
add(target);
|
|
210
|
-
if (container)
|
|
211
|
-
inside.push(target);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
// Only a walk of every container that met no error but "not there" or "not allowed" is kept for later.
|
|
215
|
-
if (listable && held.length > 0 && walked.complete && cache !== null)
|
|
216
|
-
writeLinkCache(cache, base, inside);
|
|
217
|
-
return [...out].sort();
|
|
207
|
+
return { names, denies: [...out].sort(), unfinished };
|
|
208
|
+
}
|
|
209
|
+
/** Every protected path, checked right here (protectedStorageHere), for a caller that has no prepared check. */
|
|
210
|
+
export function protectedStorageDenies(home = homedir(), env = process.env, platform = process.platform, say, list) {
|
|
211
|
+
return protectedStorageHere(home, env, platform, say, list).denies;
|
|
218
212
|
}
|
|
219
213
|
/** Where the links inside a present root lead, when outside it: a walk that never follows a link, bounded. */
|
|
220
214
|
function outwardLinks(name, root, home, say, list, walked) {
|
|
@@ -289,110 +283,165 @@ function systemMirror(link, root, target, home) {
|
|
|
289
283
|
const known = CONTAINER_MIRRORS.has(named) || named === `Library/Application Scripts/${root.split(sep).at(-1)}`;
|
|
290
284
|
return known && target === join(home, ...under);
|
|
291
285
|
}
|
|
292
|
-
/** The
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
286
|
+
/** The answers a walk takes as complete: the folder isn't there, or macOS refused it outright. */
|
|
287
|
+
const ANSWERED = new Set(["ENOENT", "ENOTDIR", "EPERM", "EACCES"]);
|
|
288
|
+
// ---- the check, off the thread ---------------------------------------------------------------------------------
|
|
289
|
+
/** How long the storage check may take before the launch is refused (a slow Mac finishes well inside it). */
|
|
290
|
+
export const STORAGE_LOOK_MS = 10_000;
|
|
291
|
+
/** At most this much of the check's answer is read. */
|
|
292
|
+
const LOOK_OUTPUT_MAX = 8 * 1024 * 1024;
|
|
293
|
+
/** The plane's own variables the check reads (protectedStorageNames), handed to its child as they are here. */
|
|
294
|
+
const STORAGE_ENV = ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "OP_CONFIG_DIR", "BITWARDENCLI_APPDATA_DIR"];
|
|
295
|
+
/** Why a launch didn't start: the storage check couldn't finish. Its words survive every catch (storageRefusalOf). */
|
|
296
|
+
export class ProtectedStorageRefusal extends Error {
|
|
297
|
+
apps;
|
|
298
|
+
why;
|
|
299
|
+
code = "protected-storage";
|
|
300
|
+
constructor(apps, why, message) {
|
|
301
|
+
super(message);
|
|
302
|
+
this.apps = apps;
|
|
303
|
+
this.why = why;
|
|
304
|
+
this.name = "ProtectedStorageRefusal";
|
|
305
|
+
}
|
|
297
306
|
}
|
|
298
|
-
/**
|
|
299
|
-
export
|
|
300
|
-
|
|
301
|
-
const CONTAINER_RELOOK_MS = 10 * 60_000;
|
|
302
|
-
/** Each set of container roots' last answer: listable, or not and when that was seen. */
|
|
303
|
-
const containerLooks = new Map();
|
|
304
|
-
/** The look itself, in a child process: the same bounded walk `outwardLinks` makes, never following a link. */
|
|
305
|
-
const CONTAINER_LOOK = `const fs=require("fs"),path=require("path");const [depth,limit,...roots]=process.argv.slice(1);
|
|
306
|
-
for(const root of roots){let seen=0;const queue=[{p:root,d:0}];while(queue.length>0){const {p,d}=queue.shift();let entries;
|
|
307
|
-
try{entries=fs.readdirSync(p,{withFileTypes:true});}catch{continue;}for(const e of entries){if(++seen>Number(limit)){queue.length=0;break;}
|
|
308
|
-
if(!e.isSymbolicLink()&&e.isDirectory()&&d+1<Number(depth))queue.push({p:path.join(p,e.name),d:d+1});}}}`;
|
|
309
|
-
/**
|
|
310
|
-
* Whether this process can list inside these app containers without macOS stopping to ask the person: the walk run
|
|
311
|
-
* first in a child process that is killed at CONTAINER_LOOK_MS. Finishing in time (listed, or refused outright) means
|
|
312
|
-
* the walk can run here too; only running into that limit means macOS is waiting on the person (only the child waited).
|
|
313
|
-
* A look that fails any other way is tried once more, then throws: the launch is refused rather than fenced with less.
|
|
314
|
-
* A yes is kept (macOS keeps the person's answer); a no is looked at again after CONTAINER_RELOOK_MS.
|
|
315
|
-
*/
|
|
316
|
-
export function containersListable(roots, look = lookInContainers, now = Date.now) {
|
|
317
|
-
const key = [...roots].sort().join("\0");
|
|
318
|
-
const last = containerLooks.get(key);
|
|
319
|
-
if (last !== undefined && (last.listable || now() - last.at < CONTAINER_RELOOK_MS))
|
|
320
|
-
return last.listable;
|
|
321
|
-
let answer = look(roots);
|
|
322
|
-
if (answer === "failed")
|
|
323
|
-
answer = look(roots);
|
|
324
|
-
if (answer === "failed")
|
|
325
|
-
throw new Error("Toolroll couldn't look inside other apps' containers to fence them (the look failed twice), so nothing was launched.");
|
|
326
|
-
const listable = answer === "listable";
|
|
327
|
-
containerLooks.set(key, { listable, at: now() });
|
|
328
|
-
return listable;
|
|
307
|
+
/** The storage refusal inside a caught error, when it is one. */
|
|
308
|
+
export function storageRefusalOf(error) {
|
|
309
|
+
return error instanceof ProtectedStorageRefusal ? error : null;
|
|
329
310
|
}
|
|
330
|
-
/** The
|
|
331
|
-
function
|
|
332
|
-
const
|
|
333
|
-
return
|
|
311
|
+
/** The apps behind these roots, by name, for the words a person reads. */
|
|
312
|
+
function appsOf(roots, home) {
|
|
313
|
+
const names = roots.map(root => PROTECTED_STORAGE.find(row => [...row.darwin, ...row.linux].some(one => real(join(home, one)) === root || join(home, one) === root))?.app ?? basename(root));
|
|
314
|
+
return [...new Set(names)];
|
|
334
315
|
}
|
|
335
|
-
/**
|
|
336
|
-
*
|
|
337
|
-
export function
|
|
338
|
-
if (
|
|
339
|
-
return "
|
|
340
|
-
|
|
316
|
+
/** Which program macOS must allow: the one this plane runs as. A launchd service is its program itself; a plane started
|
|
317
|
+
* from a terminal asks as that terminal app. */
|
|
318
|
+
export function storageAccessHolder(env = process.env, execPath = process.execPath, versions = process.versions) {
|
|
319
|
+
if (versions["electron"] !== undefined)
|
|
320
|
+
return "the Toolroll app";
|
|
321
|
+
const service = env["XPC_SERVICE_NAME"];
|
|
322
|
+
if (service !== undefined && service !== "" && service !== "0")
|
|
323
|
+
return `the program that keeps Toolroll running (${execPath})`;
|
|
324
|
+
const terminal = env["TERM_PROGRAM"];
|
|
325
|
+
if (terminal !== undefined && terminal !== "")
|
|
326
|
+
return `the terminal app Toolroll was started from (${terminal.replace(/\.app$/, "")})`;
|
|
327
|
+
return `the program Toolroll runs as (${execPath})`;
|
|
341
328
|
}
|
|
342
|
-
/**
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
export function linkCacheFile(home, env = process.env) {
|
|
349
|
-
return join(namedFolder(configBase({ ...env }, home)), "protected-links.json");
|
|
329
|
+
/** Why nothing was launched, and what the person does about it. Never says macOS is asking: that isn't known. */
|
|
330
|
+
export function storageRefusalWords(apps, why, holder) {
|
|
331
|
+
const named = apps.length === 0 ? "other apps" : apps.length === 1 ? apps[0] : `${apps.slice(0, -1).join(", ")} and ${apps.at(-1)}`;
|
|
332
|
+
const reason = why === "timed-out" ? ` (the check didn't finish within ${STORAGE_LOOK_MS / 1000} seconds)` : why === "unfinished" ? " (some of its folders couldn't be read)" : " (the check stopped with an error)";
|
|
333
|
+
return `Nothing was started: Toolroll couldn't finish checking ${apps.length === 0 ? "other apps'" : `${named}'s`} data for links to keep out of the build${reason}. `
|
|
334
|
+
+ `macOS may need to let ${holder} read it: in System Settings → Privacy & Security → Full Disk Access, turn it on for ${holder}, then try again.`;
|
|
350
335
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
336
|
+
const inFlight = new Map();
|
|
337
|
+
/**
|
|
338
|
+
* The storage check for one launch, awaited: what it denies, from a walk that finished just now. On a Mac the whole check
|
|
339
|
+
* (finding the roots, reading profiles.ini, every walk) runs in a child process, so a listing that waits on a privacy
|
|
340
|
+
* prompt holds up only that child; the thread that asked keeps serving. A check that doesn't finish within
|
|
341
|
+
* STORAGE_LOOK_MS, fails, or leaves a present root's walk unfinished refuses the launch (ProtectedStorageRefusal): no
|
|
342
|
+
* earlier walk stands in for it, since a link can move inside a root at any time. Checks asked for at once share one child.
|
|
343
|
+
*/
|
|
344
|
+
export async function prepareProtectedStorage(home = homedir(), env = process.env, platform = process.platform, look = lookInChild, holder = storageAccessHolder(env)) {
|
|
345
|
+
const base = real(home);
|
|
346
|
+
const request = { home: base, env: Object.fromEntries(STORAGE_ENV.flatMap(name => env[name] === undefined ? [] : [[name, env[name]]])), platform };
|
|
347
|
+
let answer;
|
|
348
|
+
if (platform !== "darwin") {
|
|
349
|
+
// No privacy prompt can hold a listing here: the check runs as it is.
|
|
350
|
+
const here = protectedStorageHere(base, env, platform, line => process.stderr.write(`${line}\n`));
|
|
351
|
+
answer = { kind: "done", storage: { names: here.names, denies: here.denies }, unfinished: here.unfinished };
|
|
363
352
|
}
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return null;
|
|
373
|
-
const { version, home: kept, targets } = value;
|
|
374
|
-
if (version !== 1 || kept !== home || !Array.isArray(targets) || targets.some(one => typeof one !== "string" || !isAbsolute(one)))
|
|
375
|
-
return null;
|
|
376
|
-
return targets;
|
|
353
|
+
else {
|
|
354
|
+
const key = JSON.stringify(request);
|
|
355
|
+
let pending = inFlight.get(key);
|
|
356
|
+
if (pending === undefined) {
|
|
357
|
+
pending = look(request).finally(() => inFlight.delete(key));
|
|
358
|
+
inFlight.set(key, pending);
|
|
359
|
+
}
|
|
360
|
+
answer = await pending;
|
|
377
361
|
}
|
|
378
|
-
|
|
379
|
-
|
|
362
|
+
if (answer.kind === "timed-out") {
|
|
363
|
+
const apps = answer.at === null ? [] : appsOf([answer.at], base);
|
|
364
|
+
throw new ProtectedStorageRefusal(apps, "timed-out", storageRefusalWords(apps, "timed-out", holder));
|
|
380
365
|
}
|
|
381
|
-
|
|
382
|
-
|
|
366
|
+
if (answer.kind === "failed")
|
|
367
|
+
throw new ProtectedStorageRefusal([], "failed", storageRefusalWords([], "failed", holder));
|
|
368
|
+
if (answer.unfinished.length > 0) {
|
|
369
|
+
const apps = appsOf(answer.unfinished, base);
|
|
370
|
+
throw new ProtectedStorageRefusal(apps, "unfinished", storageRefusalWords(apps, "unfinished", holder));
|
|
383
371
|
}
|
|
372
|
+
return answer.storage;
|
|
373
|
+
}
|
|
374
|
+
/** Where the check's child script is: beside this module once built, else the built copy (a run from source). */
|
|
375
|
+
export function storageLookScript() {
|
|
376
|
+
const beside = fileURLToPath(new URL("./protected-storage-look.js", import.meta.url));
|
|
377
|
+
return existsSync(beside) ? beside : fileURLToPath(new URL("../dist/protected-storage-look.js", import.meta.url));
|
|
378
|
+
}
|
|
379
|
+
/** The default check: node (or Electron as node) runs protected-storage-look.js, killed at STORAGE_LOOK_MS. */
|
|
380
|
+
async function lookInChild(request) {
|
|
381
|
+
return readLook(await spawnLook(process.execPath, [storageLookScript(), JSON.stringify(request)], STORAGE_LOOK_MS));
|
|
384
382
|
}
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
383
|
+
/** A finished child's lines as the check's answer: `at\t<root>` before each walk, `done\t<answer>` once at the end. */
|
|
384
|
+
export function readLook(ran) {
|
|
385
|
+
const lines = ran.stdout.split("\n");
|
|
386
|
+
const last = [...lines].reverse().find(line => line.startsWith("at\t"));
|
|
387
|
+
const at = last === undefined ? null : (() => { try {
|
|
388
|
+
const root = JSON.parse(last.slice(3));
|
|
389
|
+
return typeof root === "string" && isAbsolute(root) ? root : null;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return null;
|
|
393
|
+
} })();
|
|
394
|
+
if (ran.outcome === "timed-out")
|
|
395
|
+
return { kind: "timed-out", at };
|
|
396
|
+
if (ran.outcome === "failed")
|
|
397
|
+
return { kind: "failed", detail: ran.detail };
|
|
398
|
+
const done = lines.find(line => line.startsWith("done\t"));
|
|
399
|
+
if (done === undefined)
|
|
400
|
+
return { kind: "failed", detail: "the check gave no answer" };
|
|
391
401
|
try {
|
|
392
|
-
|
|
393
|
-
|
|
402
|
+
const value = JSON.parse(done.slice(5));
|
|
403
|
+
const paths = (one) => Array.isArray(one) && one.every(path => typeof path === "string" && isAbsolute(path)) ? one : null;
|
|
404
|
+
const names = paths(value["names"]), denies = paths(value["denies"]), unfinished = paths(value["unfinished"]);
|
|
405
|
+
if (names === null || denies === null || unfinished === null)
|
|
406
|
+
return { kind: "failed", detail: "the check's answer can't be read" };
|
|
407
|
+
return { kind: "done", storage: { names, denies }, unfinished };
|
|
394
408
|
}
|
|
395
409
|
catch {
|
|
396
|
-
|
|
410
|
+
return { kind: "failed", detail: "the check's answer can't be read" };
|
|
397
411
|
}
|
|
398
412
|
}
|
|
413
|
+
/** Run a child without waiting on this thread: its output, bounded, and how it ended. Killed at `limitMs`. */
|
|
414
|
+
export function spawnLook(command, args, limitMs) {
|
|
415
|
+
return new Promise(settle => {
|
|
416
|
+
let stdout = "", over = false, timedOut = false, settled = false;
|
|
417
|
+
const finish = (outcome, detail) => { if (settled)
|
|
418
|
+
return; settled = true; clearTimeout(timer); settle({ stdout, outcome, detail }); };
|
|
419
|
+
let child;
|
|
420
|
+
try {
|
|
421
|
+
child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } });
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
settle({ stdout: "", outcome: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, limitMs);
|
|
428
|
+
child.stdout?.setEncoding("utf8");
|
|
429
|
+
child.stdout?.on("data", (chunk) => { if (stdout.length + chunk.length > LOOK_OUTPUT_MAX) {
|
|
430
|
+
over = true;
|
|
431
|
+
child.kill("SIGKILL");
|
|
432
|
+
}
|
|
433
|
+
else
|
|
434
|
+
stdout += chunk; });
|
|
435
|
+
child.on("error", error => finish("failed", error.message));
|
|
436
|
+
child.on("close", (code, signal) => {
|
|
437
|
+
if (timedOut)
|
|
438
|
+
finish("timed-out", "");
|
|
439
|
+
else if (over)
|
|
440
|
+
finish("failed", "the check said too much");
|
|
441
|
+
else if (code === 0 && signal === null)
|
|
442
|
+
finish("done", "");
|
|
443
|
+
else
|
|
444
|
+
finish("failed", signal === null ? `the check exited with ${code}` : `the check was stopped (${signal})`);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
export declare const CAPTURE_TEXT_LIMIT = 4000;
|
|
3
|
+
export type ProviderCapture = {
|
|
4
|
+
id: number;
|
|
5
|
+
run: number;
|
|
6
|
+
repo: string | null;
|
|
7
|
+
provider: string;
|
|
8
|
+
model: string | null;
|
|
9
|
+
authMode: string | null;
|
|
10
|
+
exitCode: number | null;
|
|
11
|
+
code: string | null;
|
|
12
|
+
text: string;
|
|
13
|
+
hint: boolean;
|
|
14
|
+
label: "exhausted" | "not-exhausted" | null;
|
|
15
|
+
labelledBy: string | null;
|
|
16
|
+
at: string;
|
|
17
|
+
};
|
|
18
|
+
/** Save one failed attempt's last words. Bookkeeping: it never costs the run anything. */
|
|
19
|
+
export declare function captureProviderFailure(store: Store, input: {
|
|
20
|
+
run: number;
|
|
21
|
+
provider: string;
|
|
22
|
+
model: string | null;
|
|
23
|
+
authMode: string | null;
|
|
24
|
+
exitCode: number | null;
|
|
25
|
+
code: string | null;
|
|
26
|
+
texts: readonly (string | null | undefined)[];
|
|
27
|
+
}, now: Date): void;
|
|
28
|
+
/** Newest first, the ones mentioning limits at the top. */
|
|
29
|
+
export declare function providerCaptures(store: Store, repos: readonly string[] | null, limit?: number): ProviderCapture[];
|
|
30
|
+
export declare function providerCapture(store: Store, id: number): ProviderCapture | null;
|
|
31
|
+
/** A person's reading of one capture. It marks it for keeping, and nothing routes on it. */
|
|
32
|
+
export declare function labelProviderCapture(store: Store, id: number, label: "exhausted" | "not-exhausted", by: string): boolean;
|
|
33
|
+
/** What goes in a fixture file: the provider's response and which version of what produced it. */
|
|
34
|
+
export declare function captureFixture(capture: ProviderCapture): Record<string, unknown>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a provider said when an attempt failed, saved for a person (docs/plans/fleet-follow-through.md, part 5).
|
|
3
|
+
*
|
|
4
|
+
* Fallback between agents was removed in v115, and exhaustion.ts recognises only a dead sign-in. Telling a used-up
|
|
5
|
+
* subscription from any other failure needs a REAL response, so this saves them as they happen and nothing else. Nothing
|
|
6
|
+
* reads this table to route work: `hint` is a sort key for a person, never a recogniser, and a person's label decides
|
|
7
|
+
* nothing either. The text is the provider's, so it is data, scrubbed of secrets and bounded.
|
|
8
|
+
*/
|
|
9
|
+
import { readingFleet } from "./fleet-schema.js";
|
|
10
|
+
import { cleanFindingText } from "./findings.js";
|
|
11
|
+
export const CAPTURE_TEXT_LIMIT = 4_000;
|
|
12
|
+
/** Kept per provider, newest first. A labelled capture is never pruned: it is the evidence a person kept. */
|
|
13
|
+
const CAPTURES_KEPT = 40;
|
|
14
|
+
/** Words worth a person's look. Only ever a sort key. */
|
|
15
|
+
const LIMIT_WORDS = /\b(usage limit|rate limit|quota|credit|billing|exhaust|limit reached|too many requests|429|payment)/i;
|
|
16
|
+
/** Save one failed attempt's last words. Bookkeeping: it never costs the run anything. */
|
|
17
|
+
export function captureProviderFailure(store, input, now) {
|
|
18
|
+
try {
|
|
19
|
+
const text = cleanFindingText(input.texts.filter((one) => typeof one === "string" && one.trim() !== "").join("\n"), CAPTURE_TEXT_LIMIT);
|
|
20
|
+
if (text === "" && input.code === null)
|
|
21
|
+
return;
|
|
22
|
+
const run = store.getRun(input.run);
|
|
23
|
+
const repo = run === null ? null : store.refById(run.taskRef)?.repo ?? null;
|
|
24
|
+
store.handle.prepare(`INSERT INTO provider_capture (run, repo, provider, model, auth_mode, exit_code, code, text, hint, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (run) DO NOTHING`)
|
|
25
|
+
.run(input.run, repo, input.provider, input.model, input.authMode, input.exitCode, input.code === null ? null : cleanFindingText(input.code, 120), text, LIMIT_WORDS.test(`${input.code ?? ""} ${text}`) ? 1 : 0, now.toISOString());
|
|
26
|
+
store.handle.prepare(`DELETE FROM provider_capture WHERE provider = ? AND label IS NULL AND id NOT IN (SELECT id FROM provider_capture WHERE provider = ? ORDER BY id DESC LIMIT ?)`).run(input.provider, input.provider, CAPTURES_KEPT);
|
|
27
|
+
}
|
|
28
|
+
catch { /* a capture is never worth a run */ }
|
|
29
|
+
}
|
|
30
|
+
const captureOf = (row) => ({
|
|
31
|
+
id: Number(row["id"]), run: Number(row["run"]), repo: row["repo"] === null ? null : String(row["repo"]), provider: String(row["provider"]), model: row["model"] === null ? null : String(row["model"]),
|
|
32
|
+
authMode: row["auth_mode"] === null ? null : String(row["auth_mode"]), exitCode: row["exit_code"] === null ? null : Number(row["exit_code"]), code: row["code"] === null ? null : String(row["code"]),
|
|
33
|
+
text: String(row["text"]), hint: Number(row["hint"]) === 1, label: row["label"] === null ? null : String(row["label"]), labelledBy: row["labelled_by"] === null ? null : String(row["labelled_by"]), at: String(row["at"]),
|
|
34
|
+
});
|
|
35
|
+
/** Newest first, the ones mentioning limits at the top. */
|
|
36
|
+
export function providerCaptures(store, repos, limit = 50) {
|
|
37
|
+
return readingFleet(() => store.handle.prepare("SELECT * FROM provider_capture ORDER BY hint DESC, id DESC LIMIT 500").all().map(captureOf)
|
|
38
|
+
.filter(one => repos === null || (one.repo !== null && repos.includes(one.repo))).slice(0, limit), []);
|
|
39
|
+
}
|
|
40
|
+
export function providerCapture(store, id) {
|
|
41
|
+
return readingFleet(() => {
|
|
42
|
+
const row = store.handle.prepare("SELECT * FROM provider_capture WHERE id = ?").get(id);
|
|
43
|
+
return row === undefined ? null : captureOf(row);
|
|
44
|
+
}, null);
|
|
45
|
+
}
|
|
46
|
+
/** A person's reading of one capture. It marks it for keeping, and nothing routes on it. */
|
|
47
|
+
export function labelProviderCapture(store, id, label, by) {
|
|
48
|
+
return Number(store.handle.prepare("UPDATE provider_capture SET label = ?, labelled_by = ? WHERE id = ?").run(label, by, id).changes) === 1;
|
|
49
|
+
}
|
|
50
|
+
/** What goes in a fixture file: the provider's response and which version of what produced it. */
|
|
51
|
+
export function captureFixture(capture) {
|
|
52
|
+
return { version: 1, provider: capture.provider, model: capture.model, authMode: capture.authMode, exitCode: capture.exitCode, code: capture.code, text: capture.text, label: capture.label, labelledBy: capture.labelledBy, capturedAt: capture.at };
|
|
53
|
+
}
|
package/dist/release-carry.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* read only Toolroll's records and verified objects. (Coverage, in release-recheck.ts, trusts test code by Alex's
|
|
20
20
|
* explicit decision; nothing here does.)
|
|
21
21
|
*/
|
|
22
|
+
import { prepareProtectedStorage, storageRefusalOf } from "./protected-storage.js";
|
|
22
23
|
import { createHash, randomUUID } from "node:crypto";
|
|
23
24
|
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
24
25
|
import { homedir } from "node:os";
|
|
@@ -304,11 +305,19 @@ export function fencedBuildRunner(store) {
|
|
|
304
305
|
if (!macosFenceAvailable() && !linuxFenceAvailable()) {
|
|
305
306
|
return { code: 1, stdout: "", stderr: "This machine can't confine the build (no macOS sandbox, no working bubblewrap), so it didn't run.\n", timedOut: false, notFound: false };
|
|
306
307
|
}
|
|
308
|
+
// The storage check, awaited off this thread (protected-storage.ts); its words are the build's when it can't finish.
|
|
309
|
+
let storage;
|
|
310
|
+
try {
|
|
311
|
+
storage = await prepareProtectedStorage();
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
return { code: 1, stdout: "", stderr: `${storageRefusalOf(error)?.message ?? "The build's fence could not be prepared, so it didn't run."}\n`, timedOut: false, notFound: false };
|
|
315
|
+
}
|
|
307
316
|
const egress = await startEgressProxy({ hosts: [] });
|
|
308
317
|
try {
|
|
309
318
|
const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: "proxy-only", socketRoots: [...options.writable], token: egress.secret };
|
|
310
319
|
const launch = fenceFor("check", {
|
|
311
|
-
databaseFile: store.databaseFile(), worktree: options.cwd, boundary: null, binding: null, runFolders: runFolders(null), poolRoots: [],
|
|
320
|
+
databaseFile: store.databaseFile(), worktree: options.cwd, boundary: null, binding: null, runFolders: runFolders(null), poolRoots: [], storage,
|
|
312
321
|
writes: { writes: { subpaths: [...options.writable], prefixes: [] }, readOnly: [] }, network,
|
|
313
322
|
});
|
|
314
323
|
return await run(file, args, { cwd: options.cwd, env: { ...options.env, ...proxyEnv(egress, "proxy-only") }, timeoutMs: options.timeoutMs, processGroup: true,
|
package/dist/release.js
CHANGED
|
@@ -93,6 +93,8 @@ export const ADVISORY_PR_CHECKS = {
|
|
|
93
93
|
"youiyoux · UI drift": ".github/workflows/youiyoux.yml",
|
|
94
94
|
"journeys · app (scripted)": ".github/workflows/journeys.yml",
|
|
95
95
|
"journeys · flows (scripted)": ".github/workflows/journeys.yml",
|
|
96
|
+
"upgrade path · scope": ".github/workflows/upgrade-path.yml",
|
|
97
|
+
"upgrade path": ".github/workflows/upgrade-path.yml",
|
|
96
98
|
};
|
|
97
99
|
/** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, docs/plans/test-matrix.md):
|
|
98
100
|
* the release proof (the tagged tree passed every REQUIRED_PR_CHECKS check on its pull request, read from GitHub) and
|
|
@@ -249,6 +251,8 @@ export async function runRelease(options, adapters) {
|
|
|
249
251
|
let step = "preflight";
|
|
250
252
|
let freshApproval = false;
|
|
251
253
|
let supersededTask = null;
|
|
254
|
+
/** The commit whose check stopped the release being set aside. The stop tells a person to approve it again with --new. */
|
|
255
|
+
let stoppedCheck = null;
|
|
252
256
|
const save = () => { journal.updatedAt = adapters.now().toISOString(); writeJournal(file, journal); };
|
|
253
257
|
/** Tell the progress record about a step (v146). Never throws and never waits: the release goes on whatever it says. */
|
|
254
258
|
const mark = (at, state, detail = null) => {
|
|
@@ -322,6 +326,8 @@ export async function runRelease(options, adapters) {
|
|
|
322
326
|
say(`Kept the earlier release journal at ${aside}.`);
|
|
323
327
|
freshApproval = journal.needsApproval === true;
|
|
324
328
|
supersededTask = journal.task.id;
|
|
329
|
+
if (journal.stopped?.reason === "check-failed")
|
|
330
|
+
stoppedCheck = journal.candidate.sha;
|
|
325
331
|
journal = null;
|
|
326
332
|
}
|
|
327
333
|
if (journal?.step === "done") {
|
|
@@ -336,8 +342,10 @@ export async function runRelease(options, adapters) {
|
|
|
336
342
|
journal = await begin(options, adapters, adapters.now());
|
|
337
343
|
// The earlier attempt needs a person's fresh approval (a CI failure after its deploy): this one files a task of its
|
|
338
344
|
// own for the same commit, so the old approval can't carry it.
|
|
345
|
+
// A check that stopped this same commit is the same: its task keeps that failure, so reusing it would only stop again
|
|
346
|
+
// (a passing run on it, run 3333, can't erase it). A new task reruns the check and asks a person, never the plane.
|
|
339
347
|
// Only an id that has never existed, numbered after the superseded one: never a task whose approval still stands.
|
|
340
|
-
if (freshApproval) {
|
|
348
|
+
if (freshApproval || journal.candidate.sha === stoppedCheck) {
|
|
341
349
|
const base = journal.task.id;
|
|
342
350
|
const suffix = supersededTask === null ? null : supersededTask === base ? 1 : supersededTask.startsWith(`${base}-`) ? Number(supersededTask.slice(base.length + 1)) : null;
|
|
343
351
|
let picked = null;
|