@phnx-labs/agents-cli 1.22.77 → 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 +13 -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/auth.js +4 -1
- 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/actor.d.ts +17 -0
- package/dist/lib/actor.js +26 -2
- 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/identity/index.d.ts +10 -8
- package/dist/lib/identity/index.js +15 -11
- 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
|
@@ -25,6 +25,8 @@ export declare function mintInstallationId(): string;
|
|
|
25
25
|
* Never mints — use {@link ensureInstallation} for the migrating read.
|
|
26
26
|
*/
|
|
27
27
|
export declare function readInstallation(agent: AgentId, label: string): Installation | null;
|
|
28
|
+
/** Semantic feature checks use the release, while paths and settings keep the label. */
|
|
29
|
+
export declare function installedReleaseFor(agent: AgentId, label: string): string;
|
|
28
30
|
export declare function writeInstallation(installation: Installation): void;
|
|
29
31
|
/**
|
|
30
32
|
* Read the record for an existing version dir, minting and persisting one on
|
|
@@ -37,12 +39,14 @@ export declare function writeInstallation(installation: Installation): void;
|
|
|
37
39
|
* describe an install that isn't there.
|
|
38
40
|
*/
|
|
39
41
|
export declare function ensureInstallation(agent: AgentId, label: string): Installation;
|
|
42
|
+
/** Migration for callers already holding the canonical installation lock. */
|
|
43
|
+
export declare function ensureInstallationLocked(agent: AgentId, label: string, legacyCreatedAt?: string): Installation;
|
|
40
44
|
/**
|
|
41
45
|
* Create the record for a freshly-installed version dir. Idempotent: a repeat
|
|
42
46
|
* `agents add` of the same label keeps the original id (identity is frozen) and
|
|
43
47
|
* only records the release if it actually moved.
|
|
44
48
|
*/
|
|
45
|
-
export declare function createInstallation(agent: AgentId, label: string, releaseVersion: string): Installation;
|
|
49
|
+
export declare function createInstallation(agent: AgentId, label: string, releaseVersion: string, initialPolicy?: Installation['updatePolicy']): Installation;
|
|
46
50
|
/**
|
|
47
51
|
* Move an installation's recorded release forward, preserving identity. Returns
|
|
48
52
|
* the persisted record. Call only AFTER the new release is live on disk — the
|
|
@@ -4,7 +4,8 @@ import * as path from 'path';
|
|
|
4
4
|
import { execFile } from 'child_process';
|
|
5
5
|
import { promisify } from 'util';
|
|
6
6
|
import * as yaml from 'yaml';
|
|
7
|
-
import { atomicWriteFileSync } from '../fs-atomic.js';
|
|
7
|
+
import { atomicWriteFileSync, withFileLock } from '../fs-atomic.js';
|
|
8
|
+
import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
|
|
8
9
|
import { getHomeDir, getUserAgentsDir, getVersionsDir, readMeta } from '../state.js';
|
|
9
10
|
import { VERSION_RE, compareVersions } from '../agent-spec/primitives.js';
|
|
10
11
|
import { AGENTS, findInPath } from '../agents.js';
|
|
@@ -60,6 +61,9 @@ function assertValidRecord(value, file) {
|
|
|
60
61
|
if (!Array.isArray(record.history) || record.history.length === 0) {
|
|
61
62
|
throw new Error(`Installation record corrupted at ${file}: "history" must be a non-empty array.`);
|
|
62
63
|
}
|
|
64
|
+
if (record.updatePolicy !== undefined && record.updatePolicy !== 'latest' && record.updatePolicy !== 'pinned') {
|
|
65
|
+
throw new Error(`Installation record corrupted at ${file}: unknown update policy.`);
|
|
66
|
+
}
|
|
63
67
|
return record;
|
|
64
68
|
}
|
|
65
69
|
/**
|
|
@@ -84,6 +88,12 @@ export function readInstallation(agent, label) {
|
|
|
84
88
|
}
|
|
85
89
|
return assertValidRecord(parsed, file);
|
|
86
90
|
}
|
|
91
|
+
/** Semantic feature checks use the release, while paths and settings keep the label. */
|
|
92
|
+
export function installedReleaseFor(agent, label) {
|
|
93
|
+
if (!Object.hasOwn(AGENTS, agent) || !VERSION_RE.test(label))
|
|
94
|
+
return label;
|
|
95
|
+
return readInstallation(agent, label)?.releaseVersion ?? label;
|
|
96
|
+
}
|
|
87
97
|
export function writeInstallation(installation) {
|
|
88
98
|
const file = installationRecordPath(installation.agent, installation.label);
|
|
89
99
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -100,6 +110,16 @@ export function writeInstallation(installation) {
|
|
|
100
110
|
* describe an install that isn't there.
|
|
101
111
|
*/
|
|
102
112
|
export function ensureInstallation(agent, label) {
|
|
113
|
+
const existing = readInstallation(agent, label);
|
|
114
|
+
if (existing)
|
|
115
|
+
return existing;
|
|
116
|
+
if (!fs.existsSync(installationDir(agent, label)))
|
|
117
|
+
throw new Error(`No installation directory for ${agent}@${label}.`);
|
|
118
|
+
const createdAt = fs.statSync(installationDir(agent, label)).mtime.toISOString();
|
|
119
|
+
return withFileLock(installationLockTarget(agent, label), () => ensureInstallationLocked(agent, label, createdAt), { ...INSTALLATION_LOCK_OPTIONS, acquireTimeoutMs: 0 });
|
|
120
|
+
}
|
|
121
|
+
/** Migration for callers already holding the canonical installation lock. */
|
|
122
|
+
export function ensureInstallationLocked(agent, label, legacyCreatedAt) {
|
|
103
123
|
const existing = readInstallation(agent, label);
|
|
104
124
|
if (existing)
|
|
105
125
|
return existing;
|
|
@@ -109,7 +129,7 @@ export function ensureInstallation(agent, label) {
|
|
|
109
129
|
}
|
|
110
130
|
let createdAt;
|
|
111
131
|
try {
|
|
112
|
-
createdAt = fs.statSync(dir).mtime.toISOString();
|
|
132
|
+
createdAt = legacyCreatedAt ?? fs.statSync(dir).mtime.toISOString();
|
|
113
133
|
}
|
|
114
134
|
catch {
|
|
115
135
|
createdAt = nowIso();
|
|
@@ -132,7 +152,7 @@ export function ensureInstallation(agent, label) {
|
|
|
132
152
|
* `agents add` of the same label keeps the original id (identity is frozen) and
|
|
133
153
|
* only records the release if it actually moved.
|
|
134
154
|
*/
|
|
135
|
-
export function createInstallation(agent, label, releaseVersion) {
|
|
155
|
+
export function createInstallation(agent, label, releaseVersion, initialPolicy = 'latest') {
|
|
136
156
|
if (!VERSION_RE.test(label)) {
|
|
137
157
|
throw new Error(`Invalid installation label: ${JSON.stringify(label)}`);
|
|
138
158
|
}
|
|
@@ -152,6 +172,7 @@ export function createInstallation(agent, label, releaseVersion) {
|
|
|
152
172
|
createdAt: at,
|
|
153
173
|
updatedAt: at,
|
|
154
174
|
history: [{ releaseVersion, at }],
|
|
175
|
+
updatePolicy: initialPolicy,
|
|
155
176
|
};
|
|
156
177
|
writeInstallation(created);
|
|
157
178
|
return created;
|
|
@@ -17,19 +17,10 @@ function runId() {
|
|
|
17
17
|
}
|
|
18
18
|
function moveDir(from, to) {
|
|
19
19
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
}
|
|
20
|
+
// Staging and rollback are siblings on the installation filesystem. Never
|
|
21
|
+
// turn an atomic rename failure into a partially successful copy-and-delete:
|
|
22
|
+
// an open Windows file or unexpected mount must leave the source intact.
|
|
23
|
+
fs.renameSync(from, to);
|
|
33
24
|
}
|
|
34
25
|
/**
|
|
35
26
|
* Entries a swap replaces: everything npm owns inside a version dir. The lockfile
|
|
@@ -101,32 +92,61 @@ const npmPackageStrategy = {
|
|
|
101
92
|
const dir = installationDir(ctx.agent, ctx.installation.label);
|
|
102
93
|
const rollbackDir = path.join(dir, `.rollback-${runId()}`);
|
|
103
94
|
const displaced = [];
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
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) {
|
|
95
|
+
const stagedIn = [];
|
|
96
|
+
// Restore each displaced entry. If restoration itself fails, retain every
|
|
97
|
+
// remaining backup and surface its path; never erase the only good copy.
|
|
98
|
+
const restorePreCommitState = () => {
|
|
99
|
+
const errors = [];
|
|
100
|
+
for (const entry of [...stagedIn].reverse()) {
|
|
101
|
+
try {
|
|
123
102
|
fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
124
103
|
}
|
|
125
|
-
|
|
104
|
+
catch (err) {
|
|
105
|
+
errors.push(`${entry}: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const entry of [...displaced].reverse()) {
|
|
109
|
+
try {
|
|
126
110
|
moveDir(path.join(rollbackDir, entry), path.join(dir, entry));
|
|
127
111
|
}
|
|
128
|
-
|
|
129
|
-
|
|
112
|
+
catch (err) {
|
|
113
|
+
errors.push(`${entry}: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (errors.length)
|
|
117
|
+
throw new Error(`Rollback incomplete; recovery files retained at ${rollbackDir}: ${errors.join('; ')}`);
|
|
118
|
+
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
// Move the live tree aside first, then move the staged tree in. Doing it
|
|
122
|
+
// in this order means the failure window contains no half-merged tree on
|
|
123
|
+
// its own: either the old entries are all aside (restorable) or the new
|
|
124
|
+
// ones are all in place — the try/catch below is what makes a throw
|
|
125
|
+
// BETWEEN those two loops (or partway through the second one) recover
|
|
126
|
+
// to the pre-commit state instead of leaving whichever entries already
|
|
127
|
+
// moved exactly where the exception left them.
|
|
128
|
+
for (const entry of NPM_LIVE_ENTRIES) {
|
|
129
|
+
const live = path.join(dir, entry);
|
|
130
|
+
if (!fs.existsSync(live))
|
|
131
|
+
continue;
|
|
132
|
+
moveDir(live, path.join(rollbackDir, entry));
|
|
133
|
+
displaced.push(entry);
|
|
134
|
+
}
|
|
135
|
+
for (const entry of NPM_LIVE_ENTRIES) {
|
|
136
|
+
const from = path.join(staged.stagingDir, entry);
|
|
137
|
+
if (fs.existsSync(from)) {
|
|
138
|
+
moveDir(from, path.join(dir, entry));
|
|
139
|
+
stagedIn.push(entry);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
restorePreCommitState();
|
|
145
|
+
throw new Error(`${err.message} The swap was interrupted partway through and has been rolled back to `
|
|
146
|
+
+ `${ctx.installation.releaseVersion}.`);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
undo: restorePreCommitState,
|
|
130
150
|
finalize: () => fs.rmSync(rollbackDir, { recursive: true, force: true }),
|
|
131
151
|
};
|
|
132
152
|
},
|
|
@@ -50,7 +50,22 @@ export interface Installation {
|
|
|
50
50
|
updatedAt: string;
|
|
51
51
|
/** Newest last. Always non-empty: creation seeds it with the first release. */
|
|
52
52
|
history: InstallationRelease[];
|
|
53
|
+
/**
|
|
54
|
+
* How the automatic-update pass (`installations/update-runtime.ts`) treats
|
|
55
|
+
* this installation. `'latest'` (the default) lets it ride the automatic
|
|
56
|
+
* pass; `'pinned'` excludes it — set implicitly by `agents update
|
|
57
|
+
* <agent>@<label> --to <concrete-release>` and cleared by `--to latest`.
|
|
58
|
+
*
|
|
59
|
+
* Absent means `'latest'`: every installation created before this field
|
|
60
|
+
* existed is legacy data, not an opt-out, so a missing key must resolve the
|
|
61
|
+
* same as an explicit `'latest'` rather than being treated as unset/invalid.
|
|
62
|
+
* Read through {@link effectiveUpdatePolicy} in `update-policy.ts` — never
|
|
63
|
+
* compare this field directly, so that default stays in one place.
|
|
64
|
+
*/
|
|
65
|
+
updatePolicy?: UpdatePolicy;
|
|
53
66
|
}
|
|
67
|
+
/** See {@link Installation.updatePolicy}. */
|
|
68
|
+
export type UpdatePolicy = 'latest' | 'pinned';
|
|
54
69
|
/**
|
|
55
70
|
* How an installation's release is replaced. Selected from the agent registry's
|
|
56
71
|
* capabilities, never from an agent id — see `selectUpdateStrategy`.
|
|
@@ -70,6 +85,8 @@ export interface UpdateOutcome {
|
|
|
70
85
|
toRelease: string;
|
|
71
86
|
/** True when the resolved target already matched the installed release. */
|
|
72
87
|
unchanged: boolean;
|
|
88
|
+
/** No swap occurred because activity, cancellation, or policy prevented it. */
|
|
89
|
+
deferred?: string;
|
|
73
90
|
/**
|
|
74
91
|
* Installations other than the target whose recorded release also moved,
|
|
75
92
|
* because the strategy replaced a binary they share (global-binary only).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform cooperative cancellation for the harness auto-update pass
|
|
3
|
+
* (PHNX-3940).
|
|
4
|
+
*
|
|
5
|
+
* The pass mutates the filesystem transactionally — stage an npm install into a
|
|
6
|
+
* sibling dir, launch-probe it, swap it in, record the new release, drop the
|
|
7
|
+
* rollback material (see `update.ts`). Interrupting it mid-swap can leave an
|
|
8
|
+
* installation whose directory and its own metadata disagree, with no way back.
|
|
9
|
+
* So it must be cancellable from OUTSIDE the process — on the daemon's per-tick
|
|
10
|
+
* deadline, or on daemon shutdown — WITHOUT terminating a process that is mid-swap.
|
|
11
|
+
*
|
|
12
|
+
* `execFile`'s `timeout`/`AbortSignal` do exactly the wrong thing here: they
|
|
13
|
+
* FORCE-terminate the child. On Windows that is unconditional (there are no POSIX
|
|
14
|
+
* signals to cooperate with — the SIGTERM listener the pass installs never runs),
|
|
15
|
+
* so a Windows daemon would kill its own update worker mid-rename. Even on POSIX
|
|
16
|
+
* the forced kill races the swap.
|
|
17
|
+
*
|
|
18
|
+
* The fix is a request, not a kill: the daemon spawns the pass as a child over a
|
|
19
|
+
* Node IPC channel and asks it to stop by SENDING A MESSAGE. The child flips a
|
|
20
|
+
* flag that `update.ts`'s existing `shouldCancel` reads at each safe boundary
|
|
21
|
+
* (before the next installation, before staging, and immediately before commit)
|
|
22
|
+
* and finishes what it is doing cleanly. The channel closing (parent gone) and a
|
|
23
|
+
* received SIGTERM/SIGINT are treated as the same request, so a force-killed
|
|
24
|
+
* daemon can never strand a child mutating forever, and a manual `agents update
|
|
25
|
+
* --auto` interrupted with Ctrl+C also stops at a boundary rather than mid-swap.
|
|
26
|
+
*
|
|
27
|
+
* This module is a dependency-free LEAF on purpose: `index.ts` reads the guard
|
|
28
|
+
* depth below through the shared {@link GUARDED_AUTO_UPDATE_SYMBOL} registry key
|
|
29
|
+
* (never importing this module, so the slim CLI shell stays slim), and the daemon
|
|
30
|
+
* child fixture in tests imports {@link withGuardedUpdateCancellation} without
|
|
31
|
+
* pulling the installations graph.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The hidden verb the daemon spawns to run one auto-update pass with IPC-driven
|
|
35
|
+
* cancellation (dispatched in `index.ts`, handled by
|
|
36
|
+
* `update-runtime.ts`'s `runHarnessUpdateChild`). Internal protocol — not a
|
|
37
|
+
* public command.
|
|
38
|
+
*/
|
|
39
|
+
export declare const HARNESS_UPDATE_CHILD_CMD = "__harness-update-run";
|
|
40
|
+
/** IPC message `type` the daemon sends to request a cooperative stop. */
|
|
41
|
+
export declare const HARNESS_UPDATE_CANCEL_MSG = "harness-update:cancel";
|
|
42
|
+
/**
|
|
43
|
+
* Well-known cross-realm key naming the count of guarded auto-update passes
|
|
44
|
+
* running IN THIS PROCESS right now. `index.ts`'s top-level SIGINT handler reads
|
|
45
|
+
* it via `Symbol.for(...)` WITHOUT importing this module (keeping the slim entry
|
|
46
|
+
* shell free of a heavy static import — see `agent.test.ts`'s import guard), so
|
|
47
|
+
* the guard state has to live somewhere both can reach: the global symbol
|
|
48
|
+
* registry.
|
|
49
|
+
*/
|
|
50
|
+
export declare const GUARDED_AUTO_UPDATE_SYMBOL: unique symbol;
|
|
51
|
+
/** The IPC payload the daemon sends; `child.send(cancelMessage())`. */
|
|
52
|
+
export declare function cancelMessage(): {
|
|
53
|
+
type: typeof HARNESS_UPDATE_CANCEL_MSG;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* True while a guarded auto-update pass is mutating this process. Read by
|
|
57
|
+
* `index.ts`'s SIGINT handler (which reads the same registry symbol directly) to
|
|
58
|
+
* DEFER its default `process.exit(130)` so a Ctrl+C can't tear a swap apart; the
|
|
59
|
+
* pass still observes the SIGINT cooperatively and stops at its next boundary.
|
|
60
|
+
* Ref-counted (a depth, not a boolean) so it is correct even if a pass ever nests.
|
|
61
|
+
*/
|
|
62
|
+
export declare function isGuardedAutoUpdateActive(): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Run `run(cancelled)` with cooperative cancellation wired from every source
|
|
65
|
+
* that can reach this process, and the SIGINT guard held for its duration:
|
|
66
|
+
*
|
|
67
|
+
* - **IPC message** `{ type: HARNESS_UPDATE_CANCEL_MSG }` — the primary,
|
|
68
|
+
* cross-platform request the daemon sends. Works on Windows, where a signal
|
|
69
|
+
* would force-kill.
|
|
70
|
+
* - **`disconnect`** — the IPC channel closed (the daemon went away). Stop
|
|
71
|
+
* rather than continue mutating orphaned.
|
|
72
|
+
* - **SIGTERM / SIGINT** — a direct `kill`/Ctrl+C on this process. Cooperative,
|
|
73
|
+
* not fatal; paired with `index.ts`'s guard, which defers its hard exit while
|
|
74
|
+
* {@link isGuardedAutoUpdateActive} holds.
|
|
75
|
+
*
|
|
76
|
+
* `cancelled()` never un-sets once set. Listeners are installed for the call's
|
|
77
|
+
* lifetime only and removed in `finally`, and the process-wide `message`/
|
|
78
|
+
* `disconnect` listeners are harmless when there is no IPC channel (they simply
|
|
79
|
+
* never fire), so this is safe on the in-process manual `agents update --auto`
|
|
80
|
+
* path too.
|
|
81
|
+
*/
|
|
82
|
+
export declare function withGuardedUpdateCancellation<T>(run: (cancelled: () => boolean) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform cooperative cancellation for the harness auto-update pass
|
|
3
|
+
* (PHNX-3940).
|
|
4
|
+
*
|
|
5
|
+
* The pass mutates the filesystem transactionally — stage an npm install into a
|
|
6
|
+
* sibling dir, launch-probe it, swap it in, record the new release, drop the
|
|
7
|
+
* rollback material (see `update.ts`). Interrupting it mid-swap can leave an
|
|
8
|
+
* installation whose directory and its own metadata disagree, with no way back.
|
|
9
|
+
* So it must be cancellable from OUTSIDE the process — on the daemon's per-tick
|
|
10
|
+
* deadline, or on daemon shutdown — WITHOUT terminating a process that is mid-swap.
|
|
11
|
+
*
|
|
12
|
+
* `execFile`'s `timeout`/`AbortSignal` do exactly the wrong thing here: they
|
|
13
|
+
* FORCE-terminate the child. On Windows that is unconditional (there are no POSIX
|
|
14
|
+
* signals to cooperate with — the SIGTERM listener the pass installs never runs),
|
|
15
|
+
* so a Windows daemon would kill its own update worker mid-rename. Even on POSIX
|
|
16
|
+
* the forced kill races the swap.
|
|
17
|
+
*
|
|
18
|
+
* The fix is a request, not a kill: the daemon spawns the pass as a child over a
|
|
19
|
+
* Node IPC channel and asks it to stop by SENDING A MESSAGE. The child flips a
|
|
20
|
+
* flag that `update.ts`'s existing `shouldCancel` reads at each safe boundary
|
|
21
|
+
* (before the next installation, before staging, and immediately before commit)
|
|
22
|
+
* and finishes what it is doing cleanly. The channel closing (parent gone) and a
|
|
23
|
+
* received SIGTERM/SIGINT are treated as the same request, so a force-killed
|
|
24
|
+
* daemon can never strand a child mutating forever, and a manual `agents update
|
|
25
|
+
* --auto` interrupted with Ctrl+C also stops at a boundary rather than mid-swap.
|
|
26
|
+
*
|
|
27
|
+
* This module is a dependency-free LEAF on purpose: `index.ts` reads the guard
|
|
28
|
+
* depth below through the shared {@link GUARDED_AUTO_UPDATE_SYMBOL} registry key
|
|
29
|
+
* (never importing this module, so the slim CLI shell stays slim), and the daemon
|
|
30
|
+
* child fixture in tests imports {@link withGuardedUpdateCancellation} without
|
|
31
|
+
* pulling the installations graph.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The hidden verb the daemon spawns to run one auto-update pass with IPC-driven
|
|
35
|
+
* cancellation (dispatched in `index.ts`, handled by
|
|
36
|
+
* `update-runtime.ts`'s `runHarnessUpdateChild`). Internal protocol — not a
|
|
37
|
+
* public command.
|
|
38
|
+
*/
|
|
39
|
+
export const HARNESS_UPDATE_CHILD_CMD = '__harness-update-run';
|
|
40
|
+
/** IPC message `type` the daemon sends to request a cooperative stop. */
|
|
41
|
+
export const HARNESS_UPDATE_CANCEL_MSG = 'harness-update:cancel';
|
|
42
|
+
/**
|
|
43
|
+
* Well-known cross-realm key naming the count of guarded auto-update passes
|
|
44
|
+
* running IN THIS PROCESS right now. `index.ts`'s top-level SIGINT handler reads
|
|
45
|
+
* it via `Symbol.for(...)` WITHOUT importing this module (keeping the slim entry
|
|
46
|
+
* shell free of a heavy static import — see `agent.test.ts`'s import guard), so
|
|
47
|
+
* the guard state has to live somewhere both can reach: the global symbol
|
|
48
|
+
* registry.
|
|
49
|
+
*/
|
|
50
|
+
export const GUARDED_AUTO_UPDATE_SYMBOL = Symbol.for('agents.guardedAutoUpdateDepth');
|
|
51
|
+
/** The IPC payload the daemon sends; `child.send(cancelMessage())`. */
|
|
52
|
+
export function cancelMessage() {
|
|
53
|
+
return { type: HARNESS_UPDATE_CANCEL_MSG };
|
|
54
|
+
}
|
|
55
|
+
function isCancelMessage(msg) {
|
|
56
|
+
return (typeof msg === 'object' &&
|
|
57
|
+
msg !== null &&
|
|
58
|
+
msg.type === HARNESS_UPDATE_CANCEL_MSG);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* True while a guarded auto-update pass is mutating this process. Read by
|
|
62
|
+
* `index.ts`'s SIGINT handler (which reads the same registry symbol directly) to
|
|
63
|
+
* DEFER its default `process.exit(130)` so a Ctrl+C can't tear a swap apart; the
|
|
64
|
+
* pass still observes the SIGINT cooperatively and stops at its next boundary.
|
|
65
|
+
* Ref-counted (a depth, not a boolean) so it is correct even if a pass ever nests.
|
|
66
|
+
*/
|
|
67
|
+
export function isGuardedAutoUpdateActive() {
|
|
68
|
+
return (globalThis[GUARDED_AUTO_UPDATE_SYMBOL] ?? 0) > 0;
|
|
69
|
+
}
|
|
70
|
+
function beginGuardedAutoUpdate() {
|
|
71
|
+
const holder = globalThis;
|
|
72
|
+
holder[GUARDED_AUTO_UPDATE_SYMBOL] = (holder[GUARDED_AUTO_UPDATE_SYMBOL] ?? 0) + 1;
|
|
73
|
+
}
|
|
74
|
+
function endGuardedAutoUpdate() {
|
|
75
|
+
const holder = globalThis;
|
|
76
|
+
const depth = holder[GUARDED_AUTO_UPDATE_SYMBOL] ?? 0;
|
|
77
|
+
holder[GUARDED_AUTO_UPDATE_SYMBOL] = depth > 0 ? depth - 1 : 0;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run `run(cancelled)` with cooperative cancellation wired from every source
|
|
81
|
+
* that can reach this process, and the SIGINT guard held for its duration:
|
|
82
|
+
*
|
|
83
|
+
* - **IPC message** `{ type: HARNESS_UPDATE_CANCEL_MSG }` — the primary,
|
|
84
|
+
* cross-platform request the daemon sends. Works on Windows, where a signal
|
|
85
|
+
* would force-kill.
|
|
86
|
+
* - **`disconnect`** — the IPC channel closed (the daemon went away). Stop
|
|
87
|
+
* rather than continue mutating orphaned.
|
|
88
|
+
* - **SIGTERM / SIGINT** — a direct `kill`/Ctrl+C on this process. Cooperative,
|
|
89
|
+
* not fatal; paired with `index.ts`'s guard, which defers its hard exit while
|
|
90
|
+
* {@link isGuardedAutoUpdateActive} holds.
|
|
91
|
+
*
|
|
92
|
+
* `cancelled()` never un-sets once set. Listeners are installed for the call's
|
|
93
|
+
* lifetime only and removed in `finally`, and the process-wide `message`/
|
|
94
|
+
* `disconnect` listeners are harmless when there is no IPC channel (they simply
|
|
95
|
+
* never fire), so this is safe on the in-process manual `agents update --auto`
|
|
96
|
+
* path too.
|
|
97
|
+
*/
|
|
98
|
+
export async function withGuardedUpdateCancellation(run) {
|
|
99
|
+
let cancelled = typeof process.send === 'function' && process.connected === false;
|
|
100
|
+
const requestStop = () => {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
};
|
|
103
|
+
const onMessage = (msg) => {
|
|
104
|
+
if (isCancelMessage(msg))
|
|
105
|
+
requestStop();
|
|
106
|
+
};
|
|
107
|
+
process.on('SIGTERM', requestStop);
|
|
108
|
+
process.on('SIGINT', requestStop);
|
|
109
|
+
process.on('message', onMessage);
|
|
110
|
+
process.on('disconnect', requestStop);
|
|
111
|
+
beginGuardedAutoUpdate();
|
|
112
|
+
try {
|
|
113
|
+
return await run(() => cancelled);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
endGuardedAutoUpdate();
|
|
117
|
+
process.removeListener('SIGTERM', requestStop);
|
|
118
|
+
process.removeListener('SIGINT', requestStop);
|
|
119
|
+
process.removeListener('message', onMessage);
|
|
120
|
+
process.removeListener('disconnect', requestStop);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings that gate the AUTOMATIC update pass (PHNX-3940): whether it runs at
|
|
3
|
+
* all (global + per-harness), and whether one installation participates.
|
|
4
|
+
*
|
|
5
|
+
* Two independent axes, deliberately kept apart:
|
|
6
|
+
* - `updates.auto` / `updates.<agent>.auto` — an OPERATOR switch, global and
|
|
7
|
+
* per-harness, stored centrally (`~/.agents/agents.yaml` `config:`) so it
|
|
8
|
+
* syncs fleet-wide like `summarizer.*`. Both are registered as ordinary
|
|
9
|
+
* TYPED entries in `device-config.ts`'s `CONFIG_KEYS` — the one canonical
|
|
10
|
+
* store for user-scope config — rather than a second untyped read/write
|
|
11
|
+
* path against `Meta.config` directly, so a config rewrite that only knows
|
|
12
|
+
* about the registry (validation, `agents config list`, the fleet sync
|
|
13
|
+
* that round-trips `config:`) can't silently drop these keys.
|
|
14
|
+
* - `Installation.updatePolicy` — an INSTALLATION property. Manual
|
|
15
|
+
* `agents update <agent>@<label> --to <concrete>` pins it; `--to latest`
|
|
16
|
+
* (or a fresh install) leaves/returns it to `'latest'`.
|
|
17
|
+
*
|
|
18
|
+
* The global switch is a HARD KILL SWITCH, not a default an explicit
|
|
19
|
+
* per-harness `true` can override: `updates.auto=false` must stop every
|
|
20
|
+
* harness's automatic pass even when an operator separately turned one
|
|
21
|
+
* harness's switch on, because the global switch is the "something is
|
|
22
|
+
* wrong, stop touching installations fleet-wide" lever and a forgotten
|
|
23
|
+
* per-harness override must never defeat it. See
|
|
24
|
+
* {@link isAutoUpdateEnabledForAgent}.
|
|
25
|
+
*/
|
|
26
|
+
import type { AgentId } from '../types.js';
|
|
27
|
+
import type { Installation, UpdatePolicy } from './types.js';
|
|
28
|
+
/** The raw, explicitly-set global switch, or `undefined` when never set (default: on). */
|
|
29
|
+
export declare function rawGlobalAutoUpdateSetting(): boolean | undefined;
|
|
30
|
+
export declare function setGlobalAutoUpdateEnabled(enabled: boolean): void;
|
|
31
|
+
export declare function unsetGlobalAutoUpdateEnabled(): void;
|
|
32
|
+
/** True unless the operator explicitly turned automatic updates off globally. */
|
|
33
|
+
export declare function isGlobalAutoUpdateEnabled(): boolean;
|
|
34
|
+
/** The raw, explicitly-set per-harness switch, or `undefined` when it defers to the global one. */
|
|
35
|
+
export declare function rawAgentAutoUpdateSetting(agent: AgentId): boolean | undefined;
|
|
36
|
+
export declare function setAgentAutoUpdateEnabled(agent: AgentId, enabled: boolean): void;
|
|
37
|
+
export declare function unsetAgentAutoUpdateEnabled(agent: AgentId): void;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the automatic-update pass may consider this agent at all.
|
|
40
|
+
*
|
|
41
|
+
* `updates.auto=false` is a hard kill switch: it wins even over an explicit
|
|
42
|
+
* `updates.<agent>.auto=true` (see the module docblock). With the global
|
|
43
|
+
* switch on (the default), the per-harness switch refines it when explicitly
|
|
44
|
+
* set, else the harness is enabled too.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isAutoUpdateEnabledForAgent(agent: AgentId): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The effective policy for one installation. Absent (legacy data, or a fresh
|
|
49
|
+
* install that never set it) means `'latest'` — see {@link Installation.updatePolicy}.
|
|
50
|
+
* Read through this everywhere rather than comparing the field directly.
|
|
51
|
+
*/
|
|
52
|
+
export declare function effectiveUpdatePolicy(installation: Pick<Installation, 'updatePolicy'>): UpdatePolicy;
|
|
53
|
+
/**
|
|
54
|
+
* Persist an installation's update policy. Takes the SAME per-installation
|
|
55
|
+
* lock `updateInstallation` (`update.ts`) holds for its whole transaction —
|
|
56
|
+
* without it, a manual `--to <release>`/`--to latest` pin racing an automatic
|
|
57
|
+
* pass's own `recordRelease` write (both a read-modify-write of the same
|
|
58
|
+
* `installation.json`) could lose whichever wrote second, silently reverting
|
|
59
|
+
* a just-applied pin or an update's own release bump. Reloads the record
|
|
60
|
+
* fresh from disk under the lock (never trusts a possibly-stale in-memory
|
|
61
|
+
* copy) and writes back only the policy field — never touches
|
|
62
|
+
* `history`/`releaseVersion`, since pinning or unpinning is not a release
|
|
63
|
+
* change. Locks with the SAME `staleMs`/`acquireTimeoutMs`
|
|
64
|
+
* ({@link INSTALLATION_LOCK_OPTIONS}) as that transaction — the fs-atomic
|
|
65
|
+
* default (5s stale) is far shorter than an update can legitimately run, so
|
|
66
|
+
* using it here would let this write break the transaction's
|
|
67
|
+
* still-legitimately-held lock out from under it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function setInstallationUpdatePolicy(agent: AgentId, label: string, policy: UpdatePolicy): Promise<Installation>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings that gate the AUTOMATIC update pass (PHNX-3940): whether it runs at
|
|
3
|
+
* all (global + per-harness), and whether one installation participates.
|
|
4
|
+
*
|
|
5
|
+
* Two independent axes, deliberately kept apart:
|
|
6
|
+
* - `updates.auto` / `updates.<agent>.auto` — an OPERATOR switch, global and
|
|
7
|
+
* per-harness, stored centrally (`~/.agents/agents.yaml` `config:`) so it
|
|
8
|
+
* syncs fleet-wide like `summarizer.*`. Both are registered as ordinary
|
|
9
|
+
* TYPED entries in `device-config.ts`'s `CONFIG_KEYS` — the one canonical
|
|
10
|
+
* store for user-scope config — rather than a second untyped read/write
|
|
11
|
+
* path against `Meta.config` directly, so a config rewrite that only knows
|
|
12
|
+
* about the registry (validation, `agents config list`, the fleet sync
|
|
13
|
+
* that round-trips `config:`) can't silently drop these keys.
|
|
14
|
+
* - `Installation.updatePolicy` — an INSTALLATION property. Manual
|
|
15
|
+
* `agents update <agent>@<label> --to <concrete>` pins it; `--to latest`
|
|
16
|
+
* (or a fresh install) leaves/returns it to `'latest'`.
|
|
17
|
+
*
|
|
18
|
+
* The global switch is a HARD KILL SWITCH, not a default an explicit
|
|
19
|
+
* per-harness `true` can override: `updates.auto=false` must stop every
|
|
20
|
+
* harness's automatic pass even when an operator separately turned one
|
|
21
|
+
* harness's switch on, because the global switch is the "something is
|
|
22
|
+
* wrong, stop touching installations fleet-wide" lever and a forgotten
|
|
23
|
+
* per-harness override must never defeat it. See
|
|
24
|
+
* {@link isAutoUpdateEnabledForAgent}.
|
|
25
|
+
*/
|
|
26
|
+
import { getConfigValue, setConfigValue, unsetConfigValue } from '../device-config.js';
|
|
27
|
+
import { withFileLockAsync } from '../fs-atomic.js';
|
|
28
|
+
import * as fs from 'node:fs';
|
|
29
|
+
import { ensureInstallationLocked, installationDir, writeInstallation } from './store.js';
|
|
30
|
+
import { installationLockTarget, INSTALLATION_LOCK_OPTIONS } from './installation-lock.js';
|
|
31
|
+
function agentAutoKey(agent) {
|
|
32
|
+
return `updates.${agent}.auto`;
|
|
33
|
+
}
|
|
34
|
+
/** The raw, explicitly-set global switch, or `undefined` when never set (default: on). */
|
|
35
|
+
export function rawGlobalAutoUpdateSetting() {
|
|
36
|
+
return getConfigValue('updates.auto').value;
|
|
37
|
+
}
|
|
38
|
+
export function setGlobalAutoUpdateEnabled(enabled) {
|
|
39
|
+
setConfigValue('updates.auto', enabled);
|
|
40
|
+
}
|
|
41
|
+
export function unsetGlobalAutoUpdateEnabled() {
|
|
42
|
+
unsetConfigValue('updates.auto');
|
|
43
|
+
}
|
|
44
|
+
/** True unless the operator explicitly turned automatic updates off globally. */
|
|
45
|
+
export function isGlobalAutoUpdateEnabled() {
|
|
46
|
+
return rawGlobalAutoUpdateSetting() !== false;
|
|
47
|
+
}
|
|
48
|
+
/** The raw, explicitly-set per-harness switch, or `undefined` when it defers to the global one. */
|
|
49
|
+
export function rawAgentAutoUpdateSetting(agent) {
|
|
50
|
+
return getConfigValue(agentAutoKey(agent)).value;
|
|
51
|
+
}
|
|
52
|
+
export function setAgentAutoUpdateEnabled(agent, enabled) {
|
|
53
|
+
setConfigValue(agentAutoKey(agent), enabled);
|
|
54
|
+
}
|
|
55
|
+
export function unsetAgentAutoUpdateEnabled(agent) {
|
|
56
|
+
unsetConfigValue(agentAutoKey(agent));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether the automatic-update pass may consider this agent at all.
|
|
60
|
+
*
|
|
61
|
+
* `updates.auto=false` is a hard kill switch: it wins even over an explicit
|
|
62
|
+
* `updates.<agent>.auto=true` (see the module docblock). With the global
|
|
63
|
+
* switch on (the default), the per-harness switch refines it when explicitly
|
|
64
|
+
* set, else the harness is enabled too.
|
|
65
|
+
*/
|
|
66
|
+
export function isAutoUpdateEnabledForAgent(agent) {
|
|
67
|
+
if (!isGlobalAutoUpdateEnabled())
|
|
68
|
+
return false;
|
|
69
|
+
const perAgent = rawAgentAutoUpdateSetting(agent);
|
|
70
|
+
return perAgent !== false;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The effective policy for one installation. Absent (legacy data, or a fresh
|
|
74
|
+
* install that never set it) means `'latest'` — see {@link Installation.updatePolicy}.
|
|
75
|
+
* Read through this everywhere rather than comparing the field directly.
|
|
76
|
+
*/
|
|
77
|
+
export function effectiveUpdatePolicy(installation) {
|
|
78
|
+
return installation.updatePolicy ?? 'latest';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Persist an installation's update policy. Takes the SAME per-installation
|
|
82
|
+
* lock `updateInstallation` (`update.ts`) holds for its whole transaction —
|
|
83
|
+
* without it, a manual `--to <release>`/`--to latest` pin racing an automatic
|
|
84
|
+
* pass's own `recordRelease` write (both a read-modify-write of the same
|
|
85
|
+
* `installation.json`) could lose whichever wrote second, silently reverting
|
|
86
|
+
* a just-applied pin or an update's own release bump. Reloads the record
|
|
87
|
+
* fresh from disk under the lock (never trusts a possibly-stale in-memory
|
|
88
|
+
* copy) and writes back only the policy field — never touches
|
|
89
|
+
* `history`/`releaseVersion`, since pinning or unpinning is not a release
|
|
90
|
+
* change. Locks with the SAME `staleMs`/`acquireTimeoutMs`
|
|
91
|
+
* ({@link INSTALLATION_LOCK_OPTIONS}) as that transaction — the fs-atomic
|
|
92
|
+
* default (5s stale) is far shorter than an update can legitimately run, so
|
|
93
|
+
* using it here would let this write break the transaction's
|
|
94
|
+
* still-legitimately-held lock out from under it.
|
|
95
|
+
*/
|
|
96
|
+
export async function setInstallationUpdatePolicy(agent, label, policy) {
|
|
97
|
+
if (!fs.existsSync(installationDir(agent, label)))
|
|
98
|
+
throw new Error(`No installation directory for ${agent}@${label}.`);
|
|
99
|
+
// Guarantees `installation.json` exists and is VALID before locking on it,
|
|
100
|
+
// migrating a legacy pre-frozen version dir when needed — same reasoning as
|
|
101
|
+
// `launch-gate.ts`'s identical call. Throws its own clear
|
|
102
|
+
// "no installation directory" error when `label` was never installed at
|
|
103
|
+
// all, which is a real caller bug, not a race to reconcile under the lock.
|
|
104
|
+
const recordPath = installationLockTarget(agent, label);
|
|
105
|
+
return withFileLockAsync(recordPath, () => {
|
|
106
|
+
const current = ensureInstallationLocked(agent, label);
|
|
107
|
+
if (!current) {
|
|
108
|
+
throw new Error(`No installation record for ${agent}@${label} — cannot set its update policy.`);
|
|
109
|
+
}
|
|
110
|
+
const next = { ...current, updatePolicy: policy, updatedAt: new Date().toISOString() };
|
|
111
|
+
writeInstallation(next);
|
|
112
|
+
return next;
|
|
113
|
+
}, INSTALLATION_LOCK_OPTIONS);
|
|
114
|
+
}
|