@exaudeus/workrail 0.9.0 → 0.11.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 (54) hide show
  1. package/dist/manifest.json +176 -32
  2. package/dist/runtime/brand.d.ts +1 -3
  3. package/dist/v2/durable-core/canonical/hashing.d.ts +3 -1
  4. package/dist/v2/durable-core/canonical/hashing.js +9 -0
  5. package/dist/v2/durable-core/ids/index.d.ts +4 -0
  6. package/dist/v2/durable-core/ids/index.js +8 -0
  7. package/dist/v2/durable-core/ids/with-healthy-session-lock.d.ts +7 -0
  8. package/dist/v2/durable-core/ids/with-healthy-session-lock.js +2 -0
  9. package/dist/v2/durable-core/projections/snapshot-state.d.ts +3 -0
  10. package/dist/v2/durable-core/projections/snapshot-state.js +14 -0
  11. package/dist/v2/durable-core/schemas/execution-snapshot/execution-snapshot.v1.d.ts +1042 -0
  12. package/dist/v2/durable-core/schemas/execution-snapshot/execution-snapshot.v1.js +119 -0
  13. package/dist/v2/durable-core/schemas/execution-snapshot/index.d.ts +4 -0
  14. package/dist/v2/durable-core/schemas/execution-snapshot/index.js +17 -0
  15. package/dist/v2/durable-core/schemas/execution-snapshot/step-instance-key.d.ts +21 -0
  16. package/dist/v2/durable-core/schemas/execution-snapshot/step-instance-key.js +67 -0
  17. package/dist/v2/durable-core/schemas/session/events.d.ts +3 -3
  18. package/dist/v2/durable-core/schemas/session/events.js +9 -5
  19. package/dist/v2/durable-core/schemas/session/index.d.ts +1 -0
  20. package/dist/v2/durable-core/schemas/session/session-health.d.ts +25 -0
  21. package/dist/v2/durable-core/schemas/session/session-health.js +2 -0
  22. package/dist/v2/durable-core/tokens/base64url.d.ts +7 -0
  23. package/dist/v2/durable-core/tokens/base64url.js +16 -0
  24. package/dist/v2/durable-core/tokens/index.d.ts +7 -0
  25. package/dist/v2/durable-core/tokens/index.js +20 -0
  26. package/dist/v2/durable-core/tokens/payloads.d.ts +210 -0
  27. package/dist/v2/durable-core/tokens/payloads.js +53 -0
  28. package/dist/v2/durable-core/tokens/token-codec.d.ts +31 -0
  29. package/dist/v2/durable-core/tokens/token-codec.js +64 -0
  30. package/dist/v2/durable-core/tokens/token-signer.d.ts +15 -0
  31. package/dist/v2/durable-core/tokens/token-signer.js +55 -0
  32. package/dist/v2/infra/local/data-dir/index.d.ts +4 -0
  33. package/dist/v2/infra/local/data-dir/index.js +12 -0
  34. package/dist/v2/infra/local/hmac-sha256/index.d.ts +5 -0
  35. package/dist/v2/infra/local/hmac-sha256/index.js +16 -0
  36. package/dist/v2/infra/local/keyring/index.d.ts +14 -0
  37. package/dist/v2/infra/local/keyring/index.js +103 -0
  38. package/dist/v2/infra/local/session-store/index.d.ts +7 -6
  39. package/dist/v2/infra/local/session-store/index.js +244 -129
  40. package/dist/v2/infra/local/snapshot-store/index.d.ts +15 -0
  41. package/dist/v2/infra/local/snapshot-store/index.js +76 -0
  42. package/dist/v2/ports/data-dir.port.d.ts +4 -0
  43. package/dist/v2/ports/hmac-sha256.port.d.ts +4 -0
  44. package/dist/v2/ports/hmac-sha256.port.js +2 -0
  45. package/dist/v2/ports/keyring.port.d.ts +26 -0
  46. package/dist/v2/ports/keyring.port.js +2 -0
  47. package/dist/v2/ports/session-event-log-store.port.d.ts +14 -2
  48. package/dist/v2/ports/snapshot-store.port.d.ts +17 -0
  49. package/dist/v2/ports/snapshot-store.port.js +2 -0
  50. package/dist/v2/projections/session-health.d.ts +1 -15
  51. package/dist/v2/projections/session-health.js +1 -4
  52. package/dist/v2/usecases/execution-session-gate.d.ts +53 -0
  53. package/dist/v2/usecases/execution-session-gate.js +167 -0
  54. package/package.json +2 -2
@@ -11,14 +11,19 @@ class StoreFailure extends Error {
11
11
  }
12
12
  }
