@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -18
  3. package/src/commands/pulse/doctor.js +14 -0
  4. package/src/commands/pulse/hub-write.js +26 -0
  5. package/src/commands/pulse/install.js +14 -0
  6. package/src/commands/pulse/login.js +20 -0
  7. package/src/commands/pulse/mirror.js +27 -0
  8. package/src/commands/pulse/pair.js +38 -0
  9. package/src/commands/pulse/restart.js +14 -0
  10. package/src/commands/pulse/start.js +6 -18
  11. package/src/commands/pulse/status.js +6 -6
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +23 -23
  15. package/src/lib/agentSessions.js +279 -0
  16. package/src/lib/application.js +656 -0
  17. package/src/lib/backoff.js +8 -0
  18. package/src/lib/budgets.js +85 -0
  19. package/src/lib/cliDefaults.js +20 -0
  20. package/src/lib/cloudAuth.js +189 -0
  21. package/src/lib/contract.sha256 +2 -0
  22. package/src/lib/daemonComposition.js +338 -0
  23. package/src/lib/daemonLock.js +55 -0
  24. package/src/lib/daemonLoop.js +69 -0
  25. package/src/lib/daemonProcess.js +366 -0
  26. package/src/lib/daemonRuntime.js +820 -0
  27. package/src/lib/drivers.js +280 -0
  28. package/src/lib/errors.js +19 -0
  29. package/src/lib/graphWindows.js +149 -0
  30. package/src/lib/http.js +84 -0
  31. package/src/lib/hubActions.js +74 -0
  32. package/src/lib/identity.js +102 -0
  33. package/src/lib/intents.js +590 -0
  34. package/src/lib/ledger.js +535 -0
  35. package/src/lib/lifecycle.js +123 -0
  36. package/src/lib/localHub.js +217 -0
  37. package/src/lib/localHubWriter.js +69 -0
  38. package/src/lib/mirror-contract.json +4299 -0
  39. package/src/lib/mirrorContract.js +513 -0
  40. package/src/lib/mirrorDaemon.js +127 -0
  41. package/src/lib/mirrorService.js +358 -0
  42. package/src/lib/mirrorSource.js +455 -0
  43. package/src/lib/mirrorSync.js +353 -0
  44. package/src/lib/oauthLogin.js +132 -0
  45. package/src/lib/ownerLock.js +99 -0
  46. package/src/lib/pairedConfig.js +40 -0
  47. package/src/lib/pairingLifecycle.js +179 -0
  48. package/src/lib/pathRedaction.js +89 -0
  49. package/src/lib/planContract.js +141 -0
  50. package/src/lib/priorityLane.js +80 -0
  51. package/src/lib/processOwnership.js +252 -0
  52. package/src/lib/processSupervisor.js +376 -0
  53. package/src/lib/redaction.js +128 -0
  54. package/src/lib/rootPolicy.js +372 -0
  55. package/src/lib/rulePacks.js +162 -0
  56. package/src/lib/secretStore.js +169 -0
  57. package/src/lib/selfRestart.js +261 -0
  58. package/src/lib/serviceManager.js +358 -0
  59. package/src/lib/storage.js +131 -0
  60. package/src/lib/subprocess.js +45 -0
  61. package/src/lib/tokenStore.js +97 -0
  62. package/src/lib/transport.js +286 -0
  63. package/src/lib/transportContract.js +37 -0
  64. package/LICENSE +0 -21
  65. package/src/commands/pulse/gate.js +0 -25
  66. package/src/commands/pulse/next.js +0 -14
  67. package/src/commands/pulse/submit.js +0 -21
  68. package/src/commands/pulse/verify.js +0 -21
  69. package/src/runPulse.js +0 -58
