@agentpulselabs/cli-plugin-pulse 0.2.0 → 1.1.2

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 (151) hide show
  1. package/README.md +115 -32
  2. package/package.json +14 -19
  3. package/src/clients/hub-bridge.mjs +99 -0
  4. package/src/clients/hub.mjs +324 -0
  5. package/src/commands/pulse/cutover.js +39 -0
  6. package/src/commands/pulse/doctor.js +15 -0
  7. package/src/commands/pulse/hub-write.js +26 -0
  8. package/src/commands/pulse/import.js +62 -0
  9. package/src/commands/pulse/install.js +14 -0
  10. package/src/commands/pulse/log-write.js +26 -0
  11. package/src/commands/pulse/login.js +20 -0
  12. package/src/commands/pulse/mirror.js +27 -0
  13. package/src/commands/pulse/pair.js +38 -0
  14. package/src/commands/pulse/parity.js +20 -0
  15. package/src/commands/pulse/restart.js +14 -0
  16. package/src/commands/pulse/shadow.js +19 -0
  17. package/src/commands/pulse/start.js +6 -18
  18. package/src/commands/pulse/status.js +6 -6
  19. package/src/commands/pulse/stop.js +24 -0
  20. package/src/daemon/main.js +43 -0
  21. package/src/hub/core/auth.mjs +80 -0
  22. package/src/hub/core/changes.mjs +38 -0
  23. package/src/hub/core/conversations.mjs +121 -0
  24. package/src/hub/core/db.mjs +263 -0
  25. package/src/hub/core/edgeIds.mjs +9 -0
  26. package/src/hub/core/hub.mjs +20 -0
  27. package/src/hub/core/ids.mjs +39 -0
  28. package/src/hub/core/leases.mjs +55 -0
  29. package/src/hub/core/messaging.mjs +322 -0
  30. package/src/hub/core/registry.mjs +281 -0
  31. package/src/hub/core/ticks.mjs +58 -0
  32. package/src/hub/core/wake.mjs +125 -0
  33. package/src/hub/home.mjs +16 -0
  34. package/src/hub/hooks/hub-bridge.mjs +167 -0
  35. package/src/hub/keepawake/agent.mjs +96 -0
  36. package/src/hub/lib/brief.mjs +133 -0
  37. package/src/hub/lib/channels.mjs +268 -0
  38. package/src/hub/lib/chat.mjs +163 -0
  39. package/src/hub/lib/closure.mjs +92 -0
  40. package/src/hub/lib/collect-worker.mjs +20 -0
  41. package/src/hub/lib/collect.mjs +342 -0
  42. package/src/hub/lib/deliveries.mjs +222 -0
  43. package/src/hub/lib/git.mjs +93 -0
  44. package/src/hub/lib/me.mjs +123 -0
  45. package/src/hub/lib/personas.mjs +143 -0
  46. package/src/hub/lib/status.mjs +147 -0
  47. package/src/hub/lib/store.mjs +106 -0
  48. package/src/hub/lib/titles.mjs +29 -0
  49. package/src/hub/lib/transcript-feed.mjs +72 -0
  50. package/src/hub/lib/transcripts.mjs +364 -0
  51. package/src/hub/lib/writing.mjs +18 -0
  52. package/src/hub/projections/deliveries.mjs +31 -0
  53. package/src/hub/projections/graph.mjs +149 -0
  54. package/src/hub/runners/drivers.mjs +162 -0
  55. package/src/hub/workers/collector.mjs +128 -0
  56. package/src/hub/workers/manager.mjs +366 -0
  57. package/src/hub/workers/runner.mjs +145 -0
  58. package/src/lib/BaseCommand.js +23 -23
  59. package/src/lib/agentSessions.js +279 -0
  60. package/src/lib/application.js +704 -0
  61. package/src/lib/backoff.js +8 -0
  62. package/src/lib/budgets.js +85 -0
  63. package/src/lib/channelScanner.js +99 -0
  64. package/src/lib/cliDefaults.js +20 -0
  65. package/src/lib/clientInstaller.js +111 -0
  66. package/src/lib/cloudAuth.js +189 -0
  67. package/src/lib/cloudFirstCommands.js +293 -0
  68. package/src/lib/contract.sha256 +2 -0
  69. package/src/lib/cutover.js +376 -0
  70. package/src/lib/cutoverWiring.js +137 -0
  71. package/src/lib/daemonComposition.js +338 -0
  72. package/src/lib/daemonLock.js +57 -0
  73. package/src/lib/daemonLoop.js +72 -0
  74. package/src/lib/daemonProcess.js +380 -0
  75. package/src/lib/daemonRuntime.js +828 -0
  76. package/src/lib/doctorWarnings.js +53 -0
  77. package/src/lib/drivers.js +280 -0
  78. package/src/lib/errors.js +33 -0
  79. package/src/lib/graphWindows.js +149 -0
  80. package/src/lib/http.js +84 -0
  81. package/src/lib/hubActions.js +74 -0
  82. package/src/lib/hubDbReader.js +132 -0
  83. package/src/lib/hubMapping.js +108 -0
  84. package/src/lib/hubScan.js +611 -0
  85. package/src/lib/hubSnapshot.js +64 -0
  86. package/src/lib/identity.js +102 -0
  87. package/src/lib/intents.js +590 -0
  88. package/src/lib/ledger.js +535 -0
  89. package/src/lib/lifecycle.js +123 -0
  90. package/src/lib/localHub.js +217 -0
  91. package/src/lib/localHubWriter.js +69 -0
  92. package/src/lib/mirror-contract.json +4299 -0
  93. package/src/lib/mirrorContract.js +513 -0
  94. package/src/lib/mirrorDaemon.js +127 -0
  95. package/src/lib/mirrorService.js +365 -0
  96. package/src/lib/mirrorSource.js +430 -0
  97. package/src/lib/mirrorSync.js +353 -0
  98. package/src/lib/nodeFloor.js +19 -0
  99. package/src/lib/oauthLogin.js +132 -0
  100. package/src/lib/outboxFlusher.js +75 -0
  101. package/src/lib/ownerLock.js +99 -0
  102. package/src/lib/pairedConfig.js +40 -0
  103. package/src/lib/pairingLifecycle.js +179 -0
  104. package/src/lib/pathRedaction.js +89 -0
  105. package/src/lib/planContract.js +141 -0
  106. package/src/lib/priorityLane.js +80 -0
  107. package/src/lib/processOwnership.js +252 -0
  108. package/src/lib/processSupervisor.js +376 -0
  109. package/src/lib/reconcile.js +316 -0
  110. package/src/lib/reconcileView.js +129 -0
  111. package/src/lib/redaction.js +128 -0
  112. package/src/lib/rootPolicy.js +372 -0
  113. package/src/lib/rulePacks.js +162 -0
  114. package/src/lib/runnerStore.js +418 -0
  115. package/src/lib/secretStore.js +169 -0
  116. package/src/lib/selfRestart.js +261 -0
  117. package/src/lib/serviceManager.js +419 -0
  118. package/src/lib/shadowService.js +435 -0
  119. package/src/lib/sqlite.js +28 -0
  120. package/src/lib/storage.js +131 -0
  121. package/src/lib/subprocess.js +45 -0
  122. package/src/lib/tokenStore.js +136 -0
  123. package/src/lib/transport.js +334 -0
  124. package/src/lib/transportContract.js +40 -0
  125. package/src/lib/uploadRedactor.js +102 -0
  126. package/src/runner/applier.js +297 -0
  127. package/src/runner/bridge.js +289 -0
  128. package/src/runner/busIdentity.js +499 -0
  129. package/src/runner/capture.js +103 -0
  130. package/src/runner/captureMapping.js +21 -0
  131. package/src/runner/channelDiscipline.js +425 -0
  132. package/src/runner/deliveryStates.js +220 -0
  133. package/src/runner/facade.js +876 -0
  134. package/src/runner/hubCapture.js +160 -0
  135. package/src/runner/lease.js +46 -0
  136. package/src/runner/main.js +35 -0
  137. package/src/runner/render.js +91 -0
  138. package/src/runner/runnerChild.js +57 -0
  139. package/src/runner/runnerHub.js +104 -0
  140. package/src/runner/runnerProcess.js +124 -0
  141. package/src/runner/runnerSchema.js +51 -0
  142. package/src/runner/runnerService.js +140 -0
  143. package/src/runner/spool.js +86 -0
  144. package/src/runner/spoolInto.js +22 -0
  145. package/src/runner/statusPage.js +30 -0
  146. package/LICENSE +0 -21
  147. package/src/commands/pulse/gate.js +0 -25
  148. package/src/commands/pulse/next.js +0 -14
  149. package/src/commands/pulse/submit.js +0 -21
  150. package/src/commands/pulse/verify.js +0 -21
  151. package/src/runPulse.js +0 -58
