@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.
- package/dist/manifest.json +176 -32
- package/dist/runtime/brand.d.ts +1 -3
- package/dist/v2/durable-core/canonical/hashing.d.ts +3 -1
- package/dist/v2/durable-core/canonical/hashing.js +9 -0
- package/dist/v2/durable-core/ids/index.d.ts +4 -0
- package/dist/v2/durable-core/ids/index.js +8 -0
- package/dist/v2/durable-core/ids/with-healthy-session-lock.d.ts +7 -0
- package/dist/v2/durable-core/ids/with-healthy-session-lock.js +2 -0
- package/dist/v2/durable-core/projections/snapshot-state.d.ts +3 -0
- package/dist/v2/durable-core/projections/snapshot-state.js +14 -0
- package/dist/v2/durable-core/schemas/execution-snapshot/execution-snapshot.v1.d.ts +1042 -0
- package/dist/v2/durable-core/schemas/execution-snapshot/execution-snapshot.v1.js +119 -0
- package/dist/v2/durable-core/schemas/execution-snapshot/index.d.ts +4 -0
- package/dist/v2/durable-core/schemas/execution-snapshot/index.js +17 -0
- package/dist/v2/durable-core/schemas/execution-snapshot/step-instance-key.d.ts +21 -0
- package/dist/v2/durable-core/schemas/execution-snapshot/step-instance-key.js +67 -0
- package/dist/v2/durable-core/schemas/session/events.d.ts +3 -3
- package/dist/v2/durable-core/schemas/session/events.js +9 -5
- package/dist/v2/durable-core/schemas/session/index.d.ts +1 -0
- package/dist/v2/durable-core/schemas/session/session-health.d.ts +25 -0
- package/dist/v2/durable-core/schemas/session/session-health.js +2 -0
- package/dist/v2/durable-core/tokens/base64url.d.ts +7 -0
- package/dist/v2/durable-core/tokens/base64url.js +16 -0
- package/dist/v2/durable-core/tokens/index.d.ts +7 -0
- package/dist/v2/durable-core/tokens/index.js +20 -0
- package/dist/v2/durable-core/tokens/payloads.d.ts +210 -0
- package/dist/v2/durable-core/tokens/payloads.js +53 -0
- package/dist/v2/durable-core/tokens/token-codec.d.ts +31 -0
- package/dist/v2/durable-core/tokens/token-codec.js +64 -0
- package/dist/v2/durable-core/tokens/token-signer.d.ts +15 -0
- package/dist/v2/durable-core/tokens/token-signer.js +55 -0
- package/dist/v2/infra/local/data-dir/index.d.ts +4 -0
- package/dist/v2/infra/local/data-dir/index.js +12 -0
- package/dist/v2/infra/local/hmac-sha256/index.d.ts +5 -0
- package/dist/v2/infra/local/hmac-sha256/index.js +16 -0
- package/dist/v2/infra/local/keyring/index.d.ts +14 -0
- package/dist/v2/infra/local/keyring/index.js +103 -0
- package/dist/v2/infra/local/session-store/index.d.ts +7 -6
- package/dist/v2/infra/local/session-store/index.js +244 -129
- package/dist/v2/infra/local/snapshot-store/index.d.ts +15 -0
- package/dist/v2/infra/local/snapshot-store/index.js +76 -0
- package/dist/v2/ports/data-dir.port.d.ts +4 -0
- package/dist/v2/ports/hmac-sha256.port.d.ts +4 -0
- package/dist/v2/ports/hmac-sha256.port.js +2 -0
- package/dist/v2/ports/keyring.port.d.ts +26 -0
- package/dist/v2/ports/keyring.port.js +2 -0
- package/dist/v2/ports/session-event-log-store.port.d.ts +14 -2
- package/dist/v2/ports/snapshot-store.port.d.ts +17 -0
- package/dist/v2/ports/snapshot-store.port.js +2 -0
- package/dist/v2/projections/session-health.d.ts +1 -15
- package/dist/v2/projections/session-health.js +1 -4
- package/dist/v2/usecases/execution-session-gate.d.ts +53 -0
- package/dist/v2/usecases/execution-session-gate.js +167 -0
- 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
|
|
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(
|
|
21
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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:
|
|
97
|
+
manifestIndex: startIndex + i,
|
|
73
98
|
sessionId,
|
|
74
|
-
kind: '
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
events.push(...parsed);
|
|
158
164
|
}
|
|
159
|
-
|
|
160
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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,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
|
+
}
|
|
@@ -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
|
|
35
|
-
|
|
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
|
+
}
|