13
13
  class LocalSessionEventLogStoreV2 {
14
- constructor(dataDir, fs, sha256, lock) {
14
+ constructor(dataDir, fs, sha256) {
15
15
  this.dataDir = dataDir;
16
16
  this.fs = fs;
17
17
  this.sha256 = sha256;
18
- this.lock = lock;
19
18
  }
20
- append(sessionId, plan) {
21
- return neverthrow_1.ResultAsync.fromPromise(this.appendImpl(sessionId, plan), (e) => {
19
+ append(lock, plan) {
20
+ if (!lock.assertHeld()) {
21
+ return (0, neverthrow_1.errAsync)({
22
+ code: 'SESSION_STORE_INVARIANT_VIOLATION',
23
+ message: 'WithHealthySessionLock used after gate callback ended (witness misuse-after-release)',
24
+ });
25
+ }
26
+ return neverthrow_1.ResultAsync.fromPromise(this.appendImpl(lock.sessionId, plan), (e) => {
22
27
  if (e instanceof StoreFailure)
23
28
  return e.storeError;
24
29
  return { code: 'SESSION_STORE_IO_ERROR', message: e instanceof Error ? e.message : String(e) };
@@ -31,134 +36,148 @@ class LocalSessionEventLogStoreV2 {
31
36
  return { code: 'SESSION_STORE_IO_ERROR', message: e instanceof Error ? e.message : String(e) };
32
37
  });
33
38
  }
39
+ loadValidatedPrefix(sessionId) {
40
+ return neverthrow_1.ResultAsync.fromPromise(this.loadValidatedPrefixImpl(sessionId), (e) => {
41
+ if (e instanceof StoreFailure)
42
+ return e.storeError;
43
+ return { code: 'SESSION_STORE_IO_ERROR', message: e instanceof Error ? e.message : String(e) };
44
+ });
45
+ }
34
46
  async appendImpl(sessionId, plan) {
35
- const lockHandle = await this.unwrap(this.lock.acquire(sessionId), mapLockError);
36
- try {
37
- const sessionDir = this.dataDir.sessionDir(sessionId);
38
- const eventsDir = this.dataDir.sessionEventsDir(sessionId);
39
- const manifestPath = this.dataDir.sessionManifestPath(sessionId);
40
- await this.unwrap(this.fs.mkdirp(eventsDir), mapFsToStoreError);
41
- const { manifest, events: existingEvents } = await this.loadTruthOrEmpty(sessionId);
42
- validateManifestContiguityOrThrow(manifest);
43
- const existingByDedupeKey = new Set(existingEvents.map((e) => e.dedupeKey));
44
- const allExist = plan.events.every((e) => existingByDedupeKey.has(e.dedupeKey));
45
- if (allExist) {
46
- return;
47
- }
48
- const anyExist = plan.events.some((e) => existingByDedupeKey.has(e.dedupeKey));
49
- if (anyExist && !allExist) {
50
- throw new StoreFailure({
51
- code: 'SESSION_STORE_INVARIANT_VIOLATION',
52
- message: 'Partial dedupeKey collision detected (some events exist, some do not); this is an invariant violation',
53
- });
54
- }
55
- const expectedFirstEventIndex = nextEventIndexFromManifest(manifest);
56
- validateAppendPlanOrThrow(sessionId, plan, expectedFirstEventIndex);
57
- const first = plan.events[0].eventIndex;
58
- const last = plan.events[plan.events.length - 1].eventIndex;
59
- const segmentRelPath = segmentRelPathFor(first, last);
60
- const segmentPath = `${sessionDir}/${segmentRelPath}`;
61
- const tmpPath = `${segmentPath}.tmp`;
62
- const segmentBytes = concatJsonlRecords(plan.events);
63
- const tmpHandle = await this.unwrap(this.fs.openWriteTruncate(tmpPath), mapFsToStoreError);
64
- await this.unwrap(this.fs.writeAll(tmpHandle.fd, segmentBytes), mapFsToStoreError);
65
- await this.unwrap(this.fs.fsyncFile(tmpHandle.fd), mapFsToStoreError);
66
- await this.unwrap(this.fs.closeFile(tmpHandle.fd), mapFsToStoreError);
67
- await this.unwrap(this.fs.rename(tmpPath, segmentPath), mapFsToStoreError);
68
- await this.unwrap(this.fs.fsyncDir(eventsDir), mapFsToStoreError);
69
- const digest = this.sha256.sha256(segmentBytes);
70
- const segClosed = {
47
+ const sessionDir = this.dataDir.sessionDir(sessionId);
48
+ const eventsDir = this.dataDir.sessionEventsDir(sessionId);
49
+ const manifestPath = this.dataDir.sessionManifestPath(sessionId);
50
+ await this.unwrap(this.fs.mkdirp(eventsDir), mapFsToStoreError);
51
+ const { manifest, events: existingEvents } = await this.loadTruthOrEmpty(sessionId);
52
+ validateManifestContiguityOrThrow(manifest);
53
+ const existingByDedupeKey = new Set(existingEvents.map((e) => e.dedupeKey));
54
+ const allExist = plan.events.every((e) => existingByDedupeKey.has(e.dedupeKey));
55
+ if (allExist) {
56
+ return;
57
+ }
58
+ const anyExist = plan.events.some((e) => existingByDedupeKey.has(e.dedupeKey));
59
+ if (anyExist && !allExist) {
60
+ throw new StoreFailure({
61
+ code: 'SESSION_STORE_INVARIANT_VIOLATION',
62
+ message: 'Partial dedupeKey collision detected (some events exist, some do not); this is an invariant violation',
63
+ });
64
+ }
65
+ const expectedFirstEventIndex = nextEventIndexFromManifest(manifest);
66
+ validateAppendPlanOrThrow(sessionId, plan, expectedFirstEventIndex);
67
+ const first = plan.events[0].eventIndex;
68
+ const last = plan.events[plan.events.length - 1].eventIndex;
69
+ const segmentRelPath = segmentRelPathFor(first, last);
70
+ const segmentPath = `${sessionDir}/${segmentRelPath}`;
71
+ const tmpPath = `${segmentPath}.tmp`;
72
+ const segmentBytes = concatJsonlRecords(plan.events);
73
+ const tmpHandle = await this.unwrap(this.fs.openWriteTruncate(tmpPath), mapFsToStoreError);
74
+ await this.unwrap(this.fs.writeAll(tmpHandle.fd, segmentBytes), mapFsToStoreError);
75
+ await this.unwrap(this.fs.fsyncFile(tmpHandle.fd), mapFsToStoreError);
76
+ await this.unwrap(this.fs.closeFile(tmpHandle.fd), mapFsToStoreError);
77
+ await this.unwrap(this.fs.rename(tmpPath, segmentPath), mapFsToStoreError);
78
+ await this.unwrap(this.fs.fsyncDir(eventsDir), mapFsToStoreError);
79
+ const digest = this.sha256.sha256(segmentBytes);
80
+ const segClosed = {
81
+ v: 1,
82
+ manifestIndex: nextManifestIndex(manifest),
83
+ sessionId,
84
+ kind: 'segment_closed',
85
+ firstEventIndex: first,
86
+ lastEventIndex: last,
87
+ segmentRelPath,
88
+ sha256: digest,
89
+ bytes: segmentBytes.length,
90
+ };
91
+ await this.appendManifestRecords(manifestPath, [segClosed]);
92
+ const pins = sortedPins(plan.snapshotPins);
93
+ if (pins.length > 0) {
94
+ const startIndex = segClosed.manifestIndex + 1;
95
+ const records = pins.map((p, i) => ({
71
96
  v: 1,
72
- manifestIndex: nextManifestIndex(manifest),
97
+ manifestIndex: startIndex + i,
73
98
  sessionId,
74
- kind: 'segment_closed',
75
- firstEventIndex: first,
76
- lastEventIndex: last,
77
- segmentRelPath,
78
- sha256: digest,
79
- bytes: segmentBytes.length,
80
- };
81
- await this.appendManifestRecords(manifestPath, [segClosed]);
82
- const pins = sortedPins(plan.snapshotPins);
83
- if (pins.length > 0) {
84
- const startIndex = segClosed.manifestIndex + 1;
85
- const records = pins.map((p, i) => ({
86
- v: 1,
87
- manifestIndex: startIndex + i,
88
- sessionId,
89
- kind: 'snapshot_pinned',
90
- eventIndex: p.eventIndex,
91
- snapshotRef: p.snapshotRef,
92
- createdByEventId: p.createdByEventId,
93
- }));
94
- await this.appendManifestRecords(manifestPath, records);
95
- }
96
- }
97
- finally {
98
- await this.lock.release(lockHandle).match(() => undefined, () => undefined);
99
+ kind: 'snapshot_pinned',
100
+ eventIndex: p.eventIndex,
101
+ snapshotRef: p.snapshotRef,
102
+ createdByEventId: p.createdByEventId,
103
+ }));
104
+ await this.appendManifestRecords(manifestPath, records);
99
105
  }
100
106
  }
101
107
  async loadImpl(sessionId) {
102
- const lockHandle = await this.unwrap(this.lock.acquire(sessionId), mapLockError);
103
- try {
104
- const sessionDir = this.dataDir.sessionDir(sessionId);
105
- const manifestPath = this.dataDir.sessionManifestPath(sessionId);
106
- const manifest = await this.readManifestOrEmpty(sessionId);
107
- validateManifestContiguityOrThrow(manifest);
108
- validateSegmentClosedContiguityOrThrow(manifest);
109
- const segments = manifest.filter((m) => m.kind === 'segment_closed');
110
- const events = [];
111
- for (const seg of segments) {
112
- const segmentPath = `${sessionDir}/${seg.segmentRelPath}`;
113
- const bytes = await this.unwrap(this.fs.readFileBytes(segmentPath), mapFsToStoreError);
114
- const actual = this.sha256.sha256(bytes);
115
- if (actual !== seg.sha256) {
116
- throw new StoreFailure({
117
- code: 'SESSION_STORE_CORRUPTION_DETECTED',
118
- message: `Segment digest mismatch: ${seg.segmentRelPath}`,
119
- });
120
- }
121
- const parsed = parseJsonlLines(bytes, index_js_1.DomainEventV1Schema);
122
- if (parsed.length === 0) {
123
- throw new StoreFailure({
124
- code: 'SESSION_STORE_CORRUPTION_DETECTED',
125
- message: `Empty segment referenced by manifest: ${seg.segmentRelPath}`,
126
- });
127
- }
128
- if (parsed[0].eventIndex !== seg.firstEventIndex || parsed[parsed.length - 1].eventIndex !== seg.lastEventIndex) {
108
+ const sessionDir = this.dataDir.sessionDir(sessionId);
109
+ const manifest = await this.readManifestOrEmpty(sessionId);
110
+ validateManifestContiguityOrThrow(manifest);
111
+ validateSegmentClosedContiguityOrThrow(manifest);
112
+ const segments = manifest.filter((m) => m.kind === 'segment_closed');
113
+ const events = [];
114
+ for (const seg of segments) {
115
+ const segmentPath = `${sessionDir}/${seg.segmentRelPath}`;
116
+ const bytes = await this.fs.readFileBytes(segmentPath).match((v) => v, (e) => {
117
+ if (e.code === 'FS_NOT_FOUND') {
129
118
  throw new StoreFailure({
130
119
  code: 'SESSION_STORE_CORRUPTION_DETECTED',
131
- message: `Segment bounds mismatch: ${seg.segmentRelPath}`,
120
+ location: 'tail',
121
+ reason: { code: 'missing_attested_segment', message: `Missing attested segment: ${seg.segmentRelPath}` },
122
+ message: `Missing attested segment: ${seg.segmentRelPath}`,
132
123
  });
133
124
  }
134
- for (let i = 1; i < parsed.length; i++) {
135
- if (parsed[i].eventIndex !== parsed[i - 1].eventIndex + 1) {
136
- throw new StoreFailure({
137
- code: 'SESSION_STORE_CORRUPTION_DETECTED',
138
- message: `Non-contiguous eventIndex inside segment: ${seg.segmentRelPath}`,
139
- });
140
- }
141
- }
142
- events.push(...parsed);
125
+ throw new StoreFailure(mapFsToStoreError(e));
126
+ });
127
+ const actual = this.sha256.sha256(bytes);
128
+ if (actual !== seg.sha256) {
129
+ throw new StoreFailure({
130
+ code: 'SESSION_STORE_CORRUPTION_DETECTED',
131
+ location: 'tail',
132
+ reason: { code: 'digest_mismatch', message: `Segment digest mismatch: ${seg.segmentRelPath}` },
133
+ message: `Segment digest mismatch: ${seg.segmentRelPath}`,
134
+ });
135
+ }
136
+ const parsed = parseJsonlLines(bytes, index_js_1.DomainEventV1Schema);
137
+ if (parsed.length === 0) {
138
+ throw new StoreFailure({
139
+ code: 'SESSION_STORE_CORRUPTION_DETECTED',
140
+ location: 'tail',
141
+ reason: { code: 'non_contiguous_indices', message: `Empty segment referenced by manifest: ${seg.segmentRelPath}` },
142
+ message: `Empty segment referenced by manifest: ${seg.segmentRelPath}`,
143
+ });
144
+ }
145
+ if (parsed[0].eventIndex !== seg.firstEventIndex || parsed[parsed.length - 1].eventIndex !== seg.lastEventIndex) {
146
+ throw new StoreFailure({
147
+ code: 'SESSION_STORE_CORRUPTION_DETECTED',
148
+ location: 'tail',
149
+ reason: { code: 'non_contiguous_indices', message: `Segment bounds mismatch: ${seg.segmentRelPath}` },
150
+ message: `Segment bounds mismatch: ${seg.segmentRelPath}`,
151
+ });
143
152
  }
144
- const expectedPins = extractSnapshotPinsFromEvents(events);
145
- const actualPins = new Set(manifest
146
- .filter((m) => m.kind === 'snapshot_pinned')
147
- .map((p) => `${p.eventIndex}:${p.createdByEventId}:${p.snapshotRef}`));
148
- for (const ep of expectedPins) {
149
- const key = `${ep.eventIndex}:${ep.createdByEventId}:${ep.snapshotRef}`;
150
- if (!actualPins.has(key)) {
153
+ for (let i = 1; i < parsed.length; i++) {
154
+ if (parsed[i].eventIndex !== parsed[i - 1].eventIndex + 1) {
151
155
  throw new StoreFailure({
152
156
  code: 'SESSION_STORE_CORRUPTION_DETECTED',
153
- message: `Missing snapshot_pinned for introduced snapshotRef (pin-after-close violation): ${key}`,
157
+ location: 'tail',
158
+ reason: { code: 'non_contiguous_indices', message: `Non-contiguous eventIndex inside segment: ${seg.segmentRelPath}` },
159
+ message: `Non-contiguous eventIndex inside segment: ${seg.segmentRelPath}`,
154
160
  });
155
161
  }
156
162
  }
157
- return { manifest, events };
163
+ events.push(...parsed);
158
164
  }
159
- finally {
160
- await this.lock.release(lockHandle).match(() => undefined, () => undefined);
165
+ const expectedPins = extractSnapshotPinsFromEvents(events);
166
+ const actualPins = new Set(manifest
167
+ .filter((m) => m.kind === 'snapshot_pinned')
168
+ .map((p) => `${p.eventIndex}:${p.createdByEventId}:${p.snapshotRef}`));
169
+ for (const ep of expectedPins) {
170
+ const key = `${ep.eventIndex}:${ep.createdByEventId}:${ep.snapshotRef}`;
171
+ if (!actualPins.has(key)) {
172
+ throw new StoreFailure({
173
+ code: 'SESSION_STORE_CORRUPTION_DETECTED',
174
+ location: 'tail',
175
+ reason: { code: 'missing_attested_segment', message: `Missing snapshot_pinned for introduced snapshotRef: ${key}` },
176
+ message: `Missing snapshot_pinned for introduced snapshotRef: ${key}`,
177
+ });
178
+ }
161
179
  }
180
+ return { manifest, events };
162
181
  }
163
182
  async readManifestOrEmpty(sessionId) {
164
183
  const manifestPath = this.dataDir.sessionManifestPath(sessionId);
@@ -171,6 +190,94 @@ class LocalSessionEventLogStoreV2 {
171
190
  return [];
172
191
  return parseJsonlText(raw, index_js_1.ManifestRecordV1Schema);
173
192
  }
193
+ async loadValidatedPrefixImpl(sessionId) {
194
+ const sessionDir = this.dataDir.sessionDir(sessionId);
195
+ const manifestPath = this.dataDir.sessionManifestPath(sessionId);
196
+ const raw = await this.fs.readFileUtf8(manifestPath).match((v) => v, (e) => {
197
+ if (e.code === 'FS_NOT_FOUND')
198
+ return '';
199
+ throw new StoreFailure(mapFsToStoreError(e));
200
+ });
201
+ if (raw.trim() === '')
202
+ return { truth: { manifest: [], events: [] }, isComplete: true, tailReason: null };
203
+ const lines = raw.split('\n').filter((l) => l.trim() !== '');
204
+ const manifest = [];
205
+ let isComplete = true;
206
+ let tailReason = null;
207
+ for (let i = 0; i < lines.length; i++) {
208
+ const line = lines[i];
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(line);
212
+ }
213
+ catch {
214
+ isComplete = false;
215
+ tailReason = { code: 'non_contiguous_indices', message: 'Invalid JSON in manifest (corrupt tail)' };
216
+ break;
217
+ }
218
+ const validated = index_js_1.ManifestRecordV1Schema.safeParse(parsed);
219
+ if (!validated.success) {
220
+ isComplete = false;
221
+ tailReason = { code: 'unknown_schema_version', message: 'Unknown manifest schema version (corrupt tail)' };
222
+ break;
223
+ }
224
+ if (validated.data.manifestIndex !== i) {
225
+ isComplete = false;
226
+ tailReason = { code: 'non_contiguous_indices', message: 'Non-contiguous manifestIndex in prefix (corrupt tail)' };
227
+ break;
228
+ }
229
+ manifest.push(validated.data);
230
+ }
231
+ if (manifest.length === 0) {
232
+ throw new StoreFailure({
233
+ code: 'SESSION_STORE_CORRUPTION_DETECTED',
234
+ location: 'head',
235
+ reason: { code: 'non_contiguous_indices', message: 'No validated manifest prefix' },
236
+ message: 'No validated manifest prefix',
237
+ });
238
+ }
239
+ const segments = manifest.filter((m) => m.kind === 'segment_closed');
240
+ const events = [];
241
+ for (const seg of segments) {
242
+ const segmentPath = `${sessionDir}/${seg.segmentRelPath}`;
243
+ const bytes = await this.fs.readFileBytes(segmentPath).match((v) => v, (e) => {
244
+ if (e.code === 'FS_NOT_FOUND')
245
+ return null;
246
+ throw new StoreFailure(mapFsToStoreError(e));
247
+ });
248
+ if (bytes === null) {
249
+ isComplete = false;
250
+ tailReason ?? (tailReason = { code: 'missing_attested_segment', message: `Missing attested segment: ${seg.segmentRelPath}` });
251
+ break;
252
+ }
253
+ const actual = this.sha256.sha256(bytes);
254
+ if (actual !== seg.sha256) {
255
+ isComplete = false;
256
+ tailReason ?? (tailReason = { code: 'digest_mismatch', message: `Segment digest mismatch: ${seg.segmentRelPath}` });
257
+ break;
258
+ }
259
+ const parsed = parseJsonlLines(bytes, index_js_1.DomainEventV1Schema);
260
+ if (parsed.length === 0) {
261
+ isComplete = false;
262
+ tailReason ?? (tailReason = { code: 'non_contiguous_indices', message: `Empty segment referenced by manifest: ${seg.segmentRelPath}` });
263
+ break;
264
+ }
265
+ if (parsed[0].eventIndex !== seg.firstEventIndex || parsed[parsed.length - 1].eventIndex !== seg.lastEventIndex) {
266
+ isComplete = false;
267
+ tailReason ?? (tailReason = { code: 'non_contiguous_indices', message: `Segment bounds mismatch: ${seg.segmentRelPath}` });
268
+ break;
269
+ }
270
+ for (let i = 1; i < parsed.length; i++) {
271
+ if (parsed[i].eventIndex !== parsed[i - 1].eventIndex + 1) {
272
+ isComplete = false;
273
+ tailReason ?? (tailReason = { code: 'non_contiguous_indices', message: `Non-contiguous eventIndex inside segment: ${seg.segmentRelPath}` });
274
+ break;
275
+ }
276
+ }
277
+ events.push(...parsed);
278
+ }
279
+ return { truth: { manifest, events }, isComplete, tailReason };
280
+ }
174
281
  async loadTruthOrEmpty(sessionId) {
175
282
  const manifest = await this.readManifestOrEmpty(sessionId);
176
283
  if (manifest.length === 0)
@@ -200,12 +307,6 @@ class LocalSessionEventLogStoreV2 {
200
307
  }
201
308
  }
202
309
  exports.LocalSessionEventLogStoreV2 = LocalSessionEventLogStoreV2;
203
- function mapLockError(e) {
204
- if (e.code === 'SESSION_LOCK_BUSY') {
205
- return { code: 'SESSION_STORE_LOCK_BUSY', message: e.message, retry: e.retry };
206
- }
207
- return { code: 'SESSION_STORE_IO_ERROR', message: e.message };
208
- }
209
310
  function mapFsToStoreError(e) {
210
311
  return { code: 'SESSION_STORE_IO_ERROR', message: e.message };
211
312
  }
@@ -226,6 +327,11 @@ function validateManifestContiguityOrThrow(manifest) {
226
327
  if (manifest[i].manifestIndex !== expected) {
227
328
  throw new StoreFailure({
228
329
  code: 'SESSION_STORE_CORRUPTION_DETECTED',
330
+ location: i === 0 ? 'head' : 'tail',
331
+ reason: {
332
+ code: 'non_contiguous_indices',
333
+ message: `Non-contiguous manifestIndex at position ${i} (expected ${expected}, got ${manifest[i].manifestIndex})`,
334
+ },
229
335
  message: `Non-contiguous manifestIndex at position ${i} (expected ${expected}, got ${manifest[i].manifestIndex})`,
230
336
  });
231
337
  }
@@ -239,6 +345,11 @@ function validateSegmentClosedContiguityOrThrow(manifest) {
239
345
  if (cur.firstEventIndex !== prev.lastEventIndex + 1) {
240
346
  throw new StoreFailure({
241
347
  code: 'SESSION_STORE_CORRUPTION_DETECTED',
348
+ location: 'tail',
349
+ reason: {
350
+ code: 'non_contiguous_indices',
351
+ message: `Non-contiguous segment_closed bounds (expected firstEventIndex=${prev.lastEventIndex + 1}, got ${cur.firstEventIndex})`,
352
+ },
242
353
  message: `Non-contiguous segment_closed bounds (expected firstEventIndex=${prev.lastEventIndex + 1}, got ${cur.firstEventIndex})`,
243
354
  });
244
355
  }
@@ -327,11 +438,21 @@ function parseJsonlText(text, schema) {
327
438
  parsed = JSON.parse(raw);
328
439
  }
329
440
  catch {
330
- throw new StoreFailure({ code: 'SESSION_STORE_CORRUPTION_DETECTED', message: `Invalid JSONL at line ${i}` });
441
+ throw new StoreFailure({
442
+ code: 'SESSION_STORE_CORRUPTION_DETECTED',
443
+ location: i === 0 ? 'head' : 'tail',
444
+ reason: { code: 'non_contiguous_indices', message: `Invalid JSONL at line ${i}` },
445
+ message: `Invalid JSONL at line ${i}`,
446
+ });
331
447
  }
332
448
  const validated = schema.safeParse(parsed);
333
449
  if (!validated.success) {
334
- throw new StoreFailure({ code: 'SESSION_STORE_CORRUPTION_DETECTED', message: `Invalid record at line ${i}` });
450
+ throw new StoreFailure({
451
+ code: 'SESSION_STORE_CORRUPTION_DETECTED',
452
+ location: i === 0 ? 'head' : 'tail',
453
+ reason: { code: 'unknown_schema_version', message: `Invalid record at line ${i}` },
454
+ message: `Invalid record at line ${i}`,
455
+ });
335
456
  }
336
457
  out.push(validated.data);
337
458
  }
@@ -346,13 +467,7 @@ function extractSnapshotPinsFromEvents(events) {
346
467
  for (const e of events) {
347
468
  if (e.kind !== 'node_created')
348
469
  continue;
349
- const data = e.data;
350
- if (typeof data !== 'object' || data === null)
351
- continue;
352
- const snap = data.snapshotRef;
353
- if (typeof snap === 'string') {
354
- out.push({ snapshotRef: snap, eventIndex: e.eventIndex, createdByEventId: e.eventId });
355
- }
470
+ out.push({ snapshotRef: e.data.snapshotRef, eventIndex: e.eventIndex, createdByEventId: e.eventId });
356
471
  }
357
472
  return out;
358
473
  }
@@ -0,0 +1,15 @@
1
+ import type { ResultAsync } from 'neverthrow';
2
+ import type { DataDirPortV2 } from '../../../ports/data-dir.port.js';
3
+ import type { FileSystemPortV2 } from '../../../ports/fs.port.js';
4
+ import type { SnapshotStoreError, SnapshotStorePortV2 } from '../../../ports/snapshot-store.port.js';
5
+ import type { SnapshotRef } from '../../../durable-core/ids/index.js';
6
+ import type { ExecutionSnapshotFileV1 } from '../../../durable-core/schemas/execution-snapshot/index.js';
7
+ import type { CryptoPortV2 } from '../../../durable-core/canonical/hashing.js';
8
+ export declare class LocalSnapshotStoreV2 implements SnapshotStorePortV2 {
9
+ private readonly dataDir;
10
+ private readonly fs;
11
+ private readonly crypto;
12
+ constructor(dataDir: DataDirPortV2, fs: FileSystemPortV2, crypto: CryptoPortV2);
13
+ putExecutionSnapshotV1(snapshot: ExecutionSnapshotFileV1): ResultAsync<SnapshotRef, SnapshotStoreError>;
14
+ getExecutionSnapshotV1(snapshotRef: SnapshotRef): ResultAsync<ExecutionSnapshotFileV1 | null, SnapshotStoreError>;
15
+ }
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LocalSnapshotStoreV2 = void 0;
4
+ const neverthrow_1 = require("neverthrow");
5
+ const index_js_1 = require("../../../durable-core/ids/index.js");
6
+ const index_js_2 = require("../../../durable-core/schemas/execution-snapshot/index.js");
7
+ const jcs_js_1 = require("../../../durable-core/canonical/jcs.js");
8
+ function isFsErrorV2(e) {
9
+ if (typeof e !== 'object' || e === null)
10
+ return false;
11
+ const code = e.code;
12
+ return (code === 'FS_IO_ERROR' ||
13
+ code === 'FS_NOT_FOUND' ||
14
+ code === 'FS_ALREADY_EXISTS' ||
15
+ code === 'FS_PERMISSION_DENIED' ||
16
+ code === 'FS_UNSUPPORTED');
17
+ }
18
+ class LocalSnapshotStoreV2 {
19
+ constructor(dataDir, fs, crypto) {
20
+ this.dataDir = dataDir;
21
+ this.fs = fs;
22
+ this.crypto = crypto;
23
+ }
24
+ putExecutionSnapshotV1(snapshot) {
25
+ const canonical = (0, jcs_js_1.toCanonicalBytes)(snapshot).mapErr((e) => ({
26
+ code: 'SNAPSHOT_STORE_INVARIANT_VIOLATION',
27
+ message: e.message,
28
+ }));
29
+ if (canonical.isErr())
30
+ return (0, neverthrow_1.errAsync)(canonical.error);
31
+ const ref = (0, index_js_1.asSnapshotRef)(this.crypto.sha256(canonical.value));
32
+ const dir = this.dataDir.snapshotsDir();
33
+ const filePath = this.dataDir.snapshotPath(String(ref));
34
+ const tmpPath = `${filePath}.tmp`;
35
+ return this.fs
36
+ .mkdirp(dir)
37
+ .andThen(() => this.fs.openWriteTruncate(tmpPath))
38
+ .andThen((h) => this.fs
39
+ .writeAll(h.fd, canonical.value)
40
+ .andThen(() => this.fs.fsyncFile(h.fd))
41
+ .andThen(() => this.fs.closeFile(h.fd)))
42
+ .andThen(() => this.fs.rename(tmpPath, filePath))
43
+ .andThen(() => this.fs.fsyncDir(dir))
44
+ .map(() => ref)
45
+ .mapErr((e) => ({ code: 'SNAPSHOT_STORE_IO_ERROR', message: e.message }));
46
+ }
47
+ getExecutionSnapshotV1(snapshotRef) {
48
+ const filePath = this.dataDir.snapshotPath(String(snapshotRef));
49
+ return this.fs
50
+ .readFileBytes(filePath)
51
+ .andThen((bytes) => neverthrow_1.ResultAsync.fromPromise((async () => {
52
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
53
+ const validated = index_js_2.ExecutionSnapshotFileV1Schema.safeParse(parsed);
54
+ if (!validated.success) {
55
+ throw new Error('invalid_snapshot_shape');
56
+ }
57
+ return validated.data;
58
+ })(), (e) => {
59
+ const msg = e instanceof Error ? e.message : String(e);
60
+ if (msg === 'invalid_snapshot_shape') {
61
+ return { code: 'SNAPSHOT_STORE_CORRUPTION_DETECTED', message: `Invalid execution snapshot file: ${filePath}` };
62
+ }
63
+ return { code: 'SNAPSHOT_STORE_CORRUPTION_DETECTED', message: `Invalid JSON snapshot file: ${filePath}` };
64
+ }))
65
+ .map((v) => v)
66
+ .orElse((e) => {
67
+ if (isFsErrorV2(e)) {
68
+ if (e.code === 'FS_NOT_FOUND')
69
+ return (0, neverthrow_1.okAsync)(null);
70
+ return (0, neverthrow_1.errAsync)({ code: 'SNAPSHOT_STORE_IO_ERROR', message: e.message });
71
+ }
72
+ return (0, neverthrow_1.errAsync)(e);
73
+ });
74
+ }
75
+ }
76
+ exports.LocalSnapshotStoreV2 = LocalSnapshotStoreV2;
@@ -1,6 +1,10 @@
1
1
  export interface DataDirPortV2 {
2
2
  pinnedWorkflowsDir(): string;
3
3
  pinnedWorkflowPath(workflowHash: string): string;
4
+ snapshotsDir(): string;
5
+ snapshotPath(snapshotRef: string): string;
6
+ keysDir(): string;
7
+ keyringPath(): string;
4
8
  sessionsDir(): string;
5
9
  sessionDir(sessionId: string): string;
6
10
  sessionEventsDir(sessionId: string): string;
@@ -0,0 +1,4 @@
1
+ export interface HmacSha256PortV2 {
2
+ hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array;
3
+ timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean;
4
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,26 @@
1
+ import type { ResultAsync } from 'neverthrow';
2
+ export interface KeyringV1 {
3
+ readonly v: 1;
4
+ readonly current: {
5
+ readonly alg: 'hmac_sha256';
6
+ readonly keyBase64Url: string;
7
+ };
8
+ readonly previous: {
9
+ readonly alg: 'hmac_sha256';
10
+ readonly keyBase64Url: string;
11
+ } | null;
12
+ }
13
+ export type KeyringError = {
14
+ readonly code: 'KEYRING_IO_ERROR';
15
+ readonly message: string;
16
+ } | {
17
+ readonly code: 'KEYRING_CORRUPTION_DETECTED';
18
+ readonly message: string;
19
+ } | {
20
+ readonly code: 'KEYRING_INVARIANT_VIOLATION';
21
+ readonly message: string;
22
+ };
23
+ export interface KeyringPortV2 {
24
+ loadOrCreate(): ResultAsync<KeyringV1, KeyringError>;
25
+ rotate(): ResultAsync<KeyringV1, KeyringError>;
26
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,6 +1,8 @@
1
1
  import type { ResultAsync } from 'neverthrow';
2
2
  import type { SessionId, SnapshotRef } from '../durable-core/ids/index.js';
3
3
  import type { DomainEventV1, ManifestRecordV1 } from '../durable-core/schemas/session/index.js';
4
+ import type { WithHealthySessionLock } from '../durable-core/ids/with-healthy-session-lock.js';
5
+ import type { CorruptionReasonV2 } from '../durable-core/schemas/session/session-health.js';
4
6
  export interface SnapshotPinV2 {
5
7
  readonly snapshotRef: SnapshotRef;
6
8
  readonly eventIndex: number;
@@ -23,6 +25,8 @@ export type SessionEventLogStoreError = {
23
25
  } | {
24
26
  readonly code: 'SESSION_STORE_CORRUPTION_DETECTED';
25
27
  readonly message: string;
28
+ readonly location: 'head' | 'tail';
29
+ readonly reason: CorruptionReasonV2;
26
30
  } | {
27
31
  readonly code: 'SESSION_STORE_INVARIANT_VIOLATION';
28
32
  readonly message: string;
@@ -31,7 +35,15 @@ export interface LoadedSessionTruthV2 {
31
35
  readonly manifest: readonly ManifestRecordV1[];
32
36
  readonly events: readonly DomainEventV1[];
33
37
  }
34
- export interface SessionEventLogStorePortV2 {
35
- append(sessionId: SessionId, plan: AppendPlanV2): ResultAsync<void, SessionEventLogStoreError>;
38
+ export type LoadedValidatedPrefixV2 = {
39
+ readonly truth: LoadedSessionTruthV2;
40
+ readonly isComplete: boolean;
41
+ readonly tailReason: CorruptionReasonV2 | null;
42
+ };
43
+ export interface SessionEventLogReadonlyStorePortV2 {
36
44
  load(sessionId: SessionId): ResultAsync<LoadedSessionTruthV2, SessionEventLogStoreError>;
45
+ loadValidatedPrefix(sessionId: SessionId): ResultAsync<LoadedValidatedPrefixV2, SessionEventLogStoreError>;
46
+ }
47
+ export interface SessionEventLogAppendStorePortV2 {
48
+ append(lock: WithHealthySessionLock, plan: AppendPlanV2): ResultAsync<void, SessionEventLogStoreError>;
37
49
  }
@@ -0,0 +1,17 @@
1
+ import type { ResultAsync } from 'neverthrow';
2
+ import type { SnapshotRef } from '../durable-core/ids/index.js';
3
+ import type { ExecutionSnapshotFileV1 } from '../durable-core/schemas/execution-snapshot/index.js';
4
+ export type SnapshotStoreError = {
5
+ readonly code: 'SNAPSHOT_STORE_IO_ERROR';
6
+ readonly message: string;
7
+ } | {
8
+ readonly code: 'SNAPSHOT_STORE_CORRUPTION_DETECTED';
9
+ readonly message: string;
10
+ } | {
11
+ readonly code: 'SNAPSHOT_STORE_INVARIANT_VIOLATION';
12
+ readonly message: string;
13
+ };
14
+ export interface SnapshotStorePortV2 {
15
+ putExecutionSnapshotV1(snapshot: ExecutionSnapshotFileV1): ResultAsync<SnapshotRef, SnapshotStoreError>;
16
+ getExecutionSnapshotV1(snapshotRef: SnapshotRef): ResultAsync<ExecutionSnapshotFileV1 | null, SnapshotStoreError>;
17
+ }