newmark-agent 0.6.2 → 0.6.4

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.
@@ -0,0 +1,1077 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GuardedContinuationStore = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const crypto_1 = require("crypto");
40
+ const contracts_1 = require("./contracts");
41
+ /**
42
+ * Single-writer continuation ledger for one workspace.
43
+ *
44
+ * The spec's reference uses SQLite. This implementation keeps the same
45
+ * boundaries with a lock-file + atomic-replace JSON ledger because the project
46
+ * does not ship a native SQLite module and an Electron native dependency would
47
+ * change the packaging/signing contract. All continuation writes must go
48
+ * through this class; callers never mutate the ledger directly.
49
+ */
50
+ class GuardedContinuationStore {
51
+ workspacePath;
52
+ workspaceId;
53
+ ledgerPath;
54
+ lockPath;
55
+ leaseMs;
56
+ eventRetention;
57
+ fingerprint = '';
58
+ cached = null;
59
+ constructor(workspacePath, workspaceId, options = {}) {
60
+ this.workspacePath = workspacePath;
61
+ this.workspaceId = workspaceId;
62
+ this.ledgerPath = path.join(path.resolve(workspacePath), 'conversations', 'continuation-ledger.json');
63
+ this.lockPath = `${this.ledgerPath}.lock`;
64
+ this.leaseMs = Math.max(5_000, Math.floor(options.leaseMs || 60_000));
65
+ this.eventRetention = Math.max(200, Math.floor(options.eventRetention || 5_000));
66
+ }
67
+ get filePath() {
68
+ return this.ledgerPath;
69
+ }
70
+ ensureRoot(rootId, defaultBranchId) {
71
+ this.mutate(ledger => {
72
+ if (!ledger.roots[rootId]) {
73
+ ledger.roots[rootId] = {
74
+ rootId,
75
+ workspaceId: this.workspaceId,
76
+ defaultBranchId,
77
+ createdAt: new Date().toISOString(),
78
+ };
79
+ }
80
+ this.ensureBranchRecord(ledger, { rootId, branchId: defaultBranchId, parentAnchorBuildId: null });
81
+ });
82
+ }
83
+ ensureBranch(input) {
84
+ return this.mutate(ledger => this.ensureBranchRecord(ledger, input));
85
+ }
86
+ getReceipt(actorId, commandId) {
87
+ return this.readLedger().commands[(0, contracts_1.commandKey)(actorId, commandId)];
88
+ }
89
+ enqueueBuild(command) {
90
+ if (!command.target?.workspaceId || !command.target.expectedRootId || !command.target.branchId) {
91
+ throw new contracts_1.ContinuationError('TARGET_REQUIRED', 'workspaceId, expectedRootId and branchId are required');
92
+ }
93
+ if (command.target.workspaceId !== this.workspaceId) {
94
+ throw new contracts_1.ContinuationError('ROUTE_MISMATCH', `Command workspace ${command.target.workspaceId} does not match ledger workspace ${this.workspaceId}`);
95
+ }
96
+ const hash = (0, contracts_1.requestHash)(command);
97
+ return this.mutate(ledger => {
98
+ const key = (0, contracts_1.commandKey)(command.actorId, command.commandId);
99
+ const existing = ledger.commands[key];
100
+ if (existing) {
101
+ if (existing.requestHash !== hash) {
102
+ throw new contracts_1.ContinuationError('IDEMPOTENCY_KEY_REUSE', `Command ${command.commandId} was already used with a different request`, { commandId: command.commandId });
103
+ }
104
+ return { ...existing, duplicate: true };
105
+ }
106
+ const branch = this.requireBranch(ledger, {
107
+ workspaceId: command.target.workspaceId,
108
+ rootId: command.target.expectedRootId,
109
+ branchId: command.target.branchId,
110
+ });
111
+ if (branch.queueRevision !== command.expectedQueueRevision) {
112
+ throw new contracts_1.ContinuationError('QUEUE_REVISION_CONFLICT', `Queue revision ${command.expectedQueueRevision} is stale; authoritative revision is ${branch.queueRevision}`, {
113
+ expected: command.expectedQueueRevision,
114
+ actual: branch.queueRevision,
115
+ });
116
+ }
117
+ if ((branch.tailBuildId || null) !== (command.expectedTailBuildId || null)) {
118
+ throw new contracts_1.ContinuationError('QUEUE_REVISION_CONFLICT', `Expected tail ${command.expectedTailBuildId || 'null'} does not match authoritative tail ${branch.tailBuildId || 'null'}`, {
119
+ expected: command.expectedTailBuildId,
120
+ actual: branch.tailBuildId,
121
+ });
122
+ }
123
+ const parentBuildId = branch.tailBuildId;
124
+ const parent = parentBuildId ? ledger.builds[parentBuildId] : null;
125
+ if (parentBuildId && (!parent || parent.branchId !== branch.branchId)) {
126
+ throw new contracts_1.ContinuationError('ROUTE_MISMATCH', `Branch tail ${parentBuildId} is not owned by branch ${branch.branchId}`);
127
+ }
128
+ const buildId = `build-${(0, crypto_1.randomUUID)()}`;
129
+ const queueSequence = ledger.nextQueueSequence++;
130
+ const now = new Date().toISOString();
131
+ const build = {
132
+ buildId,
133
+ workspaceId: this.workspaceId,
134
+ rootId: branch.rootId,
135
+ branchId: branch.branchId,
136
+ parentBuildId,
137
+ inputHash: (0, contracts_1.buildInputHash)(command.input),
138
+ input: cloneInput(command.input),
139
+ recipeSnapshotId: command.recipeSnapshotId,
140
+ status: 'QUEUED',
141
+ queueSequence,
142
+ createdAt: now,
143
+ updatedAt: now,
144
+ };
145
+ ledger.builds[buildId] = build;
146
+ branch.tailBuildId = buildId;
147
+ branch.queueRevision += 1;
148
+ branch.lifecycleRevision += 1;
149
+ branch.updatedAt = now;
150
+ const receipt = {
151
+ schemaVersion: 1,
152
+ commandId: command.commandId,
153
+ requestHash: hash,
154
+ workspaceId: this.workspaceId,
155
+ rootId: branch.rootId,
156
+ branchId: branch.branchId,
157
+ buildId,
158
+ parentBuildId,
159
+ queueSequence,
160
+ queueRevision: branch.queueRevision,
161
+ lifecycleRevision: branch.lifecycleRevision,
162
+ acceptedAt: now,
163
+ };
164
+ ledger.commands[key] = receipt;
165
+ this.appendEvent(ledger, {
166
+ type: 'BuildAccepted',
167
+ workspaceId: this.workspaceId,
168
+ rootId: branch.rootId,
169
+ branchId: branch.branchId,
170
+ buildId,
171
+ attemptId: null,
172
+ fence: null,
173
+ payload: { parentBuildId, queueSequence, inputHash: build.inputHash },
174
+ });
175
+ return receipt;
176
+ });
177
+ }
178
+ claimBuild(input) {
179
+ return this.mutate(ledger => {
180
+ const branch = this.requireBranch(ledger, input);
181
+ if (branch.paused)
182
+ throw new contracts_1.ContinuationError('BRANCH_PAUSED', `Branch ${branch.branchId} is paused`);
183
+ const now = input.now ?? Date.now();
184
+ const guard = this.ensureGuard(ledger, branch.branchId);
185
+ if (guard.activeBuildId && guard.leaseUntil > now) {
186
+ return null;
187
+ }
188
+ if (guard.activeBuildId)
189
+ this.releaseExpiredGuard(ledger, branch, guard, now);
190
+ const candidate = Object.values(ledger.builds)
191
+ .filter(build => build.branchId === branch.branchId && build.status === 'QUEUED')
192
+ .sort((a, b) => a.queueSequence - b.queueSequence)
193
+ .find(build => {
194
+ if ((branch.headBuildId || null) !== (build.parentBuildId || null))
195
+ return false;
196
+ const parent = build.parentBuildId ? ledger.builds[build.parentBuildId] : null;
197
+ return !parent || parent.status === 'SUCCEEDED';
198
+ });
199
+ if (!candidate)
200
+ return null;
201
+ const attemptId = `attempt-${(0, crypto_1.randomUUID)()}`;
202
+ const fence = guard.fence + 1;
203
+ const leaseUntil = now + this.leaseMs;
204
+ const attempt = {
205
+ attemptId,
206
+ buildId: candidate.buildId,
207
+ branchId: branch.branchId,
208
+ fence,
209
+ status: 'RUNNING',
210
+ leaseUntil,
211
+ workerCapability: String(input.workerCapability || 'unknown'),
212
+ startedAt: new Date(now).toISOString(),
213
+ };
214
+ ledger.attempts[attemptId] = attempt;
215
+ ledger.guards[branch.branchId] = {
216
+ branchId: branch.branchId,
217
+ activeBuildId: candidate.buildId,
218
+ activeAttemptId: attemptId,
219
+ fence,
220
+ leaseUntil,
221
+ workerCapability: attempt.workerCapability,
222
+ };
223
+ candidate.status = 'RUNNING';
224
+ candidate.updatedAt = attempt.startedAt;
225
+ candidate.waitingReason = undefined;
226
+ branch.lifecycleRevision += 1;
227
+ branch.updatedAt = attempt.startedAt;
228
+ this.appendEvent(ledger, {
229
+ type: 'AttemptStarted',
230
+ workspaceId: this.workspaceId,
231
+ rootId: branch.rootId,
232
+ branchId: branch.branchId,
233
+ buildId: candidate.buildId,
234
+ attemptId,
235
+ fence,
236
+ payload: { parentBuildId: candidate.parentBuildId, workerCapability: attempt.workerCapability },
237
+ });
238
+ return {
239
+ workspaceId: this.workspaceId,
240
+ rootId: branch.rootId,
241
+ branchId: branch.branchId,
242
+ buildId: candidate.buildId,
243
+ attemptId,
244
+ fence,
245
+ parentBuildId: candidate.parentBuildId,
246
+ baseContextSnapshotId: candidate.recipeSnapshotId,
247
+ environmentSnapshotId: candidate.recipeSnapshotId,
248
+ workerCapability: attempt.workerCapability,
249
+ };
250
+ });
251
+ }
252
+ updateQueuedBuild(input) {
253
+ return this.mutate(ledger => {
254
+ const build = ledger.builds[input.buildId];
255
+ if (!build)
256
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${input.buildId} does not exist`);
257
+ if (build.workspaceId !== this.workspaceId || (input.rootId && build.rootId !== input.rootId)) {
258
+ throw new contracts_1.ContinuationError('ROUTE_MISMATCH', `Build ${input.buildId} does not belong to workspace/root ${this.workspaceId}/${input.rootId}`);
259
+ }
260
+ const branch = ledger.branches[build.branchId];
261
+ if (!branch)
262
+ throw new contracts_1.ContinuationError('UNKNOWN_BRANCH', `Build ${input.buildId} references missing branch ${build.branchId}`);
263
+ if (build.status !== 'QUEUED')
264
+ throw new contracts_1.ContinuationError('BUILD_NOT_READY', `Build ${build.buildId} is ${build.status}; only QUEUED builds can be edited`);
265
+ build.input = cloneInput(input.input);
266
+ build.inputHash = (0, contracts_1.buildInputHash)(build.input);
267
+ build.updatedAt = new Date().toISOString();
268
+ branch.queueRevision += 1;
269
+ branch.lifecycleRevision += 1;
270
+ branch.updatedAt = build.updatedAt;
271
+ return { ...build, input: cloneInput(build.input) };
272
+ });
273
+ }
274
+ /**
275
+ * dev-0.6.4 hotfix: 当某个被受理的 Build 失败/取消后,它的后继会永远无法被
276
+ * claim(`claimBuild` 只认「父 = 已提交 frontier」),整条队列就此卡死,新提交
277
+ * 也只是接在失败链后面。
278
+ *
279
+ * 这里提供一条**显式**修复通道:把被阻断的排队 Build 重新挂到当前已提交的
280
+ * frontier 上,保持原有队列顺序,并为每一次改挂写入 `BuildQueueRepaired`
281
+ * 审计事件(记录旧 parent / 新 parent / 原因)。它不是静默 rebase:调用方是
282
+ * 用户的显式动作(恢复队列)或一次新的入队命令。
283
+ */
284
+ repairBlockedQueue(input) {
285
+ return this.mutate(ledger => {
286
+ const branch = this.requireBranch(ledger, input);
287
+ const frontier = branch.headBuildId || null;
288
+ const queued = Object.values(ledger.builds)
289
+ .filter(build => build.branchId === branch.branchId && build.status === 'QUEUED')
290
+ .sort((a, b) => a.queueSequence - b.queueSequence);
291
+ const repaired = [];
292
+ let chainParent = frontier;
293
+ let mutated = false;
294
+ for (const build of queued) {
295
+ if ((build.parentBuildId || null) !== (chainParent || null)) {
296
+ const previousParentBuildId = build.parentBuildId;
297
+ build.parentBuildId = chainParent;
298
+ build.waitingReason = undefined;
299
+ build.updatedAt = new Date().toISOString();
300
+ repaired.push(build.buildId);
301
+ mutated = true;
302
+ this.appendEvent(ledger, {
303
+ type: 'BuildQueueRepaired',
304
+ workspaceId: this.workspaceId,
305
+ rootId: branch.rootId,
306
+ branchId: branch.branchId,
307
+ buildId: build.buildId,
308
+ attemptId: null,
309
+ fence: null,
310
+ payload: {
311
+ previousParentBuildId,
312
+ newParentBuildId: chainParent,
313
+ reason: String(input.reason || 'committed-frontier-repair'),
314
+ },
315
+ });
316
+ }
317
+ chainParent = build.buildId;
318
+ }
319
+ if (mutated) {
320
+ branch.queueRevision += 1;
321
+ branch.lifecycleRevision += 1;
322
+ branch.updatedAt = new Date().toISOString();
323
+ }
324
+ return { repaired, frontier };
325
+ });
326
+ }
327
+ /**
328
+ * dev-0.6.4 hotfix: 读取视角的受阻推导。
329
+ *
330
+ * 一个 QUEUED Build 只有在它的父正好是「已提交 frontier」或队列里位于它之前
331
+ * 的可执行 Build 时才可能被 claim(见 `claimBuild`)。这里按队列顺序推导哪些行
332
+ * 目前不可执行:父不是链上前驱、或父已 FAILED/CANCELLED 的行即为受阻,且受阻点
333
+ * 之后的所有行同样受阻。
334
+ */
335
+ blockedQueuedBuildIds(ledger, branch) {
336
+ const blocked = new Set();
337
+ const queued = Object.values(ledger.builds)
338
+ .filter(build => build.branchId === branch.branchId && build.status === 'QUEUED')
339
+ .sort((a, b) => a.queueSequence - b.queueSequence);
340
+ let chainParent = branch.headBuildId || null;
341
+ for (const build of queued) {
342
+ const parent = build.parentBuildId ? ledger.builds[build.parentBuildId] : null;
343
+ // 链上的前驱(frontier 或队列里位于它之前的行)在执行时已经提交,因此只有
344
+ // 「父不是链上前驱」或「父已 FAILED/CANCELLED」的行才是真正受阻的。
345
+ const parentIsChainPredecessor = (build.parentBuildId || null) === (chainParent || null);
346
+ const parentFailed = !!parent && (parent.status === 'FAILED' || parent.status === 'CANCELLED');
347
+ if (!parentIsChainPredecessor || parentFailed) {
348
+ blocked.add(build.buildId);
349
+ continue;
350
+ }
351
+ chainParent = build.buildId;
352
+ }
353
+ return blocked;
354
+ }
355
+ deleteQueuedBuild(input) {
356
+ this.mutate(ledger => {
357
+ const build = ledger.builds[input.buildId];
358
+ if (!build)
359
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${input.buildId} does not exist`);
360
+ if (build.workspaceId !== this.workspaceId || (input.rootId && build.rootId !== input.rootId)) {
361
+ throw new contracts_1.ContinuationError('ROUTE_MISMATCH', `Build ${input.buildId} does not belong to workspace/root ${this.workspaceId}/${input.rootId}`);
362
+ }
363
+ const branch = ledger.branches[build.branchId];
364
+ if (!branch)
365
+ throw new contracts_1.ContinuationError('UNKNOWN_BRANCH', `Build ${input.buildId} references missing branch ${build.branchId}`);
366
+ if (build.status !== 'QUEUED')
367
+ throw new contracts_1.ContinuationError('BUILD_NOT_READY', `Build ${build.buildId} is ${build.status}; only QUEUED builds can be deleted`);
368
+ if (Object.values(ledger.builds).some(item => item.parentBuildId === build.buildId)) {
369
+ throw new contracts_1.ContinuationError('ANCHOR_NOT_COMMITTED', `Build ${build.buildId} still has queued successors; delete them from the tail first`);
370
+ }
371
+ if (branch.tailBuildId === build.buildId)
372
+ branch.tailBuildId = build.parentBuildId;
373
+ delete ledger.builds[build.buildId];
374
+ branch.queueRevision += 1;
375
+ branch.lifecycleRevision += 1;
376
+ branch.updatedAt = new Date().toISOString();
377
+ });
378
+ }
379
+ reorderQueuedBuilds(input) {
380
+ return this.mutate(ledger => {
381
+ const branch = this.requireBranch(ledger, input);
382
+ const queued = Object.values(ledger.builds)
383
+ .filter(build => build.branchId === branch.branchId && build.status === 'QUEUED')
384
+ .sort((a, b) => a.queueSequence - b.queueSequence);
385
+ // continuation-v1 fixes parentBuildId at admission. Reordering admitted
386
+ // builds would silently change the context chain, so it is rejected.
387
+ // Changing the order requires explicit cancel + new commands.
388
+ throw new contracts_1.ContinuationError('ANCHOR_NOT_COMMITTED', `Branch ${branch.branchId} has ${queued.length} admitted queued build(s); reordering would change their fixed parents. Cancel the suffix and submit it as new commands.`, { branchId: branch.branchId, queuedBuildIds: queued.map(build => build.buildId) });
389
+ });
390
+ }
391
+ heartbeat(scope, now = Date.now()) {
392
+ return this.mutate(ledger => {
393
+ const guard = ledger.guards[scope.branchId];
394
+ const attempt = ledger.attempts[scope.attemptId];
395
+ if (!guard || !attempt
396
+ || guard.activeBuildId !== scope.buildId
397
+ || guard.activeAttemptId !== scope.attemptId
398
+ || guard.fence !== scope.fence
399
+ || attempt.fence !== scope.fence
400
+ || attempt.status !== 'RUNNING'
401
+ || guard.leaseUntil <= now) {
402
+ this.appendEvent(ledger, {
403
+ type: 'StaleWriteRejected',
404
+ workspaceId: this.workspaceId,
405
+ rootId: ledger.builds[scope.buildId]?.rootId || '',
406
+ branchId: scope.branchId,
407
+ buildId: scope.buildId,
408
+ attemptId: scope.attemptId,
409
+ fence: scope.fence,
410
+ payload: { operation: 'heartbeat' },
411
+ });
412
+ return false;
413
+ }
414
+ const leaseUntil = now + this.leaseMs;
415
+ attempt.leaseUntil = leaseUntil;
416
+ guard.leaseUntil = leaseUntil;
417
+ this.appendEvent(ledger, {
418
+ type: 'AttemptHeartbeat',
419
+ workspaceId: this.workspaceId,
420
+ rootId: ledger.builds[scope.buildId]?.rootId || '',
421
+ branchId: scope.branchId,
422
+ buildId: scope.buildId,
423
+ attemptId: scope.attemptId,
424
+ fence: scope.fence,
425
+ payload: { leaseUntil },
426
+ });
427
+ return true;
428
+ });
429
+ }
430
+ commitFinal(input) {
431
+ return this.mutate(ledger => {
432
+ const { scope } = input;
433
+ const branch = this.requireBranch(ledger, scope);
434
+ const build = ledger.builds[scope.buildId];
435
+ const attempt = ledger.attempts[scope.attemptId];
436
+ const guard = ledger.guards[scope.branchId];
437
+ const now = input.now ?? Date.now();
438
+ if (!build || build.branchId !== scope.branchId || build.rootId !== scope.rootId) {
439
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${scope.buildId} does not belong to branch ${scope.branchId}`);
440
+ }
441
+ if (ledger.finals[build.buildId]) {
442
+ throw new contracts_1.ContinuationError('FINAL_ALREADY_COMMITTED', `Build ${build.buildId} already has an immutable final`, { final: ledger.finals[build.buildId] });
443
+ }
444
+ if (!attempt || !guard
445
+ || guard.activeBuildId !== scope.buildId
446
+ || guard.activeAttemptId !== scope.attemptId
447
+ || guard.fence !== scope.fence
448
+ || attempt.fence !== scope.fence
449
+ || attempt.status !== 'RUNNING'
450
+ || guard.leaseUntil <= now) {
451
+ this.appendEvent(ledger, {
452
+ type: 'StaleWriteRejected',
453
+ workspaceId: this.workspaceId,
454
+ rootId: branch.rootId,
455
+ branchId: branch.branchId,
456
+ buildId: build.buildId,
457
+ attemptId: scope.attemptId,
458
+ fence: scope.fence,
459
+ payload: { operation: 'commit_final' },
460
+ });
461
+ throw new contracts_1.ContinuationError('STALE_EXECUTION', `Attempt ${scope.attemptId} no longer owns branch ${branch.branchId}`);
462
+ }
463
+ if ((branch.headBuildId || null) !== (build.parentBuildId || null)) {
464
+ throw new contracts_1.ContinuationError('ANCHOR_NOT_COMMITTED', `Build ${build.buildId} parent ${build.parentBuildId || 'null'} is not the current head ${branch.headBuildId || 'null'}`);
465
+ }
466
+ const final = {
467
+ finalId: `final-${(0, crypto_1.randomUUID)()}`,
468
+ buildId: build.buildId,
469
+ branchId: branch.branchId,
470
+ finalHash: String(input.finalHash || (0, crypto_1.createHash)('sha256').update(String(input.text || '')).digest('hex').slice(0, 24)),
471
+ stateSnapshotId: String(input.stateSnapshotId || build.recipeSnapshotId),
472
+ text: String(input.text || ''),
473
+ committedAt: new Date(now).toISOString(),
474
+ };
475
+ ledger.finals[build.buildId] = final;
476
+ build.status = 'SUCCEEDED';
477
+ build.finalId = final.finalId;
478
+ build.updatedAt = final.committedAt;
479
+ attempt.status = 'COMPLETED';
480
+ attempt.endedAt = final.committedAt;
481
+ branch.headBuildId = build.buildId;
482
+ branch.lifecycleRevision += 1;
483
+ branch.updatedAt = final.committedAt;
484
+ this.releaseGuard(ledger, branch.branchId, attempt.fence);
485
+ this.appendEvent(ledger, {
486
+ type: 'BuildCommitted',
487
+ workspaceId: this.workspaceId,
488
+ rootId: branch.rootId,
489
+ branchId: branch.branchId,
490
+ buildId: build.buildId,
491
+ attemptId: attempt.attemptId,
492
+ fence: attempt.fence,
493
+ payload: { finalId: final.finalId, finalHash: final.finalHash, headBuildId: branch.headBuildId },
494
+ });
495
+ return final;
496
+ });
497
+ }
498
+ /**
499
+ * Trusted external-owner completion.
500
+ *
501
+ * A Flow is a kernel-owned external run, not a leased model worker. It still
502
+ * has to be a durable build so queued Goal/Next successors have a committed
503
+ * parent. This path validates the parent/head relation transactionally but
504
+ * does not require a BranchGuard attempt; the kernel owns the Flow lifetime.
505
+ * If the build is not the next ready build, it stays QUEUED and the caller
506
+ * must retry after the predecessor commits.
507
+ */
508
+ commitExternalBuild(input) {
509
+ return this.mutate(ledger => {
510
+ const branch = this.requireBranch(ledger, input);
511
+ const build = ledger.builds[input.buildId];
512
+ if (!build || build.branchId !== branch.branchId)
513
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${input.buildId} is not in branch ${branch.branchId}`);
514
+ if (build.status === 'SUCCEEDED')
515
+ return true;
516
+ if (build.status !== 'QUEUED')
517
+ throw new contracts_1.ContinuationError('BUILD_NOT_READY', `External build ${build.buildId} is ${build.status}`);
518
+ if ((branch.headBuildId || null) !== (build.parentBuildId || null))
519
+ return false;
520
+ const now = input.now ?? Date.now();
521
+ const committedAt = new Date(now).toISOString();
522
+ const final = {
523
+ finalId: `final-${(0, crypto_1.randomUUID)()}`,
524
+ buildId: build.buildId,
525
+ branchId: branch.branchId,
526
+ finalHash: (0, crypto_1.createHash)('sha256').update(String(input.text || '')).digest('hex').slice(0, 24),
527
+ stateSnapshotId: build.recipeSnapshotId,
528
+ text: String(input.text || ''),
529
+ committedAt,
530
+ };
531
+ ledger.finals[build.buildId] = final;
532
+ build.status = 'SUCCEEDED';
533
+ build.finalId = final.finalId;
534
+ build.updatedAt = committedAt;
535
+ branch.headBuildId = build.buildId;
536
+ branch.lifecycleRevision += 1;
537
+ branch.updatedAt = committedAt;
538
+ this.appendEvent(ledger, {
539
+ type: 'BuildCommitted',
540
+ workspaceId: this.workspaceId,
541
+ rootId: branch.rootId,
542
+ branchId: branch.branchId,
543
+ buildId: build.buildId,
544
+ attemptId: null,
545
+ fence: null,
546
+ payload: { finalId: final.finalId, finalHash: final.finalHash, externalOwner: true, headBuildId: branch.headBuildId },
547
+ });
548
+ return true;
549
+ });
550
+ }
551
+ /**
552
+ * Claim a new attempt for an existing external Flow build (suspend/resume).
553
+ * The build identity and fixed parent are preserved; only the attempt/fence
554
+ * advance. This is the external-owner equivalent of retryBuild.
555
+ */
556
+ claimExternalBuild(input) {
557
+ return this.mutate(ledger => {
558
+ const branch = this.requireBranch(ledger, input);
559
+ const build = ledger.builds[input.buildId];
560
+ if (!build || build.branchId !== branch.branchId)
561
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${input.buildId} is not in branch ${branch.branchId}`);
562
+ if (build.status === 'SUCCEEDED')
563
+ throw new contracts_1.ContinuationError('FINAL_ALREADY_COMMITTED', `Build ${build.buildId} already committed`);
564
+ if (build.status !== 'QUEUED')
565
+ throw new contracts_1.ContinuationError('BUILD_NOT_READY', `External build ${build.buildId} is ${build.status}`);
566
+ if ((branch.headBuildId || null) !== (build.parentBuildId || null)) {
567
+ throw new contracts_1.ContinuationError('ANCHOR_NOT_COMMITTED', `External build ${build.buildId} parent is not the current head`);
568
+ }
569
+ const now = input.now ?? Date.now();
570
+ const guard = this.ensureGuard(ledger, branch.branchId);
571
+ const attemptId = `attempt-${(0, crypto_1.randomUUID)()}`;
572
+ const fence = guard.fence + 1;
573
+ const leaseUntil = now + this.leaseMs;
574
+ const attempt = {
575
+ attemptId,
576
+ buildId: build.buildId,
577
+ branchId: branch.branchId,
578
+ fence,
579
+ status: 'RUNNING',
580
+ leaseUntil,
581
+ workerCapability: String(input.workerCapability || 'flow-external'),
582
+ startedAt: new Date(now).toISOString(),
583
+ };
584
+ ledger.attempts[attemptId] = attempt;
585
+ ledger.guards[branch.branchId] = {
586
+ branchId: branch.branchId,
587
+ activeBuildId: build.buildId,
588
+ activeAttemptId: attemptId,
589
+ fence,
590
+ leaseUntil,
591
+ workerCapability: attempt.workerCapability,
592
+ };
593
+ build.status = 'RUNNING';
594
+ build.updatedAt = attempt.startedAt;
595
+ branch.lifecycleRevision += 1;
596
+ this.appendEvent(ledger, {
597
+ type: 'AttemptStarted',
598
+ workspaceId: this.workspaceId,
599
+ rootId: branch.rootId,
600
+ branchId: branch.branchId,
601
+ buildId: build.buildId,
602
+ attemptId,
603
+ fence,
604
+ payload: { externalOwner: true, workerCapability: attempt.workerCapability },
605
+ });
606
+ return {
607
+ workspaceId: this.workspaceId,
608
+ rootId: branch.rootId,
609
+ branchId: branch.branchId,
610
+ buildId: build.buildId,
611
+ attemptId,
612
+ fence,
613
+ parentBuildId: build.parentBuildId,
614
+ baseContextSnapshotId: build.recipeSnapshotId,
615
+ environmentSnapshotId: build.recipeSnapshotId,
616
+ workerCapability: attempt.workerCapability,
617
+ };
618
+ });
619
+ }
620
+ failBuild(input) {
621
+ this.terminateAttempt(input.scope, 'FAILED', input.reason, input.now);
622
+ }
623
+ cancelBuild(input) {
624
+ this.mutate(ledger => {
625
+ const branch = this.requireBranch(ledger, input);
626
+ const build = ledger.builds[input.buildId];
627
+ if (!build || build.branchId !== branch.branchId)
628
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${input.buildId} is not in branch ${branch.branchId}`);
629
+ if (build.status === 'SUCCEEDED')
630
+ throw new contracts_1.ContinuationError('FINAL_ALREADY_COMMITTED', `Build ${input.buildId} already committed`);
631
+ const guard = this.ensureGuard(ledger, branch.branchId);
632
+ if (guard.activeBuildId === build.buildId) {
633
+ const attempt = guard.activeAttemptId ? ledger.attempts[guard.activeAttemptId] : undefined;
634
+ if (attempt) {
635
+ attempt.status = 'CANCELLED';
636
+ attempt.endedAt = new Date(input.now ?? Date.now()).toISOString();
637
+ }
638
+ this.releaseGuard(ledger, branch.branchId, guard.fence);
639
+ }
640
+ build.status = 'CANCELLED';
641
+ build.updatedAt = new Date(input.now ?? Date.now()).toISOString();
642
+ branch.lifecycleRevision += 1;
643
+ this.markDependentsBlocked(ledger, branch, build.buildId);
644
+ this.appendEvent(ledger, {
645
+ type: 'BuildCancelled',
646
+ workspaceId: this.workspaceId,
647
+ rootId: branch.rootId,
648
+ branchId: branch.branchId,
649
+ buildId: build.buildId,
650
+ attemptId: null,
651
+ fence: null,
652
+ });
653
+ });
654
+ }
655
+ retryBuild(input) {
656
+ return this.mutate(ledger => {
657
+ const branch = this.requireBranch(ledger, input);
658
+ const build = ledger.builds[input.buildId];
659
+ if (!build || build.branchId !== branch.branchId)
660
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${input.buildId} is not in branch ${branch.branchId}`);
661
+ if (build.status !== 'FAILED' && build.status !== 'CANCELLED') {
662
+ throw new contracts_1.ContinuationError('BUILD_NOT_READY', `Build ${build.buildId} is ${build.status}; only FAILED/CANCELLED builds can be retried`);
663
+ }
664
+ if (this.hasCommittedDescendant(ledger, build.buildId)) {
665
+ throw new contracts_1.ContinuationError('ANCHOR_NOT_COMMITTED', `Build ${build.buildId} already has a committed descendant`);
666
+ }
667
+ build.status = 'QUEUED';
668
+ build.updatedAt = new Date().toISOString();
669
+ branch.lifecycleRevision += 1;
670
+ return { ...build, input: cloneInput(build.input) };
671
+ });
672
+ }
673
+ /**
674
+ * Idempotent failure recovery for an input that was never accepted.
675
+ *
676
+ * Stop/archive may release the branch guard before the runtime reports the
677
+ * failure. In that case the build is still the same durable row and must be
678
+ * returned to QUEUED for an explicit resume instead of being stranded in
679
+ * RUNNING (or permanently FAILED). A committed build is never requeued.
680
+ */
681
+ requeueBuild(input) {
682
+ return this.mutate(ledger => {
683
+ const branch = this.requireBranch(ledger, input);
684
+ const build = ledger.builds[input.buildId];
685
+ if (!build || build.branchId !== branch.branchId)
686
+ throw new contracts_1.ContinuationError('UNKNOWN_BUILD', `Build ${input.buildId} is not in branch ${branch.branchId}`);
687
+ if (build.status === 'SUCCEEDED')
688
+ throw new contracts_1.ContinuationError('FINAL_ALREADY_COMMITTED', `Build ${build.buildId} already committed and cannot be requeued`);
689
+ const guard = this.ensureGuard(ledger, branch.branchId);
690
+ if (guard.activeBuildId === build.buildId) {
691
+ const attempt = guard.activeAttemptId ? ledger.attempts[guard.activeAttemptId] : undefined;
692
+ if (attempt && attempt.status === 'RUNNING') {
693
+ attempt.status = 'EXPIRED';
694
+ attempt.endedAt = new Date().toISOString();
695
+ }
696
+ this.releaseGuard(ledger, branch.branchId, guard.fence);
697
+ }
698
+ build.status = 'QUEUED';
699
+ build.waitingReason = undefined;
700
+ build.updatedAt = new Date().toISOString();
701
+ branch.lifecycleRevision += 1;
702
+ branch.updatedAt = build.updatedAt;
703
+ return { ...build, input: cloneInput(build.input) };
704
+ });
705
+ }
706
+ forkBranch(input) {
707
+ const hash = (0, crypto_1.createHash)('sha256').update(JSON.stringify(input)).digest('hex');
708
+ return this.mutate(ledger => {
709
+ const key = (0, contracts_1.commandKey)(input.actorId, input.commandId);
710
+ const existing = ledger.commands[key];
711
+ if (existing) {
712
+ if (existing.requestHash !== hash)
713
+ throw new contracts_1.ContinuationError('IDEMPOTENCY_KEY_REUSE', `Command ${input.commandId} was already used with a different fork request`);
714
+ return { ...ledger.branches[existing.branchId] };
715
+ }
716
+ const source = this.requireBranch(ledger, { workspaceId: input.workspaceId, rootId: '', branchId: input.sourceBranchId });
717
+ const anchor = ledger.builds[input.anchorBuildId];
718
+ if (!anchor || anchor.branchId !== source.branchId || anchor.status !== 'SUCCEEDED') {
719
+ throw new contracts_1.ContinuationError('ANCHOR_NOT_COMMITTED', `Anchor ${input.anchorBuildId} is not a committed build of source branch ${source.branchId}`);
720
+ }
721
+ if (!this.isAncestorOfHead(ledger, source, anchor.buildId)) {
722
+ throw new contracts_1.ContinuationError('ANCHOR_NOT_IN_LINEAGE', `Anchor ${anchor.buildId} is not on the source branch lineage`);
723
+ }
724
+ const branch = this.ensureBranchRecord(ledger, {
725
+ rootId: source.rootId,
726
+ branchId: input.newBranchId,
727
+ parentAnchorBuildId: anchor.buildId,
728
+ });
729
+ branch.headBuildId = anchor.buildId;
730
+ branch.tailBuildId = anchor.buildId;
731
+ branch.queueRevision = 0;
732
+ branch.lifecycleRevision += 1;
733
+ const receipt = {
734
+ schemaVersion: 1,
735
+ commandId: input.commandId,
736
+ requestHash: hash,
737
+ workspaceId: this.workspaceId,
738
+ rootId: source.rootId,
739
+ branchId: branch.branchId,
740
+ buildId: anchor.buildId,
741
+ parentBuildId: anchor.buildId,
742
+ queueSequence: 0,
743
+ queueRevision: branch.queueRevision,
744
+ lifecycleRevision: branch.lifecycleRevision,
745
+ acceptedAt: new Date().toISOString(),
746
+ };
747
+ ledger.commands[key] = receipt;
748
+ this.appendEvent(ledger, {
749
+ type: 'BranchForked',
750
+ workspaceId: this.workspaceId,
751
+ rootId: source.rootId,
752
+ branchId: branch.branchId,
753
+ buildId: anchor.buildId,
754
+ attemptId: null,
755
+ fence: null,
756
+ payload: { sourceBranchId: source.branchId, anchorBuildId: anchor.buildId },
757
+ });
758
+ return { ...branch };
759
+ });
760
+ }
761
+ setPaused(input) {
762
+ return this.mutate(ledger => {
763
+ const branch = this.requireBranch(ledger, input);
764
+ branch.paused = input.paused === true;
765
+ branch.lifecycleRevision += 1;
766
+ branch.updatedAt = new Date().toISOString();
767
+ this.appendEvent(ledger, {
768
+ type: branch.paused ? 'BranchPaused' : 'BranchResumed',
769
+ workspaceId: this.workspaceId,
770
+ rootId: branch.rootId,
771
+ branchId: branch.branchId,
772
+ buildId: null,
773
+ attemptId: null,
774
+ fence: null,
775
+ });
776
+ return branch.paused;
777
+ });
778
+ }
779
+ snapshot(input) {
780
+ const ledger = this.readLedger();
781
+ const branch = this.requireBranch(ledger, input);
782
+ return this.snapshotUnlocked(ledger, branch);
783
+ }
784
+ snapshotUnlocked(ledger, branch) {
785
+ const guard = ledger.guards[branch.branchId] || (0, contracts_1.defaultGuard)(branch.branchId);
786
+ // dev-0.6.4 hotfix: 受阻状态由父链推导,而不是只在失败瞬间给直系后继打标记。
787
+ // 这样“失败之后才入队”的行同样会显示为受阻,界面才能给出显式修复入口。
788
+ const blockedBuildIds = this.blockedQueuedBuildIds(ledger, branch);
789
+ const builds = Object.values(ledger.builds)
790
+ .filter(build => build.branchId === branch.branchId)
791
+ .sort((a, b) => a.queueSequence - b.queueSequence)
792
+ .map(build => ({
793
+ ...build,
794
+ input: cloneInput(build.input),
795
+ waitingReason: build.waitingReason || (blockedBuildIds.has(build.buildId) ? 'DEPENDENCY_FAILED' : undefined),
796
+ }));
797
+ return {
798
+ workspaceId: this.workspaceId,
799
+ rootId: branch.rootId,
800
+ branchId: branch.branchId,
801
+ headBuildId: branch.headBuildId,
802
+ tailBuildId: branch.tailBuildId,
803
+ queueRevision: branch.queueRevision,
804
+ lifecycleRevision: branch.lifecycleRevision,
805
+ paused: branch.paused,
806
+ activeBuildId: guard.activeBuildId,
807
+ guard: { ...guard },
808
+ builds,
809
+ };
810
+ }
811
+ events(afterCursor = 0, limit = 500) {
812
+ return this.readLedger().events
813
+ .filter(event => event.cursor > afterCursor)
814
+ .slice(0, Math.max(1, Math.min(5_000, limit)))
815
+ .map(event => ({ ...event, payload: event.payload ? { ...event.payload } : undefined }));
816
+ }
817
+ recoverExpiredLeases(now = Date.now()) {
818
+ return this.mutate(ledger => {
819
+ let recovered = 0;
820
+ for (const guard of Object.values(ledger.guards)) {
821
+ if (!guard.activeBuildId || guard.leaseUntil > now)
822
+ continue;
823
+ const attempt = guard.activeAttemptId ? ledger.attempts[guard.activeAttemptId] : undefined;
824
+ const build = ledger.builds[guard.activeBuildId];
825
+ if (attempt && attempt.status === 'RUNNING') {
826
+ attempt.status = 'EXPIRED';
827
+ attempt.endedAt = new Date(now).toISOString();
828
+ }
829
+ if (build && build.status === 'RUNNING') {
830
+ build.status = 'QUEUED';
831
+ build.updatedAt = new Date(now).toISOString();
832
+ }
833
+ this.releaseGuard(ledger, guard.branchId, guard.fence);
834
+ recovered += 1;
835
+ }
836
+ return recovered;
837
+ });
838
+ }
839
+ terminateAttempt(scope, status, reason, now = Date.now()) {
840
+ this.mutate(ledger => {
841
+ const branch = this.requireBranch(ledger, scope);
842
+ const build = ledger.builds[scope.buildId];
843
+ const attempt = ledger.attempts[scope.attemptId];
844
+ const guard = ledger.guards[scope.branchId];
845
+ if (!build || !attempt || !guard
846
+ || guard.activeAttemptId !== scope.attemptId
847
+ || guard.fence !== scope.fence
848
+ || attempt.fence !== scope.fence) {
849
+ throw new contracts_1.ContinuationError('STALE_EXECUTION', `Attempt ${scope.attemptId} cannot terminate branch ${scope.branchId}`);
850
+ }
851
+ build.status = status;
852
+ build.updatedAt = new Date(now).toISOString();
853
+ attempt.status = status === 'FAILED' ? 'FAILED' : 'CANCELLED';
854
+ attempt.endedAt = build.updatedAt;
855
+ branch.lifecycleRevision += 1;
856
+ this.releaseGuard(ledger, branch.branchId, attempt.fence);
857
+ this.markDependentsBlocked(ledger, branch, build.buildId);
858
+ this.appendEvent(ledger, {
859
+ type: status === 'FAILED' ? 'BuildFailed' : 'BuildCancelled',
860
+ workspaceId: this.workspaceId,
861
+ rootId: branch.rootId,
862
+ branchId: branch.branchId,
863
+ buildId: build.buildId,
864
+ attemptId: attempt.attemptId,
865
+ fence: attempt.fence,
866
+ payload: { reason: String(reason || '') },
867
+ });
868
+ });
869
+ }
870
+ releaseExpiredGuard(ledger, branch, guard, now) {
871
+ const attempt = guard.activeAttemptId ? ledger.attempts[guard.activeAttemptId] : undefined;
872
+ const build = guard.activeBuildId ? ledger.builds[guard.activeBuildId] : undefined;
873
+ if (attempt && attempt.status === 'RUNNING') {
874
+ attempt.status = 'EXPIRED';
875
+ attempt.endedAt = new Date(now).toISOString();
876
+ }
877
+ if (build && build.status === 'RUNNING') {
878
+ build.status = 'QUEUED';
879
+ build.updatedAt = new Date(now).toISOString();
880
+ }
881
+ this.releaseGuard(ledger, branch.branchId, guard.fence);
882
+ }
883
+ markDependentsBlocked(ledger, branch, failedBuildId) {
884
+ for (const build of Object.values(ledger.builds)) {
885
+ if (build.branchId !== branch.branchId || build.status !== 'QUEUED')
886
+ continue;
887
+ if (build.parentBuildId === failedBuildId)
888
+ build.waitingReason = 'DEPENDENCY_FAILED';
889
+ }
890
+ }
891
+ hasCommittedDescendant(ledger, buildId) {
892
+ return Object.values(ledger.builds).some(build => build.parentBuildId === buildId && build.status === 'SUCCEEDED');
893
+ }
894
+ isAncestorOfHead(ledger, branch, anchorBuildId) {
895
+ let cursor = branch.headBuildId;
896
+ const seen = new Set();
897
+ while (cursor) {
898
+ if (seen.has(cursor))
899
+ return false;
900
+ seen.add(cursor);
901
+ if (cursor === anchorBuildId)
902
+ return true;
903
+ cursor = ledger.builds[cursor]?.parentBuildId || null;
904
+ }
905
+ return false;
906
+ }
907
+ ensureBranchRecord(ledger, input) {
908
+ const existing = ledger.branches[input.branchId];
909
+ if (existing)
910
+ return existing;
911
+ const now = new Date().toISOString();
912
+ const branch = {
913
+ branchId: input.branchId,
914
+ workspaceId: this.workspaceId,
915
+ rootId: input.rootId,
916
+ parentAnchorBuildId: input.parentAnchorBuildId,
917
+ headBuildId: input.parentAnchorBuildId,
918
+ tailBuildId: input.parentAnchorBuildId,
919
+ queueRevision: 0,
920
+ lifecycleRevision: 0,
921
+ paused: false,
922
+ createdAt: now,
923
+ updatedAt: now,
924
+ };
925
+ ledger.branches[branch.branchId] = branch;
926
+ ledger.guards[branch.branchId] = (0, contracts_1.defaultGuard)(branch.branchId);
927
+ if (!ledger.roots[branch.rootId]) {
928
+ ledger.roots[branch.rootId] = { rootId: branch.rootId, workspaceId: this.workspaceId, defaultBranchId: branch.branchId, createdAt: now };
929
+ }
930
+ return branch;
931
+ }
932
+ requireBranch(ledger, target) {
933
+ const branch = ledger.branches[target.branchId];
934
+ if (!branch)
935
+ throw new contracts_1.ContinuationError('UNKNOWN_BRANCH', `Branch ${target.branchId} does not exist`);
936
+ if (branch.workspaceId !== this.workspaceId) {
937
+ throw new contracts_1.ContinuationError('ROUTE_MISMATCH', `Branch ${target.branchId} belongs to workspace ${branch.workspaceId}`);
938
+ }
939
+ if (target.rootId && branch.rootId !== target.rootId) {
940
+ throw new contracts_1.ContinuationError('ROUTE_MISMATCH', `Branch ${target.branchId} belongs to root ${branch.rootId}, not ${target.rootId}`);
941
+ }
942
+ return branch;
943
+ }
944
+ ensureGuard(ledger, branchId) {
945
+ if (!ledger.guards[branchId])
946
+ ledger.guards[branchId] = (0, contracts_1.defaultGuard)(branchId);
947
+ return ledger.guards[branchId];
948
+ }
949
+ /**
950
+ * Release the active attempt without ever moving the branch fence backward.
951
+ * A recovered/completed attempt's fence stays as the monotonic high-water
952
+ * mark; the next claim increments it again.
953
+ */
954
+ releaseGuard(ledger, branchId, fence) {
955
+ ledger.guards[branchId] = {
956
+ ...(0, contracts_1.defaultGuard)(branchId),
957
+ fence: Math.max(0, Math.floor(Number(fence) || 0)),
958
+ };
959
+ }
960
+ appendEvent(ledger, input) {
961
+ const event = {
962
+ ...input,
963
+ eventId: `event-${(0, crypto_1.randomUUID)()}`,
964
+ cursor: ledger.nextEventCursor++,
965
+ at: input.at || new Date().toISOString(),
966
+ };
967
+ ledger.events.push(event);
968
+ if (ledger.events.length > this.eventRetention)
969
+ ledger.events = ledger.events.slice(-this.eventRetention);
970
+ return event;
971
+ }
972
+ mutate(apply) {
973
+ fs.mkdirSync(path.dirname(this.ledgerPath), { recursive: true });
974
+ const lockHandle = this.acquireLock();
975
+ try {
976
+ this.invalidateCache();
977
+ const ledger = this.readLedgerUnlocked();
978
+ const result = apply(ledger);
979
+ this.writeLedgerUnlocked(ledger);
980
+ return result;
981
+ }
982
+ finally {
983
+ try {
984
+ fs.closeSync(lockHandle);
985
+ }
986
+ catch { /* lock already released */ }
987
+ try {
988
+ fs.unlinkSync(this.lockPath);
989
+ }
990
+ catch { /* another process may have removed it */ }
991
+ }
992
+ }
993
+ acquireLock() {
994
+ for (let attempt = 0; attempt < 400; attempt += 1) {
995
+ try {
996
+ return fs.openSync(this.lockPath, 'wx');
997
+ }
998
+ catch (error) {
999
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code || '') : '';
1000
+ if (!['EEXIST', 'EPERM', 'EACCES', 'EBUSY'].includes(code))
1001
+ throw error;
1002
+ if (code === 'EEXIST') {
1003
+ try {
1004
+ if (Date.now() - fs.statSync(this.lockPath).mtimeMs > 30_000)
1005
+ fs.unlinkSync(this.lockPath);
1006
+ }
1007
+ catch { /* lock may have been released */ }
1008
+ }
1009
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 + attempt);
1010
+ }
1011
+ }
1012
+ throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Timed out acquiring continuation ledger lock: ${this.lockPath}`);
1013
+ }
1014
+ readLedger() {
1015
+ this.ensureLedgerExists();
1016
+ const stat = fs.statSync(this.ledgerPath, { bigint: true });
1017
+ const fingerprint = `${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}`;
1018
+ if (this.cached && this.fingerprint === fingerprint)
1019
+ return this.cached;
1020
+ const ledger = this.readLedgerUnlocked();
1021
+ this.cached = ledger;
1022
+ this.fingerprint = fingerprint;
1023
+ return ledger;
1024
+ }
1025
+ readLedgerUnlocked() {
1026
+ this.ensureLedgerExists();
1027
+ try {
1028
+ const raw = fs.readFileSync(this.ledgerPath, 'utf-8').replace(/^\uFEFF/, '');
1029
+ const parsed = JSON.parse(raw);
1030
+ if (!parsed || parsed.version !== 1 || !parsed.branches || !parsed.builds) {
1031
+ throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Continuation ledger ${this.ledgerPath} has an unsupported shape`);
1032
+ }
1033
+ parsed.attempts = parsed.attempts || {};
1034
+ parsed.guards = parsed.guards || {};
1035
+ parsed.commands = parsed.commands || {};
1036
+ parsed.finals = parsed.finals || {};
1037
+ parsed.events = Array.isArray(parsed.events) ? parsed.events : [];
1038
+ parsed.nextEventCursor = Math.max(1, Number(parsed.nextEventCursor) || (parsed.events.at(-1)?.cursor || 0) + 1);
1039
+ parsed.nextQueueSequence = Math.max(1, Number(parsed.nextQueueSequence) || (Object.values(parsed.builds).reduce((max, build) => Math.max(max, build.queueSequence || 0), 0) + 1));
1040
+ return parsed;
1041
+ }
1042
+ catch (error) {
1043
+ if (error instanceof contracts_1.ContinuationError)
1044
+ throw error;
1045
+ throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Continuation ledger ${this.ledgerPath} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
1046
+ }
1047
+ }
1048
+ writeLedgerUnlocked(ledger) {
1049
+ const temp = `${this.ledgerPath}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`;
1050
+ fs.writeFileSync(temp, `${JSON.stringify(ledger, null, 2)}\n`, 'utf-8');
1051
+ fs.renameSync(temp, this.ledgerPath);
1052
+ this.invalidateCache();
1053
+ }
1054
+ ensureLedgerExists() {
1055
+ if (fs.existsSync(this.ledgerPath))
1056
+ return;
1057
+ fs.mkdirSync(path.dirname(this.ledgerPath), { recursive: true });
1058
+ const ledger = (0, contracts_1.emptyLedger)(this.workspaceId);
1059
+ const temp = `${this.ledgerPath}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`;
1060
+ fs.writeFileSync(temp, `${JSON.stringify(ledger, null, 2)}\n`, 'utf-8');
1061
+ fs.renameSync(temp, this.ledgerPath);
1062
+ }
1063
+ invalidateCache() {
1064
+ this.cached = null;
1065
+ this.fingerprint = '';
1066
+ }
1067
+ }
1068
+ exports.GuardedContinuationStore = GuardedContinuationStore;
1069
+ function cloneInput(input) {
1070
+ return {
1071
+ ...input,
1072
+ images: input.images?.map(image => ({ ...image })),
1073
+ attachments: input.attachments?.map(attachment => ({ ...attachment })),
1074
+ branchPath: input.branchPath?.slice(),
1075
+ };
1076
+ }
1077
+ //# sourceMappingURL=store.js.map