@exaudeus/workrail 0.9.0 → 0.10.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 +40 -16
- 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/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/infra/local/session-store/index.d.ts +7 -6
- package/dist/v2/infra/local/session-store/index.js +244 -129
- package/dist/v2/ports/session-event-log-store.port.d.ts +14 -2
- 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
package/dist/manifest.json
CHANGED
|
@@ -881,6 +881,14 @@
|
|
|
881
881
|
"sha256": "f329b3749ee778224555e6d4eadfed232ddb216a3348ff8b69984a0ee2bf6e6c",
|
|
882
882
|
"bytes": 1042
|
|
883
883
|
},
|
|
884
|
+
"v2/durable-core/ids/with-healthy-session-lock.d.ts": {
|
|
885
|
+
"sha256": "dc1812850b9ce69c0135d575b0994ac4fa7b874bf9db7a1838b6a3390ef31ae2",
|
|
886
|
+
"bytes": 320
|
|
887
|
+
},
|
|
888
|
+
"v2/durable-core/ids/with-healthy-session-lock.js": {
|
|
889
|
+
"sha256": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230",
|
|
890
|
+
"bytes": 77
|
|
891
|
+
},
|
|
884
892
|
"v2/durable-core/schemas/compiled-workflow/index.d.ts": {
|
|
885
893
|
"sha256": "caab753a39b7abc1e3c52bfd2fca93402a8a1b11c2ef13aaf5c2c0a86f7ba135",
|
|
886
894
|
"bytes": 1171
|
|
@@ -890,16 +898,16 @@
|
|
|
890
898
|
"bytes": 621
|
|
891
899
|
},
|
|
892
900
|
"v2/durable-core/schemas/session/events.d.ts": {
|
|
893
|
-
"sha256": "
|
|
894
|
-
"bytes":
|
|
901
|
+
"sha256": "8de8d8904b67ca93d985c1e215f216d929612fb14357888f88ff270f6ac3c40f",
|
|
902
|
+
"bytes": 61834
|
|
895
903
|
},
|
|
896
904
|
"v2/durable-core/schemas/session/events.js": {
|
|
897
|
-
"sha256": "
|
|
898
|
-
"bytes":
|
|
905
|
+
"sha256": "411228cda7ef149d25413c668e858d5691810cff8e6ce64913bf8e0cb1cfda2a",
|
|
906
|
+
"bytes": 15007
|
|
899
907
|
},
|
|
900
908
|
"v2/durable-core/schemas/session/index.d.ts": {
|
|
901
|
-
"sha256": "
|
|
902
|
-
"bytes":
|
|
909
|
+
"sha256": "f4f500d33d212760f480d91fafd4474f7b12f9239a6c5e9c2d80d0fe96207b65",
|
|
910
|
+
"bytes": 259
|
|
903
911
|
},
|
|
904
912
|
"v2/durable-core/schemas/session/index.js": {
|
|
905
913
|
"sha256": "0a3d9ba129a52c33d7e01f4366e15956b637ca5de327c6c8deb449c91bac0e92",
|
|
@@ -913,6 +921,14 @@
|
|
|
913
921
|
"sha256": "735b3f6cbc7e266e882812b359754268c5eca7102f698fbe0fcb49f57f599cd3",
|
|
914
922
|
"bytes": 1237
|
|
915
923
|
},
|
|
924
|
+
"v2/durable-core/schemas/session/session-health.d.ts": {
|
|
925
|
+
"sha256": "33e927808b1b6a1f31de141289493b1359ec12943873a8fc970e0fe33775f263",
|
|
926
|
+
"bytes": 665
|
|
927
|
+
},
|
|
928
|
+
"v2/durable-core/schemas/session/session-health.js": {
|
|
929
|
+
"sha256": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230",
|
|
930
|
+
"bytes": 77
|
|
931
|
+
},
|
|
916
932
|
"v2/infra/local/crypto/index.d.ts": {
|
|
917
933
|
"sha256": "d2f6cc8e812bd4fb5c83e59f46c4b5588baa1bf33c029239d8162f8669a64370",
|
|
918
934
|
"bytes": 279
|
|
@@ -954,12 +970,12 @@
|
|
|
954
970
|
"bytes": 1640
|
|
955
971
|
},
|
|
956
972
|
"v2/infra/local/session-store/index.d.ts": {
|
|
957
|
-
"sha256": "
|
|
958
|
-
"bytes":
|
|
973
|
+
"sha256": "bd6a47ede5fc58a72b18031d57bd7357d4657f11d06aee2c2d08391be51b213d",
|
|
974
|
+
"bytes": 1467
|
|
959
975
|
},
|
|
960
976
|
"v2/infra/local/session-store/index.js": {
|
|
961
|
-
"sha256": "
|
|
962
|
-
"bytes":
|
|
977
|
+
"sha256": "3b844370c4cff55b53dd18401eec7e14fb5e5c34d7b851650eb6dfb81fc97b77",
|
|
978
|
+
"bytes": 21855
|
|
963
979
|
},
|
|
964
980
|
"v2/infra/local/sha256/index.d.ts": {
|
|
965
981
|
"sha256": "8a727b7e54a38275ca6f9f1b8730f97cfc0a212df035df1bdc58e716e6824230",
|
|
@@ -994,8 +1010,8 @@
|
|
|
994
1010
|
"bytes": 77
|
|
995
1011
|
},
|
|
996
1012
|
"v2/ports/session-event-log-store.port.d.ts": {
|
|
997
|
-
"sha256": "
|
|
998
|
-
"bytes":
|
|
1013
|
+
"sha256": "ffd5706ab752e9264c394b4960ea821fc96838b4111a38c9fb223972f568f45a",
|
|
1014
|
+
"bytes": 1991
|
|
999
1015
|
},
|
|
1000
1016
|
"v2/ports/session-event-log-store.port.js": {
|
|
1001
1017
|
"sha256": "d43aa81f5bc89faa359e0f97c814ba25155591ff078fbb9bfd40f8c7c9683230",
|
|
@@ -1074,12 +1090,12 @@
|
|
|
1074
1090
|
"bytes": 2315
|
|
1075
1091
|
},
|
|
1076
1092
|
"v2/projections/session-health.d.ts": {
|
|
1077
|
-
"sha256": "
|
|
1078
|
-
"bytes":
|
|
1093
|
+
"sha256": "0a7096bfc0d32eea9743c34a0b982e32f7eeec9e63ec7af10e27ec21be246630",
|
|
1094
|
+
"bytes": 327
|
|
1079
1095
|
},
|
|
1080
1096
|
"v2/projections/session-health.js": {
|
|
1081
|
-
"sha256": "
|
|
1082
|
-
"bytes":
|
|
1097
|
+
"sha256": "56d9fbed93ce1fa71ba8ab39f5fa49c4b221936dfec9339c15bd5aca2b4c878e",
|
|
1098
|
+
"bytes": 550
|
|
1083
1099
|
},
|
|
1084
1100
|
"v2/read-only/v1-to-v2-shim.d.ts": {
|
|
1085
1101
|
"sha256": "bc778fe2034ce1691eb089ed9415fcd162dcddfc4a9f95106200b7822d15a22c",
|
|
@@ -1088,6 +1104,14 @@
|
|
|
1088
1104
|
"v2/read-only/v1-to-v2-shim.js": {
|
|
1089
1105
|
"sha256": "e88caca3921758d1ed3806c9cb7d4ea35cc3f32689fbead2f2c96101fbbfc6e9",
|
|
1090
1106
|
"bytes": 1498
|
|
1107
|
+
},
|
|
1108
|
+
"v2/usecases/execution-session-gate.d.ts": {
|
|
1109
|
+
"sha256": "30ea073d1728abf3b71ae8c501c3fa6ecd26fde16074719349f875342ac29fdd",
|
|
1110
|
+
"bytes": 2038
|
|
1111
|
+
},
|
|
1112
|
+
"v2/usecases/execution-session-gate.js": {
|
|
1113
|
+
"sha256": "d7d6430b622e45d31d38bd71c6f7c914d2c3b4f6ebf68a0368d6d372867031e9",
|
|
1114
|
+
"bytes": 7606
|
|
1091
1115
|
}
|
|
1092
1116
|
}
|
|
1093
1117
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SessionLockHandleV2 } from '../../ports/session-lock.port.js';
|
|
2
|
+
declare const withHealthySessionLockBrand: unique symbol;
|
|
3
|
+
export type WithHealthySessionLock = SessionLockHandleV2 & {
|
|
4
|
+
readonly assertHeld: () => boolean;
|
|
5
|
+
readonly [withHealthySessionLockBrand]: 'WithHealthySessionLock';
|
|
6
|
+
};
|
|
7
|
+
export {};
|
|
@@ -281,12 +281,12 @@ export declare const DomainEventV1Schema: z.ZodDiscriminatedUnion<"kind", [z.Zod
|
|
|
281
281
|
nodeKind: z.ZodEnum<["step", "checkpoint"]>;
|
|
282
282
|
parentNodeId: z.ZodNullable<z.ZodString>;
|
|
283
283
|
workflowHash: z.ZodString;
|
|
284
|
-
snapshotRef: z.ZodString
|
|
284
|
+
snapshotRef: z.ZodEffects<z.ZodString, never, string>;
|
|
285
285
|
}, "strip", z.ZodTypeAny, {
|
|
286
286
|
workflowHash: string;
|
|
287
287
|
nodeKind: "step" | "checkpoint";
|
|
288
288
|
parentNodeId: string | null;
|
|
289
|
-
snapshotRef:
|
|
289
|
+
snapshotRef: never;
|
|
290
290
|
}, {
|
|
291
291
|
workflowHash: string;
|
|
292
292
|
nodeKind: "step" | "checkpoint";
|
|
@@ -300,7 +300,7 @@ export declare const DomainEventV1Schema: z.ZodDiscriminatedUnion<"kind", [z.Zod
|
|
|
300
300
|
workflowHash: string;
|
|
301
301
|
nodeKind: "step" | "checkpoint";
|
|
302
302
|
parentNodeId: string | null;
|
|
303
|
-
snapshotRef:
|
|
303
|
+
snapshotRef: never;
|
|
304
304
|
};
|
|
305
305
|
v: 1;
|
|
306
306
|
eventId: string;
|
|
@@ -3,10 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.DomainEventV1Schema = exports.DomainEventEnvelopeV1Schema = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const json_zod_js_1 = require("../../canonical/json-zod.js");
|
|
6
|
+
const index_js_1 = require("../../ids/index.js");
|
|
6
7
|
const sha256DigestSchema = zod_1.z
|
|
7
8
|
.string()
|
|
8
9
|
.regex(/^sha256:[0-9a-f]{64}$/, 'Expected sha256:<64 hex chars>')
|
|
9
10
|
.describe('sha256 digest in WorkRail v2 format');
|
|
11
|
+
const snapshotRefSchema = sha256DigestSchema
|
|
12
|
+
.transform((v) => (0, index_js_1.asSnapshotRef)((0, index_js_1.asSha256Digest)(v)))
|
|
13
|
+
.describe('SnapshotRef (content-addressed sha256 ref)');
|
|
10
14
|
exports.DomainEventEnvelopeV1Schema = zod_1.z.object({
|
|
11
15
|
v: zod_1.z.literal(1),
|
|
12
16
|
eventId: zod_1.z.string().min(1),
|
|
@@ -34,7 +38,7 @@ const NodeCreatedDataV1Schema = zod_1.z.object({
|
|
|
34
38
|
nodeKind: NodeKindSchema,
|
|
35
39
|
parentNodeId: zod_1.z.string().min(1).nullable(),
|
|
36
40
|
workflowHash: sha256DigestSchema,
|
|
37
|
-
snapshotRef:
|
|
41
|
+
snapshotRef: snapshotRefSchema,
|
|
38
42
|
});
|
|
39
43
|
const EdgeKindSchema = zod_1.z.enum(['acked_step', 'checkpoint']);
|
|
40
44
|
const EdgeCauseKindSchema = zod_1.z.enum(['idempotent_replay', 'intentional_fork', 'non_tip_advance', 'checkpoint_created']);
|
|
@@ -114,14 +118,14 @@ const BlockerReportV1Schema = zod_1.z
|
|
|
114
118
|
.superRefine((v, ctx) => {
|
|
115
119
|
const keyFor = (b) => {
|
|
116
120
|
const p = b.pointer;
|
|
117
|
-
const ptrStable =
|
|
121
|
+
const ptrStable = p.kind === 'context_key'
|
|
118
122
|
? p.key
|
|
119
|
-
:
|
|
123
|
+
: p.kind === 'output_contract'
|
|
120
124
|
? p.contractRef
|
|
121
|
-
:
|
|
125
|
+
: p.kind === 'capability'
|
|
122
126
|
? p.capability
|
|
123
127
|
: p.stepId;
|
|
124
|
-
return `${b.code}|${
|
|
128
|
+
return `${b.code}|${p.kind}|${String(ptrStable)}`;
|
|
125
129
|
};
|
|
126
130
|
for (let i = 1; i < v.blockers.length; i++) {
|
|
127
131
|
if (keyFor(v.blockers[i - 1]) > keyFor(v.blockers[i])) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type CorruptionReasonV2 = {
|
|
2
|
+
readonly code: 'digest_mismatch';
|
|
3
|
+
readonly message: string;
|
|
4
|
+
} | {
|
|
5
|
+
readonly code: 'non_contiguous_indices';
|
|
6
|
+
readonly message: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly code: 'missing_attested_segment';
|
|
9
|
+
readonly message: string;
|
|
10
|
+
} | {
|
|
11
|
+
readonly code: 'unknown_schema_version';
|
|
12
|
+
readonly message: string;
|
|
13
|
+
};
|
|
14
|
+
export type SessionHealthV2 = {
|
|
15
|
+
readonly kind: 'healthy';
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: 'corrupt_tail';
|
|
18
|
+
readonly reason: CorruptionReasonV2;
|
|
19
|
+
} | {
|
|
20
|
+
readonly kind: 'corrupt_head';
|
|
21
|
+
readonly reason: CorruptionReasonV2;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: 'unknown_version';
|
|
24
|
+
readonly reason: CorruptionReasonV2;
|
|
25
|
+
};
|
|
@@ -2,20 +2,21 @@ import type { ResultAsync } from 'neverthrow';
|
|
|
2
2
|
import type { DataDirPortV2 } from '../../../ports/data-dir.port.js';
|
|
3
3
|
import type { FileSystemPortV2 } from '../../../ports/fs.port.js';
|
|
4
4
|
import type { Sha256PortV2 } from '../../../ports/sha256.port.js';
|
|
5
|
-
import type {
|
|
6
|
-
import type { AppendPlanV2, SessionEventLogStoreError, LoadedSessionTruthV2, SessionEventLogStorePortV2 } from '../../../ports/session-event-log-store.port.js';
|
|
5
|
+
import type { AppendPlanV2, LoadedValidatedPrefixV2, SessionEventLogStoreError, LoadedSessionTruthV2, SessionEventLogAppendStorePortV2, SessionEventLogReadonlyStorePortV2 } from '../../../ports/session-event-log-store.port.js';
|
|
7
6
|
import type { SessionId } from '../../../durable-core/ids/index.js';
|
|
8
|
-
|
|
7
|
+
import type { WithHealthySessionLock } from '../../../durable-core/ids/with-healthy-session-lock.js';
|
|
8
|
+
export declare class LocalSessionEventLogStoreV2 implements SessionEventLogReadonlyStorePortV2, SessionEventLogAppendStorePortV2 {
|
|
9
9
|
private readonly dataDir;
|
|
10
10
|
private readonly fs;
|
|
11
11
|
private readonly sha256;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
append(sessionId: SessionId, plan: AppendPlanV2): ResultAsync<void, SessionEventLogStoreError>;
|
|
12
|
+
constructor(dataDir: DataDirPortV2, fs: FileSystemPortV2, sha256: Sha256PortV2);
|
|
13
|
+
append(lock: WithHealthySessionLock, plan: AppendPlanV2): ResultAsync<void, SessionEventLogStoreError>;
|
|
15
14
|
load(sessionId: SessionId): ResultAsync<LoadedSessionTruthV2, SessionEventLogStoreError>;
|
|
15
|
+
loadValidatedPrefix(sessionId: SessionId): ResultAsync<LoadedValidatedPrefixV2, SessionEventLogStoreError>;
|
|
16
16
|
private appendImpl;
|
|
17
17
|
private loadImpl;
|
|
18
18
|
private readManifestOrEmpty;
|
|
19
|
+
private loadValidatedPrefixImpl;
|
|
19
20
|
private loadTruthOrEmpty;
|
|
20
21
|
private appendManifestRecords;
|
|
21
22
|
private unwrap;
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
|
@@ -1,18 +1,4 @@
|
|
|
1
1
|
import type { Result } from 'neverthrow';
|
|
2
2
|
import type { LoadedSessionTruthV2 } from '../ports/session-event-log-store.port.js';
|
|
3
|
-
|
|
4
|
-
readonly kind: 'healthy';
|
|
5
|
-
} | {
|
|
6
|
-
readonly kind: 'corrupted';
|
|
7
|
-
readonly reason: {
|
|
8
|
-
readonly code: 'MANIFEST_INVALID';
|
|
9
|
-
readonly message: string;
|
|
10
|
-
} | {
|
|
11
|
-
readonly code: 'EVENT_LOG_INVALID';
|
|
12
|
-
readonly message: string;
|
|
13
|
-
} | {
|
|
14
|
-
readonly code: 'RUN_DAG_INVALID';
|
|
15
|
-
readonly message: string;
|
|
16
|
-
};
|
|
17
|
-
};
|
|
3
|
+
import type { SessionHealthV2 } from '../durable-core/schemas/session/session-health.js';
|
|
18
4
|
export declare function projectSessionHealthV2(truth: LoadedSessionTruthV2): Result<SessionHealthV2, never>;
|
|
@@ -6,10 +6,7 @@ const run_dag_js_1 = require("./run-dag.js");
|
|
|
6
6
|
function projectSessionHealthV2(truth) {
|
|
7
7
|
const dag = (0, run_dag_js_1.projectRunDagV2)(truth.events);
|
|
8
8
|
if (dag.isErr()) {
|
|
9
|
-
return (0, neverthrow_1.ok)({
|
|
10
|
-
kind: 'corrupted',
|
|
11
|
-
reason: { code: 'RUN_DAG_INVALID', message: dag.error.message },
|
|
12
|
-
});
|
|
9
|
+
return (0, neverthrow_1.ok)({ kind: 'corrupt_tail', reason: { code: 'non_contiguous_indices', message: dag.error.message } });
|
|
13
10
|
}
|
|
14
11
|
return (0, neverthrow_1.ok)({ kind: 'healthy' });
|
|
15
12
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ResultAsync } from 'neverthrow';
|
|
2
|
+
import type { SessionId } from '../durable-core/ids/index.js';
|
|
3
|
+
import type { WithHealthySessionLock } from '../durable-core/ids/with-healthy-session-lock.js';
|
|
4
|
+
import type { SessionHealthV2 } from '../durable-core/schemas/session/session-health.js';
|
|
5
|
+
import type { SessionLockPortV2 } from '../ports/session-lock.port.js';
|
|
6
|
+
import type { SessionEventLogReadonlyStorePortV2, SessionEventLogStoreError } from '../ports/session-event-log-store.port.js';
|
|
7
|
+
export type ExecutionSessionGateErrorV2 = {
|
|
8
|
+
readonly code: 'SESSION_LOCKED';
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly sessionId: SessionId;
|
|
11
|
+
readonly retry: {
|
|
12
|
+
readonly kind: 'retryable';
|
|
13
|
+
readonly afterMs: number;
|
|
14
|
+
};
|
|
15
|
+
} | {
|
|
16
|
+
readonly code: 'SESSION_LOCK_REENTRANT';
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly sessionId: SessionId;
|
|
19
|
+
} | {
|
|
20
|
+
readonly code: 'LOCK_ACQUIRE_FAILED';
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly sessionId: SessionId;
|
|
23
|
+
} | {
|
|
24
|
+
readonly code: 'LOCK_RELEASE_FAILED';
|
|
25
|
+
readonly message: string;
|
|
26
|
+
readonly sessionId: SessionId;
|
|
27
|
+
readonly retry: {
|
|
28
|
+
readonly kind: 'retryable';
|
|
29
|
+
readonly afterMs: number;
|
|
30
|
+
};
|
|
31
|
+
} | {
|
|
32
|
+
readonly code: 'SESSION_NOT_HEALTHY';
|
|
33
|
+
readonly message: string;
|
|
34
|
+
readonly sessionId: SessionId;
|
|
35
|
+
readonly health: SessionHealthV2;
|
|
36
|
+
} | {
|
|
37
|
+
readonly code: 'SESSION_LOAD_FAILED';
|
|
38
|
+
readonly message: string;
|
|
39
|
+
readonly sessionId: SessionId;
|
|
40
|
+
readonly cause: SessionEventLogStoreError;
|
|
41
|
+
} | {
|
|
42
|
+
readonly code: 'GATE_CALLBACK_FAILED';
|
|
43
|
+
readonly message: string;
|
|
44
|
+
readonly sessionId: SessionId;
|
|
45
|
+
};
|
|
46
|
+
export declare class ExecutionSessionGateV2 {
|
|
47
|
+
private readonly lock;
|
|
48
|
+
private readonly store;
|
|
49
|
+
private readonly activeSessions;
|
|
50
|
+
private readonly activeWitnessTokens;
|
|
51
|
+
constructor(lock: SessionLockPortV2, store: SessionEventLogReadonlyStorePortV2);
|
|
52
|
+
withHealthySessionLock<T, E>(sessionId: SessionId, fn: (lock: WithHealthySessionLock) => ResultAsync<T, E>): ResultAsync<T, ExecutionSessionGateErrorV2 | E>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ExecutionSessionGateV2 = void 0;
|
|
4
|
+
const neverthrow_1 = require("neverthrow");
|
|
5
|
+
const session_health_js_1 = require("../projections/session-health.js");
|
|
6
|
+
class ExecutionSessionGateV2 {
|
|
7
|
+
constructor(lock, store) {
|
|
8
|
+
this.lock = lock;
|
|
9
|
+
this.store = store;
|
|
10
|
+
this.activeSessions = new Set();
|
|
11
|
+
this.activeWitnessTokens = new Set();
|
|
12
|
+
}
|
|
13
|
+
withHealthySessionLock(sessionId, fn) {
|
|
14
|
+
if (this.activeSessions.has(sessionId)) {
|
|
15
|
+
return (0, neverthrow_1.errAsync)({ code: 'SESSION_LOCK_REENTRANT', message: `Re-entrant gate call for session: ${sessionId}`, sessionId });
|
|
16
|
+
}
|
|
17
|
+
this.activeSessions.add(sessionId);
|
|
18
|
+
const witnessToken = Symbol(`withHealthySessionLock:${sessionId}`);
|
|
19
|
+
this.activeWitnessTokens.add(witnessToken);
|
|
20
|
+
const doWork = async () => {
|
|
21
|
+
const precheckResult = await this.store.loadValidatedPrefix(sessionId).match((v) => ({ kind: 'available', prefix: v }), (e) => {
|
|
22
|
+
if (e.code === 'SESSION_STORE_CORRUPTION_DETECTED') {
|
|
23
|
+
const health = e.location === 'head'
|
|
24
|
+
? { kind: 'corrupt_head', reason: e.reason }
|
|
25
|
+
: { kind: 'corrupt_tail', reason: e.reason };
|
|
26
|
+
throw new GateFailure({
|
|
27
|
+
code: 'SESSION_NOT_HEALTHY',
|
|
28
|
+
message: 'Session is not healthy',
|
|
29
|
+
sessionId,
|
|
30
|
+
health,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { kind: 'unavailable' };
|
|
34
|
+
});
|
|
35
|
+
const pre = precheckResult.kind === 'available' ? precheckResult.prefix : null;
|
|
36
|
+
if (pre !== null) {
|
|
37
|
+
if (!pre.isComplete) {
|
|
38
|
+
throw new GateFailure({
|
|
39
|
+
code: 'SESSION_NOT_HEALTHY',
|
|
40
|
+
message: 'Session is not healthy (validated prefix indicates corrupt tail)',
|
|
41
|
+
sessionId,
|
|
42
|
+
health: {
|
|
43
|
+
kind: 'corrupt_tail',
|
|
44
|
+
reason: pre.tailReason ?? { code: 'non_contiguous_indices', message: 'Validated prefix stopped early (corrupt tail)' },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const preHealth = (0, session_health_js_1.projectSessionHealthV2)(pre.truth).match((h) => h, () => ({ kind: 'corrupt_tail', reason: { code: 'non_contiguous_indices', message: 'unknown' } }));
|
|
49
|
+
if (preHealth.kind !== 'healthy') {
|
|
50
|
+
throw new GateFailure({
|
|
51
|
+
code: 'SESSION_NOT_HEALTHY',
|
|
52
|
+
message: 'Session is not healthy',
|
|
53
|
+
sessionId,
|
|
54
|
+
health: preHealth,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const handle = await this.lock.acquire(sessionId).match((h) => h, (e) => {
|
|
59
|
+
if (e.code === 'SESSION_LOCK_BUSY') {
|
|
60
|
+
throw new GateFailure({
|
|
61
|
+
code: 'SESSION_LOCKED',
|
|
62
|
+
message: `Session is locked; retry in 1–3 seconds; if this persists >10s, ensure no other WorkRail process is running for this session.`,
|
|
63
|
+
sessionId,
|
|
64
|
+
retry: { kind: 'retryable', afterMs: 1000 },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
throw new GateFailure({ code: 'LOCK_ACQUIRE_FAILED', message: e.message, sessionId });
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
const truth = await this.store.load(sessionId).match((v) => v, (e) => {
|
|
71
|
+
if (e.code === 'SESSION_STORE_CORRUPTION_DETECTED') {
|
|
72
|
+
const health = e.location === 'head'
|
|
73
|
+
? { kind: 'corrupt_head', reason: e.reason }
|
|
74
|
+
: { kind: 'corrupt_tail', reason: e.reason };
|
|
75
|
+
throw new GateFailure({
|
|
76
|
+
code: 'SESSION_NOT_HEALTHY',
|
|
77
|
+
message: 'Session is not healthy',
|
|
78
|
+
sessionId,
|
|
79
|
+
health,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
throw new GateFailure({
|
|
83
|
+
code: 'SESSION_LOAD_FAILED',
|
|
84
|
+
message: `Failed to load session`,
|
|
85
|
+
sessionId,
|
|
86
|
+
cause: e,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
const health = (0, session_health_js_1.projectSessionHealthV2)(truth).match((h) => h, () => {
|
|
90
|
+
return { kind: 'corrupt_tail', reason: { code: 'non_contiguous_indices', message: 'unknown' } };
|
|
91
|
+
});
|
|
92
|
+
if (health.kind !== 'healthy') {
|
|
93
|
+
throw new GateFailure({
|
|
94
|
+
code: 'SESSION_NOT_HEALTHY',
|
|
95
|
+
message: `Session is not healthy`,
|
|
96
|
+
sessionId,
|
|
97
|
+
health,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const witness = {
|
|
101
|
+
...handle,
|
|
102
|
+
assertHeld: () => this.activeWitnessTokens.has(witnessToken),
|
|
103
|
+
};
|
|
104
|
+
let callback;
|
|
105
|
+
try {
|
|
106
|
+
callback = fn(witness);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
throw new GateFailure({
|
|
110
|
+
code: 'GATE_CALLBACK_FAILED',
|
|
111
|
+
message: e instanceof Error ? e.message : String(e),
|
|
112
|
+
sessionId,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const res = await callback.match((v) => ({ ok: true, value: v }), (e) => ({ ok: false, error: e }));
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
throw new GateFailure(res.error);
|
|
118
|
+
}
|
|
119
|
+
return res.value;
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
if (e instanceof GateFailure)
|
|
123
|
+
throw e;
|
|
124
|
+
throw new GateFailure({
|
|
125
|
+
code: 'GATE_CALLBACK_FAILED',
|
|
126
|
+
message: e instanceof Error ? e.message : String(e),
|
|
127
|
+
sessionId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await this.lock.release(handle).match(() => undefined, () => {
|
|
132
|
+
throw new GateFailure({
|
|
133
|
+
code: 'LOCK_RELEASE_FAILED',
|
|
134
|
+
message: 'Failed to release session lock; retry in 1–3 seconds; if this persists >10s, ensure no other WorkRail process is running for this session.',
|
|
135
|
+
sessionId,
|
|
136
|
+
retry: { kind: 'retryable', afterMs: 1000 },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
return neverthrow_1.ResultAsync.fromPromise((async () => {
|
|
142
|
+
try {
|
|
143
|
+
return await doWork();
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
this.activeSessions.delete(sessionId);
|
|
147
|
+
this.activeWitnessTokens.delete(witnessToken);
|
|
148
|
+
}
|
|
149
|
+
})(), (e) => {
|
|
150
|
+
if (e instanceof GateFailure)
|
|
151
|
+
return e.error;
|
|
152
|
+
return {
|
|
153
|
+
code: 'GATE_CALLBACK_FAILED',
|
|
154
|
+
message: e instanceof Error ? e.message : String(e),
|
|
155
|
+
sessionId,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
exports.ExecutionSessionGateV2 = ExecutionSessionGateV2;
|
|
161
|
+
class GateFailure extends Error {
|
|
162
|
+
constructor(error) {
|
|
163
|
+
const msg = error?.message;
|
|
164
|
+
super(typeof msg === 'string' ? msg : 'GateFailure');
|
|
165
|
+
this.error = error;
|
|
166
|
+
}
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exaudeus/workrail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Step-by-step workflow enforcement for AI agents via MCP",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/EtienneBBeaulac/workrail.git"
|
|
8
|
+
"url": "git+https://github.com/EtienneBBeaulac/workrail.git"
|
|
9
9
|
},
|
|
10
10
|
"bugs": {
|
|
11
11
|
"url": "https://github.com/EtienneBBeaulac/workrail/issues"
|