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.
@@ -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. */
@@ -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 laneSignedOut = signInGate(store, [racer.provider], clock());
2131
- if (laneSignedOut !== null) {
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(laneSignedOut) });
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 signedOut = signInGate(store, modeProviders, clock(), ref.id);
2605
- if (signedOut !== null) {
2606
- dispatched.push({ id, outcome: "skipped", reason: "signed-out", detail: signInWords(signedOut) });
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 entrySignedOut = signInGate(store, [peek.profile.provider], clock(), pending.taskRef);
3783
- if (entrySignedOut !== null) {
3784
- dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(entrySignedOut) });
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 continuationSignedOut = signInGate(store, [pinned.provider], clock(), parent.taskRef);
3978
- if (continuationSignedOut !== null) {
3979
- dispatched.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(continuationSignedOut) });
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
- return succeed(context.write, context.json, command, { ...status }, () => renderInstallationStatus(status));
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";
@@ -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
- /** The open pause the dispatch gate last left this task waiting on, or null. */
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): boolean;
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). The open pause the work waits
94
- * on, or null when it may go ahead — nothing paused, or this is the pause's
95
- * one trial. A waiting task is noted on its task_ref, so the work index says
96
- * exactly what the gate decided; a task let through is un-noted.
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): AuthPause | null;
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. */
@@ -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
- /** The open pause the dispatch gate last left this task waiting on, or null. */
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
- const due = new Date(now.getTime() - AUTH_TRIAL_MS).toISOString();
193
- const claimed = store.handle.prepare(`UPDATE provider_auth_pause SET last_trial_at = ?
194
- WHERE id = ? AND lifted_at IS NULL AND COALESCE(last_trial_at, opened_at) <= ?`).run(now.toISOString(), pause.id, due);
195
- return Number(claimed.changes) === 1;
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). The open pause the work waits
201
- * on, or null when it may go ahead — nothing paused, or this is the pause's
202
- * one trial. A waiting task is noted on its task_ref, so the work index says
203
- * exactly what the gate decided; a task let through is un-noted.
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 waiting = pause !== null && !claimAuthTrial(store, pause, now) ? pause : null;
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
- return waiting;
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;