@orion-agents/orion-code 0.3.0 → 0.3.1

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 (120) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +24 -8
  3. package/README.zh-CN.md +23 -9
  4. package/dist/core/tool-artifacts.d.ts +2 -0
  5. package/dist/core/tool-artifacts.d.ts.map +1 -1
  6. package/dist/core/tool-artifacts.js +27 -1
  7. package/dist/core/tool-artifacts.js.map +1 -1
  8. package/dist/runtime/agent-runtime-controller.js +1 -2
  9. package/dist/runtime/agent-runtime-controller.js.map +1 -1
  10. package/dist/runtime/agent-runtime-protocol.d.ts +4 -0
  11. package/dist/runtime/agent-runtime-protocol.d.ts.map +1 -1
  12. package/dist/runtime/agent-runtime-protocol.js.map +1 -1
  13. package/dist/runtime/durable-tool-receipt-reader.d.ts +32 -0
  14. package/dist/runtime/durable-tool-receipt-reader.d.ts.map +1 -0
  15. package/dist/runtime/durable-tool-receipt-reader.js +130 -0
  16. package/dist/runtime/durable-tool-receipt-reader.js.map +1 -0
  17. package/dist/runtime/legacy-thread-materializer.d.ts +14 -1
  18. package/dist/runtime/legacy-thread-materializer.d.ts.map +1 -1
  19. package/dist/runtime/legacy-thread-materializer.js +44 -5
  20. package/dist/runtime/legacy-thread-materializer.js.map +1 -1
  21. package/dist/runtime/release-receipts.d.ts +6 -3
  22. package/dist/runtime/release-receipts.d.ts.map +1 -1
  23. package/dist/runtime/release-receipts.js +47 -4
  24. package/dist/runtime/release-receipts.js.map +1 -1
  25. package/dist/runtime/thread-event-store.d.ts +62 -9
  26. package/dist/runtime/thread-event-store.d.ts.map +1 -1
  27. package/dist/runtime/thread-event-store.js +363 -31
  28. package/dist/runtime/thread-event-store.js.map +1 -1
  29. package/dist/runtime/thread-session-index.d.ts +84 -0
  30. package/dist/runtime/thread-session-index.d.ts.map +1 -0
  31. package/dist/runtime/thread-session-index.js +503 -0
  32. package/dist/runtime/thread-session-index.js.map +1 -0
  33. package/dist/runtime/thread-session-view.d.ts +31 -13
  34. package/dist/runtime/thread-session-view.d.ts.map +1 -1
  35. package/dist/runtime/thread-session-view.js +116 -61
  36. package/dist/runtime/thread-session-view.js.map +1 -1
  37. package/dist/runtime/thread-ui-adapter.d.ts.map +1 -1
  38. package/dist/runtime/thread-ui-adapter.js +18 -58
  39. package/dist/runtime/thread-ui-adapter.js.map +1 -1
  40. package/dist/runtime/tool-detail-repository.d.ts.map +1 -1
  41. package/dist/runtime/tool-detail-repository.js +23 -19
  42. package/dist/runtime/tool-detail-repository.js.map +1 -1
  43. package/dist/runtime/tool-receipt-validator.d.ts +21 -0
  44. package/dist/runtime/tool-receipt-validator.d.ts.map +1 -0
  45. package/dist/runtime/tool-receipt-validator.js +157 -0
  46. package/dist/runtime/tool-receipt-validator.js.map +1 -0
  47. package/dist/services/redaction.d.ts +4 -0
  48. package/dist/services/redaction.d.ts.map +1 -1
  49. package/dist/services/redaction.js +63 -0
  50. package/dist/services/redaction.js.map +1 -1
  51. package/dist/services/session-index.d.ts +16 -0
  52. package/dist/services/session-index.d.ts.map +1 -1
  53. package/dist/services/session-index.js +45 -36
  54. package/dist/services/session-index.js.map +1 -1
  55. package/dist/services/session-storage.d.ts.map +1 -1
  56. package/dist/services/session-storage.js +3 -6
  57. package/dist/services/session-storage.js.map +1 -1
  58. package/dist/services/workspace-registry.d.ts +42 -0
  59. package/dist/services/workspace-registry.d.ts.map +1 -0
  60. package/dist/services/workspace-registry.js +222 -0
  61. package/dist/services/workspace-registry.js.map +1 -0
  62. package/dist/web/errors.d.ts +6 -0
  63. package/dist/web/errors.d.ts.map +1 -0
  64. package/dist/web/errors.js +13 -0
  65. package/dist/web/errors.js.map +1 -0
  66. package/dist/web/event-hub.d.ts +4 -0
  67. package/dist/web/event-hub.d.ts.map +1 -1
  68. package/dist/web/event-hub.js +71 -11
  69. package/dist/web/event-hub.js.map +1 -1
  70. package/dist/web/file-read-service.d.ts +55 -0
  71. package/dist/web/file-read-service.d.ts.map +1 -0
  72. package/dist/web/file-read-service.js +399 -0
  73. package/dist/web/file-read-service.js.map +1 -0
  74. package/dist/web/git-read-model-service.d.ts +77 -0
  75. package/dist/web/git-read-model-service.d.ts.map +1 -0
  76. package/dist/web/git-read-model-service.js +631 -0
  77. package/dist/web/git-read-model-service.js.map +1 -0
  78. package/dist/web/protocol.d.ts +63 -1
  79. package/dist/web/protocol.d.ts.map +1 -1
  80. package/dist/web/protocol.js +22 -1
  81. package/dist/web/protocol.js.map +1 -1
  82. package/dist/web/review-service.d.ts +38 -0
  83. package/dist/web/review-service.d.ts.map +1 -0
  84. package/dist/web/review-service.js +74 -0
  85. package/dist/web/review-service.js.map +1 -0
  86. package/dist/web/server.d.ts.map +1 -1
  87. package/dist/web/server.js +273 -27
  88. package/dist/web/server.js.map +1 -1
  89. package/dist/web/terminal-manager.d.ts +155 -0
  90. package/dist/web/terminal-manager.d.ts.map +1 -0
  91. package/dist/web/terminal-manager.js +728 -0
  92. package/dist/web/terminal-manager.js.map +1 -0
  93. package/dist/web/terminal-server.d.ts +8 -0
  94. package/dist/web/terminal-server.d.ts.map +1 -0
  95. package/dist/web/terminal-server.js +275 -0
  96. package/dist/web/terminal-server.js.map +1 -0
  97. package/dist/web/workbench-controller.d.ts +48 -12
  98. package/dist/web/workbench-controller.d.ts.map +1 -1
  99. package/dist/web/workbench-controller.js +651 -158
  100. package/dist/web/workbench-controller.js.map +1 -1
  101. package/dist/web-client/assets/DiffViewer-DpKoD07C.js +5 -0
  102. package/dist/web-client/assets/FilesPanel-BkoLmgjR.js +2 -0
  103. package/dist/web-client/assets/GitPanel-D20StcdG.js +1 -0
  104. package/dist/web-client/assets/ReviewPanel-3MVWbMQV.js +1 -0
  105. package/dist/web-client/assets/TerminalPanel-BLQ592Fb.js +21 -0
  106. package/dist/web-client/assets/TerminalPanel-DLuoa74B.css +1 -0
  107. package/dist/web-client/assets/index-BkIDl_xk.js +16 -0
  108. package/dist/web-client/assets/index-DXMpnWE8.css +1 -0
  109. package/dist/web-client/index.html +2 -2
  110. package/docs/architecture/v0.3.0-web-api.yaml +44 -13
  111. package/docs/architecture/v0.3.1-web-api.yaml +2290 -0
  112. package/docs/migration/v0.3.0-to-v0.3.1.md +88 -0
  113. package/docs/plan/v0.3.0-web-workbench-plan.md +58 -7
  114. package/docs/plan/v0.3.1-web-workbench-professional-shell-plan.md +848 -0
  115. package/docs/readme.md +10 -0
  116. package/docs/test/v0.3.1-web-workbench-e2e-plan.md +140 -0
  117. package/npm-shrinkwrap.json +185 -354
  118. package/package.json +16 -4
  119. package/dist/web-client/assets/index-BAEO71Gp.css +0 -1
  120. package/dist/web-client/assets/index-DSkFy7gM.js +0 -13
