toolroll 0.6.0 → 0.8.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +413 -0
  2. package/README.md +15 -0
  3. package/dist/agent-onboard.d.ts +106 -0
  4. package/dist/agent-onboard.js +282 -0
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +23 -23
  7. package/dist/browser-shell.js +5 -3
  8. package/dist/browser-workspace.d.ts +45 -0
  9. package/dist/cli.d.ts +3 -1
  10. package/dist/cli.js +8 -1
  11. package/dist/control-setup.d.ts +4 -4
  12. package/dist/daemon.d.ts +4 -0
  13. package/dist/daemon.js +28 -4
  14. package/dist/desktop-host.js +2 -2
  15. package/dist/desktop-update.d.ts +7 -1
  16. package/dist/desktop-update.js +39 -22
  17. package/dist/evidence.d.ts +24 -0
  18. package/dist/evidence.js +103 -5
  19. package/dist/first-run.d.ts +54 -0
  20. package/dist/first-run.js +101 -0
  21. package/dist/install-method.d.ts +17 -0
  22. package/dist/install-method.js +65 -0
  23. package/dist/operate.d.ts +14 -0
  24. package/dist/operate.js +114 -18
  25. package/dist/provider-auth.d.ts +33 -8
  26. package/dist/provider-auth.js +54 -13
  27. package/dist/releases.d.ts +85 -0
  28. package/dist/releases.js +238 -0
  29. package/dist/serve.d.ts +18 -0
  30. package/dist/serve.js +237 -81
  31. package/dist/skills.d.ts +2 -0
  32. package/dist/skills.js +3 -1
  33. package/dist/store.d.ts +3 -3
  34. package/dist/store.js +9 -4
  35. package/dist/surface.js +2 -0
  36. package/dist/toolroll-update-cli.d.ts +15 -0
  37. package/dist/toolroll-update-cli.js +155 -0
  38. package/dist/toolroll-update-ui.d.ts +38 -0
  39. package/dist/toolroll-update-ui.js +89 -0
  40. package/dist/toolroll-update.d.ts +235 -0
  41. package/dist/toolroll-update.js +929 -0
  42. package/dist/update-notice.d.ts +5 -0
  43. package/dist/update-notice.js +4 -0
  44. package/dist/work-index.js +5 -3
  45. package/package.json +2 -1
