@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,535 @@
1
+ import path from 'node:path';
2
+ import { ensureOwnerOnlyDirectory, readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
3
+ import { HubError } from './errors.js';
4
+ import { HUB_JOB_TYPES } from './intents.js';
5
+ import { redactSecretsDeep } from './redaction.js';
6
+
7
+ const STATE_FILE = 'ledger.json';
8
+ const SCHEMA_VERSION = 1;
9
+ const TERMINAL_STATES = new Set(['cancelled', 'succeeded', 'failed', 'fenced']);
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
+ const TRANSITIONS = Object.freeze({
12
+ leased: new Set(['running', 'cancel_requested', 'cancelled', 'succeeded', 'failed', 'fenced']),
13
+ running: new Set(['cancel_requested', 'cancelled', 'succeeded', 'failed', 'fenced']),
14
+ cancel_requested: new Set(['cancelled', 'succeeded', 'failed', 'fenced']),
15
+ cancelled: new Set(),
16
+ succeeded: new Set(),
17
+ failed: new Set(),
18
+ fenced: new Set(),
19
+ });
20
+ const MAX_OUTBOX_EVENTS = 10_000;
21
+ const MAX_LEDGER_BYTES = 16 * 1024 * 1024;
22
+ const EVENT_TYPES = new Set([
23
+ 'agent.started',
24
+ 'agent.progress',
25
+ 'agent.waiting',
26
+ 'agent.completed',
27
+ 'agent.failed',
28
+ 'job.started',
29
+ 'job.progress',
30
+ 'job.cancel_acknowledged',
31
+ 'job.cancelled',
32
+ 'job.succeeded',
33
+ 'job.failed',
34
+ 'evidence.available',
35
+ 'content_withheld',
36
+ ]);
37
+ const JOB_EVENT_TYPES = new Map([
38
+ ['delivery.plan', new Set(['job.started', 'job.progress', 'job.succeeded', 'job.failed', 'content_withheld'])],
39
+ ['workspace.inspect', new Set(['job.started', 'job.progress', 'job.cancel_acknowledged', 'job.cancelled', 'job.succeeded', 'job.failed', 'content_withheld'])],
40
+ ['worktree.create', new Set(['job.started', 'job.progress', 'job.cancel_acknowledged', 'job.cancelled', 'job.succeeded', 'job.failed', 'content_withheld'])],
41
+ ['git.inspect', new Set(['job.started', 'job.progress', 'job.cancel_acknowledged', 'job.cancelled', 'job.succeeded', 'job.failed', 'content_withheld'])],
42
+ ['agent.start', EVENT_TYPES],
43
+ ['agent.turn', EVENT_TYPES],
44
+ ['agent.stop', EVENT_TYPES],
45
+ ['evidence.collect', new Set(['job.started', 'job.progress', 'job.cancel_acknowledged', 'job.cancelled', 'job.succeeded', 'job.failed', 'evidence.available', 'content_withheld'])],
46
+ ]);
47
+ for (const type of HUB_JOB_TYPES) JOB_EVENT_TYPES.set(type, new Set(['job.started', 'job.succeeded', 'job.failed']));
48
+ const PROCESS_JOB_TYPES = new Set(['agent.start', 'agent.turn']);
49
+ // The cloud requeues a hub action whose lease lapsed. Its new attempt replaces the old one here and keeps it as `prior`,
50
+ // so the handler can tell whether the hub may already have taken the write (startedAt set) and never post twice.
51
+ const RECLAIMABLE_JOB_TYPES = new Set(HUB_JOB_TYPES);
52
+
53
+ function initialState() {
54
+ return {
55
+ schemaVersion: SCHEMA_VERSION,
56
+ nextSequence: 1,
57
+ cloudCursor: 0,
58
+ localCursor: 0,
59
+ acceptedLocalCursor: 0,
60
+ outbox: [],
61
+ attempts: {},
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The highest local sequence the cloud has accepted. localCursor counts every event queued, uploaded or not, and the
67
+ * cloud refuses a session whose cursor is ahead of what it holds (event_cursor_invalid), so only this is reported.
68
+ * A ledger written before the field existed is read as everything below its oldest pending event.
69
+ */
70
+ function acceptedLocalCursor(state) {
71
+ if (Number.isSafeInteger(state.acceptedLocalCursor)) return state.acceptedLocalCursor;
72
+ return state.outbox.length ? Math.min(...state.outbox.map((event) => event.sequence)) - 1 : state.localCursor;
73
+ }
74
+
75
+ function clone(value) {
76
+ return structuredClone(value);
77
+ }
78
+
79
+ function validateTuple(tuple) {
80
+ if (!UUID.test(String(tuple.jobId || ''))) {
81
+ throw new HubError('HUB_ATTEMPT_INVALID', 'jobId must be a UUID');
82
+ }
83
+ if (!Number.isInteger(tuple.attemptNo) || tuple.attemptNo < 1) {
84
+ throw new HubError('HUB_ATTEMPT_INVALID', 'attemptNo must be positive');
85
+ }
86
+ if (!Number.isSafeInteger(tuple.fenceToken) || tuple.fenceToken < 1) {
87
+ throw new HubError('HUB_ATTEMPT_INVALID', 'fenceToken must be positive');
88
+ }
89
+ }
90
+
91
+ function validateEvent(event, jobType = event.jobType) {
92
+ if (
93
+ typeof event.sourceEventId !== 'string' ||
94
+ event.sourceEventId.length < 1 ||
95
+ event.sourceEventId.length > 128
96
+ ) {
97
+ throw new HubError('HUB_EVENT_INVALID', 'sourceEventId is invalid');
98
+ }
99
+ if (!EVENT_TYPES.has(event.type)) {
100
+ throw new HubError('HUB_EVENT_INVALID', `Unsupported cloud event type: ${event.type}`);
101
+ }
102
+ if (!JOB_EVENT_TYPES.get(jobType)?.has(event.type)) {
103
+ throw new HubError('HUB_EVENT_INVALID', `Event ${event.type} is not allowed for ${jobType}`);
104
+ }
105
+ if (Buffer.byteLength(JSON.stringify(event), 'utf8') > 32 * 1024) {
106
+ throw new HubError('HUB_EVENT_TOO_LARGE', 'Event exceeds 32 KiB');
107
+ }
108
+ }
109
+
110
+ function addEvent(state, event, jobType) {
111
+ validateEvent(event, jobType);
112
+ const existing = state.outbox.find((item) => item.sourceEventId === event.sourceEventId);
113
+ if (existing) return existing;
114
+ if (state.outbox.length >= MAX_OUTBOX_EVENTS) {
115
+ throw new HubError('HUB_OUTBOX_FULL', 'Durable outbox reached its local safety limit');
116
+ }
117
+ const payload = event.payload === undefined ? null : redactSecretsDeep(event.payload);
118
+ const record = {
119
+ ...clone(event),
120
+ ...(payload === null ? {} : { payload: payload.withheld ? { contentWithheld: true, redactionVersion: 1 } : payload.value }),
121
+ sequence: state.nextSequence,
122
+ createdAt: new Date().toISOString(),
123
+ };
124
+ state.nextSequence += 1;
125
+ state.localCursor = record.sequence;
126
+ state.outbox.push(record);
127
+ return record;
128
+ }
129
+
130
+ export class DurableLedger {
131
+ #queue = Promise.resolve();
132
+
133
+ static async open(directory, { write = writeOwnerOnlyJson } = {}) {
134
+ await ensureOwnerOnlyDirectory(directory);
135
+ const file = path.join(directory, STATE_FILE);
136
+ const data = await readOwnerOnlyJson(file, initialState());
137
+ if (data.schemaVersion !== SCHEMA_VERSION) {
138
+ throw new HubError('HUB_LEDGER_VERSION_UNSUPPORTED', `Unsupported ledger version: ${data.schemaVersion}`);
139
+ }
140
+ if (
141
+ !Array.isArray(data.outbox) ||
142
+ !data.attempts ||
143
+ typeof data.attempts !== 'object' ||
144
+ Buffer.byteLength(JSON.stringify(data), 'utf8') > MAX_LEDGER_BYTES
145
+ ) {
146
+ throw new HubError('HUB_LEDGER_INVALID', 'Ledger shape or size is invalid');
147
+ }
148
+ if (
149
+ data.outbox.length > MAX_OUTBOX_EVENTS ||
150
+ !Number.isSafeInteger(data.nextSequence) ||
151
+ data.nextSequence < 1
152
+ ) {
153
+ throw new HubError('HUB_LEDGER_INVALID', 'Ledger counters or outbox are invalid');
154
+ }
155
+ for (const event of data.outbox) validateEvent(event, event.jobType);
156
+ data.acceptedLocalCursor = acceptedLocalCursor(data);
157
+ if (data.acceptedLocalCursor < 0 || data.acceptedLocalCursor > data.localCursor) {
158
+ throw new HubError('HUB_LEDGER_INVALID', 'Ledger accepted event cursor is invalid');
159
+ }
160
+ for (const [jobId, attempt] of Object.entries(data.attempts)) {
161
+ validateTuple(attempt);
162
+ if (
163
+ attempt.jobId !== jobId ||
164
+ !['read', 'write'].includes(attempt.mode) ||
165
+ !TRANSITIONS[attempt.state] ||
166
+ !JOB_EVENT_TYPES.has(attempt.jobType)
167
+ ) {
168
+ throw new HubError('HUB_LEDGER_INVALID', 'Persisted attempt is invalid');
169
+ }
170
+ }
171
+ const ledger = new DurableLedger(file, data, write);
172
+ if (!await readOwnerOnlyJson(file)) await ledger.#save();
173
+ return ledger;
174
+ }
175
+
176
+ constructor(file, data, write = writeOwnerOnlyJson) {
177
+ this.file = file;
178
+ this.data = data;
179
+ this.write = write;
180
+ }
181
+
182
+ snapshot() {
183
+ return clone(this.data);
184
+ }
185
+
186
+ attempt(jobId) {
187
+ const attempt = this.data.attempts[jobId];
188
+ return attempt ? clone(attempt) : null;
189
+ }
190
+
191
+ attemptForTuple(tuple) {
192
+ validateTuple(tuple);
193
+ const attempt = this.data.attempts[tuple.jobId];
194
+ if (
195
+ !attempt ||
196
+ attempt.attemptNo !== tuple.attemptNo ||
197
+ attempt.fenceToken !== tuple.fenceToken
198
+ ) {
199
+ throw new HubError('HUB_STALE_FENCE', `Fence is stale for ${tuple.jobId}`);
200
+ }
201
+ return clone(attempt);
202
+ }
203
+
204
+ async #save(data = this.data) {
205
+ await this.write(this.file, data);
206
+ }
207
+
208
+ async #mutate(operation) {
209
+ const pending = this.#queue.then(async () => {
210
+ const draft = clone(this.data);
211
+ const result = operation(draft);
212
+ await this.#save(draft);
213
+ this.data = draft;
214
+ return clone(result);
215
+ });
216
+ this.#queue = pending.catch(() => {});
217
+ return pending;
218
+ }
219
+
220
+ async enqueueEvent(event) {
221
+ return this.#mutate((state) => addEvent(state, event));
222
+ }
223
+
224
+ async pendingEvents(limit = 100) {
225
+ await this.#queue;
226
+ return clone(this.data.outbox.slice(0, Math.max(0, Math.min(100, limit))));
227
+ }
228
+
229
+ async acknowledgeEvents(acknowledgements) {
230
+ return this.#mutate((state) => {
231
+ if (!Array.isArray(acknowledgements) || acknowledgements.length > 100) {
232
+ throw new HubError('HUB_EVENT_ACK_INVALID', 'Event acknowledgements are invalid');
233
+ }
234
+ const pendingIds = new Set(state.outbox.map((event) => event.sourceEventId));
235
+ const seen = new Set();
236
+ for (const item of acknowledgements) {
237
+ if (
238
+ !pendingIds.has(item.sourceEventId) ||
239
+ seen.has(item.sourceEventId) ||
240
+ !Number.isSafeInteger(item.cloudCursor) ||
241
+ item.cloudCursor < 1
242
+ ) {
243
+ throw new HubError('HUB_EVENT_ACK_INVALID', 'Event acknowledgement does not match the sent batch');
244
+ }
245
+ seen.add(item.sourceEventId);
246
+ }
247
+ const accepted = new Map(
248
+ acknowledgements.map((item) => [item.sourceEventId, Number(item.cloudCursor)])
249
+ );
250
+ for (const event of state.outbox) {
251
+ if (!accepted.has(event.sourceEventId) || !TERMINAL_STATES.has(event.terminalState)) continue;
252
+ const attempt = state.attempts[event.jobId];
253
+ if (attempt && attempt.state === event.terminalState) attempt.terminalUploaded = true;
254
+ }
255
+ for (const event of state.outbox) {
256
+ if (accepted.has(event.sourceEventId)) state.acceptedLocalCursor = Math.max(acceptedLocalCursor(state), event.sequence);
257
+ }
258
+ state.outbox = state.outbox.filter((item) => !accepted.has(item.sourceEventId));
259
+ for (const cursor of accepted.values()) {
260
+ if (Number.isFinite(cursor)) state.cloudCursor = Math.max(state.cloudCursor, cursor);
261
+ }
262
+ return { cloudCursor: state.cloudCursor, pending: state.outbox.length };
263
+ });
264
+ }
265
+
266
+ async claimAttempt(record) {
267
+ validateTuple(record);
268
+ if (!JOB_EVENT_TYPES.has(record.jobType)) {
269
+ throw new HubError('HUB_ATTEMPT_INVALID', 'jobType is invalid');
270
+ }
271
+ if (!['read', 'write'].includes(record.mode)) {
272
+ throw new HubError('HUB_ATTEMPT_INVALID', 'Attempt mode must be read or write');
273
+ }
274
+ return this.#mutate((state) => {
275
+ const existing = state.attempts[record.jobId];
276
+ if (
277
+ existing &&
278
+ existing.attemptNo === record.attemptNo &&
279
+ existing.fenceToken === record.fenceToken
280
+ ) {
281
+ return existing;
282
+ }
283
+ const reclaimable = existing && RECLAIMABLE_JOB_TYPES.has(record.jobType) && existing.jobType === record.jobType;
284
+ // An attempt that never started says nothing about the hub, so the older attempt it replaced stays the witness.
285
+ const prior = !reclaimable
286
+ ? null
287
+ : !existing.startedAt && !['succeeded', 'failed'].includes(existing.state) && existing.prior
288
+ ? existing.prior
289
+ : {
290
+ attemptNo: existing.attemptNo,
291
+ state: existing.state,
292
+ startedAt: existing.startedAt || null,
293
+ result: clone(existing.result),
294
+ };
295
+ if (existing && !prior && TERMINAL_STATES.has(existing.state)) {
296
+ throw new HubError('HUB_ATTEMPT_TERMINAL', `Job ${record.jobId} is already ${existing.state}`);
297
+ }
298
+ if (existing && !prior) {
299
+ throw new HubError(
300
+ existing.mode === 'write'
301
+ ? 'HUB_WRITE_RECONCILIATION_REQUIRED'
302
+ : 'HUB_ATTEMPT_RECONCILIATION_REQUIRED',
303
+ `Attempt ${record.jobId} must be reconciled before replacement`
304
+ );
305
+ }
306
+ const attempt = {
307
+ jobId: record.jobId,
308
+ attemptNo: record.attemptNo,
309
+ fenceToken: record.fenceToken,
310
+ mode: record.mode,
311
+ jobType: record.jobType,
312
+ state: 'leased',
313
+ processRef: null,
314
+ cancelRequestedAt: null,
315
+ terminalRace: null,
316
+ result: null,
317
+ resultDigest: null,
318
+ terminalUploaded: false,
319
+ cloudSettled: false,
320
+ idempotencyKey: record.idempotencyKey || null,
321
+ updatedAt: new Date().toISOString(),
322
+ ...(prior ? { prior } : {}),
323
+ };
324
+ state.attempts[record.jobId] = attempt;
325
+ return attempt;
326
+ });
327
+ }
328
+
329
+ async transitionAttempt(tuple, transition) {
330
+ validateTuple(tuple);
331
+ return this.#mutate((state) => {
332
+ const existing = state.attempts[tuple.jobId];
333
+ if (
334
+ !existing ||
335
+ existing.attemptNo !== tuple.attemptNo ||
336
+ existing.fenceToken !== tuple.fenceToken
337
+ ) {
338
+ throw new HubError('HUB_STALE_FENCE', `Fence is stale for ${tuple.jobId}`);
339
+ }
340
+ if (TERMINAL_STATES.has(existing.state)) {
341
+ if (
342
+ transition.state === existing.state &&
343
+ transition.resultDigest &&
344
+ transition.resultDigest === existing.resultDigest
345
+ ) {
346
+ return existing;
347
+ }
348
+ throw new HubError('HUB_ATTEMPT_TERMINAL', `Attempt is already ${existing.state}`);
349
+ }
350
+ if (!TRANSITIONS[existing.state]?.has(transition.state)) {
351
+ throw new HubError(
352
+ 'HUB_ATTEMPT_TRANSITION_INVALID',
353
+ `Transition ${existing.state} -> ${transition.state} is not allowed`
354
+ );
355
+ }
356
+ if (transition.state === 'running' && PROCESS_JOB_TYPES.has(existing.jobType)) {
357
+ const identity = transition.processIdentity;
358
+ if (
359
+ !identity ||
360
+ identity.jobId !== existing.jobId ||
361
+ identity.attemptNo !== existing.attemptNo ||
362
+ identity.fenceToken !== existing.fenceToken ||
363
+ !Number.isInteger(identity.pid) ||
364
+ identity.pgid !== identity.pid ||
365
+ typeof identity.bootId !== 'string' ||
366
+ typeof identity.startToken !== 'string'
367
+ ) {
368
+ throw new HubError(
369
+ 'HUB_PROCESS_OWNERSHIP_REQUIRED',
370
+ 'Process-backed attempts require an exact durable process identity'
371
+ );
372
+ }
373
+ }
374
+ if (transition.processIdentity !== undefined) {
375
+ if (existing.processIdentity && JSON.stringify(existing.processIdentity) !== JSON.stringify(transition.processIdentity)) {
376
+ throw new HubError('HUB_PROCESS_IDENTITY_IMMUTABLE', 'Process identity is immutable');
377
+ }
378
+ existing.processIdentity = clone(transition.processIdentity);
379
+ }
380
+ existing.state = transition.state;
381
+ if (transition.state === 'running') existing.startedAt = new Date().toISOString();
382
+ if (transition.cancelRequestedAt !== undefined) {
383
+ existing.cancelRequestedAt = transition.cancelRequestedAt;
384
+ }
385
+ if (transition.terminalRace !== undefined) existing.terminalRace = transition.terminalRace;
386
+ if (transition.result !== undefined) {
387
+ if (Buffer.byteLength(JSON.stringify(transition.result), 'utf8') > 64 * 1024) {
388
+ throw new HubError('HUB_RESULT_TOO_LARGE', 'Attempt result exceeds 64 KiB');
389
+ }
390
+ existing.result = clone(transition.result);
391
+ }
392
+ if (TERMINAL_STATES.has(transition.state)) {
393
+ if (!/^[0-9a-f]{64}$/.test(String(transition.resultDigest || ''))) {
394
+ throw new HubError('HUB_RESULT_DIGEST_INVALID', 'Terminal transition requires a result digest');
395
+ }
396
+ existing.resultDigest = transition.resultDigest;
397
+ existing.terminalUploaded = transition.state === 'fenced';
398
+ existing.cloudSettled = transition.state === 'fenced';
399
+ existing.settlement = transition.state === 'fenced'
400
+ ? null
401
+ : {
402
+ attemptNo: existing.attemptNo,
403
+ fenceToken: existing.fenceToken,
404
+ state: transition.state,
405
+ resultDigest: transition.resultDigest,
406
+ idempotencyKey: existing.idempotencyKey,
407
+ ...(transition.state === 'succeeded' ? { result: clone(existing.result) } : {}),
408
+ ...(transition.state === 'failed'
409
+ ? {
410
+ errorCode: existing.result?.code || 'HUB_JOB_FAILED',
411
+ errorSummary: existing.result?.summary || 'Local job failed.',
412
+ }
413
+ : {}),
414
+ };
415
+ }
416
+ existing.updatedAt = new Date().toISOString();
417
+ if (transition.event) {
418
+ addEvent(state, {
419
+ ...transition.event,
420
+ jobType: existing.jobType,
421
+ jobId: tuple.jobId,
422
+ terminalState: TERMINAL_STATES.has(transition.state) ? transition.state : undefined,
423
+ }, existing.jobType);
424
+ }
425
+ return existing;
426
+ });
427
+ }
428
+
429
+ recover() {
430
+ return Object.values(this.data.attempts)
431
+ .filter(
432
+ (attempt) =>
433
+ !attempt.settleFailed &&
434
+ (!TERMINAL_STATES.has(attempt.state) || !attempt.terminalUploaded || !attempt.cloudSettled)
435
+ )
436
+ .map((attempt) => ({
437
+ jobId: attempt.jobId,
438
+ attemptNo: attempt.attemptNo,
439
+ fenceToken: attempt.fenceToken,
440
+ disposition: TERMINAL_STATES.has(attempt.state)
441
+ ? 'upload_terminal'
442
+ : attempt.mode === 'write'
443
+ ? 'reconcile_write'
444
+ : 'reconnect_read',
445
+ }))
446
+ .sort((left, right) => left.jobId.localeCompare(right.jobId));
447
+ }
448
+
449
+ async markSettled(tuple, resultDigest) {
450
+ validateTuple(tuple);
451
+ return this.#mutate((state) => {
452
+ const attempt = state.attempts[tuple.jobId];
453
+ if (
454
+ !attempt ||
455
+ attempt.attemptNo !== tuple.attemptNo ||
456
+ attempt.fenceToken !== tuple.fenceToken ||
457
+ !TERMINAL_STATES.has(attempt.state) ||
458
+ attempt.resultDigest !== resultDigest
459
+ ) {
460
+ throw new HubError('HUB_STALE_FENCE', 'Settlement does not match the durable terminal attempt');
461
+ }
462
+ attempt.cloudSettled = true;
463
+ return attempt;
464
+ });
465
+ }
466
+
467
+ /**
468
+ * Drop a job's pending events the cloud refused for good (its fence is gone or the batch is invalid). They never
469
+ * count toward acceptedLocalCursor, and a terminal attempt has nothing left to upload.
470
+ */
471
+ async discardEvents(jobId, code) {
472
+ return this.#mutate((state) => {
473
+ const discarded = state.outbox.filter((event) => event.jobId === jobId).length;
474
+ state.outbox = state.outbox.filter((event) => event.jobId !== jobId);
475
+ const attempt = state.attempts[jobId];
476
+ if (attempt && discarded) {
477
+ attempt.eventsDiscarded = { count: discarded, code, at: new Date().toISOString() };
478
+ if (TERMINAL_STATES.has(attempt.state)) attempt.terminalUploaded = true;
479
+ }
480
+ return { discarded };
481
+ });
482
+ }
483
+
484
+ /**
485
+ * Give up settling a terminal attempt the cloud keeps refusing. It leaves reconnect and recovery so it cannot hold up
486
+ * other work; the cloud lapses the job into connector_no_result.
487
+ */
488
+ async markSettleFailed(tuple, code) {
489
+ validateTuple(tuple);
490
+ return this.#mutate((state) => {
491
+ const attempt = state.attempts[tuple.jobId];
492
+ if (
493
+ !attempt ||
494
+ attempt.attemptNo !== tuple.attemptNo ||
495
+ attempt.fenceToken !== tuple.fenceToken ||
496
+ !TERMINAL_STATES.has(attempt.state)
497
+ ) {
498
+ throw new HubError('HUB_STALE_FENCE', 'Settle failure does not match the terminal attempt');
499
+ }
500
+ attempt.settleFailed = { code, at: new Date().toISOString() };
501
+ attempt.updatedAt = attempt.settleFailed.at;
502
+ state.outbox = state.outbox.filter((event) => event.jobId !== tuple.jobId);
503
+ return attempt;
504
+ });
505
+ }
506
+
507
+ /** Attempts whose settlement Pulse gave up on, newest first, for the health file. */
508
+ settleFailed() {
509
+ return Object.values(this.data.attempts)
510
+ .filter((attempt) => attempt.settleFailed)
511
+ .map((attempt) => ({ jobId: attempt.jobId, jobType: attempt.jobType, attemptNo: attempt.attemptNo, ...attempt.settleFailed }))
512
+ .sort((left, right) => right.at.localeCompare(left.at));
513
+ }
514
+
515
+ async markReconciled(tuple, code) {
516
+ validateTuple(tuple);
517
+ return this.#mutate((state) => {
518
+ const attempt = state.attempts[tuple.jobId];
519
+ if (
520
+ !attempt ||
521
+ attempt.attemptNo !== tuple.attemptNo ||
522
+ attempt.fenceToken !== tuple.fenceToken ||
523
+ !TERMINAL_STATES.has(attempt.state)
524
+ ) {
525
+ throw new HubError('HUB_STALE_FENCE', 'Reconciliation does not match the terminal attempt');
526
+ }
527
+ attempt.cloudSettled = true;
528
+ attempt.terminalUploaded = true;
529
+ attempt.reconciliationCode = code;
530
+ attempt.settlement = null;
531
+ attempt.updatedAt = new Date().toISOString();
532
+ return attempt;
533
+ });
534
+ }
535
+ }
@@ -0,0 +1,123 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { HubError } from './errors.js';
3
+ import { validateJobResult } from './intents.js';
4
+ import { redactText } from './redaction.js';
5
+
6
+ function eventId(tuple, type) {
7
+ return createHash('sha256')
8
+ .update(`${tuple.jobId}\0${tuple.attemptNo}\0${tuple.fenceToken}\0${type}`)
9
+ .digest('base64url');
10
+ }
11
+
12
+ export class AttemptCoordinator {
13
+ constructor({ ledger, supervisor = null }) {
14
+ this.ledger = ledger;
15
+ this.supervisor = supervisor;
16
+ }
17
+
18
+ #event(tuple, type, payload = {}) {
19
+ return {
20
+ sourceEventId: eventId(tuple, type),
21
+ type,
22
+ payload: {
23
+ jobId: tuple.jobId,
24
+ attemptNo: tuple.attemptNo,
25
+ fenceToken: tuple.fenceToken,
26
+ ...payload,
27
+ },
28
+ };
29
+ }
30
+
31
+ #resultDigest(result) {
32
+ return createHash('sha256').update(JSON.stringify(result ?? null)).digest('hex');
33
+ }
34
+
35
+ async claim(tuple) {
36
+ return this.ledger.claimAttempt(tuple);
37
+ }
38
+
39
+ async started(tuple) {
40
+ return this.ledger.transitionAttempt(tuple, {
41
+ state: 'running',
42
+ processIdentity: tuple.processIdentity,
43
+ event: this.#event(tuple, 'job.started'),
44
+ });
45
+ }
46
+
47
+ async requestCancel(tuple) {
48
+ return this.ledger.transitionAttempt(tuple, {
49
+ state: 'cancel_requested',
50
+ cancelRequestedAt: new Date().toISOString(),
51
+ event: this.#event(tuple, 'job.cancel_acknowledged'),
52
+ });
53
+ }
54
+
55
+ async cancel(tuple) {
56
+ let attempt = this.ledger.attemptForTuple(tuple);
57
+ if (attempt.state !== 'cancel_requested') {
58
+ attempt = await this.requestCancel(tuple);
59
+ }
60
+ const processBacked = ['agent.start', 'agent.turn'].includes(attempt.jobType);
61
+ if (processBacked && (!attempt.processIdentity || !this.supervisor)) {
62
+ throw new HubError('HUB_CANCELLATION_UNPROVEN', 'Durable process ownership is unavailable');
63
+ }
64
+ if (attempt.processIdentity) {
65
+ const outcome = await this.supervisor.cancel(attempt.processIdentity);
66
+ if (!outcome.cancelled || outcome.verifiedStopped !== true) {
67
+ throw new HubError(
68
+ 'HUB_CANCELLATION_UNPROVEN',
69
+ 'Owned process termination was not proven'
70
+ );
71
+ }
72
+ }
73
+ const result = { cancelled: true };
74
+ return this.ledger.transitionAttempt(tuple, {
75
+ state: 'cancelled',
76
+ result,
77
+ resultDigest: this.#resultDigest(result),
78
+ event: this.#event(tuple, 'job.cancelled'),
79
+ });
80
+ }
81
+
82
+ async complete(tuple) {
83
+ const before = this.ledger.attemptForTuple(tuple);
84
+ const terminalRace = before?.state === 'cancel_requested' ? 'completed_before_cancel' : null;
85
+ const result = validateJobResult(before.jobType, tuple.result);
86
+ return this.ledger.transitionAttempt(tuple, {
87
+ state: 'succeeded',
88
+ result,
89
+ resultDigest: this.#resultDigest(result),
90
+ terminalRace,
91
+ event: this.#event(tuple, 'job.succeeded', { terminalRace }),
92
+ });
93
+ }
94
+
95
+ async fail(tuple) {
96
+ const result = {
97
+ code: tuple.code,
98
+ summary: redactText(tuple.summary, { maxBytes: 2_000 }).text,
99
+ };
100
+ return this.ledger.transitionAttempt(tuple, {
101
+ state: 'failed',
102
+ result,
103
+ resultDigest: this.#resultDigest(result),
104
+ event: this.#event(tuple, 'job.failed', result),
105
+ });
106
+ }
107
+
108
+ async fence(tuple) {
109
+ const attempt = this.ledger.attemptForTuple(tuple);
110
+ if (attempt.processIdentity && this.supervisor) {
111
+ const outcome = await this.supervisor.cancel(attempt.processIdentity);
112
+ if (!outcome.verifiedStopped) {
113
+ throw new HubError('HUB_CANCELLATION_UNPROVEN', 'Fenced process termination was not proven');
114
+ }
115
+ }
116
+ const result = { fenced: true };
117
+ return this.ledger.transitionAttempt(tuple, {
118
+ state: 'fenced',
119
+ result,
120
+ resultDigest: this.#resultDigest(result),
121
+ });
122
+ }
123
+ }