@@ -8,6 +8,7 @@ const file_lock_1 = require("../services/file-lock");
8
8
  const canonical_1 = require("./protocol/canonical");
9
9
  const runtime_protocol_v1_1 = require("./protocol/runtime-protocol-v1");
10
10
  const thread_projection_1 = require("./thread-projection");
11
+ const thread_session_index_1 = require("./thread-session-index");
11
12
  class ThreadEventStoreError extends Error {
12
13
  constructor(code, message, offset) {
13
14
  super(message);
@@ -36,6 +37,7 @@ class ThreadEventStore {
36
37
  }
37
38
  this.logPath = (0, path_1.join)(rootDir, `${threadId}.events.v1.jsonl`);
38
39
  this.projectionPath = (0, path_1.join)(rootDir, `${threadId}.projection.v1.json`);
40
+ this.headPath = (0, path_1.join)(rootDir, `${threadId}.head.v1.json`);
39
41
  this.compactStatePath = (0, path_1.join)(rootDir, `${threadId}.compact-state.v1.json`);
40
42
  this.compactCheckpointsDir = (0, path_1.join)(rootDir, `${threadId}.compact-checkpoints.v1`);
41
43
  this.maxLogBytes = positiveInteger(options.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES, 'maxLogBytes');
@@ -56,13 +58,12 @@ class ThreadEventStore {
56
58
  const commit = this.withLogLock(() => {
57
59
  ensurePrivateDirectory(this.rootDir);
58
60
  const head = this.loadVerifiedHead();
59
- const { scan } = head;
60
- if (scan.discardedTailBytes > 0)
61
- (0, fs_1.truncateSync)(this.logPath, scan.safeByteLength);
62
- let seq = scan.events.length;
63
- let previousHash = scan.records.at(-1)?.hash ?? null;
61
+ if (head.discardedTailBytes > 0)
62
+ (0, fs_1.truncateSync)(this.logPath, head.safeByteLength);
63
+ let seq = head.projection.cursor;
64
+ let previousHash = head.lastRecordHash;
64
65
  const records = [];
65
- const eventIds = new Set(scan.events.map(event => event.eventId));
66
+ const eventIds = new Set(head.eventIdSet);
66
67
  for (const input of inputs) {
67
68
  seq += 1;
68
69
  const eventId = this.idFactory();
@@ -103,18 +104,47 @@ class ThreadEventStore {
103
104
  writeProjection(this.projectionPath, projection);
104
105
  fsyncDirectory(this.rootDir);
105
106
  this.boundary('after_projection_write', seq);
106
- const nextScan = {
107
- records: [...scan.records, ...records],
108
- events: [...scan.events, ...committedEvents],
109
- safeByteLength: (0, fs_1.statSync)(this.logPath).size,
110
- discardedTailBytes: 0,
111
- };
112
- this.cachedHead = {
113
- logFingerprint: fileFingerprint(this.logPath),
107
+ const nextLogIdentity = readFileIdentity(this.logPath);
108
+ const safeByteLength = nextLogIdentity.bytes;
109
+ const nextScan = head.scan
110
+ ? {
111
+ records: [...head.scan.records, ...records],
112
+ events: [...head.scan.events, ...committedEvents],
113
+ safeByteLength,
114
+ discardedTailBytes: 0,
115
+ }
116
+ : undefined;
117
+ const nextHead = {
118
+ generation: head.generation + 1,
119
+ logFingerprint: nextLogIdentity.fingerprint,
120
+ log: storedLogIdentity(nextLogIdentity),
114
121
  projectionFingerprint: fileFingerprint(this.projectionPath),
115
- scan: nextScan,
122
+ ...(nextScan ? { scan: nextScan } : {}),
116
123
  projection,
124
+ lastEventTimestamp: committedEvents.at(-1)?.timestamp ?? head.lastEventTimestamp,
125
+ lastRecordHash: previousHash,
126
+ safeByteLength,
127
+ discardedTailBytes: 0,
128
+ eventIds: [...eventIds],
129
+ eventIdSet: eventIds,
130
+ verifiedPrefixes: head.verifiedPrefixes,
117
131
  };
132
+ this.cachedHead = nextHead;
133
+ this.tryPersistVerifiedHead(nextHead);
134
+ try {
135
+ (0, thread_session_index_1.advanceThreadSessionIndexV1)({
136
+ rootDir: this.rootDir,
137
+ threadId: this.threadId,
138
+ previousHead: sessionIndexHead(head),
139
+ nextHead: sessionIndexHead(nextHead),
140
+ projection,
141
+ committedEvents,
142
+ });
143
+ }
144
+ catch {
145
+ // The immutable transcript index is derived. A stale manifest forces
146
+ // a verified rebuild on the next snapshot and cannot veto the fact.
147
+ }
118
148
  return deepFreeze({ events: committedEvents, projection });
119
149
  });
120
150
  this.notifyCommitted(commit.events);
@@ -151,7 +181,7 @@ class ThreadEventStore {
151
181
  throw new ThreadEventStoreError('ORION_THREAD_EVENT_LIMIT', `Replay limit ${safeLimit} exceeds ${this.maxReplayEvents}`);
152
182
  }
153
183
  return this.withLogLock(() => {
154
- const { scan } = this.loadVerifiedHead();
184
+ const scan = requireVerifiedScan(this.loadVerifiedHead(true));
155
185
  const lastSeq = scan.events.length;
156
186
  if (cursor > lastSeq) {
157
187
  throw new ThreadEventStoreError('ORION_THREAD_EVENT_CURSOR', `Replay cursor ${cursor} is ahead of durable cursor ${lastSeq}`);
@@ -166,6 +196,44 @@ class ThreadEventStore {
166
196
  });
167
197
  });
168
198
  }
199
+ /**
200
+ * Verify and seal an immutable imported prefix. The first call may scan the
201
+ * authoritative log; later processes reuse the prefix receipt while the
202
+ * exact log identity/projection checkpoint remains current. Internal append
203
+ * carries the proof forward because the hash-chained prefix cannot change.
204
+ */
205
+ verifyDurablePrefix(cursor, eventDigest, projectionDigest) {
206
+ if (!Number.isSafeInteger(cursor) ||
207
+ cursor <= 0 ||
208
+ !isSha256(eventDigest) ||
209
+ !isSha256(projectionDigest)) {
210
+ return false;
211
+ }
212
+ return this.withLogLock(() => {
213
+ let head = this.loadVerifiedHead();
214
+ if (cursor > head.projection.cursor)
215
+ return false;
216
+ const expected = { cursor, eventDigest, projectionDigest };
217
+ if (head.verifiedPrefixes.some(prefix => sameVerifiedPrefix(prefix, expected)))
218
+ return true;
219
+ head = this.loadVerifiedHead(true);
220
+ const scan = requireVerifiedScan(head);
221
+ const events = scan.events.slice(0, cursor);
222
+ if (events.length !== cursor ||
223
+ (0, canonical_1.digestRuntimeValue)(events) !== eventDigest ||
224
+ (0, thread_projection_1.projectThreadEvents)(this.threadId, events).digest !== projectionDigest) {
225
+ return false;
226
+ }
227
+ const next = {
228
+ ...head,
229
+ generation: head.generation + 1,
230
+ verifiedPrefixes: [...head.verifiedPrefixes, expected].sort((left, right) => left.cursor - right.cursor),
231
+ };
232
+ this.cachedHead = next;
233
+ this.tryPersistVerifiedHead(next);
234
+ return true;
235
+ });
236
+ }
169
237
  getCursor() {
170
238
  const cached = this.cachedHead;
171
239
  if (cached && cached.logFingerprint === fileFingerprint(this.logPath)) {
@@ -173,6 +241,34 @@ class ThreadEventStore {
173
241
  }
174
242
  return this.withLogLock(() => this.loadVerifiedHead().projection.cursor);
175
243
  }
244
+ /**
245
+ * Read the compact persisted receipt without parsing the potentially large
246
+ * projection. Callers must fall back to the authoritative scan whenever the
247
+ * receipt is missing, stale, or lacks their required cutover proof.
248
+ */
249
+ capturePersistedCheckpointHead() {
250
+ return this.withLogLock(() => {
251
+ const identity = readFileIdentity(this.logPath);
252
+ const cached = this.cachedHead;
253
+ if (cached?.logFingerprint === identity.fingerprint) {
254
+ return checkpointHead(cached);
255
+ }
256
+ const stored = readStoredThreadHead(this.headPath, this.threadId);
257
+ if (!stored ||
258
+ !sameStoredLogIdentity(stored.log, identity) ||
259
+ stored.safeByteLength + stored.discardedTailBytes !== identity.bytes) {
260
+ return undefined;
261
+ }
262
+ return deepFreeze({
263
+ cursor: stored.cursor,
264
+ projectionDigest: stored.projectionDigest,
265
+ lastEventTimestamp: stored.lastEventTimestamp,
266
+ lastRecordHash: stored.lastRecordHash,
267
+ log: { ...stored.log },
268
+ verifiedPrefixes: stored.verifiedPrefixes.map(prefix => ({ ...prefix })),
269
+ });
270
+ });
271
+ }
176
272
  /** Capture the catalog read model and its exact verified log identity atomically. */
177
273
  captureReadModelHead() {
178
274
  return this.withLogLock(() => {
@@ -185,8 +281,8 @@ class ThreadEventStore {
185
281
  }
186
282
  return deepFreeze({
187
283
  projection: head.projection,
188
- lastEventTimestamp: head.scan.events.at(-1)?.timestamp ?? 0,
189
- lastRecordHash: head.scan.records.at(-1)?.hash ?? null,
284
+ lastEventTimestamp: head.lastEventTimestamp,
285
+ lastRecordHash: head.lastRecordHash,
190
286
  log: {
191
287
  bytes: identity.bytes,
192
288
  device: identity.device,
@@ -205,23 +301,29 @@ class ThreadEventStore {
205
301
  const head = this.loadVerifiedHead();
206
302
  const replayed = head.projection;
207
303
  const projectionFingerprint = fileFingerprint(this.projectionPath);
208
- if (head.projectionFingerprint === projectionFingerprint)
304
+ if (head.projectionFingerprint === projectionFingerprint) {
305
+ this.tryPersistVerifiedHead(head);
209
306
  return replayed;
307
+ }
210
308
  const cached = readProjection(this.projectionPath);
211
309
  if (cached &&
212
310
  cached.threadId === this.threadId &&
213
311
  cached.cursor === replayed.cursor &&
214
312
  cached.digest === replayed.digest &&
215
313
  (0, thread_projection_1.verifyThreadProjectionDigest)(cached)) {
216
- this.cachedHead = { ...head, projectionFingerprint };
314
+ const current = { ...head, projectionFingerprint };
315
+ this.cachedHead = current;
316
+ this.tryPersistVerifiedHead(current);
217
317
  return replayed;
218
318
  }
219
319
  writeProjection(this.projectionPath, replayed);
220
320
  fsyncDirectory(this.rootDir);
221
- this.cachedHead = {
321
+ const current = {
222
322
  ...head,
223
323
  projectionFingerprint: fileFingerprint(this.projectionPath),
224
324
  };
325
+ this.cachedHead = current;
326
+ this.tryPersistVerifiedHead(current);
225
327
  return replayed;
226
328
  });
227
329
  }
@@ -231,7 +333,7 @@ class ThreadEventStore {
231
333
  */
232
334
  loadAuthoritativeModelHistory() {
233
335
  return this.withLogLock(() => {
234
- const { scan } = this.loadVerifiedHead();
336
+ const scan = requireVerifiedScan(this.loadVerifiedHead(true));
235
337
  const state = readCompactState(this.compactStatePath, this.threadId);
236
338
  if (state)
237
339
  this.assertCompactCheckpoint(state);
@@ -248,7 +350,9 @@ class ThreadEventStore {
248
350
  captureCompactSource(turnId) {
249
351
  return this.withLogLock(() => {
250
352
  ensurePrivateDirectory(this.rootDir);
251
- const { scan, projection } = this.loadVerifiedHead();
353
+ const head = this.loadVerifiedHead(true);
354
+ const scan = requireVerifiedScan(head);
355
+ const { projection } = head;
252
356
  assertActiveMaintenanceTurn(projection, turnId);
253
357
  const state = readCompactState(this.compactStatePath, this.threadId);
254
358
  if (state)
@@ -283,7 +387,9 @@ class ThreadEventStore {
283
387
  ensurePrivateDirectory(this.compactCheckpointsDir);
284
388
  validateCompactCommitInput(input, this.threadId);
285
389
  input.onBoundary('before_cas_recheck');
286
- const { scan, projection } = this.loadVerifiedHead();
390
+ const head = this.loadVerifiedHead(true);
391
+ const scan = requireVerifiedScan(head);
392
+ const { projection } = head;
287
393
  const eventsAfterAnchor = scan.events.slice(input.expectedEventAnchor.cursor);
288
394
  const onlyQueuedFollowUps = eventsAfterAnchor.length > 0 &&
289
395
  eventsAfterAnchor.every(event => event.payload.type === 'turn.queued');
@@ -348,7 +454,7 @@ class ThreadEventStore {
348
454
  }
349
455
  listCompactEvents() {
350
456
  return this.withLogLock(() => {
351
- const { scan } = this.loadVerifiedHead();
457
+ const scan = requireVerifiedScan(this.loadVerifiedHead(true));
352
458
  return deepFreeze(scan.events.filter((event) => event.payload.type === 'compact.started' ||
353
459
  event.payload.type === 'compact.completed' ||
354
460
  event.payload.type === 'compact.failed'));
@@ -400,14 +506,27 @@ class ThreadEventStore {
400
506
  return inputs.length > 0 ? this.appendDurableBatch(inputs).projection : projection;
401
507
  }
402
508
  /**
403
- * Return the last fully verified log head, rescanning only after the file
404
- * identity changes. Callers already hold the Thread log lock.
509
+ * Return the last verified log head. A fresh process may adopt the persisted
510
+ * receipt only when the exact JSONL identity and independently validated
511
+ * projection still match. Callers that need historical events explicitly
512
+ * request a scan; cursor/projection/append paths stay O(head + projection).
405
513
  */
406
- loadVerifiedHead() {
514
+ loadVerifiedHead(requireScan = false) {
407
515
  ensurePrivateDirectory(this.rootDir);
408
- const logFingerprint = fileFingerprint(this.logPath);
409
- if (this.cachedHead?.logFingerprint === logFingerprint)
516
+ const logIdentity = readFileIdentity(this.logPath);
517
+ if (this.cachedHead?.logFingerprint === logIdentity.fingerprint &&
518
+ (!requireScan || this.cachedHead.scan)) {
410
519
  return this.cachedHead;
520
+ }
521
+ const stored = readStoredThreadHead(this.headPath, this.threadId);
522
+ if (!requireScan && stored) {
523
+ const projection = readProjection(this.projectionPath);
524
+ const persisted = hydratePersistedThreadHead(stored, logIdentity, projection, fileFingerprint(this.projectionPath));
525
+ if (persisted) {
526
+ this.cachedHead = persisted;
527
+ return persisted;
528
+ }
529
+ }
411
530
  const scan = scanThreadEventLog(this.logPath, this.threadId, this.maxLogBytes);
412
531
  this.onLogScan?.({
413
532
  threadId: this.threadId,
@@ -415,10 +534,50 @@ class ThreadEventStore {
415
534
  events: scan.events.length,
416
535
  });
417
536
  const projection = (0, thread_projection_1.projectThreadEvents)(this.threadId, scan.events);
418
- const head = { logFingerprint, scan, projection };
537
+ const eventIds = scan.events.map(event => event.eventId);
538
+ const projectionFingerprint = projectionFileMatches(this.projectionPath, projection)
539
+ ? fileFingerprint(this.projectionPath)
540
+ : undefined;
541
+ const head = {
542
+ generation: (stored?.generation ?? 0) + 1,
543
+ logFingerprint: logIdentity.fingerprint,
544
+ log: storedLogIdentity(logIdentity),
545
+ ...(projectionFingerprint ? { projectionFingerprint } : {}),
546
+ scan,
547
+ projection,
548
+ lastEventTimestamp: scan.events.at(-1)?.timestamp ?? 0,
549
+ lastRecordHash: scan.records.at(-1)?.hash ?? null,
550
+ safeByteLength: scan.safeByteLength,
551
+ discardedTailBytes: scan.discardedTailBytes,
552
+ eventIds,
553
+ eventIdSet: new Set(eventIds),
554
+ verifiedPrefixes: retainVerifiedPrefixes(stored?.verifiedPrefixes ?? [], scan, this.threadId),
555
+ };
419
556
  this.cachedHead = head;
557
+ if (projectionFingerprint)
558
+ this.tryPersistVerifiedHead(head);
420
559
  return head;
421
560
  }
561
+ tryPersistVerifiedHead(head) {
562
+ try {
563
+ const logIdentity = readFileIdentity(this.logPath);
564
+ if (logIdentity.fingerprint !== head.logFingerprint)
565
+ return;
566
+ if (!projectionFileMatches(this.projectionPath, head.projection))
567
+ return;
568
+ const stored = createStoredThreadHead(head, logIdentity);
569
+ (0, atomic_write_1.atomicWriteFileSync)(this.headPath, `${(0, canonical_1.canonicalRuntimeJson)(stored)}\n`, {
570
+ mode: 0o600,
571
+ fsync: true,
572
+ });
573
+ fsyncDirectory(this.rootDir);
574
+ }
575
+ catch {
576
+ // The JSONL fact and projection are already durable. A missing/stale
577
+ // derived head merely makes the next process take the full verification
578
+ // path; it must never turn a committed fact into a reported failure.
579
+ }
580
+ }
422
581
  withLogLock(operation) {
423
582
  ensurePrivateDirectory((0, path_1.dirname)(this.logPath));
424
583
  return (0, file_lock_1.withFileLockSync)(this.logPath, operation, { waitMs: this.lockWaitMs });
@@ -569,6 +728,179 @@ function readProjection(path) {
569
728
  return null;
570
729
  }
571
730
  }
731
+ function projectionFileMatches(path, expected) {
732
+ const projection = readProjection(path);
733
+ return Boolean(projection &&
734
+ projection.threadId === expected.threadId &&
735
+ projection.cursor === expected.cursor &&
736
+ projection.digest === expected.digest &&
737
+ (0, thread_projection_1.verifyThreadProjectionDigest)(projection));
738
+ }
739
+ function readStoredThreadHead(path, threadId) {
740
+ if (!(0, fs_1.existsSync)(path))
741
+ return null;
742
+ try {
743
+ const value = JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
744
+ if (!isRecord(value))
745
+ return null;
746
+ const { digest: _digest, ...content } = value;
747
+ void _digest;
748
+ if (value.version !== 1 ||
749
+ value.threadId !== threadId ||
750
+ !Number.isSafeInteger(value.generation) ||
751
+ value.generation < 1 ||
752
+ !Number.isSafeInteger(value.cursor) ||
753
+ value.cursor < 0 ||
754
+ typeof value.projectionDigest !== 'string' ||
755
+ !Number.isSafeInteger(value.lastEventTimestamp) ||
756
+ value.lastEventTimestamp < 0 ||
757
+ (value.lastRecordHash !== null && !isSha256(value.lastRecordHash)) ||
758
+ !Number.isSafeInteger(value.safeByteLength) ||
759
+ value.safeByteLength < 0 ||
760
+ !Number.isSafeInteger(value.discardedTailBytes) ||
761
+ value.discardedTailBytes < 0 ||
762
+ !Array.isArray(value.eventIds) ||
763
+ !Array.isArray(value.verifiedPrefixes) ||
764
+ value.verifiedPrefixes.length > 32 ||
765
+ !isStoredLogIdentity(value.log) ||
766
+ typeof value.digest !== 'string' ||
767
+ (0, canonical_1.digestRuntimeValue)(content) !== value.digest) {
768
+ return null;
769
+ }
770
+ const eventIds = value.eventIds;
771
+ if (eventIds.length !== value.cursor ||
772
+ eventIds.some(eventId => typeof eventId !== 'string' || !(0, runtime_protocol_v1_1.isRuntimeId)(eventId)) ||
773
+ new Set(eventIds).size !== eventIds.length ||
774
+ (value.cursor === 0) !== (value.lastRecordHash === null) ||
775
+ (value.cursor === 0 && value.lastEventTimestamp !== 0)) {
776
+ return null;
777
+ }
778
+ const prefixes = value.verifiedPrefixes;
779
+ if (prefixes.some(prefix => !isStoredVerifiedPrefix(prefix, value.cursor)) ||
780
+ new Set(prefixes.map(prefix => prefix.cursor)).size !==
781
+ prefixes.length) {
782
+ return null;
783
+ }
784
+ return deepFreeze(value);
785
+ }
786
+ catch {
787
+ return null;
788
+ }
789
+ }
790
+ function hydratePersistedThreadHead(stored, logIdentity, projection, projectionFingerprint) {
791
+ if (!sameStoredLogIdentity(stored.log, logIdentity) ||
792
+ stored.safeByteLength + stored.discardedTailBytes !== logIdentity.bytes ||
793
+ !projection ||
794
+ projection.threadId !== stored.threadId ||
795
+ projection.cursor !== stored.cursor ||
796
+ projection.digest !== stored.projectionDigest ||
797
+ !(0, thread_projection_1.verifyThreadProjectionDigest)(projection)) {
798
+ return null;
799
+ }
800
+ const eventIds = [...stored.eventIds];
801
+ return {
802
+ generation: stored.generation,
803
+ logFingerprint: logIdentity.fingerprint,
804
+ log: storedLogIdentity(logIdentity),
805
+ projectionFingerprint,
806
+ projection,
807
+ lastEventTimestamp: stored.lastEventTimestamp,
808
+ lastRecordHash: stored.lastRecordHash,
809
+ safeByteLength: stored.safeByteLength,
810
+ discardedTailBytes: stored.discardedTailBytes,
811
+ eventIds,
812
+ eventIdSet: new Set(eventIds),
813
+ verifiedPrefixes: stored.verifiedPrefixes.map(prefix => ({ ...prefix })),
814
+ };
815
+ }
816
+ function createStoredThreadHead(head, logIdentity) {
817
+ const content = {
818
+ version: 1,
819
+ generation: head.generation,
820
+ threadId: head.projection.threadId,
821
+ cursor: head.projection.cursor,
822
+ projectionDigest: head.projection.digest,
823
+ lastEventTimestamp: head.lastEventTimestamp,
824
+ lastRecordHash: head.lastRecordHash,
825
+ safeByteLength: head.safeByteLength,
826
+ discardedTailBytes: head.discardedTailBytes,
827
+ eventIds: [...head.eventIds],
828
+ verifiedPrefixes: head.verifiedPrefixes.map(prefix => ({ ...prefix })),
829
+ log: storedLogIdentity(logIdentity),
830
+ };
831
+ return deepFreeze({ ...content, digest: (0, canonical_1.digestRuntimeValue)(content) });
832
+ }
833
+ function retainVerifiedPrefixes(prefixes, scan, threadId) {
834
+ return prefixes.filter(prefix => {
835
+ if (prefix.cursor > scan.events.length)
836
+ return false;
837
+ const events = scan.events.slice(0, prefix.cursor);
838
+ return ((0, canonical_1.digestRuntimeValue)(events) === prefix.eventDigest &&
839
+ (0, thread_projection_1.projectThreadEvents)(threadId, events).digest === prefix.projectionDigest);
840
+ });
841
+ }
842
+ function requireVerifiedScan(head) {
843
+ if (!head.scan)
844
+ throw new Error('Thread event history was not loaded');
845
+ return head.scan;
846
+ }
847
+ function sessionIndexHead(head) {
848
+ return {
849
+ cursor: head.projection.cursor,
850
+ projectionDigest: head.projection.digest,
851
+ lastEventTimestamp: head.lastEventTimestamp,
852
+ lastRecordHash: head.lastRecordHash,
853
+ log: head.log,
854
+ };
855
+ }
856
+ function checkpointHead(head) {
857
+ return deepFreeze({
858
+ cursor: head.projection.cursor,
859
+ projectionDigest: head.projection.digest,
860
+ lastEventTimestamp: head.lastEventTimestamp,
861
+ lastRecordHash: head.lastRecordHash,
862
+ log: { ...head.log },
863
+ verifiedPrefixes: head.verifiedPrefixes.map(prefix => ({ ...prefix })),
864
+ });
865
+ }
866
+ function isStoredVerifiedPrefix(value, headCursor) {
867
+ return (isRecord(value) &&
868
+ Number.isSafeInteger(value.cursor) &&
869
+ value.cursor > 0 &&
870
+ value.cursor <= headCursor &&
871
+ isSha256(value.eventDigest) &&
872
+ isSha256(value.projectionDigest));
873
+ }
874
+ function sameVerifiedPrefix(left, right) {
875
+ return (left.cursor === right.cursor &&
876
+ left.eventDigest === right.eventDigest &&
877
+ left.projectionDigest === right.projectionDigest);
878
+ }
879
+ function isStoredLogIdentity(value) {
880
+ return (isRecord(value) &&
881
+ Number.isSafeInteger(value.bytes) &&
882
+ value.bytes >= 0 &&
883
+ ['device', 'inode', 'mtimeNs', 'ctimeNs'].every(key => typeof value[key] === 'string' && /^\d+$/.test(value[key])));
884
+ }
885
+ function sameStoredLogIdentity(stored, current) {
886
+ return (stored.bytes === current.bytes &&
887
+ stored.device === current.device &&
888
+ stored.inode === current.inode &&
889
+ stored.mtimeNs === current.mtimeNs &&
890
+ stored.ctimeNs === current.ctimeNs);
891
+ }
892
+ function storedLogIdentity(identity) {
893
+ return {
894
+ bytes: identity.bytes,
895
+ device: identity.device,
896
+ inode: identity.inode,
897
+ mtimeNs: identity.mtimeNs,
898
+ ctimeNs: identity.ctimeNs,
899
+ };
900
+ }
901
+ function isSha256(value) {
902
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
903
+ }
572
904
  function readFileIdentity(path) {
573
905
  try {
574
906
  const stats = (0, fs_1.statSync)(path, { bigint: true });