@@ -0,0 +1,69 @@
1
+ export const TERMINAL_STATES = new Set(['revoked', 'upgrade_required', 'auth_required', 'stopped']);
2
+ const LOOP_RETRY_MS = 5_000;
3
+ /** The heartbeat step that waits on the mirror; a pass that keeps reading or pushing keeps it alive. */
4
+ export const MIRROR_UPDATE_STEP = 'mirror_update';
5
+
6
+ /**
7
+ * One daemon heartbeat: re-read config.json, run a runtime cycle, then always hand the same fresh config to the mirror
8
+ * and publish health. A failed cycle must not leave the mirror on an old policy version, so the cycle's error becomes
9
+ * the heartbeat's state instead of skipping the rest. progress(step) names each step for the watchdog; extraHealth()
10
+ * adds fields such as the restart state to every health write.
11
+ */
12
+ export function createHeartbeat({
13
+ readConfig, runtime, mirror, lock, ledger, ownerStore, progress = () => {}, extraHealth = () => ({}),
14
+ }) {
15
+ let last = null;
16
+ const health = (config, status) => ({
17
+ state: status.state,
18
+ ...(status.code ? { code: status.code } : {}),
19
+ ...(status.message ? { message: status.message } : {}),
20
+ ...(status.accepts ? { accepts: status.accepts } : {}),
21
+ paired: Boolean(config?.paired),
22
+ protocolVersion: config?.protocolVersion ?? 1,
23
+ recovery: ledger.recover(),
24
+ ownedProcesses: Object.values(ownerStore.data.owners)
25
+ .filter((owner) => owner.state === 'running')
26
+ .map((owner) => ({
27
+ processRef: owner.processRef,
28
+ jobId: owner.jobId,
29
+ attemptNo: owner.attemptNo,
30
+ fenceToken: owner.fenceToken,
31
+ })),
32
+ recovering: runtime.recovering(),
33
+ settleFailed: ledger.settleFailed().slice(0, 5),
34
+ ...(runtime.lastJobError ? { lastJobError: runtime.lastJobError } : {}),
35
+ ...extraHealth(),
36
+ });
37
+ // Writes are chained and each builds its health when its turn comes, so a republish never overwrites a newer cycle.
38
+ let writing = Promise.resolve();
39
+ const write = (override = null) => {
40
+ writing = writing.catch(() => {}).then(async () => {
41
+ await lock.heartbeat(health(last?.config ?? null, { ...(last?.status ?? {}), ...override }));
42
+ heartbeat.writtenAt = Date.now();
43
+ });
44
+ return writing;
45
+ };
46
+ const heartbeat = async () => {
47
+ progress('config');
48
+ const config = await readConfig();
49
+ runtime.updateConfig(config);
50
+ let status;
51
+ try {
52
+ progress('cycle');
53
+ status = await runtime.runOnce();
54
+ } catch (error) {
55
+ status = { state: 'error', code: error.code || 'HUB_DAEMON_LOOP_FAILED', retryAfterMs: LOOP_RETRY_MS };
56
+ }
57
+ progress(MIRROR_UPDATE_STEP);
58
+ // A cloud that refused the contract would refuse every batch too, so the mirror waits for the next session.
59
+ await mirror.update(TERMINAL_STATES.has(status.state) || status.code === 'contract_unsupported' ? null : config);
60
+ progress('health');
61
+ last = { config, status };
62
+ await write();
63
+ return status;
64
+ };
65
+ /** Writes health again from the last cycle, with status fields overridden, while a cycle is still running. */
66
+ heartbeat.republish = write;
67
+ heartbeat.writtenAt = 0;
68
+ return heartbeat;
69
+ }
@@ -0,0 +1,366 @@
1
+ import path from 'node:path';
2
+ import { createProductionDependencies } from './daemonComposition.js';
3
+ import { DaemonLock } from './daemonLock.js';
4
+ import { createHeartbeat, MIRROR_UPDATE_STEP, TERMINAL_STATES } from './daemonLoop.js';
5
+ import { DaemonRuntime } from './daemonRuntime.js';
6
+ import { assertNever, HubError } from './errors.js';
7
+ import { DurableLedger } from './ledger.js';
8
+ import { LocalHubReader } from './localHub.js';
9
+ import { MIRROR_PATH } from './mirrorContract.js';
10
+ import { MirrorController } from './mirrorDaemon.js';
11
+ import { ProcessOwnerStore } from './processOwnership.js';
12
+ import {
13
+ consumeRestartRequest,
14
+ RESTART_EXIT_CODE,
15
+ RestartGuard,
16
+ restartRequested,
17
+ Watchdog,
18
+ watchSource,
19
+ } from './selfRestart.js';
20
+ import { readOwnerOnlyJson } from './storage.js';
21
+
22
+ // The longest a healthy cycle goes between two steps: a transport retry backoff (capped at 60 s) or a held claim (35 s).
23
+ // A running job counts its lease renewals, every 10 s, as steps.
24
+ const CYCLE_INTERVAL_MS = 60_000;
25
+ // The mirror reconciles every 60 s, and a pass reads or pushes at least once per request timeout (30 s).
26
+ const MIRROR_INTERVAL_MS = 60_000;
27
+ const STALL_FACTOR = 3;
28
+ const MIRROR_LOOPS = ['mirror', 'mirror windows'];
29
+ /** The failure code of a job a restart had to abort: a drain that ran out of time, or a stalled loop. */
30
+ export const RESTART_ABORT_CODE = 'HUB_DAEMON_RESTARTING';
31
+ const DEFAULT_TIMING = Object.freeze({
32
+ cycleStallMs: STALL_FACTOR * CYCLE_INTERVAL_MS,
33
+ mirrorStallMs: STALL_FACTOR * MIRROR_INTERVAL_MS,
34
+ // An agent turn can run for many minutes; a code or request restart waits this long for it before aborting it.
35
+ drainMs: 15 * 60_000,
36
+ tickMs: 1_000,
37
+ healthEveryMs: 10_000,
38
+ watchDebounceMs: 2_000,
39
+ watchPollMs: 5_000,
40
+ watchRearmMs: 30_000,
41
+ shutdownMs: 20_000,
42
+ errorRetryMs: 5_000,
43
+ });
44
+
45
+ const inside = (child, parent) => {
46
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
47
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
48
+ };
49
+
50
+ /**
51
+ * The Pulse daemon process. Besides the cycle and the mirror it restarts itself, by exiting RESTART_EXIT_CODE for
52
+ * launchd or systemd to start it again:
53
+ * - code: its plugin sources changed;
54
+ * - request: restart.request appeared in the config directory after this process booted;
55
+ * - stall: the cycle or a mirror pass made no progress for its limit;
56
+ * - start_failed: it could not start while it was holding restarts back.
57
+ * A code or request restart drains (no new claim; running jobs finish and settle) for up to drainMs; a stall aborts at
58
+ * once. Every trigger goes through one check that runs each tickMs. While the daemon has restarted too often it holds
59
+ * the restart (health says restart_loop) until the window clears. SIGTERM always wins and exits 0.
60
+ */
61
+ export async function runDaemon({
62
+ directory,
63
+ pluginRoot,
64
+ logDir = null,
65
+ log = () => {},
66
+ mirrorLog = () => {},
67
+ exit = (code) => process.exit(code),
68
+ signals = process,
69
+ createDependencies = createProductionDependencies,
70
+ createMirror = (options) => new MirrorController(options),
71
+ dependencyOptions = {},
72
+ runtimeOptions = {},
73
+ lockOptions = {},
74
+ now = Date.now,
75
+ timing = {},
76
+ }) {
77
+ const t = { ...DEFAULT_TIMING, ...timing };
78
+ const bootedAt = Date.now();
79
+ const lock = new DaemonLock({ directory, ...lockOptions });
80
+ await lock.acquire();
81
+ // The first cycle (token exchange, reconnect, claim) can outlast `pulse start`'s wait; say we are up before it.
82
+ await lock.heartbeat({ state: 'starting' });
83
+ let startingHealthAt = Date.now();
84
+ const guard = new RestartGuard({ directory, now });
85
+ await guard.recordStart();
86
+
87
+ let exiting = false;
88
+ let pending = null;
89
+ let restarting = null;
90
+ let deferralLogged = false;
91
+ let failure = null;
92
+ let heartbeat = null;
93
+ let runtime = null;
94
+ let stopServices = async () => {};
95
+ let timer = null;
96
+ let watcher = null;
97
+ let checking = false;
98
+ let finishing = null;
99
+ let signalled = false;
100
+
101
+ const degraded = () => {
102
+ const loop = guard.status();
103
+ if (!loop.looping) return null;
104
+ return {
105
+ code: 'restart_loop',
106
+ restarts: loop.restarts,
107
+ windowMs: loop.windowMs,
108
+ clearsAt: new Date(loop.clearsAt).toISOString(),
109
+ ...(pending ? { pending: pending.reason } : {}),
110
+ };
111
+ };
112
+
113
+ const finish = (code, { failCode = code, abortCode = null } = {}) => {
114
+ if (finishing) return finishing;
115
+ exiting = true;
116
+ clearTimeout(timer);
117
+ clearInterval(ticker);
118
+ watcher?.close();
119
+ let deadline;
120
+ const timedOut = new Promise((_, reject) => {
121
+ deadline = setTimeout(() => reject(new HubError('HUB_SHUTDOWN_TIMEOUT', 'Daemon shutdown did not finish')), t.shutdownMs);
122
+ });
123
+ finishing = Promise.race([stopServices({ abortCode }), timedOut])
124
+ .then(
125
+ () => code,
126
+ (error) => {
127
+ log('daemon_shutdown_failed', { code: error.code || 'HUB_SHUTDOWN_FAILED' });
128
+ return failCode;
129
+ }
130
+ )
131
+ .then(async (exitCode) => {
132
+ clearTimeout(deadline);
133
+ // A service stuck past shutdownMs dies with this process; the next one must not find the lock held.
134
+ await lock.release().catch((error) => log('daemon_lock_release_failed', { code: error.code || 'HUB_LOCK_RELEASE_FAILED' }));
135
+ exit(signalled ? 0 : exitCode);
136
+ });
137
+ return finishing;
138
+ };
139
+
140
+ const drainThenExit = async (reason) => {
141
+ restarting = { reason, since: new Date().toISOString() };
142
+ clearTimeout(timer);
143
+ let deadline;
144
+ const drained = runtime ? runtime.drain().then(() => true) : Promise.resolve(true);
145
+ const outOfTime = new Promise((resolve) => {
146
+ deadline = setTimeout(() => resolve(false), t.drainMs);
147
+ });
148
+ const finished = await Promise.race([drained, outOfTime]);
149
+ clearTimeout(deadline);
150
+ if (exiting) return;
151
+ if (!finished) log('drain_timed_out', { reason, draining: runtime?.activeJobIds() ?? [] });
152
+ await finish(RESTART_EXIT_CODE, finished ? {} : { abortCode: RESTART_ABORT_CODE });
153
+ };
154
+
155
+ const restart = (reason) => {
156
+ log('reload_requested', pending);
157
+ switch (reason) {
158
+ case 'code':
159
+ case 'request':
160
+ void drainThenExit(reason);
161
+ break;
162
+ case 'stall':
163
+ case 'start_failed':
164
+ void finish(RESTART_EXIT_CODE, { abortCode: RESTART_ABORT_CODE });
165
+ break;
166
+ default:
167
+ assertNever(reason, 'restart reason');
168
+ }
169
+ };
170
+
171
+ // A cycle writes health when it ends; a long job, a drain or a stall would leave it stale, so it is republished.
172
+ const publishHealth = async () => {
173
+ if (Date.now() - Math.max(heartbeat?.writtenAt ?? 0, startingHealthAt) < t.healthEveryMs) return;
174
+ if (heartbeat?.writtenAt) {
175
+ await heartbeat.republish(failure);
176
+ return;
177
+ }
178
+ startingHealthAt = Date.now();
179
+ await lock.heartbeat({ state: 'starting', ...failure, ...extraHealth() });
180
+ };
181
+
182
+ const check = async () => {
183
+ if (exiting || checking) return;
184
+ checking = true;
185
+ try {
186
+ watchdog.check();
187
+ await publishHealth();
188
+ if (restarting) return;
189
+ const loop = degraded();
190
+ if (loop) {
191
+ if (!pending && (await restartRequested(directory))) pending = { reason: 'request' };
192
+ if (pending && !deferralLogged) {
193
+ deferralLogged = true;
194
+ log('restart_deferred', { reason: pending.reason, restarts: loop.restarts, clearsAt: loop.clearsAt });
195
+ }
196
+ return;
197
+ }
198
+ deferralLogged = false;
199
+ const requestedAt = await consumeRestartRequest(directory);
200
+ if (requestedAt !== null && requestedAt < bootedAt) log('restart_request_satisfied', { requestedAt: new Date(requestedAt).toISOString() });
201
+ else if (requestedAt !== null) pending ??= { reason: 'request' };
202
+ if (pending) restart(pending.reason);
203
+ } catch (error) {
204
+ log('restart_check_failed', { code: error.code || 'HUB_RESTART_CHECK_FAILED' });
205
+ } finally {
206
+ checking = false;
207
+ }
208
+ };
209
+
210
+ const extraHealth = () => {
211
+ const loop = degraded();
212
+ return {
213
+ ...(loop ? { degraded: loop } : {}),
214
+ ...(restarting ? { restarting: { ...restarting, draining: runtime?.activeJobIds() ?? [] } } : {}),
215
+ };
216
+ };
217
+
218
+ const watchdog = new Watchdog({
219
+ now,
220
+ onStall: (stall) => {
221
+ log('cycle_stalled', stall);
222
+ failure = { state: 'stalled', code: 'cycle_stalled' };
223
+ if (restarting) {
224
+ void finish(RESTART_EXIT_CODE, { abortCode: RESTART_ABORT_CODE });
225
+ return;
226
+ }
227
+ pending ??= { reason: 'stall', loop: stall.loop, awaiting: stall.awaiting };
228
+ },
229
+ });
230
+ const ticker = setInterval(() => void check(), t.tickMs);
231
+ const onSignal = () => {
232
+ signalled = true;
233
+ return finish(0, { failCode: 1 });
234
+ };
235
+ signals.once('SIGTERM', onSignal);
236
+ signals.once('SIGINT', onSignal);
237
+ const handle = { stop: onSignal };
238
+
239
+ const refused = [['config', directory], ['logs', logDir]].find(([, dir]) => dir && inside(dir, pluginRoot));
240
+ if (refused) {
241
+ log('source_watch_refused', { reason: `${refused[0]}_inside_plugin` });
242
+ } else {
243
+ try {
244
+ watcher = await watchSource({
245
+ root: pluginRoot,
246
+ debounceMs: t.watchDebounceMs,
247
+ pollMs: t.watchPollMs,
248
+ rearmMs: t.watchRearmMs,
249
+ onError: (error) => log('source_watch_polling', { code: error.code || 'HUB_WATCH_FAILED' }),
250
+ onChange: (files) => {
251
+ pending ??= { reason: 'code', changed: files.length, files: files.slice(0, 5) };
252
+ void check();
253
+ },
254
+ });
255
+ } catch (error) {
256
+ log('source_watch_failed', { code: error.code || 'HUB_WATCH_FAILED' });
257
+ }
258
+ }
259
+
260
+ const cycleProgress = (step) => watchdog.progress('cycle', { step });
261
+ // A heartbeat waiting on the mirror is as alive as the mirror: a long pass that keeps reading or pushing is no stall.
262
+ // A windows pass runs beside the change passes (see MirrorService) and is watched as a loop of its own; reads and
263
+ // pushes are not told apart by lane, so they count as progress for both.
264
+ const mirrorProgress = (call) => {
265
+ for (const loop of MIRROR_LOOPS) watchdog.progress(loop, { call });
266
+ if (watchdog.loops.get('cycle')?.step === MIRROR_UPDATE_STEP) watchdog.progress('cycle', { call: `mirror ${call}` });
267
+ };
268
+ try {
269
+ const ledger = await DurableLedger.open(directory);
270
+ const ownerStore = await ProcessOwnerStore.open(directory);
271
+ const configFile = path.join(directory, 'config.json');
272
+ const initialConfig = await readOwnerOnlyJson(configFile);
273
+ const production = await createDependencies({
274
+ config: initialConfig,
275
+ configDir: directory,
276
+ log,
277
+ onTransportAttempt: ({ path: requestPath, phase }) => {
278
+ const call = `${phase} ${requestPath}`;
279
+ if (requestPath === MIRROR_PATH) mirrorProgress(call);
280
+ else watchdog.progress('cycle', { call });
281
+ },
282
+ ...dependencyOptions,
283
+ });
284
+ runtime = new DaemonRuntime({
285
+ config: initialConfig,
286
+ ledger,
287
+ ownerStore,
288
+ transportFactory: production.transportFactory,
289
+ handlersFactory: production.handlersFactory,
290
+ sessionResolver: production.sessionResolver,
291
+ settlementObserver: production.settlementObserver,
292
+ log,
293
+ progress: cycleProgress,
294
+ ...runtimeOptions,
295
+ });
296
+ const mirror = createMirror({
297
+ directory,
298
+ transportFactory: production.transportFactory,
299
+ tokenStore: production.tokenStore,
300
+ log: mirrorLog,
301
+ createReader: (options) => new LocalHubReader({ ...options, onRead: (pathname) => mirrorProgress(`read ${pathname}`) }),
302
+ serviceOptions: {
303
+ onPassStart: ({ lane } = {}) => {
304
+ const loop = lane === 'windows' ? 'mirror windows' : 'mirror';
305
+ const entry = watchdog.begin(loop, 'pass', t.mirrorStallMs);
306
+ mirrorProgress('pass start');
307
+ return { loop, entry };
308
+ },
309
+ onPassEnd: ({ loop, entry }) => {
310
+ mirrorProgress('pass end');
311
+ watchdog.end(loop, entry);
312
+ },
313
+ },
314
+ });
315
+ heartbeat = createHeartbeat({
316
+ readConfig: () => readOwnerOnlyJson(configFile),
317
+ runtime,
318
+ mirror,
319
+ lock,
320
+ ledger,
321
+ ownerStore,
322
+ progress: cycleProgress,
323
+ extraHealth,
324
+ });
325
+ stopServices = async ({ abortCode }) => {
326
+ const results = await Promise.allSettled([mirror.stop(), runtime.shutdown({ abortCode })]);
327
+ const failed = results.find((result) => result.status === 'rejected');
328
+ if (failed) throw failed.reason;
329
+ };
330
+ } catch (error) {
331
+ runtime = null;
332
+ const code = error.code || 'HUB_DAEMON_FAILED';
333
+ log('daemon_start_failed', { code, message: error.message });
334
+ if (!degraded()) {
335
+ await finish(1);
336
+ return handle;
337
+ }
338
+ failure = { state: 'error', code };
339
+ pending ??= { reason: 'start_failed', code };
340
+ void check();
341
+ return handle;
342
+ }
343
+
344
+ const schedule = async () => {
345
+ if (exiting || restarting) return;
346
+ const entry = watchdog.begin('cycle', 'start', t.cycleStallMs);
347
+ let wait;
348
+ try {
349
+ const status = await heartbeat();
350
+ if (TERMINAL_STATES.has(status.state)) return;
351
+ wait = status.retryAfterMs || 15_000;
352
+ } catch (error) {
353
+ // A cycle that ends after shutdown released the lock has no health left to report.
354
+ if (exiting) return;
355
+ await Promise.resolve()
356
+ .then(() => lock.heartbeat({ state: 'error', code: error.code || 'HUB_DAEMON_LOOP_FAILED' }))
357
+ .catch(() => {});
358
+ wait = t.errorRetryMs;
359
+ } finally {
360
+ watchdog.end('cycle', entry);
361
+ }
362
+ if (!exiting && !restarting) timer = setTimeout(() => void schedule(), wait);
363
+ };
364
+ void schedule();
365
+ return handle;
366
+ }