@phnx-labs/agents-cli 1.22.65 → 1.22.68
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 +39 -0
- package/README.md +7 -2
- package/dist/bootstrap.js +3 -3
- package/dist/commands/apply.js +9 -2
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +40 -0
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +33 -9
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +11 -3
- package/dist/lib/accounting/usage.js +23 -10
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/known-hosts.d.ts +15 -0
- package/dist/lib/devices/known-hosts.js +18 -0
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/exec.js +11 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +9 -2
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +13 -0
- package/dist/lib/run-trace-sync.js +77 -0
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +120 -102
- package/dist/lib/session/active.d.ts +3 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +5 -2
- package/dist/lib/session/db.js +54 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +106 -25
- package/dist/lib/session/prompt.d.ts +11 -0
- package/dist/lib/session/prompt.js +25 -0
- package/dist/lib/session/recovery.d.ts +30 -2
- package/dist/lib/session/recovery.js +56 -4
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +185 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/publish.d.ts +55 -9
- package/dist/lib/share/publish.js +87 -9
- package/dist/lib/share/worker-template.js +100 -8
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/package.json +1 -1
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic transport for the non-secret fleet state stored in the user repo.
|
|
3
|
+
*
|
|
4
|
+
* Usage/auth services publish only their own conflict-free
|
|
5
|
+
* `devices/<device>/daemon-state.json` file. This module commits that one path,
|
|
6
|
+
* rebases the user repo, and pushes it so peer daemons can read the snapshot
|
|
7
|
+
* after their own fetch/rebase. Git runs asynchronously under one cross-process
|
|
8
|
+
* lock; every invocation has a wall-clock deadline and a timed-out process tree
|
|
9
|
+
* is terminated, including git's ssh child and its remote channel.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import lockfile from 'proper-lockfile';
|
|
15
|
+
import { fleetSharedStatePath } from './fleet-shared-state.js';
|
|
16
|
+
import { ensureLockTarget } from './fs-atomic.js';
|
|
17
|
+
import { logAndContinueOnLockCompromised } from './lock-compromise.js';
|
|
18
|
+
import { machineId } from './session/sync/config.js';
|
|
19
|
+
import { getDaemonDir, getUserAgentsDir } from './state.js';
|
|
20
|
+
export const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45_000;
|
|
21
|
+
export const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
|
|
22
|
+
const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES = 1024 * 1024;
|
|
23
|
+
const FLEET_SHARED_REPO_PUSH_ATTEMPTS = 3;
|
|
24
|
+
const FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS = 5_000;
|
|
25
|
+
function signalProcessTree(child, signal) {
|
|
26
|
+
const pid = child.pid;
|
|
27
|
+
if (!pid)
|
|
28
|
+
return;
|
|
29
|
+
if (process.platform === 'win32') {
|
|
30
|
+
// Windows has no process-group signal. taskkill is asynchronous here so a
|
|
31
|
+
// timeout path cannot replace one event-loop stall with another.
|
|
32
|
+
const killer = spawn('taskkill', ['/F', '/T', '/PID', String(pid)], {
|
|
33
|
+
stdio: 'ignore',
|
|
34
|
+
windowsHide: true,
|
|
35
|
+
});
|
|
36
|
+
killer.unref();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
// The child is spawned detached on POSIX, so -pid reaches git plus ssh.
|
|
41
|
+
process.kill(-pid, signal);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
try {
|
|
45
|
+
child.kill(signal);
|
|
46
|
+
}
|
|
47
|
+
catch { /* already gone */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
|
|
51
|
+
export function runBoundedProcess(command, args, options) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const child = spawn(command, args, {
|
|
54
|
+
cwd: options.cwd,
|
|
55
|
+
env: options.env ?? process.env,
|
|
56
|
+
detached: process.platform !== 'win32',
|
|
57
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
});
|
|
60
|
+
let stdout = '';
|
|
61
|
+
let stderr = '';
|
|
62
|
+
let settled = false;
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
let overflow = false;
|
|
65
|
+
let killTimer = null;
|
|
66
|
+
const finish = (code) => {
|
|
67
|
+
if (settled)
|
|
68
|
+
return;
|
|
69
|
+
settled = true;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
if (killTimer)
|
|
72
|
+
clearTimeout(killTimer);
|
|
73
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
74
|
+
};
|
|
75
|
+
const hardKill = () => {
|
|
76
|
+
signalProcessTree(child, 'SIGKILL');
|
|
77
|
+
if (process.platform === 'win32' && !killTimer) {
|
|
78
|
+
// taskkill owns the descendant cleanup; this fallback only guarantees
|
|
79
|
+
// the direct child's Promise settles if taskkill itself cannot run.
|
|
80
|
+
killTimer = setTimeout(() => {
|
|
81
|
+
if (!settled) {
|
|
82
|
+
try {
|
|
83
|
+
child.kill('SIGKILL');
|
|
84
|
+
}
|
|
85
|
+
catch { /* already gone */ }
|
|
86
|
+
}
|
|
87
|
+
}, FLEET_SHARED_REPO_KILL_GRACE_MS);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
timedOut = true;
|
|
92
|
+
if (process.platform === 'win32') {
|
|
93
|
+
hardKill();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
signalProcessTree(child, 'SIGTERM');
|
|
97
|
+
killTimer = setTimeout(() => {
|
|
98
|
+
if (!settled)
|
|
99
|
+
hardKill();
|
|
100
|
+
}, FLEET_SHARED_REPO_KILL_GRACE_MS);
|
|
101
|
+
}, Math.max(1, options.timeoutMs));
|
|
102
|
+
const append = (stream, chunk) => {
|
|
103
|
+
if (overflow)
|
|
104
|
+
return;
|
|
105
|
+
const value = String(chunk);
|
|
106
|
+
if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) + Buffer.byteLength(value) > FLEET_SHARED_REPO_OUTPUT_MAX_BYTES) {
|
|
107
|
+
overflow = true;
|
|
108
|
+
stderr += '\ngit output exceeded 1 MiB';
|
|
109
|
+
hardKill();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (stream === 'stdout')
|
|
113
|
+
stdout += value;
|
|
114
|
+
else
|
|
115
|
+
stderr += value;
|
|
116
|
+
};
|
|
117
|
+
child.stdout?.on('data', (chunk) => append('stdout', chunk));
|
|
118
|
+
child.stderr?.on('data', (chunk) => append('stderr', chunk));
|
|
119
|
+
child.on('error', (err) => {
|
|
120
|
+
stderr += err.message;
|
|
121
|
+
finish(null);
|
|
122
|
+
});
|
|
123
|
+
child.on('close', (code) => finish(code));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function failure(command, result) {
|
|
127
|
+
const detail = result.timedOut
|
|
128
|
+
? `${command} timed out`
|
|
129
|
+
: result.stderr.trim() || result.stdout.trim() || `${command} exited ${result.code ?? 'without a status'}`;
|
|
130
|
+
return {
|
|
131
|
+
success: false,
|
|
132
|
+
committed: false,
|
|
133
|
+
pushed: false,
|
|
134
|
+
commit: null,
|
|
135
|
+
timedOut: result.timedOut,
|
|
136
|
+
skipped: null,
|
|
137
|
+
error: detail,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function isPushRace(result) {
|
|
141
|
+
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
142
|
+
return text.includes('non-fast-forward') || text.includes('fetch first') || text.includes('[rejected]');
|
|
143
|
+
}
|
|
144
|
+
function createdAutostashObject(result) {
|
|
145
|
+
return `${result.stdout}\n${result.stderr}`.match(/created autostash:\s*([0-9a-f]+)/i)?.[1] ?? null;
|
|
146
|
+
}
|
|
147
|
+
function retainedAutostash(stashList, objectId) {
|
|
148
|
+
if (stashList.code === 0) {
|
|
149
|
+
const entries = stashList.stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
150
|
+
const [ref = '', hash = '', ...subject] = line.split('\t');
|
|
151
|
+
return { ref, hash, subject: subject.join('\t') };
|
|
152
|
+
});
|
|
153
|
+
const matched = objectId
|
|
154
|
+
? entries.find((entry) => entry.hash.startsWith(objectId) || objectId.startsWith(entry.hash))
|
|
155
|
+
: entries.find((entry) => /autostash/i.test(entry.subject));
|
|
156
|
+
if (matched?.ref)
|
|
157
|
+
return { label: `${matched.ref} (${matched.hash.slice(0, 8)})`, ref: matched.ref };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
label: objectId ? `autostash object ${objectId}` : 'the retained autostash',
|
|
161
|
+
ref: null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
165
|
+
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
committed: false,
|
|
169
|
+
pushed: false,
|
|
170
|
+
commit: null,
|
|
171
|
+
timedOut: false,
|
|
172
|
+
skipped: `user store is not git-backed: ${root}`,
|
|
173
|
+
error: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const deadline = Date.now() + timeoutMs;
|
|
177
|
+
const git = async (args, reserveMs = 0) => {
|
|
178
|
+
const remaining = deadline - Date.now() - reserveMs;
|
|
179
|
+
if (remaining <= 0)
|
|
180
|
+
return { code: null, stdout: '', stderr: '', timedOut: true };
|
|
181
|
+
return runBoundedProcess('git', args, {
|
|
182
|
+
cwd: root,
|
|
183
|
+
timeoutMs: remaining,
|
|
184
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
const branchResult = await git(['branch', '--show-current']);
|
|
188
|
+
if (branchResult.code !== 0)
|
|
189
|
+
return failure('git branch --show-current', branchResult);
|
|
190
|
+
const branch = branchResult.stdout.trim();
|
|
191
|
+
if (!branch) {
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
committed: false,
|
|
195
|
+
pushed: false,
|
|
196
|
+
commit: null,
|
|
197
|
+
timedOut: false,
|
|
198
|
+
skipped: null,
|
|
199
|
+
error: 'user store is on a detached HEAD',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const ownedFile = fleetSharedStatePath(device, root);
|
|
203
|
+
const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
|
|
204
|
+
let committed = false;
|
|
205
|
+
if (fs.existsSync(ownedFile)) {
|
|
206
|
+
const status = await git(['status', '--porcelain=v1', '--', relativeOwnedFile]);
|
|
207
|
+
if (status.code !== 0)
|
|
208
|
+
return failure('git status', status);
|
|
209
|
+
if (status.stdout.trim()) {
|
|
210
|
+
const add = await git(['add', '--', relativeOwnedFile]);
|
|
211
|
+
if (add.code !== 0)
|
|
212
|
+
return failure('git add', add);
|
|
213
|
+
const commit = await git([
|
|
214
|
+
'-c', 'commit.gpgsign=false',
|
|
215
|
+
'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
|
|
216
|
+
'--', relativeOwnedFile,
|
|
217
|
+
]);
|
|
218
|
+
if (commit.code !== 0)
|
|
219
|
+
return failure('git commit', commit);
|
|
220
|
+
committed = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
|
|
224
|
+
const fetch = await git(['fetch', 'origin']);
|
|
225
|
+
if (fetch.code !== 0)
|
|
226
|
+
return { ...failure('git fetch', fetch), committed };
|
|
227
|
+
// Keep enough of the same wall-clock bound available to remove a botched
|
|
228
|
+
// autostash pop from the operator's live checkout before returning.
|
|
229
|
+
const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
|
|
230
|
+
if (rebase.code !== 0) {
|
|
231
|
+
// Rebase can leave control files behind even when the child was killed.
|
|
232
|
+
// Abort is bounded by the same overall deadline and never hides the cause.
|
|
233
|
+
await git(['rebase', '--abort']);
|
|
234
|
+
return { ...failure('git rebase', rebase), committed };
|
|
235
|
+
}
|
|
236
|
+
// `git rebase --autostash` exits zero when the rebase succeeds but applying
|
|
237
|
+
// the autostash conflicts. Never push or leave those conflict markers in
|
|
238
|
+
// the operator's real ~/.agents checkout. Git retains the original changes
|
|
239
|
+
// in the stash on this path, so reset only the failed pop to rebased HEAD.
|
|
240
|
+
const unmerged = await git(['ls-files', '--unmerged']);
|
|
241
|
+
if (unmerged.code !== 0)
|
|
242
|
+
return { ...failure('git ls-files --unmerged', unmerged), committed };
|
|
243
|
+
if (unmerged.stdout.trim()) {
|
|
244
|
+
const autostashObject = createdAutostashObject(rebase);
|
|
245
|
+
const reset = await git(['reset', '--hard', 'HEAD']);
|
|
246
|
+
const cleaned = await git(['ls-files', '--unmerged']);
|
|
247
|
+
const stashList = await git(['stash', 'list', '--format=%gd%x09%H%x09%gs']);
|
|
248
|
+
const stash = retainedAutostash(stashList, autostashObject);
|
|
249
|
+
if (reset.code !== 0 || cleaned.code !== 0 || cleaned.stdout.trim()) {
|
|
250
|
+
const cleanup = reset.code !== 0
|
|
251
|
+
? failure('git reset --hard HEAD', reset).error
|
|
252
|
+
: cleaned.code !== 0
|
|
253
|
+
? failure('git ls-files --unmerged', cleaned).error
|
|
254
|
+
: 'unmerged index entries remain after git reset --hard HEAD';
|
|
255
|
+
return {
|
|
256
|
+
success: false,
|
|
257
|
+
committed,
|
|
258
|
+
pushed: false,
|
|
259
|
+
commit: null,
|
|
260
|
+
timedOut: reset.timedOut || cleaned.timedOut,
|
|
261
|
+
skipped: null,
|
|
262
|
+
error: `autostash pop conflicted; push refused, but cleanup failed: ${cleanup}. Original tracked changes remain safe in ${stash.label}`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
success: false,
|
|
267
|
+
committed,
|
|
268
|
+
pushed: false,
|
|
269
|
+
commit: null,
|
|
270
|
+
timedOut: false,
|
|
271
|
+
skipped: null,
|
|
272
|
+
error: `autostash pop conflicted; push refused and the worktree was reset to rebased HEAD without conflict markers. Original tracked changes remain safe in ${stash.label}; inspect with ${stash.ref ? `git stash show -p ${stash.ref}` : 'git stash list'}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const push = await git(['push', '--', 'origin', branch]);
|
|
276
|
+
if (push.code === 0) {
|
|
277
|
+
const head = await git(['rev-parse', '--short=8', 'HEAD']);
|
|
278
|
+
return {
|
|
279
|
+
success: true,
|
|
280
|
+
committed,
|
|
281
|
+
pushed: committed || push.stdout.trim().length > 0 || push.stderr.includes('->'),
|
|
282
|
+
commit: head.code === 0 ? head.stdout.trim() : null,
|
|
283
|
+
timedOut: false,
|
|
284
|
+
skipped: null,
|
|
285
|
+
error: null,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
|
|
289
|
+
return { ...failure('git push', push), committed };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
throw new Error('unreachable fleet shared repo push loop');
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Commit/pull/push this device's shared state under one cross-process lock.
|
|
296
|
+
* Callers publish their local fields first and consume peer fields only after
|
|
297
|
+
* this resolves successfully.
|
|
298
|
+
*/
|
|
299
|
+
export async function syncFleetSharedStateRepo(options = {}) {
|
|
300
|
+
const root = options.userAgentsDir ?? getUserAgentsDir();
|
|
301
|
+
const device = options.device ?? machineId();
|
|
302
|
+
const timeoutMs = options.timeoutMs ?? FLEET_SHARED_REPO_SYNC_DEADLINE_MS;
|
|
303
|
+
const lockPath = options.lockPath ?? path.join(getDaemonDir(), 'fleet-shared-repo-sync');
|
|
304
|
+
ensureLockTarget(lockPath, '');
|
|
305
|
+
let release = null;
|
|
306
|
+
try {
|
|
307
|
+
release = await lockfile.lock(lockPath, {
|
|
308
|
+
stale: Math.max(10_000, timeoutMs + 5_000),
|
|
309
|
+
retries: { retries: 20, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
|
310
|
+
onCompromised: logAndContinueOnLockCompromised('fleet shared repo sync'),
|
|
311
|
+
});
|
|
312
|
+
return await performFleetSharedRepoSync(root, device, timeoutMs);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
committed: false,
|
|
318
|
+
pushed: false,
|
|
319
|
+
commit: null,
|
|
320
|
+
timedOut: false,
|
|
321
|
+
skipped: null,
|
|
322
|
+
error: err.message,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
if (release) {
|
|
327
|
+
try {
|
|
328
|
+
await release();
|
|
329
|
+
}
|
|
330
|
+
catch { /* compromised/already released */ }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CachedUsageSnapshot } from './accounting/usage.js';
|
|
2
|
+
export declare const FLEET_SHARED_STATE_VERSION = 1;
|
|
3
|
+
export declare const FLEET_SHARED_STATE_FILE = "daemon-state.json";
|
|
4
|
+
export type SharedAuthStatus = 'ready' | 'missing' | 'invalid';
|
|
5
|
+
export interface FleetSharedDeviceState {
|
|
6
|
+
version: typeof FLEET_SHARED_STATE_VERSION;
|
|
7
|
+
device: string;
|
|
8
|
+
usage?: {
|
|
9
|
+
rows: Record<string, CachedUsageSnapshot>;
|
|
10
|
+
};
|
|
11
|
+
auth?: {
|
|
12
|
+
status: SharedAuthStatus;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface FleetSharedStatePatch {
|
|
16
|
+
usage?: FleetSharedDeviceState['usage'];
|
|
17
|
+
auth?: FleetSharedDeviceState['auth'];
|
|
18
|
+
}
|
|
19
|
+
export interface FleetSharedStateReadResult {
|
|
20
|
+
states: FleetSharedDeviceState[];
|
|
21
|
+
errors: Array<{
|
|
22
|
+
device: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
/** Path owned by one device in the conflict-free tracked device-doc tree. */
|
|
27
|
+
export declare function fleetSharedStatePath(device: string, userAgentsDir?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Merge one daemon-owned field into this device's shared file under a real
|
|
30
|
+
* inter-process lock. Stable serialization avoids dirtying the user repo when
|
|
31
|
+
* neither usage nor auth state changed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function updateFleetSharedDeviceState(device: string, patch: FleetSharedStatePatch, userAgentsDir?: string): {
|
|
34
|
+
changed: boolean;
|
|
35
|
+
path: string;
|
|
36
|
+
};
|
|
37
|
+
/** Read every valid peer-owned state file; malformed peers fail separately. */
|
|
38
|
+
export declare function readFleetSharedDeviceStates(userAgentsDir?: string): FleetSharedStateReadResult;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-secret daemon state exchanged through the already fleet-synced user repo.
|
|
3
|
+
*
|
|
4
|
+
* Each device owns exactly one file under `~/.agents/devices/<device>/`, so
|
|
5
|
+
* normal `agents repo push/pull user` merges the fleet without devices dialing
|
|
6
|
+
* one another on every daemon tick. OAuth/setup-token values never belong here:
|
|
7
|
+
* auth publishes only a readiness verdict; the existing encrypted SSH transport
|
|
8
|
+
* remains the one path that may carry secret material.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { assertValidDeviceName } from './devices/registry.js';
|
|
13
|
+
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
14
|
+
import { getUserAgentsDir } from './state.js';
|
|
15
|
+
export const FLEET_SHARED_STATE_VERSION = 1;
|
|
16
|
+
export const FLEET_SHARED_STATE_FILE = 'daemon-state.json';
|
|
17
|
+
/** Path owned by one device in the conflict-free tracked device-doc tree. */
|
|
18
|
+
export function fleetSharedStatePath(device, userAgentsDir = getUserAgentsDir()) {
|
|
19
|
+
assertValidDeviceName(device);
|
|
20
|
+
return path.join(userAgentsDir, 'devices', device, FLEET_SHARED_STATE_FILE);
|
|
21
|
+
}
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function parseFleetSharedDeviceState(raw, owner) {
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (!isRecord(parsed) || parsed.version !== FLEET_SHARED_STATE_VERSION || parsed.device !== owner) {
|
|
28
|
+
throw new Error('unrecognized shared-state envelope');
|
|
29
|
+
}
|
|
30
|
+
const usage = parsed.usage;
|
|
31
|
+
if (usage !== undefined && (!isRecord(usage) || !isRecord(usage.rows))) {
|
|
32
|
+
throw new Error('unrecognized usage snapshot');
|
|
33
|
+
}
|
|
34
|
+
const auth = parsed.auth;
|
|
35
|
+
if (auth !== undefined &&
|
|
36
|
+
(!isRecord(auth) || !['ready', 'missing', 'invalid'].includes(String(auth.status)))) {
|
|
37
|
+
throw new Error('unrecognized auth verdict');
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Merge one daemon-owned field into this device's shared file under a real
|
|
43
|
+
* inter-process lock. Stable serialization avoids dirtying the user repo when
|
|
44
|
+
* neither usage nor auth state changed.
|
|
45
|
+
*/
|
|
46
|
+
export function updateFleetSharedDeviceState(device, patch, userAgentsDir = getUserAgentsDir()) {
|
|
47
|
+
const file = fleetSharedStatePath(device, userAgentsDir);
|
|
48
|
+
ensureLockTarget(file, '');
|
|
49
|
+
return withFileLock(file, () => {
|
|
50
|
+
let current = {
|
|
51
|
+
version: FLEET_SHARED_STATE_VERSION,
|
|
52
|
+
device,
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(file, 'utf-8').trim();
|
|
56
|
+
if (raw)
|
|
57
|
+
current = parseFleetSharedDeviceState(raw, device);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// The owning device repairs its own malformed file from authoritative
|
|
61
|
+
// local state. Peer files are never repaired by this writer.
|
|
62
|
+
}
|
|
63
|
+
const next = {
|
|
64
|
+
...current,
|
|
65
|
+
...(patch.usage !== undefined ? { usage: patch.usage } : {}),
|
|
66
|
+
...(patch.auth !== undefined ? { auth: patch.auth } : {}),
|
|
67
|
+
version: FLEET_SHARED_STATE_VERSION,
|
|
68
|
+
device,
|
|
69
|
+
};
|
|
70
|
+
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
|
71
|
+
if (fs.readFileSync(file, 'utf-8') === serialized)
|
|
72
|
+
return { changed: false, path: file };
|
|
73
|
+
atomicWriteFileSync(file, serialized, 'utf-8');
|
|
74
|
+
return { changed: true, path: file };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** Read every valid peer-owned state file; malformed peers fail separately. */
|
|
78
|
+
export function readFleetSharedDeviceStates(userAgentsDir = getUserAgentsDir()) {
|
|
79
|
+
const devicesDir = path.join(userAgentsDir, 'devices');
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = fs.readdirSync(devicesDir, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (err.code === 'ENOENT')
|
|
86
|
+
return { states: [], errors: [] };
|
|
87
|
+
return { states: [], errors: [{ device: '*', message: err.message }] };
|
|
88
|
+
}
|
|
89
|
+
const states = [];
|
|
90
|
+
const errors = [];
|
|
91
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
92
|
+
if (!entry.isDirectory())
|
|
93
|
+
continue;
|
|
94
|
+
const file = path.join(devicesDir, entry.name, FLEET_SHARED_STATE_FILE);
|
|
95
|
+
if (!fs.existsSync(file))
|
|
96
|
+
continue;
|
|
97
|
+
try {
|
|
98
|
+
states.push(parseFleetSharedDeviceState(fs.readFileSync(file, 'utf-8'), entry.name));
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
errors.push({ device: entry.name, message: err.message });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { states, errors };
|
|
105
|
+
}
|
|
@@ -118,6 +118,8 @@ export interface WindowsAgentsCommand {
|
|
|
118
118
|
* pass false for probes whose reachability keys off a sentinel, not the code.
|
|
119
119
|
*/
|
|
120
120
|
propagateExit?: boolean;
|
|
121
|
+
/** Remap a reached peer command's 255 to 254 so SSH's own 255 stays unambiguous. */
|
|
122
|
+
remapExit255?: boolean;
|
|
121
123
|
}
|
|
122
124
|
/**
|
|
123
125
|
* The PowerShell script (pre-encoding) that {@link buildWindowsAgentsCommand}
|
|
@@ -131,6 +131,11 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
131
131
|
// process follows the remote run to completion, so its exit handler fires at
|
|
132
132
|
// the right moment anyway.
|
|
133
133
|
notify: 'local-only',
|
|
134
|
+
// --no-trace-sync (traceSync=false) gates the LOCAL run-exit trace auto-sync,
|
|
135
|
+
// which only arms for local runs anyway (exec.ts skips it for --device/--lease).
|
|
136
|
+
// On a --device dispatch the remote box runs its own run-exit sync, so this
|
|
137
|
+
// flag is never forwarded — it is a local-exit-behavior toggle, not remote.
|
|
138
|
+
traceSync: 'local-only',
|
|
134
139
|
// Deprecated alias for --device auto; resolved on the launching box before SSH.
|
|
135
140
|
smart: 'local-only',
|
|
136
141
|
// Broadcast mode (agents run --broadcast) is its own fan-out dispatch — mutually
|
|
@@ -289,7 +294,7 @@ export function stripClixml(stdout) {
|
|
|
289
294
|
.trim();
|
|
290
295
|
}
|
|
291
296
|
export function windowsAgentsScript(cmd) {
|
|
292
|
-
const { args, env, cwd, propagateExit = true } = cmd;
|
|
297
|
+
const { args, env, cwd, propagateExit = true, remapExit255 = false } = cmd;
|
|
293
298
|
const parts = [POWERSHELL_PROGRESS_SILENCE];
|
|
294
299
|
if (env)
|
|
295
300
|
for (const [k, v] of Object.entries(env))
|
|
@@ -297,8 +302,12 @@ export function windowsAgentsScript(cmd) {
|
|
|
297
302
|
if (cwd)
|
|
298
303
|
parts.push(`Set-Location -LiteralPath ${powershellQuote(cwd)}`);
|
|
299
304
|
parts.push(`& ${['agents', ...args].map(powershellQuote).join(' ')}`);
|
|
300
|
-
if (propagateExit)
|
|
301
|
-
|
|
305
|
+
if (propagateExit) {
|
|
306
|
+
if (remapExit255)
|
|
307
|
+
parts.push('$agentsExit = $LASTEXITCODE', 'if ($agentsExit -eq 255) { exit 254 }', 'exit $agentsExit');
|
|
308
|
+
else
|
|
309
|
+
parts.push('exit $LASTEXITCODE');
|
|
310
|
+
}
|
|
302
311
|
return parts.join('; ');
|
|
303
312
|
}
|
|
304
313
|
/**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proper-lockfile invokes this callback from its background refresh timer.
|
|
3
|
+
* Its default is to throw there, outside the caller's promise chain, which can
|
|
4
|
+
* terminate the daemon. These leases are advisory serialization barriers: log
|
|
5
|
+
* a broken lease and let the owning operation finish instead of crashing the
|
|
6
|
+
* shared process.
|
|
7
|
+
*/
|
|
8
|
+
export declare function logAndContinueOnLockCompromised(scope: string): (err: Error) => void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proper-lockfile invokes this callback from its background refresh timer.
|
|
3
|
+
* Its default is to throw there, outside the caller's promise chain, which can
|
|
4
|
+
* terminate the daemon. These leases are advisory serialization barriers: log
|
|
5
|
+
* a broken lease and let the owning operation finish instead of crashing the
|
|
6
|
+
* shared process.
|
|
7
|
+
*/
|
|
8
|
+
export function logAndContinueOnLockCompromised(scope) {
|
|
9
|
+
return (err) => {
|
|
10
|
+
console.warn(`[agents ${scope}] Lock was compromised; continuing without crashing: ${err.message}`);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -58,6 +58,7 @@ export declare class MonitorEngine {
|
|
|
58
58
|
private monitors;
|
|
59
59
|
private lastEval;
|
|
60
60
|
private ticking;
|
|
61
|
+
private running;
|
|
61
62
|
constructor(logFn?: LogFn);
|
|
62
63
|
/** Load owned+enabled monitors and start the tick loop. */
|
|
63
64
|
start(options?: {
|
|
@@ -65,7 +66,7 @@ export declare class MonitorEngine {
|
|
|
65
66
|
}): void;
|
|
66
67
|
/** Reload monitor configs (SIGHUP). */
|
|
67
68
|
reload(): void;
|
|
68
|
-
/** Stop the tick loop. */
|
|
69
|
+
/** Stop the tick loop. No monitor dispatches after this until `start()`. */
|
|
69
70
|
stop(): void;
|
|
70
71
|
private loadAll;
|
|
71
72
|
private intervalMs;
|
|
@@ -122,11 +122,13 @@ export class MonitorEngine {
|
|
|
122
122
|
monitors = [];
|
|
123
123
|
lastEval = new Map();
|
|
124
124
|
ticking = false;
|
|
125
|
+
running = false;
|
|
125
126
|
constructor(logFn = () => { }) {
|
|
126
127
|
this.logFn = logFn;
|
|
127
128
|
}
|
|
128
129
|
/** Load owned+enabled monitors and start the tick loop. */
|
|
129
130
|
start(options = {}) {
|
|
131
|
+
this.running = true;
|
|
130
132
|
this.loadAll();
|
|
131
133
|
this.logFn('INFO', `Monitor engine started (${this.monitors.length} monitor(s) on this device)`);
|
|
132
134
|
if (!options.externalScheduler) {
|
|
@@ -138,8 +140,9 @@ export class MonitorEngine {
|
|
|
138
140
|
this.loadAll();
|
|
139
141
|
this.logFn('INFO', `Monitor engine reloaded (${this.monitors.length} monitor(s) on this device)`);
|
|
140
142
|
}
|
|
141
|
-
/** Stop the tick loop. */
|
|
143
|
+
/** Stop the tick loop. No monitor dispatches after this until `start()`. */
|
|
142
144
|
stop() {
|
|
145
|
+
this.running = false;
|
|
143
146
|
if (this.timer) {
|
|
144
147
|
clearInterval(this.timer);
|
|
145
148
|
this.timer = null;
|
|
@@ -159,7 +162,11 @@ export class MonitorEngine {
|
|
|
159
162
|
}
|
|
160
163
|
/** Evaluate every due monitor once. Overlap-guarded so a slow cycle never stacks. */
|
|
161
164
|
async tick() {
|
|
162
|
-
|
|
165
|
+
// A stopped engine dispatches nothing (PHNX-3608): under the external
|
|
166
|
+
// scheduler the supervisor owns the timer, so a `stop()` (monitors service
|
|
167
|
+
// disabled) must be honoured HERE — otherwise the next supervised tick would
|
|
168
|
+
// still fire the last-loaded monitors even though the engine is stopped.
|
|
169
|
+
if (!this.running || this.ticking)
|
|
163
170
|
return;
|
|
164
171
|
this.ticking = true;
|
|
165
172
|
try {
|
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import lockfile from 'proper-lockfile';
|
|
5
5
|
import { getCacheDir } from './state.js';
|
|
6
|
+
import { logAndContinueOnLockCompromised } from './lock-compromise.js';
|
|
6
7
|
const REFRESH_LOCK_STALE_MS = 2 * 60_000;
|
|
7
8
|
let refreshLockRootOverride = null;
|
|
8
9
|
export function setRefreshLockRootForTest(root) {
|
|
@@ -39,6 +40,7 @@ export async function withRefreshLease(options) {
|
|
|
39
40
|
stale: REFRESH_LOCK_STALE_MS,
|
|
40
41
|
update: REFRESH_LOCK_STALE_MS / 4,
|
|
41
42
|
retries: { retries: 240, minTimeout: 25, maxTimeout: 500, factor: 1.2 },
|
|
43
|
+
onCompromised: logAndContinueOnLockCompromised('refresh lease'),
|
|
42
44
|
});
|
|
43
45
|
try {
|
|
44
46
|
const completed = options.readCompleted();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy gate, pure and unit-testable. `disabled` is the resolved
|
|
3
|
+
* `--no-trace-sync` (commander maps it to `traceSync === false`).
|
|
4
|
+
*/
|
|
5
|
+
export declare function shouldAutoSyncTraces(disabled: boolean): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Arm a fire-and-forget `agents traces sync` for when this process exits.
|
|
8
|
+
* No-op unless {@link shouldAutoSyncTraces} passes. Best-effort by
|
|
9
|
+
* construction: a missing binary or a stalled child never affects the run.
|
|
10
|
+
*/
|
|
11
|
+
export declare function armRunFinishTraceSync(opts?: {
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}): void;
|