@phnx-labs/agents-cli 1.22.31 → 1.22.33
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 +72 -0
- package/README.md +8 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/daemon.js +52 -12
- package/dist/commands/doctor.d.ts +19 -0
- package/dist/commands/doctor.js +119 -17
- package/dist/commands/routines.js +164 -36
- package/dist/commands/sessions-browser.js +2 -2
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +66 -22
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +148 -0
- package/dist/index.js +3 -1
- package/dist/lib/catchup.js +4 -1
- package/dist/lib/daemon.d.ts +17 -0
- package/dist/lib/daemon.js +69 -3
- package/dist/lib/devices/doctor-findings.d.ts +7 -2
- package/dist/lib/devices/doctor-findings.js +53 -2
- package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
- package/dist/lib/devices/doctor-overview-cache.js +15 -0
- package/dist/lib/devices/fleet-divergence.d.ts +11 -0
- package/dist/lib/devices/fleet-divergence.js +6 -0
- package/dist/lib/devices/fleet-inventory.js +16 -2
- package/dist/lib/drift.d.ts +6 -1
- package/dist/lib/drift.js +9 -0
- package/dist/lib/hooks/cache.js +20 -1
- package/dist/lib/hooks.d.ts +91 -1
- package/dist/lib/hooks.js +289 -3
- package/dist/lib/hosts/passthrough.js +3 -0
- package/dist/lib/installations/index.d.ts +14 -0
- package/dist/lib/installations/index.js +14 -0
- package/dist/lib/installations/resolve.d.ts +43 -0
- package/dist/lib/installations/resolve.js +93 -0
- package/dist/lib/installations/store.d.ts +56 -0
- package/dist/lib/installations/store.js +196 -0
- package/dist/lib/installations/strategies.d.ts +73 -0
- package/dist/lib/installations/strategies.js +293 -0
- package/dist/lib/installations/types.d.ts +78 -0
- package/dist/lib/installations/types.js +8 -0
- package/dist/lib/installations/update.d.ts +40 -0
- package/dist/lib/installations/update.js +131 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +27 -0
- package/dist/lib/migrate.js +112 -2
- package/dist/lib/routine-context.d.ts +144 -0
- package/dist/lib/routine-context.js +268 -0
- package/dist/lib/routine-readiness.d.ts +47 -0
- package/dist/lib/routine-readiness.js +239 -0
- package/dist/lib/routines.d.ts +97 -1
- package/dist/lib/routines.js +107 -1
- package/dist/lib/runner.d.ts +18 -4
- package/dist/lib/runner.js +291 -98
- package/dist/lib/scheduler.d.ts +7 -1
- package/dist/lib/scheduler.js +5 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
- package/dist/lib/self-heal/registry.js +5 -2
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/state.js +4 -1
- package/dist/lib/session/team-filter.d.ts +11 -0
- package/dist/lib/session/team-filter.js +10 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/versions.d.ts +24 -0
- package/dist/lib/versions.js +49 -16
- package/package.json +2 -2
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { exec, execFile } from 'child_process';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { AGENTS, findInPath, isSelfUpdatingAgent } from '../agents.js';
|
|
7
|
+
import { VERSION_RE } from '../agent-spec/primitives.js';
|
|
8
|
+
import { importInstallScriptBinary } from '../import.js';
|
|
9
|
+
import { getBinaryPath, getLatestNpmVersion, getOldestNpmVersion, getLiveVersion, getVersionHomePath, invalidateLiveVersionCache, isGlobalBinaryAgent, } from '../versions.js';
|
|
10
|
+
import { installationDir } from './store.js';
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
/** npm install timeout, matching the install path's own installer budget. */
|
|
14
|
+
const INSTALL_TIMEOUT_MS = 120_000;
|
|
15
|
+
function runId() {
|
|
16
|
+
return `${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
17
|
+
}
|
|
18
|
+
function moveDir(from, to) {
|
|
19
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
20
|
+
try {
|
|
21
|
+
fs.renameSync(from, to);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
// Windows refuses a rename while any file in the tree is open, and a
|
|
25
|
+
// cross-device staging dir cannot be renamed at all. Copy+remove is the same
|
|
26
|
+
// observable move; it is slower, so it is the fallback, not the default.
|
|
27
|
+
const code = err.code;
|
|
28
|
+
if (code !== 'EPERM' && code !== 'EACCES' && code !== 'EXDEV')
|
|
29
|
+
throw err;
|
|
30
|
+
fs.cpSync(from, to, { recursive: true });
|
|
31
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Entries a swap replaces: everything npm owns inside a version dir. The lockfile
|
|
36
|
+
* is included deliberately — leaving the previous release's `package-lock.json`
|
|
37
|
+
* beside the new `node_modules` would make the directory describe a release it no
|
|
38
|
+
* longer contains, and the next repair reinstall would resolve from that stale lock.
|
|
39
|
+
* Entries absent on either side are skipped, so a dir without a lockfile is fine.
|
|
40
|
+
*/
|
|
41
|
+
const NPM_LIVE_ENTRIES = ['node_modules', 'package.json', 'package-lock.json'];
|
|
42
|
+
/**
|
|
43
|
+
* npm-packaged harnesses (claude, codex, kimi, opencode, …). The only fully
|
|
44
|
+
* transactional class: a pinned release can be fetched into a sibling directory,
|
|
45
|
+
* probed there, and swapped in, with the displaced tree kept until the swap is
|
|
46
|
+
* proven — so a failed update leaves the previous release running.
|
|
47
|
+
*/
|
|
48
|
+
const npmPackageStrategy = {
|
|
49
|
+
id: 'npm-package',
|
|
50
|
+
transactional: true,
|
|
51
|
+
sharedBinary: false,
|
|
52
|
+
async resolveTarget(ctx) {
|
|
53
|
+
if (ctx.requested === 'latest' || ctx.requested === 'oldest') {
|
|
54
|
+
const resolved = ctx.requested === 'latest'
|
|
55
|
+
? await getLatestNpmVersion(ctx.agent)
|
|
56
|
+
: await getOldestNpmVersion(ctx.agent);
|
|
57
|
+
if (!resolved) {
|
|
58
|
+
throw new Error(`Could not resolve the ${ctx.requested} published version for ${AGENTS[ctx.agent].name} from npm.`);
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
return ctx.requested;
|
|
63
|
+
},
|
|
64
|
+
async stage(ctx, target) {
|
|
65
|
+
const pkg = AGENTS[ctx.agent].npmPackage;
|
|
66
|
+
const dir = installationDir(ctx.agent, ctx.installation.label);
|
|
67
|
+
const stagingDir = path.join(dir, `.staging-${runId()}`);
|
|
68
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
69
|
+
fs.writeFileSync(path.join(stagingDir, 'package.json'), JSON.stringify({ name: `agents-${ctx.agent}-${target}`, version: '1.0.0', private: true }, null, 2));
|
|
70
|
+
const winShell = process.platform === 'win32';
|
|
71
|
+
ctx.onProgress?.(`Staging ${pkg}@${target}...`);
|
|
72
|
+
// `--ignore-scripts` for the dependency tree; the first-party package's own
|
|
73
|
+
// postinstall is re-run below, exactly as the install path does — several
|
|
74
|
+
// harnesses ship their native binary via that script and are unlaunchable
|
|
75
|
+
// without it.
|
|
76
|
+
await execFileAsync('npm', ['install', `${pkg}@${target}`, '--ignore-scripts'], {
|
|
77
|
+
cwd: stagingDir,
|
|
78
|
+
shell: winShell,
|
|
79
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
80
|
+
});
|
|
81
|
+
const pkgRoot = path.join(stagingDir, 'node_modules', pkg);
|
|
82
|
+
try {
|
|
83
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8'));
|
|
84
|
+
const postinstall = manifest?.scripts?.postinstall;
|
|
85
|
+
if (typeof postinstall === 'string' && postinstall.trim()) {
|
|
86
|
+
ctx.onProgress?.(`Running ${AGENTS[ctx.agent].name} postinstall...`);
|
|
87
|
+
await execFileAsync(postinstall, [], { cwd: pkgRoot, shell: true, timeout: INSTALL_TIMEOUT_MS });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* non-fatal: the launch probe in update.ts is the real gate */
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
release: target,
|
|
95
|
+
binary: path.join(stagingDir, 'node_modules', '.bin', AGENTS[ctx.agent].cliCommand),
|
|
96
|
+
home: getVersionHomePath(ctx.agent, ctx.installation.label),
|
|
97
|
+
stagingDir,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
async commit(ctx, staged) {
|
|
101
|
+
const dir = installationDir(ctx.agent, ctx.installation.label);
|
|
102
|
+
const rollbackDir = path.join(dir, `.rollback-${runId()}`);
|
|
103
|
+
const displaced = [];
|
|
104
|
+
// Move the live tree aside first, then move the staged tree in. Doing it in
|
|
105
|
+
// this order means the failure window contains no half-merged tree: either
|
|
106
|
+
// the old entries are all aside (undo restores them) or the new ones are all
|
|
107
|
+
// in place.
|
|
108
|
+
for (const entry of NPM_LIVE_ENTRIES) {
|
|
109
|
+
const live = path.join(dir, entry);
|
|
110
|
+
if (!fs.existsSync(live))
|
|
111
|
+
continue;
|
|
112
|
+
moveDir(live, path.join(rollbackDir, entry));
|
|
113
|
+
displaced.push(entry);
|
|
114
|
+
}
|
|
115
|
+
for (const entry of NPM_LIVE_ENTRIES) {
|
|
116
|
+
const from = path.join(staged.stagingDir, entry);
|
|
117
|
+
if (fs.existsSync(from))
|
|
118
|
+
moveDir(from, path.join(dir, entry));
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
undo: () => {
|
|
122
|
+
for (const entry of NPM_LIVE_ENTRIES) {
|
|
123
|
+
fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
for (const entry of displaced) {
|
|
126
|
+
moveDir(path.join(rollbackDir, entry), path.join(dir, entry));
|
|
127
|
+
}
|
|
128
|
+
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
|
129
|
+
},
|
|
130
|
+
finalize: () => fs.rmSync(rollbackDir, { recursive: true, force: true }),
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Harnesses that are ONE global self-updating binary (droid, muse, warp): every
|
|
136
|
+
* installation of the agent points at the same file, so there is nothing
|
|
137
|
+
* per-installation to stage or swap, and updating one necessarily updates all.
|
|
138
|
+
* The honest model is therefore: run the official installer, probe the live
|
|
139
|
+
* binary, and record the new release on every installation that shares it.
|
|
140
|
+
*/
|
|
141
|
+
const globalBinaryStrategy = {
|
|
142
|
+
id: 'global-binary',
|
|
143
|
+
transactional: false,
|
|
144
|
+
sharedBinary: true,
|
|
145
|
+
async resolveTarget(ctx) {
|
|
146
|
+
// The installer for these carries no version token, so a requested release
|
|
147
|
+
// cannot be honoured. Fail loud rather than install something else and
|
|
148
|
+
// report it as the pin the user asked for.
|
|
149
|
+
if (ctx.requested !== 'latest') {
|
|
150
|
+
throw new Error(`${AGENTS[ctx.agent].name} is a single self-updating binary with no pinnable releases — `
|
|
151
|
+
+ `it can only be updated to the current one. Re-run: agents update ${ctx.agent}@${ctx.installation.label} --to latest`);
|
|
152
|
+
}
|
|
153
|
+
// Resolved after the installer runs — `latest` here is whatever it fetches.
|
|
154
|
+
return 'latest';
|
|
155
|
+
},
|
|
156
|
+
async stage(ctx) {
|
|
157
|
+
const script = AGENTS[ctx.agent].installScript;
|
|
158
|
+
ctx.onProgress?.(`Updating ${AGENTS[ctx.agent].name} via official installer...`);
|
|
159
|
+
await execAsync(script, { timeout: INSTALL_TIMEOUT_MS });
|
|
160
|
+
invalidateLiveVersionCache(ctx.agent);
|
|
161
|
+
const live = await getLiveVersion(ctx.agent);
|
|
162
|
+
if (!live) {
|
|
163
|
+
throw new Error(`${AGENTS[ctx.agent].name} installer finished but its version could not be determined.`);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
release: live,
|
|
167
|
+
binary: getBinaryPath(ctx.agent, ctx.installation.label),
|
|
168
|
+
home: getVersionHomePath(ctx.agent, ctx.installation.label),
|
|
169
|
+
stagingDir: null,
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
async commit() {
|
|
173
|
+
// The installer already replaced the shared binary; there is no per-install
|
|
174
|
+
// swap to perform and no previous copy to restore.
|
|
175
|
+
return { undo: () => { }, finalize: () => { } };
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Harnesses installed by an official script that keeps a per-installation copy
|
|
180
|
+
* or symlink farm (grok, cursor, antigravity, hermes, kiro, goose, …). The
|
|
181
|
+
* vendor artifact lands in a global location the installer owns, so the fetch
|
|
182
|
+
* itself is not reversible; what IS per-installation — the version dir's binary
|
|
183
|
+
* link farm — is staged and swapped so a failed re-import cannot strand the
|
|
184
|
+
* installation without a launch target.
|
|
185
|
+
*/
|
|
186
|
+
const installScriptStrategy = {
|
|
187
|
+
id: 'install-script',
|
|
188
|
+
transactional: false,
|
|
189
|
+
sharedBinary: false,
|
|
190
|
+
async resolveTarget(ctx) {
|
|
191
|
+
const script = AGENTS[ctx.agent].installScript;
|
|
192
|
+
if (!script.includes('VERSION') && ctx.requested !== 'latest') {
|
|
193
|
+
throw new Error(`${AGENTS[ctx.agent].name}'s installer takes no version, so it can only be updated to the current release. `
|
|
194
|
+
+ `Re-run: agents update ${ctx.agent}@${ctx.installation.label} --to latest`);
|
|
195
|
+
}
|
|
196
|
+
return ctx.requested;
|
|
197
|
+
},
|
|
198
|
+
async stage(ctx, target) {
|
|
199
|
+
const config = AGENTS[ctx.agent];
|
|
200
|
+
const script = config.installScript.replaceAll('VERSION', target === 'latest' ? 'latest' : target);
|
|
201
|
+
ctx.onProgress?.(`Updating ${config.name} via official installer...`);
|
|
202
|
+
await execAsync(script, { timeout: INSTALL_TIMEOUT_MS });
|
|
203
|
+
invalidateLiveVersionCache(ctx.agent);
|
|
204
|
+
// findInPath skips our own shims dir, so this is the genuine vendor binary
|
|
205
|
+
// and never our dispatcher (which would produce a self-execing link farm).
|
|
206
|
+
const installed = findInPath(config.cliCommand);
|
|
207
|
+
if (!installed) {
|
|
208
|
+
throw new Error(`${config.name} installer finished but ${config.cliCommand} is not on PATH — the install did not complete.`);
|
|
209
|
+
}
|
|
210
|
+
// On Windows there is no `.cmd` wrapper beside an imported install-script
|
|
211
|
+
// binary, so the staged launch probe cannot run and reports healthy. The
|
|
212
|
+
// gate is therefore weaker here than on POSIX; the unconditional undo in
|
|
213
|
+
// update.ts is what keeps a bad swap recoverable.
|
|
214
|
+
const release = target === 'latest'
|
|
215
|
+
? (await getLiveVersion(ctx.agent)) ?? target
|
|
216
|
+
: target;
|
|
217
|
+
const dir = installationDir(ctx.agent, ctx.installation.label);
|
|
218
|
+
const stagingDir = path.join(dir, `.staging-${runId()}`);
|
|
219
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
220
|
+
const imported = importInstallScriptBinary({ agentId: ctx.agent, npmPackage: config.npmPackage, cliCommand: config.cliCommand }, ctx.installation.label, installed, stagingDir);
|
|
221
|
+
if (!imported.success) {
|
|
222
|
+
// Swallowing this reported the launch probe's generic "binary not found"
|
|
223
|
+
// instead of the real reason the import failed.
|
|
224
|
+
throw new Error(`${config.name} ${release} was installed but could not be linked into the version directory: ${imported.error ?? 'unknown error'}`);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
release,
|
|
228
|
+
binary: path.join(stagingDir, 'node_modules', '.bin', config.cliCommand),
|
|
229
|
+
home: getVersionHomePath(ctx.agent, ctx.installation.label),
|
|
230
|
+
stagingDir,
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
commit: npmPackageStrategy.commit,
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Pick the update strategy for an agent from the registry's declared shape.
|
|
237
|
+
*
|
|
238
|
+
* The ordering mirrors `installVersion`: an npm package wins whenever one is
|
|
239
|
+
* declared (kimi declares both a package and a script, and its package is what
|
|
240
|
+
* `agents add` installs), then a single shared global binary, then a per-install
|
|
241
|
+
* script. Anything else is an integration boundary we do not handle — it throws
|
|
242
|
+
* rather than silently no-opping and reporting success.
|
|
243
|
+
*/
|
|
244
|
+
export function selectUpdateStrategy(agent) {
|
|
245
|
+
const config = AGENTS[agent];
|
|
246
|
+
if (config.npmPackage)
|
|
247
|
+
return npmPackageStrategy;
|
|
248
|
+
if (config.installScript && isGlobalBinaryAgent(agent))
|
|
249
|
+
return globalBinaryStrategy;
|
|
250
|
+
if (config.installScript) {
|
|
251
|
+
if (!usesVersionDirLinkFarm(agent)) {
|
|
252
|
+
// The install path resolves this harness's binary somewhere the version
|
|
253
|
+
// dir's link farm does not describe (grok keeps a real per-release copy
|
|
254
|
+
// under its version home). Staging and swapping the link farm would leave
|
|
255
|
+
// the launch target untouched, so the update would record a release that
|
|
256
|
+
// is not actually installed. Refuse instead of reporting a false success.
|
|
257
|
+
throw new Error(`${config.name} keeps its binary outside the managed version directory, so agents-cli cannot yet update an `
|
|
258
|
+
+ `installation in place. Install the current release as a new installation: agents add ${agent}@latest`);
|
|
259
|
+
}
|
|
260
|
+
return installScriptStrategy;
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`${config.name} is not installed by agents-cli (it declares no npm package and no installer), so there is nothing to update. `
|
|
263
|
+
+ `Update it with its own tooling.`);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Does this harness's launch target live in the version dir's own
|
|
267
|
+
* `node_modules/.bin` link farm — the thing an installation can stage and swap?
|
|
268
|
+
*
|
|
269
|
+
* Probed through `getBinaryPath`, the single resolver the shims and `agents run`
|
|
270
|
+
* use, rather than tested against an agent id, so a harness that resolves its
|
|
271
|
+
* binary elsewhere is recognised without being enumerated here.
|
|
272
|
+
*/
|
|
273
|
+
function usesVersionDirLinkFarm(agent) {
|
|
274
|
+
const probe = '0.0.0-probe';
|
|
275
|
+
const expected = path.join(installationDir(agent, probe), 'node_modules', '.bin', AGENTS[agent].cliCommand);
|
|
276
|
+
return getBinaryPath(agent, probe) === expected;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Whether a concrete release can be requested for this agent at all. False for
|
|
280
|
+
* every self-updating harness — their installers carry no version token.
|
|
281
|
+
*/
|
|
282
|
+
export function supportsPinnedUpdate(agent) {
|
|
283
|
+
const config = AGENTS[agent];
|
|
284
|
+
if (config.npmPackage)
|
|
285
|
+
return true;
|
|
286
|
+
return !isSelfUpdatingAgent(agent);
|
|
287
|
+
}
|
|
288
|
+
/** Guard a user-supplied release token before it reaches a path or a package spec. */
|
|
289
|
+
export function assertValidRelease(requested) {
|
|
290
|
+
if (!VERSION_RE.test(requested)) {
|
|
291
|
+
throw new Error(`Invalid release: ${JSON.stringify(requested)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AgentId } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Schema version of the on-disk installation record. Bump only for a change a
|
|
4
|
+
* previous CLI could not read; {@link INSTALLATION_SCHEMA} is asserted on read
|
|
5
|
+
* so a newer record fails loud instead of being silently misinterpreted.
|
|
6
|
+
*/
|
|
7
|
+
export declare const INSTALLATION_SCHEMA = 1;
|
|
8
|
+
/** File name of the record, written at the root of a version dir. */
|
|
9
|
+
export declare const INSTALLATION_RECORD_FILE = "installation.json";
|
|
10
|
+
/**
|
|
11
|
+
* One entry in an installation's release history — appended on every successful
|
|
12
|
+
* update so `agents update --json` can report where a frozen installation came
|
|
13
|
+
* from without consulting the vendor.
|
|
14
|
+
*/
|
|
15
|
+
export interface InstallationRelease {
|
|
16
|
+
/** The vendor release that was live for this span. */
|
|
17
|
+
releaseVersion: string;
|
|
18
|
+
/** ISO-8601 timestamp at which this release became live. */
|
|
19
|
+
at: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A frozen agent installation.
|
|
23
|
+
*
|
|
24
|
+
* The load-bearing idea: an installation's IDENTITY ({@link id}, {@link label})
|
|
25
|
+
* is stable for the life of the install, while the vendor release it carries
|
|
26
|
+
* ({@link releaseVersion}) moves only on an explicit `agents update`. Every
|
|
27
|
+
* persisted reference — the global default, an isolated default, a project pin,
|
|
28
|
+
* a routine's agent spec — names the {@link label}, so a release change never
|
|
29
|
+
* invalidates a reference.
|
|
30
|
+
*
|
|
31
|
+
* Before this record existed the version-dir NAME was the only identity, which
|
|
32
|
+
* made those two concepts the same string: updating a release necessarily
|
|
33
|
+
* renamed the directory and broke every reference pointing at it, and two
|
|
34
|
+
* installations of the same release could not coexist at all. Splitting them is
|
|
35
|
+
* what makes both possible.
|
|
36
|
+
*/
|
|
37
|
+
export interface Installation {
|
|
38
|
+
schema: number;
|
|
39
|
+
/** Opaque, stable, never reused. Survives every update. */
|
|
40
|
+
id: string;
|
|
41
|
+
agent: AgentId;
|
|
42
|
+
/**
|
|
43
|
+
* The addressable name of this installation — the version-dir basename, and
|
|
44
|
+
* the token users type in `agents update <agent>@<label>`. Frozen at creation.
|
|
45
|
+
*/
|
|
46
|
+
label: string;
|
|
47
|
+
/** The vendor release currently installed on disk. Moves on update. */
|
|
48
|
+
releaseVersion: string;
|
|
49
|
+
createdAt: string;
|
|
50
|
+
updatedAt: string;
|
|
51
|
+
/** Newest last. Always non-empty: creation seeds it with the first release. */
|
|
52
|
+
history: InstallationRelease[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* How an installation's release is replaced. Selected from the agent registry's
|
|
56
|
+
* capabilities, never from an agent id — see `selectUpdateStrategy`.
|
|
57
|
+
*/
|
|
58
|
+
export type UpdateStrategyId =
|
|
59
|
+
/** Agent ships an npm package: a pinnable release staged into the version dir. */
|
|
60
|
+
'npm-package'
|
|
61
|
+
/** One global self-updating binary shared by every installation of the agent. */
|
|
62
|
+
| 'global-binary'
|
|
63
|
+
/** An official install script with no pinnable version, re-imported per install. */
|
|
64
|
+
| 'install-script';
|
|
65
|
+
/** Outcome of a single `agents update` run against one installation. */
|
|
66
|
+
export interface UpdateOutcome {
|
|
67
|
+
installation: Installation;
|
|
68
|
+
strategy: UpdateStrategyId;
|
|
69
|
+
fromRelease: string;
|
|
70
|
+
toRelease: string;
|
|
71
|
+
/** True when the resolved target already matched the installed release. */
|
|
72
|
+
unchanged: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Installations other than the target whose recorded release also moved,
|
|
75
|
+
* because the strategy replaced a binary they share (global-binary only).
|
|
76
|
+
*/
|
|
77
|
+
alsoUpdated: Installation[];
|
|
78
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema version of the on-disk installation record. Bump only for a change a
|
|
3
|
+
* previous CLI could not read; {@link INSTALLATION_SCHEMA} is asserted on read
|
|
4
|
+
* so a newer record fails loud instead of being silently misinterpreted.
|
|
5
|
+
*/
|
|
6
|
+
export const INSTALLATION_SCHEMA = 1;
|
|
7
|
+
/** File name of the record, written at the root of a version dir. */
|
|
8
|
+
export const INSTALLATION_RECORD_FILE = 'installation.json';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type UpdateStrategy } from './strategies.js';
|
|
2
|
+
import type { Installation, UpdateOutcome } from './types.js';
|
|
3
|
+
export interface UpdateInstallationOptions {
|
|
4
|
+
/** `latest` (default), `oldest`, or a concrete release. */
|
|
5
|
+
to?: string;
|
|
6
|
+
onProgress?: (message: string) => void;
|
|
7
|
+
/**
|
|
8
|
+
* Replace the registry-selected strategy. The seam exists so the transaction
|
|
9
|
+
* below can be exercised against a real filesystem without a vendor fetch, and
|
|
10
|
+
* so a track that installs a harness differently (per-installation Cursor
|
|
11
|
+
* isolation) can reuse this orchestration instead of re-implementing it.
|
|
12
|
+
* Omitted in every normal call — `selectUpdateStrategy` is the default.
|
|
13
|
+
*/
|
|
14
|
+
strategy?: UpdateStrategy;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Move one frozen installation to a new vendor release, preserving its identity.
|
|
18
|
+
*
|
|
19
|
+
* The transaction is stage → verify → commit → record, with rollback on any
|
|
20
|
+
* failure after the swap:
|
|
21
|
+
*
|
|
22
|
+
* 1. **stage** — fetch the target release somewhere that is not yet live.
|
|
23
|
+
* 2. **verify** — launch the STAGED binary. This is the gate that makes the
|
|
24
|
+
* update safe: a release that cannot start is discarded while
|
|
25
|
+
* the working one is still in place, so the failure mode is
|
|
26
|
+
* "nothing changed", not "the agent no longer runs".
|
|
27
|
+
* 3. **commit** — swap it in, keeping the displaced release until step 4.
|
|
28
|
+
* 4. **record** — re-verify in place, then write the new release into the
|
|
29
|
+
* installation record and drop the rollback material.
|
|
30
|
+
*
|
|
31
|
+
* The installation's `id` and `label` are never touched, so the global default,
|
|
32
|
+
* an isolated default, a project pin, a routine's `version:`, and a profile's
|
|
33
|
+
* `host.version` all keep resolving to this installation across the update —
|
|
34
|
+
* that reference preservation is the whole point of freezing the label.
|
|
35
|
+
*
|
|
36
|
+
* Strategies whose vendor artifact is global (a self-updating binary) report
|
|
37
|
+
* `transactional: false`; for those, step 3 is a no-op and a failed verify is
|
|
38
|
+
* surfaced as a failed update rather than pretended to be reversible.
|
|
39
|
+
*/
|
|
40
|
+
export declare function updateInstallation(installation: Installation, options?: UpdateInstallationOptions): Promise<UpdateOutcome>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { AGENTS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
|
|
3
|
+
import { emit } from '../events.js';
|
|
4
|
+
import { getBinaryPath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, verifyBinaryLaunches, } from '../versions.js';
|
|
5
|
+
import { assertValidRelease, selectUpdateStrategy, } from './strategies.js';
|
|
6
|
+
import { listInstallations, recordRelease } from './store.js';
|
|
7
|
+
/**
|
|
8
|
+
* Move one frozen installation to a new vendor release, preserving its identity.
|
|
9
|
+
*
|
|
10
|
+
* The transaction is stage → verify → commit → record, with rollback on any
|
|
11
|
+
* failure after the swap:
|
|
12
|
+
*
|
|
13
|
+
* 1. **stage** — fetch the target release somewhere that is not yet live.
|
|
14
|
+
* 2. **verify** — launch the STAGED binary. This is the gate that makes the
|
|
15
|
+
* update safe: a release that cannot start is discarded while
|
|
16
|
+
* the working one is still in place, so the failure mode is
|
|
17
|
+
* "nothing changed", not "the agent no longer runs".
|
|
18
|
+
* 3. **commit** — swap it in, keeping the displaced release until step 4.
|
|
19
|
+
* 4. **record** — re-verify in place, then write the new release into the
|
|
20
|
+
* installation record and drop the rollback material.
|
|
21
|
+
*
|
|
22
|
+
* The installation's `id` and `label` are never touched, so the global default,
|
|
23
|
+
* an isolated default, a project pin, a routine's `version:`, and a profile's
|
|
24
|
+
* `host.version` all keep resolving to this installation across the update —
|
|
25
|
+
* that reference preservation is the whole point of freezing the label.
|
|
26
|
+
*
|
|
27
|
+
* Strategies whose vendor artifact is global (a self-updating binary) report
|
|
28
|
+
* `transactional: false`; for those, step 3 is a no-op and a failed verify is
|
|
29
|
+
* surfaced as a failed update rather than pretended to be reversible.
|
|
30
|
+
*/
|
|
31
|
+
export async function updateInstallation(installation, options = {}) {
|
|
32
|
+
const agent = installation.agent;
|
|
33
|
+
if (isAgentHardDeprecated(agent))
|
|
34
|
+
throw new Error(hardDeprecationError(agent));
|
|
35
|
+
const requested = options.to ?? 'latest';
|
|
36
|
+
assertValidRelease(requested);
|
|
37
|
+
const strategy = options.strategy ?? selectUpdateStrategy(agent);
|
|
38
|
+
const ctx = { agent, installation, requested, onProgress: options.onProgress };
|
|
39
|
+
const target = await strategy.resolveTarget(ctx);
|
|
40
|
+
if (target === installation.releaseVersion) {
|
|
41
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${target}; nothing to update.`);
|
|
42
|
+
return {
|
|
43
|
+
installation,
|
|
44
|
+
strategy: strategy.id,
|
|
45
|
+
fromRelease: installation.releaseVersion,
|
|
46
|
+
toRelease: target,
|
|
47
|
+
unchanged: true,
|
|
48
|
+
alsoUpdated: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let staged = null;
|
|
52
|
+
try {
|
|
53
|
+
staged = await strategy.stage(ctx, target);
|
|
54
|
+
const stagedHealth = await verifyBinaryLaunches(staged.binary, staged.home);
|
|
55
|
+
if (!stagedHealth.ok) {
|
|
56
|
+
throw new Error(`${AGENTS[agent].name} release ${staged.release} was fetched but its binary failed to launch`
|
|
57
|
+
+ `${stagedHealth.detail ? ` (${stagedHealth.detail})` : ''}. `
|
|
58
|
+
+ `${installation.label} is unchanged and still on ${installation.releaseVersion}.`);
|
|
59
|
+
}
|
|
60
|
+
// The installer may have reported a release the installation already has
|
|
61
|
+
// (a self-updating binary that was already current). Recording it would
|
|
62
|
+
// claim a change that did not happen and append a bogus history entry.
|
|
63
|
+
if (staged.release === installation.releaseVersion) {
|
|
64
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${staged.release}; nothing to update.`);
|
|
65
|
+
// Deliberately NOT committed: there is no new release to make live, and
|
|
66
|
+
// for a strategy that swaps the version dir a commit here would displace
|
|
67
|
+
// a working tree, discard its rollback material, and skip the live probe —
|
|
68
|
+
// all while reporting that nothing changed. The `finally` clears staging.
|
|
69
|
+
return {
|
|
70
|
+
installation,
|
|
71
|
+
strategy: strategy.id,
|
|
72
|
+
fromRelease: installation.releaseVersion,
|
|
73
|
+
toRelease: staged.release,
|
|
74
|
+
unchanged: true,
|
|
75
|
+
alsoUpdated: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const handles = await strategy.commit(ctx, staged);
|
|
79
|
+
try {
|
|
80
|
+
// Probe what will actually execute — `getBinaryPath` is the same resolver
|
|
81
|
+
// the shims and `agents run` use — not the staging copy probed above.
|
|
82
|
+
const liveBinary = getBinaryPath(agent, installation.label);
|
|
83
|
+
const liveHealth = await verifyBinaryLaunches(liveBinary, staged.home);
|
|
84
|
+
if (!liveHealth.ok) {
|
|
85
|
+
throw new Error(`${AGENTS[agent].name} release ${staged.release} failed to launch after being installed`
|
|
86
|
+
+ `${liveHealth.detail ? ` (${liveHealth.detail})` : ''}.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Undo unconditionally. `transactional` describes whether the VENDOR
|
|
91
|
+
// artifact can be put back, not whether this directory can — gating the
|
|
92
|
+
// undo on it left an installer-driven harness with the broken tree live
|
|
93
|
+
// AND the previous one orphaned in rollback material nothing deletes.
|
|
94
|
+
// A strategy with nothing to restore returns a no-op undo.
|
|
95
|
+
handles.undo();
|
|
96
|
+
throw new Error(strategy.transactional
|
|
97
|
+
? `${err.message} Rolled back to ${installation.releaseVersion}.`
|
|
98
|
+
: `${err.message} The version directory was restored, but ${AGENTS[agent].name}'s installer `
|
|
99
|
+
+ `had already replaced the binary it manages globally — repair it with: agents add ${agent}@latest`);
|
|
100
|
+
}
|
|
101
|
+
handles.finalize();
|
|
102
|
+
const updated = recordRelease(installation, staged.release);
|
|
103
|
+
// Several installations of a global-binary harness point at the same file,
|
|
104
|
+
// so the one we just replaced is live for all of them. Recording the release
|
|
105
|
+
// only on the target would leave the others claiming a release that is no
|
|
106
|
+
// longer on disk.
|
|
107
|
+
const alsoUpdated = strategy.sharedBinary
|
|
108
|
+
? listInstallations(agent)
|
|
109
|
+
.filter((other) => other.label !== installation.label && other.releaseVersion !== staged.release)
|
|
110
|
+
.map((other) => recordRelease(other, staged.release))
|
|
111
|
+
: [];
|
|
112
|
+
invalidateInstalledVersionsCache(agent);
|
|
113
|
+
invalidateLiveVersionCache(agent);
|
|
114
|
+
// A release really was installed, so this is the right event; `installation`
|
|
115
|
+
// says WHICH frozen install received it, since the label no longer equals
|
|
116
|
+
// the release.
|
|
117
|
+
emit('version.install', { agent, version: staged.release, installation: installation.label });
|
|
118
|
+
return {
|
|
119
|
+
installation: updated,
|
|
120
|
+
strategy: strategy.id,
|
|
121
|
+
fromRelease: installation.releaseVersion,
|
|
122
|
+
toRelease: staged.release,
|
|
123
|
+
unchanged: false,
|
|
124
|
+
alsoUpdated,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (staged?.stagingDir)
|
|
129
|
+
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
Binary file
|
|
Binary file
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -117,6 +117,33 @@ export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string):
|
|
|
117
117
|
* Params default to the real routines dir; injectable for tests.
|
|
118
118
|
*/
|
|
119
119
|
export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
|
|
120
|
+
/**
|
|
121
|
+
* Fold the legacy host-placement `remoteCwd` field into the canonical portable
|
|
122
|
+
* `cwd` (RUSH-2290). Host dispatch used to read `remoteCwd` while a local run
|
|
123
|
+
* inferred its cwd from `repo` — two path semantics for one concept. The runner
|
|
124
|
+
* now resolves every placement from `cwd`, so this idempotently rewrites the
|
|
125
|
+
* field:
|
|
126
|
+
*
|
|
127
|
+
* - `remoteCwd` present, no `cwd` → rename to `cwd`.
|
|
128
|
+
* - both present and equal → drop the duplicate `remoteCwd`.
|
|
129
|
+
* - both present and DIFFERENT → conflict: leave BOTH fields untouched so
|
|
130
|
+
* the migration never silently chooses one; `validateJob`/`doctor` then flag the
|
|
131
|
+
* pair and the routine stays paused rather than running against a guessed path.
|
|
132
|
+
*
|
|
133
|
+
* Idempotent: a file with only `cwd` (already migrated) is skipped.
|
|
134
|
+
*/
|
|
135
|
+
export declare function migrateRoutineRemoteCwdToCwd(routinesDir?: string): void;
|
|
136
|
+
/**
|
|
137
|
+
* Pause every currently-active routine whose execution context no longer
|
|
138
|
+
* resolves ready (RUSH-2290). An agent/workflow routine with no project/cwd, a
|
|
139
|
+
* missing directory, or a non-portable path used to fire and fail every tick —
|
|
140
|
+
* the mass auth_failed / untrusted-home storm this ticket exists to stop. After
|
|
141
|
+
* the fold, such a routine is deactivated on THIS device (only), preventing it
|
|
142
|
+
* from being scheduled until `agents routines doctor --all --fix` (or a repair +
|
|
143
|
+
* `resume`) makes it ready. Never materializes a device manifest that does not
|
|
144
|
+
* yet exist, and never touches command routines (they run in the target home).
|
|
145
|
+
*/
|
|
146
|
+
export declare function pauseUnreadyEnabledRoutines(): void;
|
|
120
147
|
/**
|
|
121
148
|
* Fold the legacy watchdog enable sentinel into the watchdog routine.
|
|
122
149
|
*
|