@@ -0,0 +1,338 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { AgentSessionStore } from './agentSessions.js';
3
+ import { CloudAuth } from './cloudAuth.js';
4
+ import { defaultSecretStore } from './secretStore.js';
5
+ import { HubError } from './errors.js';
6
+ import { createHubActionHandlers } from './hubActions.js';
7
+ import { discoverLocalHub } from './localHub.js';
8
+ import { LocalHubWriter } from './localHubWriter.js';
9
+ import { assertPairedConfig } from './pairedConfig.js';
10
+ import { canonicalJson, validateDeliveryPlan } from './planContract.js';
11
+ import { TokenStore } from './tokenStore.js';
12
+ import { ConnectorTransport } from './transport.js';
13
+ import { validateRootGrant, WorktreeRegistry, createOwnedWorktree } from './rootPolicy.js';
14
+ import { execFileBounded as execFile } from './subprocess.js';
15
+
16
+ export async function createProductionDependencies({
17
+ config,
18
+ configDir,
19
+ secretStore = defaultSecretStore(),
20
+ fetch = globalThis.fetch,
21
+ log = () => {},
22
+ onTransportAttempt = () => {},
23
+ }) {
24
+ assertPairedConfig(config);
25
+ const tokenStore = new TokenStore({ secretStore, configDir });
26
+ const sessionId = randomUUID();
27
+ const worktrees = await WorktreeRegistry.open(configDir);
28
+ const sessions = await AgentSessionStore.open(configDir);
29
+ const roots = new Map((config.localRoots || []).map((root) => [root.rootId, root]));
30
+ let transport;
31
+ let transportKey;
32
+
33
+ const transportFactory = async (currentConfig) => {
34
+ const paired = assertPairedConfig(currentConfig);
35
+ const key = [paired.machineId, paired.policyVersion, paired.baseUrl, paired.userId].join('\0');
36
+ if (transport && transportKey === key) return transport;
37
+ const auth = new CloudAuth({
38
+ tokenStore,
39
+ fetch,
40
+ target: {
41
+ platformUrl: paired.target.platformUrl,
42
+ devOrg: paired.target.devOrg,
43
+ instanceUrl: paired.baseUrl,
44
+ userId: paired.userId,
45
+ teamId: paired.teamId,
46
+ },
47
+ });
48
+ transport = new ConnectorTransport({
49
+ baseUrl: paired.baseUrl,
50
+ machine: { machineId: paired.machineId, sessionId, policyVersion: paired.policyVersion },
51
+ auth,
52
+ fetch,
53
+ onAttempt: onTransportAttempt,
54
+ });
55
+ transportKey = key;
56
+ return transport;
57
+ };
58
+
59
+ const hubActions = createHubActionHandlers({
60
+ writerFor: async () => {
61
+ let hub;
62
+ try {
63
+ hub = await discoverLocalHub({ config });
64
+ } catch (error) {
65
+ throw new HubError('hub_unreachable', error.message);
66
+ }
67
+ return new LocalHubWriter({ ...hub, fetch, log });
68
+ },
69
+ });
70
+
71
+ const handlersFactory = ({ drivers, policy }) => {
72
+ const plannerChoices = Object.entries(policy.providers).flatMap(([provider, models]) =>
73
+ models.map((model) => ({ provider, model }))
74
+ );
75
+ const defaultProvider = plannerChoices.length === 1 ? plannerChoices[0].provider : null;
76
+ const defaultModel = plannerChoices.length === 1 ? plannerChoices[0].model : null;
77
+ const budget = (requested = {}) => ({
78
+ maxTokens: requested.maxTokens || policy.limits.maxTokensPerRun,
79
+ maxCostUsd: requested.maxCostUsd ?? policy.limits.maxCostUsd,
80
+ maxWallMs: requested.maxWallMs || policy.limits.maxWallMs,
81
+ maxOutputBytes: requested.maxOutputBytes || policy.limits.maxOutputBytes,
82
+ });
83
+ const workspaceFor = (rootId, mode, cloudJob = {}, expectedWorktreeId = null) => {
84
+ const root = roots.get(rootId);
85
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
86
+ if (mode === 'read') return root.canonicalRoot;
87
+ const candidates = worktrees
88
+ .list(rootId)
89
+ .filter(
90
+ (record) =>
91
+ record.deliveryId === cloudJob.delivery_id &&
92
+ record.streamId === cloudJob.stream_id
93
+ );
94
+ if (candidates.length !== 1) {
95
+ throw new HubError(
96
+ 'HUB_WRITE_REQUIRES_WORKTREE',
97
+ 'Write-mode agent requires exactly one connector-owned worktree'
98
+ );
99
+ }
100
+ if (expectedWorktreeId && candidates[0].worktreeId !== expectedWorktreeId) {
101
+ throw new HubError(
102
+ 'HUB_AGENT_SESSION_BINDING_MISMATCH',
103
+ 'Continuation worktree does not match its durable local agent binding'
104
+ );
105
+ }
106
+ return candidates[0].canonicalPath;
107
+ };
108
+ return {
109
+ 'delivery.plan': async (job, context) => {
110
+ if (!defaultProvider || !defaultModel) {
111
+ throw new HubError(
112
+ 'HUB_PROVIDER_DENIED',
113
+ 'Planner requires exactly one signed provider/model assignment'
114
+ );
115
+ }
116
+ const rootId = job.arguments.rootIds[0];
117
+ const result = await drivers.driver(defaultProvider, defaultModel).start({
118
+ workspace: workspaceFor(rootId, 'read', context.cloudJob),
119
+ provider: defaultProvider,
120
+ model: defaultModel,
121
+ role: 'architect',
122
+ mode: 'read',
123
+ prompt: `Produce only a bounded JSON delivery plan for this approved outcome:\n${job.arguments.description}`,
124
+ budget: budget(job.arguments.budget),
125
+ attempt: context.attempt,
126
+ onStart: context.onProcessStart,
127
+ signal: context.signal,
128
+ });
129
+ let plan;
130
+ try {
131
+ plan = JSON.parse(result.text);
132
+ } catch {
133
+ throw new HubError('HUB_PROVIDER_OUTPUT_INVALID', 'Planner did not return JSON');
134
+ }
135
+ const validatedPlan = validateDeliveryPlan(plan, grant);
136
+ return {
137
+ plan: validatedPlan,
138
+ planDigest: createHash('sha256').update(canonicalJson(validatedPlan)).digest('hex'),
139
+ };
140
+ },
141
+ 'workspace.inspect': async (job) => {
142
+ const root = roots.get(job.arguments.rootId);
143
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
144
+ await validateRootGrant(root);
145
+ return {
146
+ summary: 'Approved workspace root is available.',
147
+ repo: { rootId: root.rootId, repoWrite: root.repoWrite },
148
+ fileManifestHash: createHash('sha256').update(root.repositoryIdentity).digest('hex'),
149
+ };
150
+ },
151
+ 'worktree.create': async (job, context) => {
152
+ const root = roots.get(job.arguments.rootId);
153
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
154
+ const record = await createOwnedWorktree({
155
+ grant: root,
156
+ deliveryId: context.cloudJob.delivery_id,
157
+ streamId: context.cloudJob.stream_id,
158
+ baseRef: root.baseRef,
159
+ registry: worktrees,
160
+ });
161
+ return {
162
+ summary: 'Connector-owned worktree created.',
163
+ branch: record.branch,
164
+ worktreeRef: record.worktreeId,
165
+ repoHash: root.repositoryIdentity,
166
+ };
167
+ },
168
+ 'git.inspect': async (job) => {
169
+ const root = roots.get(job.arguments.rootId);
170
+ if (!root) throw new HubError('HUB_CAPABILITY_DENIED', 'Root is not locally approved');
171
+ await validateRootGrant(root);
172
+ const env = {
173
+ PATH: process.env.PATH || '/usr/bin:/bin',
174
+ LC_ALL: 'C',
175
+ GIT_CONFIG_GLOBAL: '/dev/null',
176
+ GIT_CONFIG_SYSTEM: '/dev/null',
177
+ GIT_TERMINAL_PROMPT: '0',
178
+ };
179
+ const status = await execFile(
180
+ 'git',
181
+ ['status', '--porcelain=v1', '--untracked-files=no'],
182
+ { cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 512 * 1024 }
183
+ );
184
+ let refSha = null;
185
+ let diffSummary = '';
186
+ if (job.arguments.ref) {
187
+ const resolved = await execFile(
188
+ 'git',
189
+ ['rev-parse', '--verify', '--end-of-options', `${job.arguments.ref}^{commit}`],
190
+ { cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 64 * 1024 }
191
+ );
192
+ refSha = resolved.stdout.trim();
193
+ if (job.arguments.includeDiffSummary) {
194
+ const diff = await execFile(
195
+ 'git',
196
+ ['diff', '--stat', '--no-ext-diff', `${refSha}..HEAD`],
197
+ { cwd: root.canonicalRoot, env, timeout: 30_000, maxBuffer: 512 * 1024 }
198
+ );
199
+ diffSummary = diff.stdout;
200
+ }
201
+ }
202
+ return {
203
+ summary: 'Git metadata inspected under the approved root.',
204
+ status: {
205
+ clean: status.stdout.length === 0,
206
+ changedEntries: status.stdout.trim() ? status.stdout.trim().split('\n').length : 0,
207
+ refSha,
208
+ },
209
+ diffSummaryHash: createHash('sha256').update(diffSummary).digest('hex'),
210
+ };
211
+ },
212
+ 'agent.start': async (job, context) => {
213
+ const args = job.arguments;
214
+ const mode = args.role === 'coder' ? 'write' : 'read';
215
+ const result = await drivers.driver(args.provider, args.model).start({
216
+ workspace: workspaceFor(args.rootId, mode, context.cloudJob),
217
+ model: args.model,
218
+ role: args.role,
219
+ mode,
220
+ prompt: `Execute this approved structured intent only:\n${JSON.stringify(args.intent)}`,
221
+ budget: budget(args.budget),
222
+ attempt: context.attempt,
223
+ onStart: context.onProcessStart,
224
+ signal: context.signal,
225
+ });
226
+ await sessions.recordPendingStart(context.cloudJob.id, {
227
+ agentId: args.agentId,
228
+ provider: args.provider,
229
+ model: args.model,
230
+ role: args.role,
231
+ mode,
232
+ rootId: args.rootId,
233
+ deliveryId: context.cloudJob.delivery_id,
234
+ streamId: context.cloudJob.stream_id,
235
+ worktreeId:
236
+ mode === 'write'
237
+ ? worktrees
238
+ .list(args.rootId)
239
+ .find(
240
+ (record) =>
241
+ record.deliveryId === context.cloudJob.delivery_id &&
242
+ record.streamId === context.cloudJob.stream_id
243
+ )?.worktreeId
244
+ : null,
245
+ providerSessionId: result.sessionId,
246
+ processIdentity: result.processIdentity,
247
+ state: 'active',
248
+ });
249
+ return {
250
+ summary: drivers.driver(args.provider, args.model).collectSummary(result).text,
251
+ agentSessionRefHash: createHash('sha256')
252
+ .update(String(result.sessionId))
253
+ .digest('hex'),
254
+ };
255
+ },
256
+ 'agent.turn': async (job, context) => {
257
+ const args = job.arguments;
258
+ const session = context.sessionBinding?.session;
259
+ if (!session) throw new HubError('HUB_AGENT_SESSION_MISSING', 'Agent session is not local');
260
+ await sessions.put(args.agentSessionId, session);
261
+ const result = await drivers.driver(session.provider, session.model).send({
262
+ workspace: workspaceFor(
263
+ args.rootId,
264
+ session.mode,
265
+ context.cloudJob,
266
+ session.worktreeId
267
+ ),
268
+ model: session.model,
269
+ role: session.role,
270
+ mode: session.mode,
271
+ sessionId: session.providerSessionId,
272
+ prompt: `Execute this approved structured intent only:\n${JSON.stringify(args.intent)}`,
273
+ budget: budget(args.budget),
274
+ attempt: context.attempt,
275
+ onStart: context.onProcessStart,
276
+ signal: context.signal,
277
+ });
278
+ await sessions.put(args.agentSessionId, {
279
+ ...session,
280
+ providerSessionId: result.sessionId,
281
+ processIdentity: result.processIdentity,
282
+ });
283
+ const summary = drivers.driver(session.provider, session.model).collectSummary(result);
284
+ return {
285
+ summary: summary.text,
286
+ usage: result.usage,
287
+ evidenceIds: [],
288
+ needsUser: false,
289
+ };
290
+ },
291
+ 'agent.stop': async (job, context) => {
292
+ const args = job.arguments;
293
+ const session = context.sessionBinding?.session;
294
+ if (!session) throw new HubError('HUB_AGENT_SESSION_MISSING', 'Agent session is not local');
295
+ await sessions.put(args.agentSessionId, session);
296
+ if (session.processIdentity) {
297
+ const outcome = await drivers
298
+ .driver(session.provider, session.model)
299
+ .cancel(session.processIdentity);
300
+ if (!outcome.verifiedStopped) {
301
+ throw new HubError('HUB_CANCELLATION_UNPROVEN', 'Agent process did not stop');
302
+ }
303
+ }
304
+ await sessions.put(args.agentSessionId, { ...session, state: 'stopped' });
305
+ return { summary: args.reason, stopped: true };
306
+ },
307
+ 'evidence.collect': async (job) => ({
308
+ summary: 'Evidence remains local until explicitly approved for upload.',
309
+ artifacts: job.arguments.kinds.map((kind) => ({
310
+ kind,
311
+ label: `Local ${kind.replaceAll('_', ' ')}`,
312
+ mimeType: 'application/octet-stream',
313
+ byteSize: 0,
314
+ sha256: createHash('sha256').update('').digest('hex'),
315
+ storageMode: 'local_only',
316
+ redactionVersion: 1,
317
+ userVisible: true,
318
+ })),
319
+ }),
320
+ ...hubActions,
321
+ };
322
+ };
323
+
324
+ const sessionResolver = (job) => sessions.resolveContinuation(job);
325
+ const settlementObserver = (attempt, response, error = null) => {
326
+ if (attempt.jobType !== 'agent.start') return null;
327
+ if (error) return sessions.rejectStartSettlement(attempt.jobId);
328
+ return sessions.applyStartAcknowledgement(attempt.jobId, response);
329
+ };
330
+
331
+ return {
332
+ tokenStore,
333
+ transportFactory,
334
+ handlersFactory,
335
+ sessionResolver,
336
+ settlementObserver,
337
+ };
338
+ }
@@ -0,0 +1,57 @@
1
+ import path from 'node:path';
2
+ import { HubError } from './errors.js';
3
+ import { acquireOwnerLock } from './ownerLock.js';
4
+ import { CONNECTOR_VERSION } from './pairingLifecycle.js';
5
+ import { ProcessIdentityProvider } from './processOwnership.js';
6
+ import { ensureOwnerOnlyDirectory, writeOwnerOnlyJson } from './storage.js';
7
+
8
+ export class DaemonLock {
9
+ constructor({
10
+ directory,
11
+ pid = process.pid,
12
+ identityProvider = new ProcessIdentityProvider(),
13
+ }) {
14
+ this.directory = directory;
15
+ this.pid = pid;
16
+ this.identityProvider = identityProvider;
17
+ this.pidFile = path.join(directory, 'daemon.pid');
18
+ this.healthFile = path.join(directory, 'daemon-health.json');
19
+ this.acquired = false;
20
+ }
21
+
22
+ async acquire() {
23
+ await ensureOwnerOnlyDirectory(this.directory);
24
+ this.releaseLock = await acquireOwnerLock({
25
+ file: this.pidFile,
26
+ identity: await this.identityProvider.capture(this.pid),
27
+ identityProvider: this.identityProvider,
28
+ heldError: (owner) => new HubError(
29
+ 'HUB_DAEMON_ALREADY_RUNNING',
30
+ `Pulse daemon is already running${owner?.pid ? ` with pid ${owner.pid}` : ''}`
31
+ ),
32
+ });
33
+ this.acquired = true;
34
+ return { pid: this.pid };
35
+ }
36
+
37
+ heartbeat(status) {
38
+ if (!this.acquired) {
39
+ throw new HubError('HUB_DAEMON_NOT_OWNER', 'Daemon lock is not held by this process');
40
+ }
41
+ return writeOwnerOnlyJson(this.healthFile, {
42
+ pid: this.pid,
43
+ heartbeatAt: new Date().toISOString(),
44
+ ...status,
45
+ connectorVersion: CONNECTOR_VERSION,
46
+ });
47
+ }
48
+
49
+ async release() {
50
+ if (!this.acquired) return;
51
+ try {
52
+ await this.releaseLock();
53
+ } finally {
54
+ this.acquired = false;
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,72 @@
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, shadow = null, runner = null, 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
+ const usable = !(TERMINAL_STATES.has(status.state) || status.code === 'contract_unsupported');
60
+ await mirror.update(usable ? config : null);
61
+ shadow?.update(usable ? config : null);
62
+ runner?.update(usable ? config : null);
63
+ progress('health');
64
+ last = { config, status };
65
+ await write();
66
+ return status;
67
+ };
68
+ /** Writes health again from the last cycle, with status fields overridden, while a cycle is still running. */
69
+ heartbeat.republish = write;
70
+ heartbeat.writtenAt = 0;
71
+ return heartbeat;
72
+ }