@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.
@@ -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": "9e76fa1f16a30799f79b609434bff483e71203115e81c741d39c251dacf8254f",
894
- "bytes": 61807
901
+ "sha256": "8de8d8904b67ca93d985c1e215f216d929612fb14357888f88ff270f6ac3c40f",
902
+ "bytes": 61834
895
903
  },
896
904
  "v2/durable-core/schemas/session/events.js": {
897
- "sha256": "5647ebba23a5421457bd66611253a51ace9adb67020557cb08d8066e19dc7670",
898
- "bytes": 14796
905
+ "sha256": "411228cda7ef149d25413c668e858d5691810cff8e6ce64913bf8e0cb1cfda2a",
906
+ "bytes": 15007
899
907
  },
900
908
  "v2/durable-core/schemas/session/index.d.ts": {
901
- "sha256": "a005aad0fcd3cd5c7134f834c4dc49e01ac2672945ef7e38324fc8fc4f024751",
902
- "bytes": 179
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": "74b5492e4d87f40883fad6bfad2e8fb60a0f4897dc51d84e09071600df6115e9",
958
- "bytes": 1229
973
+ "sha256": "bd6a47ede5fc58a72b18031d57bd7357d4657f11d06aee2c2d08391be51b213d",
974
+ "bytes": 1467
959
975
  },
960
976
  "v2/infra/local/session-store/index.js": {
961
- "sha256": "66512ea01106dab9f208870c54522322d7705cc26cbcfbfd9d623302935445dd",
962
- "bytes": 16065
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": "ccc7cf95569aa03c96d88982c21c55078f2fc7b72d1911870b0db595fa299e00",
998
- "bytes": 1368
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": "06dc992814e11965cbfe337c65253cb21ddea6efe0301ad818dfe21d03463c4d",
1078
- "bytes": 621
1093
+ "sha256": "0a7096bfc0d32eea9743c34a0b982e32f7eeec9e63ec7af10e27ec21be246630",
1094
+ "bytes": 327
1079
1095
  },
1080
1096
  "v2/projections/session-health.js": {
1081
- "sha256": "b1f13d193fdbe2fdd97c7786412666d33e8e57d762e8ee105bda7647384920ea",
1082
- "bytes": 573
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 {};
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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: string;
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: string;
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: sha256DigestSchema,
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 = b.pointer.kind === 'context_key'
121
+ const ptrStable = p.kind === 'context_key'
118
122
  ? p.key
119
- : b.pointer.kind === 'output_contract'
123
+ : p.kind === 'output_contract'
120
124
  ? p.contractRef
121
- : b.pointer.kind === 'capability'
125
+ : p.kind === 'capability'
122
126
  ? p.capability
123
127
  : p.stepId;
124
- return `${b.code}|${b.pointer.kind}|${String(ptrStable)}`;
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])) {
@@ -1,2 +1,3 @@
1
1
  export { ManifestRecordV1Schema, type ManifestRecordV1 } from './manifest.js';
2
2
  export { DomainEventEnvelopeV1Schema, DomainEventV1Schema, type DomainEventV1 } from './events.js';
3
+ export type { CorruptionReasonV2, SessionHealthV2 } from './session-health.js';
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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 { SessionLockPortV2 } from '../../../ports/session-lock.port.js';
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
- export declare class LocalSessionEventLogStoreV2 implements SessionEventLogStorePortV2 {
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
- private readonly lock;
13
- constructor(dataDir: DataDirPortV2, fs: FileSystemPortV2, sha256: Sha256PortV2, lock: SessionLockPortV2);
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, 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
  }
@@ -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
  }
@@ -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
- export type SessionHealthV2 = {
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.9.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"