@phnx-labs/agents-cli 1.22.78 → 1.22.79
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 +11 -0
- package/README.md +28 -1
- package/dist/bootstrap.js +40 -12
- package/dist/commands/accounts.d.ts +22 -0
- package/dist/commands/accounts.js +158 -35
- package/dist/commands/config.js +37 -0
- package/dist/commands/exec.js +21 -10
- package/dist/commands/update.js +169 -18
- package/dist/commands/versions.d.ts +11 -0
- package/dist/commands/versions.js +30 -4
- package/dist/commands/view.d.ts +4 -0
- package/dist/commands/view.js +114 -19
- package/dist/index.js +35 -2
- package/dist/lib/account-catalog.d.ts +97 -1
- package/dist/lib/account-catalog.js +134 -6
- package/dist/lib/account-registry.d.ts +43 -0
- package/dist/lib/account-registry.js +97 -2
- package/dist/lib/accounting/rotate.d.ts +2 -2
- package/dist/lib/accounting/rotate.js +5 -5
- package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
- package/dist/lib/accounts/auth-operation-lock.js +55 -0
- package/dist/lib/accounts/connect.d.ts +170 -0
- package/dist/lib/accounts/connect.js +383 -0
- package/dist/lib/capabilities.js +2 -0
- package/dist/lib/commands.js +2 -0
- package/dist/lib/config-keys.d.ts +11 -2
- package/dist/lib/config-keys.js +21 -1
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/harness-update-service.d.ts +110 -0
- package/dist/lib/daemon/harness-update-service.js +216 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +0 -1
- package/dist/lib/device-config.js +50 -5
- package/dist/lib/exec.js +26 -2
- package/dist/lib/fs-atomic.d.ts +2 -0
- package/dist/lib/fs-atomic.js +2 -0
- package/dist/lib/hooks/install.js +7 -2
- package/dist/lib/installations/active-check.d.ts +48 -0
- package/dist/lib/installations/active-check.js +84 -0
- package/dist/lib/installations/index.d.ts +5 -1
- package/dist/lib/installations/index.js +4 -0
- package/dist/lib/installations/installation-lock.d.ts +6 -0
- package/dist/lib/installations/installation-lock.js +29 -0
- package/dist/lib/installations/launch-gate.d.ts +69 -0
- package/dist/lib/installations/launch-gate.js +133 -0
- package/dist/lib/installations/native-command.d.ts +5 -0
- package/dist/lib/installations/native-command.js +52 -0
- package/dist/lib/installations/shims.d.ts +8 -2
- package/dist/lib/installations/shims.js +105 -2
- package/dist/lib/installations/store.d.ts +5 -1
- package/dist/lib/installations/store.js +24 -3
- package/dist/lib/installations/strategies.js +55 -35
- package/dist/lib/installations/types.d.ts +17 -0
- package/dist/lib/installations/update-cancellation.d.ts +82 -0
- package/dist/lib/installations/update-cancellation.js +122 -0
- package/dist/lib/installations/update-policy.d.ts +69 -0
- package/dist/lib/installations/update-policy.js +114 -0
- package/dist/lib/installations/update-runtime.d.ts +118 -0
- package/dist/lib/installations/update-runtime.js +321 -0
- package/dist/lib/installations/update.d.ts +25 -0
- package/dist/lib/installations/update.js +141 -2
- package/dist/lib/installations/versions.d.ts +1 -0
- package/dist/lib/installations/versions.js +166 -131
- package/dist/lib/platform/process.d.ts +3 -1
- package/dist/lib/platform/process.js +2 -2
- package/dist/lib/staleness/detectors/commands.d.ts +1 -2
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
- package/dist/lib/staleness/detectors/rules.d.ts +1 -2
- package/dist/lib/staleness/detectors/skills.d.ts +1 -2
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
- package/dist/lib/staleness/writers/commands.d.ts +1 -2
- package/dist/lib/staleness/writers/hooks.d.ts +1 -2
- package/dist/lib/staleness/writers/mcp.d.ts +1 -2
- package/dist/lib/staleness/writers/permissions.d.ts +1 -12
- package/dist/lib/staleness/writers/plugins.d.ts +1 -6
- package/dist/lib/staleness/writers/rules.d.ts +1 -2
- package/dist/lib/staleness/writers/skills.d.ts +1 -2
- package/dist/lib/staleness/writers/subagents.d.ts +1 -2
- package/dist/lib/staleness/writers/workflows.d.ts +1 -8
- package/dist/lib/state.d.ts +3 -1
- package/dist/lib/state.js +38 -13
- package/dist/lib/types.d.ts +26 -1
- package/dist/lib/types.js +5 -0
- package/dist/lib/view-types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The automatic-update pass (PHNX-3940): decide which installations may be
|
|
3
|
+
* moved to their harness's latest release with no operator in the loop, then
|
|
4
|
+
* (optionally) move them.
|
|
5
|
+
*
|
|
6
|
+
* Split into a PLAN (`planAutoUpdates` — the `--check` dry-run and the
|
|
7
|
+
* daemon's own decision step both read it; it reads through
|
|
8
|
+
* {@link listInstallationSnapshots}, which NEVER mutates disk, so a preview is
|
|
9
|
+
* genuinely a preview) and a RUN (`runAutoUpdatePass`, which migrates a
|
|
10
|
+
* legacy installation for real, under its own lock, and regenerates its
|
|
11
|
+
* already-owned shim/versioned-alias, before driving eligible plan entries
|
|
12
|
+
* through the existing `updateInstallation` transaction). Both share one
|
|
13
|
+
* eligibility computation so a dry-run can never report "would update" for
|
|
14
|
+
* something the real run would skip, or vice versa.
|
|
15
|
+
*
|
|
16
|
+
* Eligibility is deliberately narrow:
|
|
17
|
+
* - Only the `npm-package` strategy is transactional AND isolated per
|
|
18
|
+
* installation (stage into a sibling dir, launch-probe it, swap, keep the
|
|
19
|
+
* displaced tree until the swap is proven) — the shape every existing
|
|
20
|
+
* safety property in `update.ts` depends on. A global-binary or
|
|
21
|
+
* install-script harness has no reversible per-installation swap to offer,
|
|
22
|
+
* so it is reported honestly as manual/vendor-managed rather than silently
|
|
23
|
+
* skipped or, worse, "updated" via an irreversible reinstall with no
|
|
24
|
+
* operator watching.
|
|
25
|
+
* - The operator switch (`updates.auto` / `updates.<agent>.auto`,
|
|
26
|
+
* `update-policy.ts`) and the per-installation policy
|
|
27
|
+
* (`Installation.updatePolicy`) must both allow it.
|
|
28
|
+
* - The installation must not look ACTIVE right now (see
|
|
29
|
+
* {@link isInstallationLikelyActive}) — a real OS process-table scan, not
|
|
30
|
+
* just this box's session registry, so a harness launched by a bare
|
|
31
|
+
* generated shim with no session bookkeeping still defers correctly.
|
|
32
|
+
*
|
|
33
|
+
* The latest release for a harness is resolved ONCE per pass (not once per
|
|
34
|
+
* installation) — `resolveSharedTargets` below — so a fleet with a dozen
|
|
35
|
+
* pinned-to-latest Claude installations issues one npm registry read, not a
|
|
36
|
+
* dozen.
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from 'fs';
|
|
39
|
+
import { AGENTS, isAgentHardDeprecated } from '../agents.js';
|
|
40
|
+
import { MANAGED_AGENT_IDS } from '../agent-spec/agents.js';
|
|
41
|
+
import { withFileLockAsync } from '../fs-atomic.js';
|
|
42
|
+
import { ensureInstallationLocked, installationDir, listInstallationLabels, readInstallation, } from './store.js';
|
|
43
|
+
import { refreshOwnedLaunchers, hasLiveLaunchLease } from './shims.js';
|
|
44
|
+
import { installationLooksActive, realProcessSnapshot } from './active-check.js';
|
|
45
|
+
import { selectUpdateStrategy } from './strategies.js';
|
|
46
|
+
import { updateInstallation } from './update.js';
|
|
47
|
+
import { effectiveUpdatePolicy, isAutoUpdateEnabledForAgent } from './update-policy.js';
|
|
48
|
+
import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
|
|
49
|
+
import { withGuardedUpdateCancellation } from './update-cancellation.js';
|
|
50
|
+
import { INSTALLATION_SCHEMA } from './types.js';
|
|
51
|
+
/** The only strategy shape the automatic pass will ever touch — see the module docblock. */
|
|
52
|
+
function autoUpdateStrategyFor(agent) {
|
|
53
|
+
let strategy;
|
|
54
|
+
try {
|
|
55
|
+
strategy = selectUpdateStrategy(agent);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return strategy.id === 'npm-package' && strategy.transactional ? strategy : null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build the record a real migration (`ensureInstallation`, `store.ts`) would
|
|
64
|
+
* mint for a legacy version dir that has no `installation.json` yet — the
|
|
65
|
+
* same fields (schema, label-as-release, history seeded from the directory's
|
|
66
|
+
* own mtime) — but never persisted, and never carrying the real
|
|
67
|
+
* `mintInstallationId()` format, so nothing downstream can mistake it for an
|
|
68
|
+
* id that survived a lock-protected migration. Returns null when the
|
|
69
|
+
* directory itself is gone mid-scan, tolerated the same way
|
|
70
|
+
* `listInstallations` tolerates that.
|
|
71
|
+
*/
|
|
72
|
+
function ephemeralInstallationSnapshot(agent, label) {
|
|
73
|
+
const dir = installationDir(agent, label);
|
|
74
|
+
let createdAt;
|
|
75
|
+
try {
|
|
76
|
+
createdAt = fs.statSync(dir).mtime.toISOString();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
schema: INSTALLATION_SCHEMA,
|
|
83
|
+
id: `preview:${agent}:${label}`,
|
|
84
|
+
agent,
|
|
85
|
+
label,
|
|
86
|
+
releaseVersion: label,
|
|
87
|
+
createdAt,
|
|
88
|
+
updatedAt: createdAt,
|
|
89
|
+
history: [{ releaseVersion: label, at: createdAt }],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read-only enumeration of installations — the ONLY listing {@link planAutoUpdates}
|
|
94
|
+
* may use. `listInstallations` (`store.ts`) migrates a legacy version dir's
|
|
95
|
+
* `installation.json` into existence via `ensureInstallation` as a side
|
|
96
|
+
* effect of merely being READ, which made `agents update --check` (a
|
|
97
|
+
* "preview") write to disk. A legacy dir with no persisted record yet gets an
|
|
98
|
+
* {@link ephemeralInstallationSnapshot} instead — real fields, but never
|
|
99
|
+
* written and never a real id. The real run migrates it for real, under this
|
|
100
|
+
* installation's own lock, immediately before acting on it — see
|
|
101
|
+
* {@link runAutoUpdatePass}.
|
|
102
|
+
*/
|
|
103
|
+
export function listInstallationSnapshots(agent) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const label of listInstallationLabels(agent)) {
|
|
106
|
+
let record;
|
|
107
|
+
try {
|
|
108
|
+
record = readInstallation(agent, label);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
continue; // corrupted record — not an installation this pass can act on
|
|
112
|
+
}
|
|
113
|
+
const snapshot = record ?? ephemeralInstallationSnapshot(agent, label);
|
|
114
|
+
if (snapshot)
|
|
115
|
+
out.push(snapshot);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Build the automatic-update plan: one entry per installation of every scoped
|
|
121
|
+
* agent, with eligibility, deferral, and the resolved target release already
|
|
122
|
+
* computed. Never mutates anything — reads only through
|
|
123
|
+
* {@link listInstallationSnapshots} — so it is genuinely safe to call from
|
|
124
|
+
* `--check` or before every real pass.
|
|
125
|
+
*/
|
|
126
|
+
export async function planAutoUpdates(opts = {}) {
|
|
127
|
+
const agents = (opts.agents ?? MANAGED_AGENT_IDS).filter((agent) => !isAgentHardDeprecated(agent));
|
|
128
|
+
let commandLines = null;
|
|
129
|
+
let processScanError;
|
|
130
|
+
try {
|
|
131
|
+
commandLines = await realProcessSnapshot.listCommandLines();
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
processScanError = err instanceof Error ? err.message : String(err);
|
|
135
|
+
}
|
|
136
|
+
const plan = [];
|
|
137
|
+
for (const agent of agents) {
|
|
138
|
+
const installations = listInstallationSnapshots(agent);
|
|
139
|
+
if (installations.length === 0)
|
|
140
|
+
continue;
|
|
141
|
+
const strategy = autoUpdateStrategyFor(agent);
|
|
142
|
+
const agentAutoEnabled = isAutoUpdateEnabledForAgent(agent);
|
|
143
|
+
// Resolve the latest release ONCE per agent — not once per installation —
|
|
144
|
+
// and only when at least the strategy/switch preconditions hold, so a
|
|
145
|
+
// harness nobody enabled auto-updates for never triggers a registry read.
|
|
146
|
+
let target = null;
|
|
147
|
+
let resolveError;
|
|
148
|
+
if (strategy && agentAutoEnabled) {
|
|
149
|
+
try {
|
|
150
|
+
const ctx = {
|
|
151
|
+
agent,
|
|
152
|
+
installation: installations[0],
|
|
153
|
+
requested: 'latest',
|
|
154
|
+
onProgress: opts.onProgress,
|
|
155
|
+
};
|
|
156
|
+
target = await strategy.resolveTarget(ctx);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
resolveError = err instanceof Error ? err.message : String(err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const installation of installations) {
|
|
163
|
+
const policy = effectiveUpdatePolicy(installation);
|
|
164
|
+
let eligible = true;
|
|
165
|
+
let reason;
|
|
166
|
+
if (!strategy) {
|
|
167
|
+
eligible = false;
|
|
168
|
+
reason = `${AGENTS[agent].name} is manual/vendor-managed for updates (no isolated, reversible per-installation swap) — update it yourself.`;
|
|
169
|
+
}
|
|
170
|
+
else if (!agentAutoEnabled) {
|
|
171
|
+
eligible = false;
|
|
172
|
+
reason = 'automatic updates are disabled for this harness (updates.auto / updates.<agent>.auto).';
|
|
173
|
+
}
|
|
174
|
+
else if (policy === 'pinned') {
|
|
175
|
+
eligible = false;
|
|
176
|
+
reason = 'installation is pinned to a concrete release (agents update … --to <release> unpins with --to latest).';
|
|
177
|
+
}
|
|
178
|
+
else if (resolveError) {
|
|
179
|
+
eligible = false;
|
|
180
|
+
reason = `could not resolve the latest release: ${resolveError}`;
|
|
181
|
+
}
|
|
182
|
+
let deferred = false;
|
|
183
|
+
if (eligible) {
|
|
184
|
+
if (hasLiveLaunchLease(installation.agent, installation.label)) {
|
|
185
|
+
deferred = true;
|
|
186
|
+
reason = 'a launch is in flight for this installation (live launch lease); deferring.';
|
|
187
|
+
}
|
|
188
|
+
else if (commandLines) {
|
|
189
|
+
deferred = installationLooksActive(installation, commandLines);
|
|
190
|
+
if (deferred)
|
|
191
|
+
reason = 'the installation appears to have a process running right now; deferring.';
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// The process scan itself failed — fail closed for every installation
|
|
195
|
+
// this pass would otherwise touch, not just the ones a scan would have
|
|
196
|
+
// flagged as active.
|
|
197
|
+
deferred = true;
|
|
198
|
+
reason = `could not confirm no process is running (${processScanError}); deferring.`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
plan.push({
|
|
202
|
+
agent,
|
|
203
|
+
installation,
|
|
204
|
+
currentRelease: installation.releaseVersion,
|
|
205
|
+
targetRelease: target,
|
|
206
|
+
policy,
|
|
207
|
+
eligible,
|
|
208
|
+
deferred,
|
|
209
|
+
reason,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return plan;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Run the automatic-update pass: plan, then drive every eligible,
|
|
217
|
+
* non-deferred, actually-behind entry through {@link updateInstallation}.
|
|
218
|
+
* Installations update sequentially — this runs from a bounded daemon-spawned
|
|
219
|
+
* child process (`harness-update-service.ts`) on its own schedule, not a
|
|
220
|
+
* user-facing wait, so there is no reason to parallelize and every reason not
|
|
221
|
+
* to (concurrent npm installs sharing this box's npm cache have a history of
|
|
222
|
+
* corrupting each other).
|
|
223
|
+
*
|
|
224
|
+
* `abortIfPinnedBeforeCommit` / `abortIfAutoDisabledBeforeCommit: true` on
|
|
225
|
+
* every call: this is the ONE caller for whom a policy or switch change
|
|
226
|
+
* mid-staging must cancel the commit (see `update.ts`'s docblock on those
|
|
227
|
+
* options) — a manual `agents update` never routes through here.
|
|
228
|
+
*/
|
|
229
|
+
export async function runAutoUpdatePass(opts = {}) {
|
|
230
|
+
// Cancellation is wired from IPC (the daemon's cross-platform request),
|
|
231
|
+
// channel `disconnect` (daemon gone), and SIGTERM/SIGINT — never a forced
|
|
232
|
+
// process kill (see `update-cancellation.ts`). The guard it holds is what lets
|
|
233
|
+
// `index.ts` defer its SIGINT hard-exit while a swap is in flight.
|
|
234
|
+
return withGuardedUpdateCancellation(async (cancelled) => {
|
|
235
|
+
const result = await runAutoUpdatePassUntilCancelled(opts, cancelled);
|
|
236
|
+
return { ...result, cancelled: cancelled() };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The hidden `__harness-update-run` verb the daemon spawns with an IPC channel
|
|
241
|
+
* (dispatched in `index.ts`). Runs ONE auto-update pass with the cooperative,
|
|
242
|
+
* cross-platform cancellation above, writes a compact JSON summary to stdout for
|
|
243
|
+
* the daemon's log, and returns the process exit code.
|
|
244
|
+
*
|
|
245
|
+
* Exit code mirrors the manual `agents update --auto` path
|
|
246
|
+
* (`commands/update.ts`): a per-installation error is a NORMAL tick outcome (a
|
|
247
|
+
* bad vendor release) surfaced as a non-zero exit the daemon logs as a warning,
|
|
248
|
+
* not a service failure. A cooperative cancel is not itself an error, so it does
|
|
249
|
+
* not force a non-zero exit — the child exits on its own and the daemon observes
|
|
250
|
+
* that true completion rather than reading a killed process.
|
|
251
|
+
*/
|
|
252
|
+
export async function runHarnessUpdateChild() {
|
|
253
|
+
const result = await runAutoUpdatePass({});
|
|
254
|
+
const anyError = result.outcomes.some((o) => o.error);
|
|
255
|
+
const summary = {
|
|
256
|
+
v: 1,
|
|
257
|
+
cancelled: result.cancelled ?? false,
|
|
258
|
+
outcomes: result.outcomes.map((o) => ({
|
|
259
|
+
agent: o.entry.agent,
|
|
260
|
+
label: o.entry.installation.label,
|
|
261
|
+
fromRelease: o.outcome?.fromRelease ?? null,
|
|
262
|
+
toRelease: o.outcome?.toRelease ?? null,
|
|
263
|
+
unchanged: o.outcome?.unchanged ?? null,
|
|
264
|
+
deferred: o.outcome?.deferred ?? null,
|
|
265
|
+
error: o.error ?? null,
|
|
266
|
+
})),
|
|
267
|
+
};
|
|
268
|
+
process.stdout.write(JSON.stringify(summary));
|
|
269
|
+
return anyError ? 1 : 0;
|
|
270
|
+
}
|
|
271
|
+
async function runAutoUpdatePassUntilCancelled(opts, cancelled) {
|
|
272
|
+
const plan = await planAutoUpdates(opts);
|
|
273
|
+
const outcomes = [];
|
|
274
|
+
// One shim resolves every installation of an agent dynamically at launch
|
|
275
|
+
// time, so regenerating it once per agent (not once per installation) this
|
|
276
|
+
// pass touches is enough — a second entry for the same agent would just
|
|
277
|
+
// redo `ensureShimCurrent`'s own no-op "already current" check.
|
|
278
|
+
for (const entry of plan) {
|
|
279
|
+
if (cancelled())
|
|
280
|
+
break;
|
|
281
|
+
if (!entry.eligible || entry.deferred)
|
|
282
|
+
continue;
|
|
283
|
+
if (!entry.targetRelease || entry.targetRelease === entry.currentRelease)
|
|
284
|
+
continue;
|
|
285
|
+
try {
|
|
286
|
+
// The plan above is deliberately read-only (`listInstallationSnapshots`),
|
|
287
|
+
// so a legacy version dir with no persisted `installation.json` yet is
|
|
288
|
+
// represented there by an ephemeral, never-written snapshot. A REAL
|
|
289
|
+
// pass must migrate it for real before acting on it — done here, under
|
|
290
|
+
// the SAME per-installation lock `updateInstallation` itself takes
|
|
291
|
+
// (`INSTALLATION_LOCK_OPTIONS`), so two concurrent real passes can never
|
|
292
|
+
// mint two different ids for the same legacy install. Already-migrated
|
|
293
|
+
// installations round-trip through this unchanged (`ensureInstallation`
|
|
294
|
+
// is a plain read when a record already exists).
|
|
295
|
+
const installation = await withFileLockAsync(installationLockTarget(entry.agent, entry.installation.label), () => ensureInstallationLocked(entry.agent, entry.installation.label, entry.installation.createdAt), INSTALLATION_LOCK_OPTIONS);
|
|
296
|
+
// Regenerate only what this pass already owns — the agent's generated
|
|
297
|
+
// shim, and this installation's versioned alias if it was installed
|
|
298
|
+
// isolated — using the existing upgrade-in-place helpers
|
|
299
|
+
// (`ensureShimCurrent`/`ensureVersionedAliasCurrent`). Deliberately
|
|
300
|
+
// NEVER `adoptShadowingLauncher`: that seizes a launcher this pass does
|
|
301
|
+
// not own (a user's own PATH entry, or another install's), and is an
|
|
302
|
+
// operator-triggered `doctor --fix` action, not something an unattended
|
|
303
|
+
// background pass may do on its own. Real-pass-only, same reason as the
|
|
304
|
+
// migration above — a `--check` preview must not touch PATH or a
|
|
305
|
+
// config-dir symlink.
|
|
306
|
+
refreshOwnedLaunchers(entry.agent, installation.label);
|
|
307
|
+
const outcome = await updateInstallation(installation, {
|
|
308
|
+
to: entry.targetRelease,
|
|
309
|
+
onProgress: opts.onProgress,
|
|
310
|
+
abortIfPinnedBeforeCommit: true,
|
|
311
|
+
abortIfAutoDisabledBeforeCommit: true,
|
|
312
|
+
shouldCancel: cancelled,
|
|
313
|
+
});
|
|
314
|
+
outcomes.push({ entry, outcome });
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
outcomes.push({ entry, error: err instanceof Error ? err.message : String(err) });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { plan, outcomes };
|
|
321
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { type UpdateStrategy } from './strategies.js';
|
|
2
2
|
import type { Installation, UpdateOutcome } from './types.js';
|
|
3
3
|
export interface UpdateInstallationOptions {
|
|
4
|
+
/** Persist together with a successfully selected release, under this transaction's lock. */
|
|
5
|
+
updatePolicy?: Installation['updatePolicy'];
|
|
6
|
+
/** Cooperative cancellation is honored before the swap, never during record/rollback. */
|
|
7
|
+
shouldCancel?: () => boolean;
|
|
4
8
|
/** `latest` (default), `oldest`, or a concrete release. */
|
|
5
9
|
to?: string;
|
|
6
10
|
onProgress?: (message: string) => void;
|
|
@@ -12,6 +16,27 @@ export interface UpdateInstallationOptions {
|
|
|
12
16
|
* Omitted in every normal call — `selectUpdateStrategy` is the default.
|
|
13
17
|
*/
|
|
14
18
|
strategy?: UpdateStrategy;
|
|
19
|
+
/**
|
|
20
|
+
* Abort just before `commit()` if the installation's update policy has
|
|
21
|
+
* flipped to `'pinned'` since staging began, instead of making the staged
|
|
22
|
+
* release live. Set only by the automatic-update pass
|
|
23
|
+
* (`update-runtime.ts`): a manual `agents update` is itself the user's
|
|
24
|
+
* request and must never be undercut by a policy read mid-flight, but an
|
|
25
|
+
* automatic run must not commit an update the operator pinned away from
|
|
26
|
+
* while the (potentially minutes-long) stage was in flight.
|
|
27
|
+
*/
|
|
28
|
+
abortIfPinnedBeforeCommit?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Set only by the automatic-update pass (`update-runtime.ts`). Right before
|
|
31
|
+
* `commit()`, re-reads the global/per-harness `updates.auto` switches
|
|
32
|
+
* (`update-policy.ts`) in addition to the pin check above, aborting the
|
|
33
|
+
* commit if automatic updates were turned off for this harness while the
|
|
34
|
+
* (potentially minutes-long) stage was in flight. Same reasoning as
|
|
35
|
+
* {@link abortIfPinnedBeforeCommit}: a manual `agents update` is itself the
|
|
36
|
+
* user's own current request and must never be undercut by a policy read
|
|
37
|
+
* mid-flight, so it never sets this.
|
|
38
|
+
*/
|
|
39
|
+
abortIfAutoDisabledBeforeCommit?: boolean;
|
|
15
40
|
}
|
|
16
41
|
/**
|
|
17
42
|
* Move one frozen installation to a new vendor release, preserving its identity.
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import { AGENTS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
|
|
3
3
|
import { emit } from '../feed/events.js';
|
|
4
|
+
import { withFileLockAsync } from '../fs-atomic.js';
|
|
4
5
|
import { getBinaryPath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, verifyBinaryLaunches, } from './versions.js';
|
|
5
6
|
import { assertValidRelease, selectUpdateStrategy, } from './strategies.js';
|
|
6
|
-
import { listInstallations, recordRelease } from './store.js';
|
|
7
|
+
import { listInstallations, readInstallation, recordRelease, writeInstallation } from './store.js';
|
|
8
|
+
import { effectiveUpdatePolicy, isAutoUpdateEnabledForAgent } from './update-policy.js';
|
|
9
|
+
import { isInstallationLikelyActive } from './active-check.js';
|
|
10
|
+
import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
|
|
7
11
|
/**
|
|
8
12
|
* Move one frozen installation to a new vendor release, preserving its identity.
|
|
9
13
|
*
|
|
@@ -32,12 +36,32 @@ export async function updateInstallation(installation, options = {}) {
|
|
|
32
36
|
const agent = installation.agent;
|
|
33
37
|
if (isAgentHardDeprecated(agent))
|
|
34
38
|
throw new Error(hardDeprecationError(agent));
|
|
39
|
+
// Serialize every update of this SAME installation — manual and automatic
|
|
40
|
+
// alike — behind a cross-process lock keyed on its own record file. Without
|
|
41
|
+
// it, an operator's `agents update claude@2.0.65` racing the automatic pass
|
|
42
|
+
// (or two automatic passes on two boxes sharing this ~/.agents, which
|
|
43
|
+
// shouldn't happen but isn't prevented at this layer) could stage two
|
|
44
|
+
// releases into the same version dir and interleave their swaps. The record
|
|
45
|
+
// is re-read fresh under the lock (never trusting the possibly-stale
|
|
46
|
+
// `installation` the caller passed in) so a concurrent update that already
|
|
47
|
+
// landed is observed before this one decides what to do; a corrupted record
|
|
48
|
+
// makes `readInstallation` throw, which propagates out of the locked section
|
|
49
|
+
// and fails the update closed rather than proceeding on unreadable state.
|
|
50
|
+
return withFileLockAsync(installationLockTarget(agent, installation.label), () => runUpdateInstallation(agent, installation, options), INSTALLATION_LOCK_OPTIONS);
|
|
51
|
+
}
|
|
52
|
+
async function runUpdateInstallation(agent, requestedInstallation, options) {
|
|
53
|
+
const installation = readInstallation(agent, requestedInstallation.label) ?? requestedInstallation;
|
|
35
54
|
const requested = options.to ?? 'latest';
|
|
36
55
|
assertValidRelease(requested);
|
|
37
56
|
const strategy = options.strategy ?? selectUpdateStrategy(agent);
|
|
38
57
|
const ctx = { agent, installation, requested, onProgress: options.onProgress };
|
|
39
58
|
const target = await strategy.resolveTarget(ctx);
|
|
40
59
|
if (target === installation.releaseVersion) {
|
|
60
|
+
if (options.updatePolicy) {
|
|
61
|
+
installation.updatePolicy = options.updatePolicy;
|
|
62
|
+
installation.updatedAt = new Date().toISOString();
|
|
63
|
+
writeInstallation(installation);
|
|
64
|
+
}
|
|
41
65
|
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${target}; nothing to update.`);
|
|
42
66
|
return {
|
|
43
67
|
installation,
|
|
@@ -48,6 +72,35 @@ export async function updateInstallation(installation, options = {}) {
|
|
|
48
72
|
alsoUpdated: [],
|
|
49
73
|
};
|
|
50
74
|
}
|
|
75
|
+
// Mandatory for every transactional strategy, every caller — manual
|
|
76
|
+
// `agents update` included, not just the automatic pass. A transactional
|
|
77
|
+
// strategy's commit is a swap of THIS installation's own directory, so a
|
|
78
|
+
// live process or launch lease naming it (`active-check.ts`) means staging
|
|
79
|
+
// right now risks a launch racing the swap. This is the pre-STAGE half of
|
|
80
|
+
// the check, taken under the same lock this whole transaction holds — the
|
|
81
|
+
// identical check runs again immediately before commit below, since staging
|
|
82
|
+
// an npm package can take the better part of `INSTALL_TIMEOUT_MS` and a
|
|
83
|
+
// launch can start during that window. Non-transactional strategies (a
|
|
84
|
+
// global-binary/install-script harness) have no reversible swap to protect,
|
|
85
|
+
// so they are unaffected by either check.
|
|
86
|
+
if (options.shouldCancel?.() || (options.abortIfPinnedBeforeCommit && effectiveUpdatePolicy(installation) === 'pinned')
|
|
87
|
+
|| (options.abortIfAutoDisabledBeforeCommit && !isAutoUpdateEnabledForAgent(agent))) {
|
|
88
|
+
return { installation, strategy: strategy.id, fromRelease: installation.releaseVersion, toRelease: target,
|
|
89
|
+
unchanged: true, deferred: 'Update cancelled or automatic update policy changed.', alsoUpdated: [] };
|
|
90
|
+
}
|
|
91
|
+
if (strategy.transactional && await isInstallationLikelyActive(installation)) {
|
|
92
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} looks active right now (a process or launch lease); `
|
|
93
|
+
+ `not staging release ${target}.`);
|
|
94
|
+
return {
|
|
95
|
+
installation,
|
|
96
|
+
strategy: strategy.id,
|
|
97
|
+
fromRelease: installation.releaseVersion,
|
|
98
|
+
toRelease: target,
|
|
99
|
+
unchanged: true,
|
|
100
|
+
deferred: 'Account home is in use; retry after its sessions finish.',
|
|
101
|
+
alsoUpdated: [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
51
104
|
let staged = null;
|
|
52
105
|
try {
|
|
53
106
|
staged = await strategy.stage(ctx, target);
|
|
@@ -61,6 +114,11 @@ export async function updateInstallation(installation, options = {}) {
|
|
|
61
114
|
// (a self-updating binary that was already current). Recording it would
|
|
62
115
|
// claim a change that did not happen and append a bogus history entry.
|
|
63
116
|
if (staged.release === installation.releaseVersion) {
|
|
117
|
+
if (options.updatePolicy) {
|
|
118
|
+
installation.updatePolicy = options.updatePolicy;
|
|
119
|
+
installation.updatedAt = new Date().toISOString();
|
|
120
|
+
writeInstallation(installation);
|
|
121
|
+
}
|
|
64
122
|
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${staged.release}; nothing to update.`);
|
|
65
123
|
// Deliberately NOT committed: there is no new release to make live, and
|
|
66
124
|
// for a strategy that swaps the version dir a commit here would displace
|
|
@@ -75,6 +133,65 @@ export async function updateInstallation(installation, options = {}) {
|
|
|
75
133
|
alsoUpdated: [],
|
|
76
134
|
};
|
|
77
135
|
}
|
|
136
|
+
// The automatic pass may have started staging while the installation was
|
|
137
|
+
// still eligible, then had it pinned out from under it — staging an npm
|
|
138
|
+
// package can legitimately take the better part of INSTALL_TIMEOUT_MS. Check
|
|
139
|
+
// ONE more time, right before the point of no return, so a policy change
|
|
140
|
+
// that lands mid-staging is honored instead of committed anyway. A manual
|
|
141
|
+
// `agents update` never sets this option: the user's own invocation IS their
|
|
142
|
+
// current intent, so there is nothing to defer to.
|
|
143
|
+
if (options.abortIfPinnedBeforeCommit) {
|
|
144
|
+
const fresh = readInstallation(agent, installation.label);
|
|
145
|
+
if (fresh && effectiveUpdatePolicy(fresh) === 'pinned') {
|
|
146
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} was pinned while ${staged.release} was staging; not committing it.`);
|
|
147
|
+
return {
|
|
148
|
+
installation: fresh,
|
|
149
|
+
strategy: strategy.id,
|
|
150
|
+
fromRelease: fresh.releaseVersion,
|
|
151
|
+
toRelease: staged.release,
|
|
152
|
+
unchanged: true,
|
|
153
|
+
deferred: 'Installation was pinned while the update was being prepared.',
|
|
154
|
+
alsoUpdated: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Automatic-only, same reasoning as the pin recheck above: the operator
|
|
159
|
+
// may have flipped `updates.auto` / `updates.<agent>.auto` off while this
|
|
160
|
+
// release was staging (minutes for a real npm install), and an automatic
|
|
161
|
+
// run must honor that instead of committing an update the operator just
|
|
162
|
+
// asked to stop. A manual `agents update` never sets this — the user's
|
|
163
|
+
// own invocation already IS the decision to update, regardless of the
|
|
164
|
+
// switch that only gates the UNATTENDED pass.
|
|
165
|
+
if (options.abortIfAutoDisabledBeforeCommit && !isAutoUpdateEnabledForAgent(agent)) {
|
|
166
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label}: automatic updates were turned off while ${staged.release} `
|
|
167
|
+
+ `was staging; not committing it.`);
|
|
168
|
+
return {
|
|
169
|
+
installation,
|
|
170
|
+
strategy: strategy.id,
|
|
171
|
+
fromRelease: installation.releaseVersion,
|
|
172
|
+
toRelease: staged.release,
|
|
173
|
+
unchanged: true,
|
|
174
|
+
deferred: 'Automatic updates were turned off while the update was being prepared.',
|
|
175
|
+
alsoUpdated: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// Mandatory for every transactional strategy, every caller — see the
|
|
179
|
+
// identical pre-stage check above for why this cannot be opt-in: a launch
|
|
180
|
+
// that starts AFTER that first check but before this swap is exactly the
|
|
181
|
+
// window this closes.
|
|
182
|
+
if (options.shouldCancel?.() || (strategy.transactional && await isInstallationLikelyActive(installation))) {
|
|
183
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} looks active now (a process or launch lease appeared while `
|
|
184
|
+
+ `${staged.release} was staging); not committing it.`);
|
|
185
|
+
return {
|
|
186
|
+
installation,
|
|
187
|
+
strategy: strategy.id,
|
|
188
|
+
fromRelease: installation.releaseVersion,
|
|
189
|
+
toRelease: staged.release,
|
|
190
|
+
unchanged: true,
|
|
191
|
+
deferred: 'Update cancelled or the account home became active.',
|
|
192
|
+
alsoUpdated: [],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
78
195
|
const handles = await strategy.commit(ctx, staged);
|
|
79
196
|
try {
|
|
80
197
|
// Probe what will actually execute — `getBinaryPath` is the same resolver
|
|
@@ -98,8 +215,30 @@ export async function updateInstallation(installation, options = {}) {
|
|
|
98
215
|
: `${err.message} The version directory was restored, but ${AGENTS[agent].name}'s installer `
|
|
99
216
|
+ `had already replaced the binary it manages globally — repair it with: agents add ${agent}@latest`);
|
|
100
217
|
}
|
|
218
|
+
// Record BEFORE finalizing the commit's rollback material — not after.
|
|
219
|
+
// `finalize()` discards the only thing that can put the previous release
|
|
220
|
+
// back (deletes the rollback dir / no-ops for a non-transactional
|
|
221
|
+
// strategy). If `recordRelease` throws (e.g. disk full, an unwritable
|
|
222
|
+
// installation.json) AFTER finalize, the new release is live on disk with
|
|
223
|
+
// no rollback path AND no record of it — an installation whose directory
|
|
224
|
+
// and its own metadata now disagree, with no way back. Recording first
|
|
225
|
+
// means a record-write failure still has the rollback material available
|
|
226
|
+
// to undo the live swap, so the failure mode stays "nothing changed"
|
|
227
|
+
// instead of "half updated, unrecoverable."
|
|
228
|
+
let updated;
|
|
229
|
+
try {
|
|
230
|
+
updated = recordRelease({ ...installation, ...(options.updatePolicy ? { updatePolicy: options.updatePolicy } : {}) }, staged.release);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
handles.undo();
|
|
234
|
+
throw new Error(strategy.transactional
|
|
235
|
+
? `${err.message} ${AGENTS[agent].name} release ${staged.release} could not be recorded. `
|
|
236
|
+
+ `Rolled back to ${installation.releaseVersion}.`
|
|
237
|
+
: `${err.message} ${AGENTS[agent].name} release ${staged.release} could not be recorded. `
|
|
238
|
+
+ `The version directory was restored, but ${AGENTS[agent].name}'s installer had already replaced the `
|
|
239
|
+
+ `binary it manages globally — repair it with: agents add ${agent}@latest`);
|
|
240
|
+
}
|
|
101
241
|
handles.finalize();
|
|
102
|
-
const updated = recordRelease(installation, staged.release);
|
|
103
242
|
// Several installations of a global-binary harness point at the same file,
|
|
104
243
|
// so the one we just replaced is live for all of them. Recording the release
|
|
105
244
|
// only on the target would leave the others claiming a release that is no
|
|
@@ -87,6 +87,7 @@ export declare function setIsolatedDefault(agent: AgentId, version: string | und
|
|
|
87
87
|
/** Install a specific version of an agent. */
|
|
88
88
|
export declare function installVersion(agent: AgentId, version: string, onProgress?: (message: string) => void, opts?: {
|
|
89
89
|
clean?: boolean;
|
|
90
|
+
installationLabel?: string;
|
|
90
91
|
}): Promise<{
|
|
91
92
|
success: boolean;
|
|
92
93
|
installedVersion: string;
|