@@ -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,85 @@
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
+ /** The npm registry `toolroll update` downloads releases from. */
20
+ export declare const REGISTRY = "https://registry.npmjs.org";
21
+ export declare const REGISTRY_URL = "https://registry.npmjs.org/toolroll/latest";
22
+ export declare const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
23
+ export type Release = {
24
+ version: string;
25
+ notes: string;
26
+ url: string;
27
+ security: boolean;
28
+ };
29
+ export type ReleaseIo = {
30
+ fetch?: typeof fetch;
31
+ now?: () => Date;
32
+ env?: Record<string, string | undefined>;
33
+ };
34
+ /** Off by the environment (it wins) or by the Settings switch. */
35
+ export declare function updateChecksOff(env: Record<string, string | undefined>, dir: string): {
36
+ off: boolean;
37
+ byEnv: boolean;
38
+ };
39
+ export declare function setUpdateChecks(dir: string, on: boolean): void;
40
+ /** What the last check found, whenever it ran; null when none has succeeded. Never touches the network. */
41
+ export declare function cachedRelease(dir: string): Release | null;
42
+ /**
43
+ * The latest published release, checked at most once a day. Returns what the
44
+ * cache knows while it is fresh, asks again once it is a day old, and on any
45
+ * failure keeps what it knew before (null when it knew nothing). Never throws.
46
+ */
47
+ export declare function latestRelease(dir: string, io?: ReleaseIo): Promise<Release | null>;
48
+ /** Notes that mention "Security" mark a security release. */
49
+ /** A release marks itself as a security release with a line or heading that starts with "Security" (for example
50
+ * "## Security" or "- **Security fix.**"), not by mentioning the word ("no security changes"). */
51
+ export declare function isSecurityRelease(notes: string): boolean;
52
+ /** a > b for dotted versions; a pre-release sorts before its release. */
53
+ export declare function isNewer(a: string, b: string): boolean;
54
+ /** The release worth mentioning: newer than `current`, else null. */
55
+ export declare function newerRelease(release: Release | null, current: string): Release | null;
56
+ /** The one status line, only when a newer release exists. */
57
+ export declare function updateLine(release: Release | null, current: string, updateCommand: string): string | null;
58
+ /**
59
+ * A security release tells each instance operator once per version, through
60
+ * the outbox and addressed to them (like a sign-in pause): only someone at
61
+ * this computer can update it. Returns how many messages were queued.
62
+ */
63
+ export declare function notifySecurityRelease(store: Store, release: Release | null, current: string, updateCommand: string, now: Date): number;
64
+ /** Each runner's Toolroll version, as it last said at start. */
65
+ export type RunnerVersion = {
66
+ runner: string;
67
+ version: string;
68
+ at: string;
69
+ };
70
+ export declare function recordRunnerVersion(dir: string, runner: string, version: string, now: Date): void;
71
+ export declare function runnerVersions(dir: string): RunnerVersion[];
72
+ /**
73
+ * The console's background check: at start and then hourly (the daily limit
74
+ * is latestRelease's own), and a security release notifies operators.
75
+ */
76
+ export declare function startUpdateChecks(store: Store, dir: string, current: string, updateCommand: string, io?: ReleaseIo & {
77
+ everyMs?: number;
78
+ }): () => void;
79
+ /** The latest version, asked of npm now: for an explicit update (the command, Check now), not the once-a-day
80
+ * notice, so neither its cache nor its switch applies. Throws with npm's own reason. */
81
+ export declare function latestVersionNow(io?: {
82
+ fetch?: typeof fetch;
83
+ }): Promise<{
84
+ version: string;
85
+ }>;
@@ -0,0 +1,238 @@
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 { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { envValue } from "./names.js";
15
+ export const RELEASE_CACHE_FILE = "latest-release.json";
16
+ /** Holds "off" while a person has switched the daily check off on Settings → Updates. */
17
+ export const UPDATE_SWITCH_FILE = "update-check";
18
+ export const RUNNER_VERSIONS_FILE = "runner-versions.json";
19
+ export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
20
+ export const CHECK_TIMEOUT_MS = 5_000;
21
+ /** The npm registry `toolroll update` downloads releases from. */
22
+ export const REGISTRY = "https://registry.npmjs.org";
23
+ export const REGISTRY_URL = `${REGISTRY}/toolroll/latest`;
24
+ export const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
25
+ const NOTES_API = "https://api.github.com/repos/ap9000/toolroll/releases/tags/";
26
+ const NOTES_MAX = 20_000;
27
+ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
28
+ /** Off by the environment (it wins) or by the Settings switch. */
29
+ export function updateChecksOff(env, dir) {
30
+ const flag = envValue(env, "NO_UPDATE_CHECK");
31
+ if (flag !== undefined && flag !== "" && flag !== "0")
32
+ return { off: true, byEnv: true };
33
+ try {
34
+ return { off: readFileSync(join(dir, UPDATE_SWITCH_FILE), "utf8").trim() === "off", byEnv: false };
35
+ }
36
+ catch {
37
+ return { off: false, byEnv: false };
38
+ }
39
+ }
40
+ export function setUpdateChecks(dir, on) {
41
+ writeAtomic(join(dir, UPDATE_SWITCH_FILE), on ? "on\n" : "off\n");
42
+ }
43
+ /** What the last check found, whenever it ran; null when none has succeeded. Never touches the network. */
44
+ export function cachedRelease(dir) {
45
+ return readCache(dir)?.release ?? null;
46
+ }
47
+ /**
48
+ * The latest published release, checked at most once a day. Returns what the
49
+ * cache knows while it is fresh, asks again once it is a day old, and on any
50
+ * failure keeps what it knew before (null when it knew nothing). Never throws.
51
+ */
52
+ export async function latestRelease(dir, io = {}) {
53
+ const env = io.env ?? process.env;
54
+ if (updateChecksOff(env, dir).off)
55
+ return null;
56
+ const now = (io.now ?? (() => new Date()))();
57
+ const cache = readCache(dir);
58
+ if (cache !== null && now.getTime() - Date.parse(cache.checkedAt) < CHECK_EVERY_MS && Date.parse(cache.checkedAt) <= now.getTime())
59
+ return cache.release;
60
+ const request = io.fetch ?? fetch;
61
+ let release = cache?.release ?? null;
62
+ try {
63
+ const version = await askVersion(request);
64
+ if (version !== null) {
65
+ const notes = version === cache?.release?.version && cache.release.notes !== "" ? cache.release.notes : await askNotes(request, version);
66
+ release = { version, notes, url: `${RELEASES_PAGE}v${version}`, security: isSecurityRelease(notes) };
67
+ }
68
+ }
69
+ catch {
70
+ // Offline, slow or refused: nothing to say.
71
+ }
72
+ // The attempt is stamped either way, so a failure does not ask again until tomorrow.
73
+ try {
74
+ writeAtomic(join(dir, RELEASE_CACHE_FILE), `${JSON.stringify({ checkedAt: now.toISOString(), release })}\n`);
75
+ }
76
+ catch {
77
+ // A read-only folder only means asking again next time.
78
+ }
79
+ return release;
80
+ }
81
+ /** Notes that mention "Security" mark a security release. */
82
+ /** A release marks itself as a security release with a line or heading that starts with "Security" (for example
83
+ * "## Security" or "- **Security fix.**"), not by mentioning the word ("no security changes"). */
84
+ export function isSecurityRelease(notes) {
85
+ return /^\s*(?:#{1,6}\s*|[-*]\s*(?:\*\*)?)?security\b/im.test(notes);
86
+ }
87
+ /** a > b for dotted versions; a pre-release sorts before its release. */
88
+ export function isNewer(a, b) {
89
+ const parse = (value) => {
90
+ const [core = "", pre] = value.replace(/^v/, "").split("-", 2);
91
+ return { parts: core.split(".").map(part => Number.parseInt(part, 10) || 0), pre: pre ?? null };
92
+ };
93
+ const left = parse(a), right = parse(b);
94
+ for (let index = 0; index < 3; index++) {
95
+ const diff = (left.parts[index] ?? 0) - (right.parts[index] ?? 0);
96
+ if (diff !== 0)
97
+ return diff > 0;
98
+ }
99
+ if (left.pre === right.pre)
100
+ return false;
101
+ if (left.pre === null)
102
+ return true;
103
+ if (right.pre === null)
104
+ return false;
105
+ return left.pre.localeCompare(right.pre, "en", { numeric: true }) > 0;
106
+ }
107
+ /** The release worth mentioning: newer than `current`, else null. */
108
+ export function newerRelease(release, current) {
109
+ return release !== null && isNewer(release.version, current) ? release : null;
110
+ }
111
+ /** The one status line, only when a newer release exists. */
112
+ export function updateLine(release, current, updateCommand) {
113
+ const newer = newerRelease(release, current);
114
+ return newer === null ? null : `${newer.version} is available — ${updateCommand} · ${newer.url}`;
115
+ }
116
+ /**
117
+ * A security release tells each instance operator once per version, through
118
+ * the outbox and addressed to them (like a sign-in pause): only someone at
119
+ * this computer can update it. Returns how many messages were queued.
120
+ */
121
+ export function notifySecurityRelease(store, release, current, updateCommand, now) {
122
+ const newer = newerRelease(release, current);
123
+ if (newer === null || !newer.security)
124
+ return 0;
125
+ const operators = store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name));
126
+ let queued = 0;
127
+ for (const recipient of operators) {
128
+ const added = store.enqueueNotification({
129
+ dedupeKey: `release:security:${newer.version}:${recipient}`,
130
+ kind: "security-release",
131
+ pushClass: "attention",
132
+ link: "/settings#updates",
133
+ subject: `Toolroll ${newer.version} is a security release`,
134
+ body: `This computer runs ${current}. Update with \`${updateCommand}\`. Notes: ${newer.url}`,
135
+ recipient,
136
+ source: { installation: true },
137
+ }, now);
138
+ if (added)
139
+ queued++;
140
+ }
141
+ return queued;
142
+ }
143
+ export function recordRunnerVersion(dir, runner, version, now) {
144
+ const known = runnerVersions(dir).filter(one => one.runner !== runner);
145
+ known.push({ runner, version, at: now.toISOString() });
146
+ try {
147
+ writeAtomic(join(dir, RUNNER_VERSIONS_FILE), `${JSON.stringify(known.sort((a, b) => a.runner.localeCompare(b.runner)))}\n`);
148
+ }
149
+ catch {
150
+ // The version is a display fact; a failed write only leaves it unknown.
151
+ }
152
+ }
153
+ export function runnerVersions(dir) {
154
+ try {
155
+ const parsed = JSON.parse(readFileSync(join(dir, RUNNER_VERSIONS_FILE), "utf8"));
156
+ if (!Array.isArray(parsed))
157
+ return [];
158
+ return parsed.filter((one) => typeof one === "object" && one !== null
159
+ && typeof one.runner === "string" && typeof one.version === "string" && VERSION.test(one.version) && typeof one.at === "string");
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ }
165
+ /**
166
+ * The console's background check: at start and then hourly (the daily limit
167
+ * is latestRelease's own), and a security release notifies operators.
168
+ */
169
+ export function startUpdateChecks(store, dir, current, updateCommand, io = {}) {
170
+ let stopped = false;
171
+ const pass = async () => {
172
+ const release = await latestRelease(dir, io);
173
+ if (!stopped) {
174
+ try {
175
+ notifySecurityRelease(store, release, current, updateCommand, (io.now ?? (() => new Date()))());
176
+ }
177
+ catch {
178
+ // A closed store at shutdown: nothing to tell.
179
+ }
180
+ }
181
+ };
182
+ void pass();
183
+ const timer = setInterval(() => void pass(), io.everyMs ?? 60 * 60_000);
184
+ timer.unref?.();
185
+ return () => { stopped = true; clearInterval(timer); };
186
+ }
187
+ async function askVersion(request) {
188
+ const answer = await request(REGISTRY_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), credentials: "omit", referrerPolicy: "no-referrer" });
189
+ if (!answer.ok)
190
+ return null;
191
+ const version = (await answer.json()).version;
192
+ return typeof version === "string" && VERSION.test(version) ? version : null;
193
+ }
194
+ async function askNotes(request, version) {
195
+ try {
196
+ const answer = await request(`${NOTES_API}v${version}`, { headers: { accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), credentials: "omit", referrerPolicy: "no-referrer" });
197
+ if (!answer.ok)
198
+ return "";
199
+ const body = (await answer.json()).body;
200
+ return typeof body === "string" ? body.replace(/\r\n/g, "\n").slice(0, NOTES_MAX) : "";
201
+ }
202
+ catch {
203
+ return "";
204
+ }
205
+ }
206
+ function readCache(dir) {
207
+ const file = join(dir, RELEASE_CACHE_FILE);
208
+ if (!existsSync(file))
209
+ return null;
210
+ try {
211
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
212
+ if (typeof parsed.checkedAt !== "string" || Number.isNaN(Date.parse(parsed.checkedAt)))
213
+ return null;
214
+ const release = parsed.release;
215
+ const valid = release !== null && typeof release === "object" && release !== undefined
216
+ && typeof release.version === "string" && VERSION.test(release.version) && typeof release.notes === "string";
217
+ return { checkedAt: parsed.checkedAt, release: valid ? { version: release.version, notes: release.notes, url: `${RELEASES_PAGE}v${release.version}`, security: isSecurityRelease(release.notes) } : null };
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ }
223
+ function writeAtomic(file, content) {
224
+ const temporary = `${file}.${process.pid}.tmp`;
225
+ writeFileSync(temporary, content, { mode: 0o600 });
226
+ renameSync(temporary, file);
227
+ }
228
+ /** The latest version, asked of npm now: for an explicit update (the command, Check now), not the once-a-day
229
+ * notice, so neither its cache nor its switch applies. Throws with npm's own reason. */
230
+ export async function latestVersionNow(io = {}) {
231
+ const answer = await (io.fetch ?? fetch)(REGISTRY_URL, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
232
+ if (!answer.ok)
233
+ throw new Error(`npm answered ${answer.status}`);
234
+ const version = (await answer.json()).version;
235
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version))
236
+ throw new Error("npm did not name a version");
237
+ return { version };
238
+ }
package/dist/serve.d.ts CHANGED
@@ -11,9 +11,13 @@ import { run as execRun } from "./exec.js";
11
11
  import { type AgentChoice } from "./agentconfig.js";
