toolroll 0.6.0 → 0.7.0
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/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +23 -23
- package/dist/browser-workspace.d.ts +34 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/install-method.d.ts +17 -0
- package/dist/install-method.js +61 -0
- package/dist/operate.d.ts +5 -0
- package/dist/operate.js +46 -14
- package/dist/provider-auth.d.ts +33 -8
- package/dist/provider-auth.js +54 -13
- package/dist/releases.d.ts +76 -0
- package/dist/releases.js +225 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +91 -4
- package/dist/update-notice.d.ts +5 -0
- package/dist/update-notice.js +4 -0
- package/dist/work-index.js +5 -3
- package/package.json +1 -1
|
@@ -241,6 +241,31 @@ export type BrowserSettingsView = {
|
|
|
241
241
|
redirect: string | null;
|
|
242
242
|
};
|
|
243
243
|
} | null;
|
|
244
|
+
/** Settings → Updates: this version, the latest known one and its notes, how to update, the daily-check switch, and each worker's version. */
|
|
245
|
+
updates?: BrowserUpdates | null;
|
|
246
|
+
};
|
|
247
|
+
export type BrowserUpdates = {
|
|
248
|
+
current: string;
|
|
249
|
+
/** The latest release the last check found; null before any check has worked. */
|
|
250
|
+
latest: {
|
|
251
|
+
version: string;
|
|
252
|
+
newer: boolean;
|
|
253
|
+
security: boolean;
|
|
254
|
+
notes: string;
|
|
255
|
+
url: string;
|
|
256
|
+
} | null;
|
|
257
|
+
updateCommand: string;
|
|
258
|
+
/** The daily check: off by TOOLROLL_NO_UPDATE_CHECK (byEnv) or by this switch; only an operator may flip it. */
|
|
259
|
+
check: {
|
|
260
|
+
on: boolean;
|
|
261
|
+
byEnv: boolean;
|
|
262
|
+
canManage: boolean;
|
|
263
|
+
};
|
|
264
|
+
workers: {
|
|
265
|
+
name: string;
|
|
266
|
+
version: string | null;
|
|
267
|
+
older: boolean;
|
|
268
|
+
}[];
|
|
244
269
|
};
|
|
245
270
|
/** A fold on the task page. Its HTML is the server's own section body, so
|
|
246
271
|
* forms, ids and page scripts are unchanged. */
|
|
@@ -756,6 +781,15 @@ export type BrowserWorkspace = {
|
|
|
756
781
|
refreshSeconds?: number;
|
|
757
782
|
/** Providers whose sign-in stopped working: their work waits (one per provider). */
|
|
758
783
|
signIn?: BrowserSignIn[];
|
|
784
|
+
/** A newer Toolroll exists: a quiet notice for an operator, until they dismiss this version. */
|
|
785
|
+
update?: BrowserUpdateNotice;
|
|
786
|
+
};
|
|
787
|
+
/** The console's update notice: neutral, never the accent — an update does not need a person. */
|
|
788
|
+
export type BrowserUpdateNotice = {
|
|
789
|
+
version: string;
|
|
790
|
+
security: boolean;
|
|
791
|
+
href: string;
|
|
792
|
+
dismissHref: string;
|
|
759
793
|
};
|
|
760
794
|
/** A provider's sign-in pause, as the console shows it: the plain reason,
|
|
761
795
|
* what to run, and the one action that resumes its work. */
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -65,6 +65,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
65
65
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
66
66
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
67
67
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
68
|
+
version?: never;
|
|
68
69
|
active: boolean;
|
|
69
70
|
phase: string;
|
|
70
71
|
detail: string;
|
|
@@ -78,7 +79,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
78
79
|
wasRunning?: never;
|
|
79
80
|
backupPath?: never;
|
|
80
81
|
updateId?: never;
|
|
81
|
-
version?: never;
|
|
82
82
|
buildId?: never;
|
|
83
83
|
workDir?: never;
|
|
84
84
|
} | {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this Toolroll was installed, read from where its bin really lives, and
|
|
3
|
+
* the one command that updates it that way. Only the path is read: nothing is
|
|
4
|
+
* run and nothing leaves the computer.
|
|
5
|
+
*/
|
|
6
|
+
export type InstallKind = "npm" | "pnpm" | "bun" | "yarn" | "homebrew" | "npx" | "source";
|
|
7
|
+
export type InstallMethod = {
|
|
8
|
+
kind: InstallKind;
|
|
9
|
+
updateCommand: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const UPDATE_COMMANDS: Record<InstallKind, string>;
|
|
12
|
+
export type InstallProbe = {
|
|
13
|
+
realpath?: (path: string) => string;
|
|
14
|
+
exists?: (path: string) => boolean;
|
|
15
|
+
};
|
|
16
|
+
/** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
|
|
17
|
+
export declare function installMethod(bin?: string | undefined, probe?: InstallProbe): InstallMethod;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this Toolroll was installed, read from where its bin really lives, and
|
|
3
|
+
* the one command that updates it that way. Only the path is read: nothing is
|
|
4
|
+
* run and nothing leaves the computer.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
export const UPDATE_COMMANDS = {
|
|
9
|
+
npm: "npm install -g toolroll@latest",
|
|
10
|
+
pnpm: "pnpm add -g toolroll@latest",
|
|
11
|
+
bun: "bun add -g toolroll@latest",
|
|
12
|
+
yarn: "yarn global add toolroll@latest",
|
|
13
|
+
homebrew: "brew upgrade ap9000/toolroll/toolroll",
|
|
14
|
+
npx: "npx toolroll@latest",
|
|
15
|
+
source: "git pull && npm install && npm run build",
|
|
16
|
+
};
|
|
17
|
+
/** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
|
|
18
|
+
export function installMethod(bin = process.argv[1], probe = {}) {
|
|
19
|
+
const kind = installKind(bin, probe);
|
|
20
|
+
return { kind, updateCommand: UPDATE_COMMANDS[kind] };
|
|
21
|
+
}
|
|
22
|
+
function installKind(bin, probe) {
|
|
23
|
+
if (bin === undefined || bin === "")
|
|
24
|
+
return "npm";
|
|
25
|
+
let real = bin;
|
|
26
|
+
try {
|
|
27
|
+
real = (probe.realpath ?? realpathSync)(bin);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// A bin that cannot be resolved is judged by the path it was run by.
|
|
31
|
+
}
|
|
32
|
+
const path = real.replace(/\\/g, "/");
|
|
33
|
+
// A plane deployed from a checkout by scripts/deploy-browser.mjs runs from staged-upgrades/browser-*: it updates
|
|
34
|
+
// by deploying from source, never from npm.
|
|
35
|
+
if (path.includes("/staged-upgrades/browser-"))
|
|
36
|
+
return "source";
|
|
37
|
+
if (/\/Cellar\/toolroll\//i.test(path))
|
|
38
|
+
return "homebrew";
|
|
39
|
+
if (path.includes("/_npx/"))
|
|
40
|
+
return "npx";
|
|
41
|
+
if (/\/\.bun\/install\/global\//.test(path))
|
|
42
|
+
return "bun";
|
|
43
|
+
if (/\/pnpm\/global\//i.test(path))
|
|
44
|
+
return "pnpm";
|
|
45
|
+
if (/\/(?:\.config\/yarn|yarn\/data)\/global\//i.test(path))
|
|
46
|
+
return "yarn";
|
|
47
|
+
if (path.includes("/node_modules/"))
|
|
48
|
+
return "npm";
|
|
49
|
+
// Not inside node_modules: a checkout, when the package's own folder is a git working copy.
|
|
50
|
+
const exists = probe.exists ?? existsSync;
|
|
51
|
+
let folder = dirname(real);
|
|
52
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
53
|
+
if (exists(join(folder, "package.json")) && exists(join(folder, ".git")))
|
|
54
|
+
return "source";
|
|
55
|
+
const parent = dirname(folder);
|
|
56
|
+
if (parent === folder)
|
|
57
|
+
break;
|
|
58
|
+
folder = parent;
|
|
59
|
+
}
|
|
60
|
+
return "npm";
|
|
61
|
+
}
|
package/dist/operate.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type TelegramTransport } from "./telegram.js";
|
|
|
6
6
|
import { type MateCliSeams } from "./mate-cli.js";
|
|
7
7
|
import { type PublishExec } from "./publish.js";
|
|
8
8
|
import { type Runner as CommandRunner } from "./builder.js";
|
|
9
|
+
import { type ReleaseIo } from "./releases.js";
|
|
9
10
|
export type Write = (line: string) => void;
|
|
10
11
|
/**
|
|
11
12
|
* 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
|
|
@@ -33,6 +34,10 @@ export type OperateOptions = {
|
|
|
33
34
|
additionalProjectRepos?: () => readonly string[];
|
|
34
35
|
/** Overridden by tests and by an agent that wants its own queue. */
|
|
35
36
|
databaseFile?: string;
|
|
37
|
+
/** Injected by tests: how `status` and the console ask for the latest release. */
|
|
38
|
+
releaseIo?: ReleaseIo;
|
|
39
|
+
/** Injected by tests: the bin whose real path says how Toolroll was installed. */
|
|
40
|
+
installBin?: string;
|
|
36
41
|
openDatabase?: (file: string) => Store;
|
|
37
42
|
now?: Date;
|
|
38
43
|
/**
|
package/dist/operate.js
CHANGED
|
@@ -181,6 +181,9 @@ import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
|
|
|
181
181
|
import { beads } from "./beads.js";
|
|
182
182
|
import { githubIssues } from "./issues.js";
|
|
183
183
|
import { installationStatus, renderInstallationStatus, renderTaskWait, taskWaitSnapshot } from "./lead-status.js";
|
|
184
|
+
import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
|
|
185
|
+
import { installMethod } from "./install-method.js";
|
|
186
|
+
import { PACKAGE_VERSION } from "./version.js";
|
|
184
187
|
/**
|
|
185
188
|
* 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
|
|
186
189
|
*
|
|
@@ -720,6 +723,8 @@ export async function runOperate(command, argv, write, options = {}) {
|
|
|
720
723
|
...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
|
|
721
724
|
...(options.heldCoordinator === undefined ? {} : { heldCoordinator: options.heldCoordinator }),
|
|
722
725
|
...(options.waitSleep === undefined ? {} : { waitSleep: options.waitSleep }),
|
|
726
|
+
...(options.releaseIo === undefined ? {} : { releaseIo: options.releaseIo }),
|
|
727
|
+
...(options.installBin === undefined ? {} : { installBin: options.installBin }),
|
|
723
728
|
});
|
|
724
729
|
}
|
|
725
730
|
catch (error) {
|
|
@@ -2127,10 +2132,10 @@ async function tickCommand(flags, context) {
|
|
|
2127
2132
|
continue;
|
|
2128
2133
|
}
|
|
2129
2134
|
// The sign-in pause holds a resumed lane too: it waits, parked, instead of failing.
|
|
2130
|
-
const
|
|
2131
|
-
if (
|
|
2135
|
+
const laneGate = signInGate(store, [racer.provider], clock());
|
|
2136
|
+
if (laneGate.waiting !== null) {
|
|
2132
2137
|
backToParked();
|
|
2133
|
-
resumed.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(
|
|
2138
|
+
resumed.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(laneGate.waiting) });
|
|
2134
2139
|
continue;
|
|
2135
2140
|
}
|
|
2136
2141
|
// Legacy comparison lanes carried an absolute clock. New profiles use
|
|
@@ -2139,6 +2144,7 @@ async function tickCommand(flags, context) {
|
|
|
2139
2144
|
// wall-clock contract.
|
|
2140
2145
|
const remaining = waiting.kind === "comparison" ? null : racer.budgetMicrousd - racer.accountedMicrousd;
|
|
2141
2146
|
if (remaining !== null && remaining <= 0) {
|
|
2147
|
+
laneGate.giveBack();
|
|
2142
2148
|
const current = store.getContestant(racer.id);
|
|
2143
2149
|
if (current !== null)
|
|
2144
2150
|
store.casContestantState(racer.id, ["ready"], "stopped", current.generation);
|
|
@@ -2150,6 +2156,7 @@ async function tickCommand(flags, context) {
|
|
|
2150
2156
|
const laneProfile = racer.profile ?? contestantProfileOf(racer.provider, racer.model, racer.repairModel);
|
|
2151
2157
|
const clockCapMs = 3 * laneProfile.timeoutSeconds * 1000;
|
|
2152
2158
|
if (laneProfile.timeoutKind !== "idle" && store.contestantCumulativeMs(racer.id) >= clockCapMs) {
|
|
2159
|
+
laneGate.giveBack();
|
|
2153
2160
|
const current = store.getContestant(racer.id);
|
|
2154
2161
|
if (current !== null)
|
|
2155
2162
|
store.casContestantState(racer.id, ["ready"], "stopped", current.generation);
|
|
@@ -2160,12 +2167,14 @@ async function tickCommand(flags, context) {
|
|
|
2160
2167
|
}
|
|
2161
2168
|
const reclaimed = acquire(store, waiting.taskRef, runner, { now: clock(), token, ttlMs: leaseTtlMs });
|
|
2162
2169
|
if (!reclaimed.ok) {
|
|
2170
|
+
laneGate.giveBack();
|
|
2163
2171
|
backToParked();
|
|
2164
2172
|
continue;
|
|
2165
2173
|
}
|
|
2166
2174
|
const freshContest = store.getContest(waiting.id);
|
|
2167
2175
|
if (freshContest === null || !store.casContestState(waiting.id, ["decision-wait", "racing"], "racing", freshContest.generation)) {
|
|
2168
2176
|
release(store, reclaimed.claim.leaseId, clock());
|
|
2177
|
+
laneGate.giveBack();
|
|
2169
2178
|
backToParked();
|
|
2170
2179
|
continue;
|
|
2171
2180
|
}
|
|
@@ -2384,7 +2393,10 @@ async function tickCommand(flags, context) {
|
|
|
2384
2393
|
// gateway re-checks freshly at spawn; this cache only keeps a skipped
|
|
2385
2394
|
// queue from probing once per task.
|
|
2386
2395
|
const attestedThisPass = new Map();
|
|
2396
|
+
let untakenTrial = null;
|
|
2387
2397
|
for (const ref of ready) {
|
|
2398
|
+
untakenTrial?.giveBack();
|
|
2399
|
+
untakenTrial = null;
|
|
2388
2400
|
// The build budget governs UNATTENDED admissions (round-1 finding 4):
|
|
2389
2401
|
// once it is spent, the pass keeps SCANNING for attended
|
|
2390
2402
|
// authorizations — operator-invoked sessions launch regardless —
|
|
@@ -2601,11 +2613,14 @@ async function tickCommand(flags, context) {
|
|
|
2601
2613
|
// One task every AUTH_TRIAL_MS goes ahead as the trial: a sign-in check
|
|
2602
2614
|
// can say "logged in" for a session that cannot refresh, so a real run
|
|
2603
2615
|
// decides — it lifts the pause, or fails into the same incident.
|
|
2604
|
-
const
|
|
2605
|
-
if (
|
|
2606
|
-
dispatched.push({ id, outcome: "skipped", reason: "signed-out", detail: signInWords(
|
|
2616
|
+
const gate = signInGate(store, modeProviders, clock(), ref.id);
|
|
2617
|
+
if (gate.waiting !== null) {
|
|
2618
|
+
dispatched.push({ id, outcome: "skipped", reason: "signed-out", detail: signInWords(gate.waiting) });
|
|
2607
2619
|
continue;
|
|
2608
2620
|
}
|
|
2621
|
+
// A trial whose task is skipped before its claim succeeds is given back
|
|
2622
|
+
// at the next task (or after the pass), so the next pass may take it.
|
|
2623
|
+
untakenTrial = gate;
|
|
2609
2624
|
// The leg is the authority: what resolved must BE the leg, exactly —
|
|
2610
2625
|
// the sealed build leg, or the parked fallback entry's own pair.
|
|
2611
2626
|
const governingLeg = wantsPlan ? planLeg : parkedEntry !== null ? { provider: parkedEntry.provider, model: parkedEntry.model, chosen: "fallback" } : sealedBuildLeg;
|
|
@@ -2772,6 +2787,8 @@ async function tickCommand(flags, context) {
|
|
|
2772
2787
|
? {}
|
|
2773
2788
|
: { maxOpenDecisions: Number(text(flags, "max-open-decisions")) }),
|
|
2774
2789
|
});
|
|
2790
|
+
if (claimed.ok)
|
|
2791
|
+
untakenTrial = null;
|
|
2775
2792
|
if (!claimed.ok) {
|
|
2776
2793
|
// Losing a race, finding the task no longer ready, and a machine that
|
|
2777
2794
|
// lacks what the task needs are all the system working. None fails the
|
|
@@ -3732,6 +3749,7 @@ async function tickCommand(flags, context) {
|
|
|
3732
3749
|
break;
|
|
3733
3750
|
}
|
|
3734
3751
|
}
|
|
3752
|
+
untakenTrial?.giveBack();
|
|
3735
3753
|
// THE CHAIN ADMISSION PASS (E3d): cycles a recognized exhaustion advanced
|
|
3736
3754
|
// to pending-admission dispatch their NEXT approved entry here — the ONLY
|
|
3737
3755
|
// road that runs a fallback entry. Every authority is re-derived inside
|
|
@@ -3779,9 +3797,9 @@ async function tickCommand(flags, context) {
|
|
|
3779
3797
|
continue;
|
|
3780
3798
|
}
|
|
3781
3799
|
// The sign-in pause holds a fallback entry too: the admission stays pending until its provider works.
|
|
3782
|
-
const
|
|
3783
|
-
if (
|
|
3784
|
-
dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(
|
|
3800
|
+
const entryGate = signInGate(store, [peek.profile.provider], clock(), pending.taskRef);
|
|
3801
|
+
if (entryGate.waiting !== null) {
|
|
3802
|
+
dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(entryGate.waiting) });
|
|
3785
3803
|
continue;
|
|
3786
3804
|
}
|
|
3787
3805
|
// The FALLBACK claim (finding 4): every acquireIfReady gate — task
|
|
@@ -3806,6 +3824,7 @@ async function tickCommand(flags, context) {
|
|
|
3806
3824
|
: { maxOpenDecisions: Number(text(flags, "max-open-decisions")) }),
|
|
3807
3825
|
});
|
|
3808
3826
|
if (!claimed.ok) {
|
|
3827
|
+
entryGate.giveBack();
|
|
3809
3828
|
dispatched.push({ id: pending.taskId, outcome: "skipped", reason: claimed.reason });
|
|
3810
3829
|
continue;
|
|
3811
3830
|
}
|
|
@@ -3974,13 +3993,14 @@ async function tickCommand(flags, context) {
|
|
|
3974
3993
|
continue;
|
|
3975
3994
|
}
|
|
3976
3995
|
// The sign-in pause holds a continuation too: its authorization stays open and it starts once the sign-in works.
|
|
3977
|
-
const
|
|
3978
|
-
if (
|
|
3979
|
-
dispatched.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(
|
|
3996
|
+
const continuationGate = signInGate(store, [pinned.provider], clock(), parent.taskRef);
|
|
3997
|
+
if (continuationGate.waiting !== null) {
|
|
3998
|
+
dispatched.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(continuationGate.waiting) });
|
|
3980
3999
|
continue;
|
|
3981
4000
|
}
|
|
3982
4001
|
const claimed = acquireContinuation(store, continuation, runner, { now: clock(), token, ttlMs: leaseTtlMs });
|
|
3983
4002
|
if (!claimed.ok) {
|
|
4003
|
+
continuationGate.giveBack();
|
|
3984
4004
|
dispatched.push({ id: taskId, outcome: "skipped", reason: claimed.reason, ...("message" in claimed ? { detail: claimed.message } : {}) });
|
|
3985
4005
|
continue;
|
|
3986
4006
|
}
|
|
@@ -4683,6 +4703,8 @@ async function startConsole(options) {
|
|
|
4683
4703
|
// Sprint 8: scheduled backups, a pass a minute; its lease also tells a restore that the console is running.
|
|
4684
4704
|
server.on("close", startBackups(context.store, context.databaseFile));
|
|
4685
4705
|
server.on("close", startCodexLimits(context.store));
|
|
4706
|
+
// At most one anonymous look a day for a newer Toolroll (Settings → Updates can switch it off).
|
|
4707
|
+
server.on("close", startUpdateChecks(context.store, dirname(context.databaseFile), PACKAGE_VERSION, installMethod(context.installBin).updateCommand, context.releaseIo ?? {}));
|
|
4686
4708
|
const bound = server.address();
|
|
4687
4709
|
const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
|
|
4688
4710
|
// A bind-everywhere address is not a place a browser can go: the URL
|
|
@@ -7075,6 +7097,7 @@ async function runWatchLoop(args) {
|
|
|
7075
7097
|
// attributes to this episode by runner and window, and `brief
|
|
7076
7098
|
// --latest-watch` bounds itself to exactly it.
|
|
7077
7099
|
store.startWatchEpisode({ repo, runner, incarnation }, new Date());
|
|
7100
|
+
recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
|
|
7078
7101
|
args.onReady?.();
|
|
7079
7102
|
// A false renewal is FATAL (arc 2 finding 15): the lease or the
|
|
7080
7103
|
// credential is gone, and admitting one more pass would be work done for
|
|
@@ -8064,6 +8087,7 @@ async function upCommand(flags, context) {
|
|
|
8064
8087
|
});
|
|
8065
8088
|
};
|
|
8066
8089
|
observeReadiness();
|
|
8090
|
+
recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
|
|
8067
8091
|
const runnerHeartbeat = setInterval(() => {
|
|
8068
8092
|
const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
|
|
8069
8093
|
if (!beat.ok && !stopping) {
|
|
@@ -8949,7 +8973,7 @@ function readPaths(flags, backend) {
|
|
|
8949
8973
|
return backend === BUILT_IN ? [BUILT_IN] : [];
|
|
8950
8974
|
}
|
|
8951
8975
|
// ---- authoring ------------------------------------------------------------
|
|
8952
|
-
function statusCommand(positional, flags, context) {
|
|
8976
|
+
async function statusCommand(positional, flags, context) {
|
|
8953
8977
|
const command = "status";
|
|
8954
8978
|
const allowed = new Set(["db", "json"]);
|
|
8955
8979
|
for (const name of flags.keys()) {
|
|
@@ -8959,7 +8983,15 @@ function statusCommand(positional, flags, context) {
|
|
|
8959
8983
|
if (positional.length > 0)
|
|
8960
8984
|
return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
|
|
8961
8985
|
const status = installationStatus(context.store, context.clock());
|
|
8962
|
-
|
|
8986
|
+
// One line, only when a newer Toolroll exists; offline or switched off says nothing.
|
|
8987
|
+
const current = PACKAGE_VERSION;
|
|
8988
|
+
const method = installMethod(context.installBin);
|
|
8989
|
+
const release = newerRelease(await latestRelease(dirname(context.databaseFile), { now: context.clock, ...context.releaseIo }), current);
|
|
8990
|
+
if (release !== null)
|
|
8991
|
+
notifySecurityRelease(context.store, release, current, method.updateCommand, context.clock());
|
|
8992
|
+
const line = updateLine(release, current, method.updateCommand);
|
|
8993
|
+
const update = release === null ? {} : { update: { current, latest: release.version, security: release.security, updateCommand: method.updateCommand, url: release.url } };
|
|
8994
|
+
return succeed(context.write, context.json, command, { ...status, ...update }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line])]);
|
|
8963
8995
|
}
|
|
8964
8996
|
async function waitTask(positional, flags, context) {
|
|
8965
8997
|
const command = "task wait";
|
package/dist/provider-auth.d.ts
CHANGED
|
@@ -38,7 +38,17 @@ export declare function signInCommand(pause: Pick<AuthPause, "provider" | "authM
|
|
|
38
38
|
export declare function signInWords(pause: AuthPause): string;
|
|
39
39
|
/** The open pause for a provider, or null. */
|
|
40
40
|
export declare function authPauseOf(store: Store, provider: string): AuthPause | null;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* SQL: whether the pause `pause` (a provider_auth_pause alias) is on one of
|
|
43
|
+
* the task_ref `taskRef`'s current providers — its pins, its working profile,
|
|
44
|
+
* route legs and fallback entries — so a task re-routed to another provider
|
|
45
|
+
* stops saying it waits on the old one. A task that names none yet (an
|
|
46
|
+
* unpinned planner, whose agent comes from configuration) keeps what the gate
|
|
47
|
+
* found.
|
|
48
|
+
*/
|
|
49
|
+
export declare function pauseOnTaskProviders(pause: string, taskRef: string): string;
|
|
50
|
+
/** The open pause the dispatch gate last left this task waiting on, while it
|
|
51
|
+
* is still on one of the task's providers; or null. */
|
|
42
52
|
export declare function authWaitOf(store: Store, taskRef: number): AuthPause | null;
|
|
43
53
|
/** Every open pause, oldest first. */
|
|
44
54
|
export declare function openAuthPauses(store: Store): AuthPause[];
|
|
@@ -80,22 +90,37 @@ export declare function noteSignInProbe(store: Store, provider: string, state: s
|
|
|
80
90
|
} | null;
|
|
81
91
|
/** How often a paused provider lets one task through as a trial. */
|
|
82
92
|
export declare const AUTH_TRIAL_MS: number;
|
|
93
|
+
/** A trial taken: the pause, when it was taken, and the trial time it replaced. */
|
|
94
|
+
export type AuthTrial = {
|
|
95
|
+
pauseId: number;
|
|
96
|
+
takenAt: string;
|
|
97
|
+
previous: string | null;
|
|
98
|
+
};
|
|
83
99
|
/**
|
|
84
100
|
* Whether this task may go ahead as the paused provider's trial: at most one
|
|
85
101
|
* every AUTH_TRIAL_MS. A trial that works lifts the pause (its run proves the
|
|
86
102
|
* sign-in); one that fails the same way only counts against the open
|
|
87
|
-
* incident, so nobody is told twice.
|
|
103
|
+
* incident, so nobody is told twice. Null when no trial is due.
|
|
88
104
|
*/
|
|
89
|
-
export declare function claimAuthTrial(store: Store, pause: AuthPause, now: Date):
|
|
105
|
+
export declare function claimAuthTrial(store: Store, pause: AuthPause, now: Date): AuthTrial | null;
|
|
106
|
+
/** Give a trial back when its task never started (its claim failed), so the next pass may take it. */
|
|
107
|
+
export declare function giveBackAuthTrial(store: Store, trial: AuthTrial): void;
|
|
108
|
+
/** What the sign-in gate decided: the open pause the work waits on, or null
|
|
109
|
+
* when it may go ahead; and, when it goes ahead as a pause's trial, how to
|
|
110
|
+
* give that trial back if its claim then fails. */
|
|
111
|
+
export type SignInGate = {
|
|
112
|
+
waiting: AuthPause | null;
|
|
113
|
+
giveBack: () => void;
|
|
114
|
+
};
|
|
90
115
|
/**
|
|
91
116
|
* The sign-in gate every road that starts an agent asks before it claims
|
|
92
117
|
* anything: the tick's queue, and the roads beside it (fallback entries,
|
|
93
|
-
* attended continuations, contest resumes).
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
118
|
+
* attended continuations, contest resumes). Waits when a provider is paused,
|
|
119
|
+
* unless this is the pause's one trial. A waiting task is noted on its
|
|
120
|
+
* task_ref, so the work index says exactly what the gate decided; a task let
|
|
121
|
+
* through is un-noted. A road whose claim fails after a trial calls giveBack.
|
|
97
122
|
*/
|
|
98
|
-
export declare function signInGate(store: Store, providers: readonly string[], now: Date, taskRef?: number):
|
|
123
|
+
export declare function signInGate(store: Store, providers: readonly string[], now: Date, taskRef?: number): SignInGate;
|
|
99
124
|
/** The loop beside the console: every two minutes, probe each paused
|
|
100
125
|
* provider's sign-in (no model, nothing spent) and lift the pause when it
|
|
101
126
|
* works again. Nothing is probed while nothing is paused. */
|
package/dist/provider-auth.js
CHANGED
|
@@ -69,10 +69,31 @@ export function authPauseOf(store, provider) {
|
|
|
69
69
|
const row = store.handle.prepare("SELECT * FROM provider_auth_pause WHERE provider = ? AND lifted_at IS NULL").get(provider);
|
|
70
70
|
return row === undefined ? null : readPause(row);
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* SQL: whether the pause `pause` (a provider_auth_pause alias) is on one of
|
|
74
|
+
* the task_ref `taskRef`'s current providers — its pins, its working profile,
|
|
75
|
+
* route legs and fallback entries — so a task re-routed to another provider
|
|
76
|
+
* stops saying it waits on the old one. A task that names none yet (an
|
|
77
|
+
* unpinned planner, whose agent comes from configuration) keeps what the gate
|
|
78
|
+
* found.
|
|
79
|
+
*/
|
|
80
|
+
export function pauseOnTaskProviders(pause, taskRef) {
|
|
81
|
+
const providers = `SELECT r.agent_provider provider FROM task_ref r WHERE r.id = ${taskRef}
|
|
82
|
+
UNION ALL SELECT r.plan_provider FROM task_ref r WHERE r.id = ${taskRef}
|
|
83
|
+
UNION ALL SELECT json_extract(ts.profile_json, '$.provider') FROM task_ref r JOIN task_scope ts ON ts.task_id = r.external_id
|
|
84
|
+
WHERE r.id = ${taskRef} AND json_valid(ts.profile_json)
|
|
85
|
+
UNION ALL SELECT json_extract(leg.value, '$.provider') FROM task_ref r JOIN task_scope ts ON ts.task_id = r.external_id,
|
|
86
|
+
json_each(CASE WHEN json_valid(ts.proposed_route_json) THEN ts.proposed_route_json ELSE '{}' END, '$.legs') leg WHERE r.id = ${taskRef}
|
|
87
|
+
UNION ALL SELECT json_extract(entry.value, '$.profile.provider') FROM task_ref r JOIN task_scope ts ON ts.task_id = r.external_id,
|
|
88
|
+
json_each(CASE WHEN json_valid(ts.proposed_chain_json) THEN ts.proposed_chain_json ELSE '{}' END, '$.chain') entry WHERE r.id = ${taskRef}`;
|
|
89
|
+
return `(${pause}.provider IN (SELECT provider FROM (${providers}) WHERE provider IS NOT NULL)
|
|
90
|
+
OR NOT EXISTS (SELECT 1 FROM (${providers}) WHERE provider IS NOT NULL))`;
|
|
91
|
+
}
|
|
92
|
+
/** The open pause the dispatch gate last left this task waiting on, while it
|
|
93
|
+
* is still on one of the task's providers; or null. */
|
|
73
94
|
export function authWaitOf(store, taskRef) {
|
|
74
95
|
const row = store.handle.prepare(`SELECT p.* FROM task_ref r JOIN provider_auth_pause p ON p.id = r.auth_wait_pause
|
|
75
|
-
WHERE r.id = ? AND p.lifted_at IS NULL`).get(taskRef);
|
|
96
|
+
WHERE r.id = ? AND p.lifted_at IS NULL AND ${pauseOnTaskProviders("p", "r.id")}`).get(taskRef);
|
|
76
97
|
return row === undefined ? null : readPause(row);
|
|
77
98
|
}
|
|
78
99
|
/** Every open pause, oldest first. */
|
|
@@ -186,29 +207,49 @@ export const AUTH_TRIAL_MS = 10 * 60_000;
|
|
|
186
207
|
* Whether this task may go ahead as the paused provider's trial: at most one
|
|
187
208
|
* every AUTH_TRIAL_MS. A trial that works lifts the pause (its run proves the
|
|
188
209
|
* sign-in); one that fails the same way only counts against the open
|
|
189
|
-
* incident, so nobody is told twice.
|
|
210
|
+
* incident, so nobody is told twice. Null when no trial is due.
|
|
190
211
|
*/
|
|
191
212
|
export function claimAuthTrial(store, pause, now) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
213
|
+
return store.transact(() => {
|
|
214
|
+
const due = new Date(now.getTime() - AUTH_TRIAL_MS).toISOString();
|
|
215
|
+
const row = store.handle.prepare("SELECT last_trial_at FROM provider_auth_pause WHERE id = ?").get(pause.id);
|
|
216
|
+
const previous = row?.["last_trial_at"] == null ? null : String(row["last_trial_at"]);
|
|
217
|
+
const takenAt = now.toISOString();
|
|
218
|
+
const claimed = store.handle.prepare(`UPDATE provider_auth_pause SET last_trial_at = ?
|
|
219
|
+
WHERE id = ? AND lifted_at IS NULL AND COALESCE(last_trial_at, opened_at) <= ?`).run(takenAt, pause.id, due);
|
|
220
|
+
return Number(claimed.changes) === 1 ? { pauseId: pause.id, takenAt, previous } : null;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Give a trial back when its task never started (its claim failed), so the next pass may take it. */
|
|
224
|
+
export function giveBackAuthTrial(store, trial) {
|
|
225
|
+
store.handle.prepare("UPDATE provider_auth_pause SET last_trial_at = ? WHERE id = ? AND last_trial_at = ? AND lifted_at IS NULL")
|
|
226
|
+
.run(trial.previous, trial.pauseId, trial.takenAt);
|
|
196
227
|
}
|
|
197
228
|
/**
|
|
198
229
|
* The sign-in gate every road that starts an agent asks before it claims
|
|
199
230
|
* anything: the tick's queue, and the roads beside it (fallback entries,
|
|
200
|
-
* attended continuations, contest resumes).
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
231
|
+
* attended continuations, contest resumes). Waits when a provider is paused,
|
|
232
|
+
* unless this is the pause's one trial. A waiting task is noted on its
|
|
233
|
+
* task_ref, so the work index says exactly what the gate decided; a task let
|
|
234
|
+
* through is un-noted. A road whose claim fails after a trial calls giveBack.
|
|
204
235
|
*/
|
|
205
236
|
export function signInGate(store, providers, now, taskRef) {
|
|
206
237
|
const pause = [...new Set(providers)].map(one => authPauseOf(store, one)).find(one => one !== null) ?? null;
|
|
207
|
-
const
|
|
238
|
+
const trial = pause === null ? null : claimAuthTrial(store, pause, now);
|
|
239
|
+
const waiting = pause !== null && trial === null ? pause : null;
|
|
208
240
|
if (taskRef !== undefined) {
|
|
209
241
|
store.handle.prepare("UPDATE task_ref SET auth_wait_pause = ? WHERE id = ? AND auth_wait_pause IS NOT ?").run(waiting?.id ?? null, taskRef, waiting?.id ?? null);
|
|
210
242
|
}
|
|
211
|
-
|
|
243
|
+
let given = false;
|
|
244
|
+
return {
|
|
245
|
+
waiting,
|
|
246
|
+
giveBack: () => {
|
|
247
|
+
if (trial === null || given)
|
|
248
|
+
return;
|
|
249
|
+
given = true;
|
|
250
|
+
giveBackAuthTrial(store, trial);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
212
253
|
}
|
|
213
254
|
/** The loop beside the console: every two minutes, probe each paused
|
|
214
255
|
* provider's sign-in (no model, nothing spent) and lift the pause when it
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowing, calmly, when a newer Toolroll exists.
|
|
3
|
+
*
|
|
4
|
+
* At most once a day one anonymous GET asks the npm registry for the latest
|
|
5
|
+
* version, and one more reads that version's GitHub release notes (the body
|
|
6
|
+
* only). Nothing identifies this installation: no telemetry, no id, no
|
|
7
|
+
* query string. The answer is cached in a small JSON file beside the
|
|
8
|
+
* database; offline or any other failure is silent and changes nothing.
|
|
9
|
+
* TOOLROLL_NO_UPDATE_CHECK=1 or the switch on Settings → Updates turns the
|
|
10
|
+
* check off entirely.
|
|
11
|
+
*/
|
|
12
|
+
import type { Store } from "./store.js";
|
|
13
|
+
export declare const RELEASE_CACHE_FILE = "latest-release.json";
|
|
14
|
+
/** Holds "off" while a person has switched the daily check off on Settings → Updates. */
|
|
15
|
+
export declare const UPDATE_SWITCH_FILE = "update-check";
|
|
16
|
+
export declare const RUNNER_VERSIONS_FILE = "runner-versions.json";
|
|
17
|
+
export declare const CHECK_EVERY_MS: number;
|
|
18
|
+
export declare const CHECK_TIMEOUT_MS = 5000;
|
|
19
|
+
export declare const REGISTRY_URL = "https://registry.npmjs.org/toolroll/latest";
|
|
20
|
+
export declare const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
|
|
21
|
+
export type Release = {
|
|
22
|
+
version: string;
|
|
23
|
+
notes: string;
|
|
24
|
+
url: string;
|
|
25
|
+
security: boolean;
|
|
26
|
+
};
|
|
27
|
+
export type ReleaseIo = {
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
now?: () => Date;
|
|
30
|
+
env?: Record<string, string | undefined>;
|
|
31
|
+
};
|
|
32
|
+
/** Off by the environment (it wins) or by the Settings switch. */
|
|
33
|
+
export declare function updateChecksOff(env: Record<string, string | undefined>, dir: string): {
|
|
34
|
+
off: boolean;
|
|
35
|
+
byEnv: boolean;
|
|
36
|
+
};
|
|
37
|
+
export declare function setUpdateChecks(dir: string, on: boolean): void;
|
|
38
|
+
/** What the last check found, whenever it ran; null when none has succeeded. Never touches the network. */
|
|
39
|
+
export declare function cachedRelease(dir: string): Release | null;
|
|
40
|
+
/**
|
|
41
|
+
* The latest published release, checked at most once a day. Returns what the
|
|
42
|
+
* cache knows while it is fresh, asks again once it is a day old, and on any
|
|
43
|
+
* failure keeps what it knew before (null when it knew nothing). Never throws.
|
|
44
|
+
*/
|
|
45
|
+
export declare function latestRelease(dir: string, io?: ReleaseIo): Promise<Release | null>;
|
|
46
|
+
/** Notes that mention "Security" mark a security release. */
|
|
47
|
+
/** A release marks itself as a security release with a line or heading that starts with "Security" (for example
|
|
48
|
+
* "## Security" or "- **Security fix.**"), not by mentioning the word ("no security changes"). */
|
|
49
|
+
export declare function isSecurityRelease(notes: string): boolean;
|
|
50
|
+
/** a > b for dotted versions; a pre-release sorts before its release. */
|
|
51
|
+
export declare function isNewer(a: string, b: string): boolean;
|
|
52
|
+
/** The release worth mentioning: newer than `current`, else null. */
|
|
53
|
+
export declare function newerRelease(release: Release | null, current: string): Release | null;
|
|
54
|
+
/** The one status line, only when a newer release exists. */
|
|
55
|
+
export declare function updateLine(release: Release | null, current: string, updateCommand: string): string | null;
|
|
56
|
+
/**
|
|
57
|
+
* A security release tells each instance operator once per version, through
|
|
58
|
+
* the outbox and addressed to them (like a sign-in pause): only someone at
|
|
59
|
+
* this computer can update it. Returns how many messages were queued.
|
|
60
|
+
*/
|
|
61
|
+
export declare function notifySecurityRelease(store: Store, release: Release | null, current: string, updateCommand: string, now: Date): number;
|
|
62
|
+
/** Each runner's Toolroll version, as it last said at start. */
|
|
63
|
+
export type RunnerVersion = {
|
|
64
|
+
runner: string;
|
|
65
|
+
version: string;
|
|
66
|
+
at: string;
|
|
67
|
+
};
|
|
68
|
+
export declare function recordRunnerVersion(dir: string, runner: string, version: string, now: Date): void;
|
|
69
|
+
export declare function runnerVersions(dir: string): RunnerVersion[];
|
|
70
|
+
/**
|
|
71
|
+
* The console's background check: at start and then hourly (the daily limit
|
|
72
|
+
* is latestRelease's own), and a security release notifies operators.
|
|
73
|
+
*/
|
|
74
|
+
export declare function startUpdateChecks(store: Store, dir: string, current: string, updateCommand: string, io?: ReleaseIo & {
|
|
75
|
+
everyMs?: number;
|
|
76
|
+
}): () => void;
|