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.
- package/CHANGELOG.md +413 -0
- package/README.md +15 -0
- package/dist/agent-onboard.d.ts +106 -0
- package/dist/agent-onboard.js +282 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +23 -23
- package/dist/browser-shell.js +5 -3
- package/dist/browser-workspace.d.ts +45 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +8 -1
- package/dist/control-setup.d.ts +4 -4
- package/dist/daemon.d.ts +4 -0
- package/dist/daemon.js +28 -4
- package/dist/desktop-host.js +2 -2
- package/dist/desktop-update.d.ts +7 -1
- package/dist/desktop-update.js +39 -22
- package/dist/evidence.d.ts +24 -0
- package/dist/evidence.js +103 -5
- package/dist/first-run.d.ts +54 -0
- package/dist/first-run.js +101 -0
- package/dist/install-method.d.ts +17 -0
- package/dist/install-method.js +65 -0
- package/dist/operate.d.ts +14 -0
- package/dist/operate.js +114 -18
- package/dist/provider-auth.d.ts +33 -8
- package/dist/provider-auth.js +54 -13
- package/dist/releases.d.ts +85 -0
- package/dist/releases.js +238 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +237 -81
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +3 -1
- package/dist/store.d.ts +3 -3
- package/dist/store.js +9 -4
- package/dist/surface.js +2 -0
- package/dist/toolroll-update-cli.d.ts +15 -0
- package/dist/toolroll-update-cli.js +155 -0
- package/dist/toolroll-update-ui.d.ts +38 -0
- package/dist/toolroll-update-ui.js +89 -0
- package/dist/toolroll-update.d.ts +235 -0
- package/dist/toolroll-update.js +929 -0
- 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 +2 -1
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,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
|
+
}>;
|
package/dist/releases.js
ADDED
|
@@ -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;
|