12
12
  import { type RouteProjection, type RouteOverride, type RouteStamp, type RiskLevel } from "./phase-routing.js";
13
13
  import { type Phase } from "./provider.js";
14
+ import { launchRuntimeUpdate } from "./toolroll-update.js";
14
15
  import type { RevisionLineage } from "./store.js";
15
16
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
17
+ import { type InstallMethod } from "./install-method.js";
16
18
  export type ServeOptions = {
19
+ /** Tests: the bin whose real path says how Toolroll was installed (Settings → Updates' command). */
20
+ installBin?: string;
17
21
  /** Native coding workspace injection for isolated integration tests. */
18
22
  codingWorkspace?: CodingWorkspace;
19
23
  store: Store;
@@ -51,6 +55,18 @@ export type ServeOptions = {
51
55
  configDir?: string;
52
56
  /** Test seam for the Slack setup handshake. */
53
57
  slackFetcher?: typeof fetch;
58
+ /** Settings → Updates seams: the latest release, how this Toolroll was
59
+ * installed, its version, and how the updater job starts. Tests and
60
+ * screenshots inject these; production reads the registry and launchd. */
61
+ updates?: {
62
+ latest?: () => Promise<{
63
+ version: string;
64
+ }>;
65
+ method?: InstallMethod;
66
+ current?: string;
67
+ dist?: string;
68
+ launch?: typeof launchRuntimeUpdate;
69
+ };
54
70
  discordFetcher?: typeof fetch;
55
71
  /** Injected by tests: Microsoft sign-in, key metadata and Teams conversation calls. */
56
72
  teamsFetcher?: typeof fetch;
@@ -105,6 +121,8 @@ export type ServeOptions = {
105
121
  additionalProjectRepos?: () => readonly string[];
106
122
  connectionProbe?: typeof execRun;
107
123
  connectionHome?: string;
124
+ /** Test seam for Chat's first tasks: the `gh` and `git grep` reads. */
125
+ firstTaskRunner?: typeof execRun;
108
126
  modelCatalogFetcher?: typeof fetch;
109
127
  /** Test seams for Settings → Models: CLI version probes and the PATH they search. */
110
128
  modelRunner?: VersionRunner;