@modelprofile.com/flexharness 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.d.ts +13 -1
  3. package/dist_ts/classes.flexharness.js +302 -188
  4. package/dist_ts/classes.stores.js +11 -119
  5. package/dist_ts/errors.d.ts +7 -2
  6. package/dist_ts/errors.js +1 -1
  7. package/dist_ts/interfaces.d.ts +18 -0
  8. package/dist_ts/interfaces.js +1 -1
  9. package/dist_ts/utils.storerules.d.ts +28 -0
  10. package/dist_ts/utils.storerules.js +107 -0
  11. package/dist_ts_chat_harness/classes.transcript.d.ts +103 -0
  12. package/dist_ts_chat_harness/classes.transcript.js +296 -0
  13. package/dist_ts_chat_harness/functions.intents.d.ts +62 -0
  14. package/dist_ts_chat_harness/functions.intents.js +96 -0
  15. package/dist_ts_chat_harness/functions.mapping.d.ts +33 -0
  16. package/dist_ts_chat_harness/functions.mapping.js +220 -0
  17. package/dist_ts_chat_harness/index.d.ts +3 -0
  18. package/dist_ts_chat_harness/index.js +4 -0
  19. package/dist_ts_chat_harness/plugins.d.ts +1 -0
  20. package/dist_ts_chat_harness/plugins.js +2 -0
  21. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +31 -0
  22. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +312 -0
  23. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +110 -0
  24. package/dist_ts_stores_nosqldb/classes.recordstore.js +437 -0
  25. package/dist_ts_stores_nosqldb/index.d.ts +2 -0
  26. package/dist_ts_stores_nosqldb/index.js +3 -0
  27. package/dist_ts_stores_nosqldb/plugins.d.ts +7 -0
  28. package/dist_ts_stores_nosqldb/plugins.js +10 -0
  29. package/package.json +24 -4
  30. package/readme.md +6 -0
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/classes.flexharness.ts +324 -205
  33. package/ts/classes.stores.ts +28 -172
  34. package/ts/errors.ts +8 -2
  35. package/ts/interfaces.ts +19 -0
  36. package/ts/readme.md +6 -0
  37. package/ts/utils.storerules.ts +177 -0
  38. package/ts_chat_harness/classes.transcript.ts +399 -0
  39. package/ts_chat_harness/functions.intents.ts +169 -0
  40. package/ts_chat_harness/functions.mapping.ts +267 -0
  41. package/ts_chat_harness/index.ts +33 -0
  42. package/ts_chat_harness/plugins.ts +18 -0
  43. package/ts_chat_harness/readme.md +117 -0
  44. package/ts_stores_nosqldb/classes.nosqldbstores.ts +465 -0
  45. package/ts_stores_nosqldb/classes.recordstore.ts +560 -0
  46. package/ts_stores_nosqldb/index.ts +9 -0
  47. package/ts_stores_nosqldb/plugins.ts +38 -0
  48. package/ts_stores_nosqldb/readme.md +53 -0
@@ -35,8 +35,6 @@ import {
35
35
  assertFlexProjectManagementTombstone,
36
36
  } from './utils.projectmanagement.js';
37
37
  import {
38
- assertExactKeys,
39
- assertRecord,
40
38
  createAgentEventSnapshot,
41
39
  createToolJobSnapshot,
42
40
  normalizeJsonObjectUndefined,
@@ -44,101 +42,17 @@ import {
44
42
  validateAgentEventSnapshot,
45
43
  validateToolJobSnapshot,
46
44
  } from './utils.storecodecs.js';
47
-
48
- type TSnapshot =
49
- | IFlexScopeSnapshot
50
- | TFlexProjectionSnapshot
51
- | IFlexPermissionSnapshot
52
- | TFlexProjectManagementRecord;
53
- type TSnapshotValidator<TSnapshotValue extends TSnapshot> = (
54
- value: unknown,
55
- ) => asserts value is TSnapshotValue;
56
-
57
- function validateIdentifier(value: string, name: string): void {
58
- if (typeof value !== 'string' || !value.trim()) {
59
- throw new FlexHarnessValidationError(`${name} must be a non-empty string.`);
60
- }
61
- }
62
-
63
- function validateExpectedRevision(expectedRevision: number): void {
64
- if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
65
- throw new FlexHarnessValidationError('expectedRevision must be a non-negative integer.');
66
- }
67
- }
68
-
69
- function validateProjectManagementWriteContext(
70
- writeContext: IFlexProjectManagementWriteContext,
71
- ): void {
72
- assertJsonSerializable(writeContext, '$writeContext');
73
- assertRecord(writeContext, 'writeContext');
74
- assertExactKeys(writeContext, ['actor', 'runId', 'agent', 'toolCallId'], 'writeContext');
75
- if (writeContext.actor !== 'agent' && writeContext.actor !== 'application') {
76
- throw new FlexHarnessValidationError('writeContext.actor must be agent or application.');
77
- }
78
- for (const key of ['runId', 'agent', 'toolCallId'] as const) {
79
- if (writeContext[key] !== undefined) validateIdentifier(writeContext[key], `writeContext.${key}`);
80
- }
81
- }
82
-
83
- function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
84
- snapshot: TSnapshotValue,
85
- expectedRevision: number,
86
- validator: TSnapshotValidator<TSnapshotValue>,
87
- ): TSnapshotValue {
88
- validator(snapshot);
89
- validateExpectedRevision(expectedRevision);
90
- if (snapshot.revision !== expectedRevision + 1) {
91
- throw new FlexHarnessStoreFormatError(
92
- `Snapshot revision ${snapshot.revision} must equal expected revision ${expectedRevision} plus one.`,
93
- );
94
- }
95
- return cloneSerializable(snapshot);
96
- }
97
-
98
- function providerKey(storageKey: string, sessionId: string): string {
99
- validateIdentifier(storageKey, 'storageKey');
100
- validateIdentifier(sessionId, 'sessionId');
101
- return JSON.stringify([storageKey, sessionId]);
102
- }
103
-
104
- function sameProjectManagementGeneration(
105
- left: TFlexProjectManagementRecord,
106
- right: TFlexProjectManagementRecord,
107
- ): boolean {
108
- return left.sessionGenerationId === right.sessionGenerationId
109
- && left.sessionGenerationSequence === right.sessionGenerationSequence;
110
- }
111
-
112
- function canInitializeProjectManagementGeneration(
113
- current: TFlexProjectManagementRecord,
114
- next: TFlexProjectManagementRecord,
115
- expectedRevision: number,
116
- ): boolean {
117
- return 'deletedAt' in current
118
- && expectedRevision === 0
119
- && next.sessionGenerationId !== current.sessionGenerationId
120
- && next.sessionGenerationSequence > current.sessionGenerationSequence;
121
- }
122
-
123
- function assertProjectionSession(snapshot: TFlexProjectionSnapshot, sessionId: string): void {
124
- for (const message of snapshot.messages) {
125
- if (message.sessionId !== sessionId) {
126
- throw new FlexHarnessStoreFormatError(
127
- `Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
128
- );
129
- }
130
- }
131
- for (const terminal of snapshot.stagedTerminals) {
132
- if (
133
- terminal.userMessage.sessionId !== sessionId
134
- || terminal.assistantMessage.sessionId !== sessionId
135
- ) {
136
- throw new FlexHarnessStoreFormatError(
137
- `Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
138
- );
139
- }
140
- }
141
- }
45
+ import {
46
+ assertProjectionSession,
47
+ assertProjectManagementSaveAdmitted,
48
+ assertSameArchiveEvents,
49
+ isProjectManagementTombstoneAdmitted,
50
+ providerKey,
51
+ validateExpectedRevision,
52
+ validateIdentifier,
53
+ validateProjectManagementWriteContext,
54
+ validateSnapshotSave,
55
+ } from './utils.storerules.js';
142
56
 
143
57
  class InMemoryScopeStore implements IFlexScopeStore {
144
58
  private readonly snapshots = new Map<string, IFlexScopeSnapshot>();
@@ -253,15 +167,7 @@ export class InMemoryFlexProjectManagementStore implements IFlexProjectManagemen
253
167
  expectedRevision,
254
168
  assertFlexProjectManagementSnapshot,
255
169
  );
256
- const current = this.snapshots.get(key);
257
- const actualRevision = current?.revision ?? 0;
258
- if (current && !sameProjectManagementGeneration(current, validated)) {
259
- if (!canInitializeProjectManagementGeneration(current, validated, expectedRevision)) {
260
- throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
261
- }
262
- } else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
263
- throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
264
- }
170
+ assertProjectManagementSaveAdmitted(key, this.snapshots.get(key), validated, expectedRevision);
265
171
  this.snapshots.set(key, validated);
266
172
  }
267
173
 
@@ -277,19 +183,8 @@ export class InMemoryFlexProjectManagementStore implements IFlexProjectManagemen
277
183
  expectedRevision,
278
184
  assertFlexProjectManagementTombstone,
279
185
  );
280
- const current = this.snapshots.get(key);
281
- const actualRevision = current?.revision ?? 0;
282
- if (current && sameProjectManagementGeneration(current, validated)) {
283
- if ('deletedAt' in current) return;
284
- if (actualRevision !== expectedRevision) {
285
- throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
286
- }
287
- } else if (current) {
288
- if (!canInitializeProjectManagementGeneration(current, validated, expectedRevision)) {
289
- throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
290
- }
291
- } else if (actualRevision !== expectedRevision) {
292
- throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
186
+ if (!isProjectManagementTombstoneAdmitted(key, this.snapshots.get(key), validated, expectedRevision)) {
187
+ return;
293
188
  }
294
189
  this.snapshots.set(key, validated);
295
190
  }
@@ -346,12 +241,7 @@ class InMemoryAgentEventStore implements plugins.IAgentEventStoreV2 {
346
241
  const validated = validateAgentEventArchive(normalized, this.sessionId, archive.archiveId);
347
242
  const existing = this.archives.get(archive.archiveId);
348
243
  if (existing) {
349
- if (
350
- JSON.stringify(existing.events.map((event) => event.id))
351
- !== JSON.stringify(validated.events.map((event) => event.id))
352
- ) {
353
- throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
354
- }
244
+ assertSameArchiveEvents(existing, validated);
355
245
  return;
356
246
  }
357
247
  this.archives.set(archive.archiveId, cloneSerializable(validated));
@@ -1007,22 +897,12 @@ class JsonProjectManagementStore implements IFlexProjectManagementStore {
1007
897
  assertFlexProjectManagementRecord(value);
1008
898
  return value;
1009
899
  });
1010
- const actualRevision = current?.revision ?? 0;
1011
- if (current && !sameProjectManagementGeneration(current, validated)) {
1012
- if (!canInitializeProjectManagementGeneration(current, validated, expectedRevision)) {
1013
- throw new FlexHarnessStoreConflictError(
1014
- providerKey(storageKey, sessionId),
1015
- expectedRevision,
1016
- actualRevision,
1017
- );
1018
- }
1019
- } else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
1020
- throw new FlexHarnessStoreConflictError(
1021
- providerKey(storageKey, sessionId),
1022
- expectedRevision,
1023
- actualRevision,
1024
- );
1025
- }
900
+ assertProjectManagementSaveAdmitted(
901
+ providerKey(storageKey, sessionId),
902
+ current,
903
+ validated,
904
+ expectedRevision,
905
+ );
1026
906
  await this.root.write(filePath, validated);
1027
907
  }),
1028
908
  );
@@ -1046,31 +926,12 @@ class JsonProjectManagementStore implements IFlexProjectManagementStore {
1046
926
  assertFlexProjectManagementRecord(value);
1047
927
  return value;
1048
928
  });
1049
- const actualRevision = current?.revision ?? 0;
1050
- if (current && sameProjectManagementGeneration(current, validated)) {
1051
- if ('deletedAt' in current) return;
1052
- if (actualRevision !== expectedRevision) {
1053
- throw new FlexHarnessStoreConflictError(
1054
- providerKey(storageKey, sessionId),
1055
- expectedRevision,
1056
- actualRevision,
1057
- );
1058
- }
1059
- } else if (current) {
1060
- if (!canInitializeProjectManagementGeneration(current, validated, expectedRevision)) {
1061
- throw new FlexHarnessStoreConflictError(
1062
- providerKey(storageKey, sessionId),
1063
- expectedRevision,
1064
- actualRevision,
1065
- );
1066
- }
1067
- } else if (actualRevision !== expectedRevision) {
1068
- throw new FlexHarnessStoreConflictError(
1069
- providerKey(storageKey, sessionId),
1070
- expectedRevision,
1071
- actualRevision,
1072
- );
1073
- }
929
+ if (!isProjectManagementTombstoneAdmitted(
930
+ providerKey(storageKey, sessionId),
931
+ current,
932
+ validated,
933
+ expectedRevision,
934
+ )) return;
1074
935
  await this.root.write(filePath, validated);
1075
936
  }),
1076
937
  );
@@ -1163,12 +1024,7 @@ class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
1163
1024
  (value) => validateAgentEventArchive(value, this.sessionId, archive.archiveId),
1164
1025
  );
1165
1026
  if (current) {
1166
- if (
1167
- JSON.stringify(current.events.map((event) => event.id))
1168
- !== JSON.stringify(validated.events.map((event) => event.id))
1169
- ) {
1170
- throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
1171
- }
1027
+ assertSameArchiveEvents(current, validated);
1172
1028
  return;
1173
1029
  }
1174
1030
  await this.root.write(filePath, validated);
package/ts/errors.ts CHANGED
@@ -173,13 +173,19 @@ export class FlexHarnessStoreFormatError extends FlexHarnessError {
173
173
  }
174
174
  }
175
175
 
176
+ /**
177
+ * What a store was doing when it could not confirm the outcome: `write` and `delete` name a
178
+ * database record, `delete-file` and `delete-directory` a file-system path.
179
+ */
180
+ export type TFlexStoreCommitOperation = 'write' | 'delete' | 'delete-file' | 'delete-directory';
181
+
176
182
  export class FlexHarnessStoreCommitUncertainError extends FlexHarnessError {
177
183
  public readonly path: string;
178
- public readonly operation: 'write' | 'delete-file' | 'delete-directory';
184
+ public readonly operation: TFlexStoreCommitOperation;
179
185
 
180
186
  constructor(
181
187
  path: string,
182
- operation: 'write' | 'delete-file' | 'delete-directory',
188
+ operation: TFlexStoreCommitOperation,
183
189
  options: ErrorOptions,
184
190
  ) {
185
191
  super(
package/ts/interfaces.ts CHANGED
@@ -513,6 +513,25 @@ export interface IFlexMessagePage {
513
513
  nextCursor?: string;
514
514
  }
515
515
 
516
+ /**
517
+ * A session as the events up to `sequence` describe it, read in one step. Replaying the events of
518
+ * the same harness instance after `sequence` onto it gives the session's later states.
519
+ */
520
+ export interface IFlexSessionView {
521
+ /** Current metadata. Its status can be ahead of `sequence`: status changes emit no event. */
522
+ session: IFlexSession;
523
+ /** The visible messages, including what a running turn has streamed so far. */
524
+ messages: IFlexMessage[];
525
+ /**
526
+ * The session's pending permission requests, oldest first. A request its run leaves unanswered,
527
+ * because the run was stopped or failed, leaves this list without an event of its own; the run's
528
+ * `run.finished` follows.
529
+ */
530
+ pendingPermissions: IFlexPermissionRequest[];
531
+ /** The `sequence` of the instance's last event this view includes; 0 before the first event. */
532
+ sequence: number;
533
+ }
534
+
516
535
  export interface IFlexResolvedScope<TScope> {
517
536
  storageKey: string;
518
537
  scope: TScope;
package/ts/readme.md CHANGED
@@ -61,10 +61,12 @@ or provider SDKs.
61
61
  | `/chat` | Portable streaming `ChatSession`, history and usage | None |
62
62
  | `/chat/cli` | Ink/React terminal chat | `ink ink-text-input react`; TypeScript: `@types/react` |
63
63
  | `/chat/web` | Lit `flexchat-window`, `flexchat-message`, `flexchat-input` | `lit` |
64
+ | `/chat/harness` | `FlexHarnessTranscript` and intents for the `@design.estate/dees-catalog` harness components, browser-safe | `@design.estate/dees-catalog` (types only) |
64
65
  | `/kvm` | Browser control, terminal framing and OCR observation | `puppeteer` |
65
66
  | `/documents` | PDF and image documents, `extractTextFromPdf` | `@push.rocks/smartpdf`; for image documents `sharp` |
66
67
  | `/mcp` | MCP clients and AI SDK tool conversion | `@modelcontextprotocol/sdk` |
67
68
  | `/migration` | Existing versioned harness migrations | None |
69
+ | `/stores/nosqldb` | `NoSqlFlexHarnessStores`: cross-process CAS stores on `@lossless.org/client/nosqldb` | `@lossless.org/client` |
68
70
 
69
71
  The integrations in the last column use optional peer dependencies. Install them
70
72
  only for the entrypoints your project imports, for example:
@@ -1032,6 +1034,8 @@ unsubscribe();
1032
1034
 
1033
1035
  Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Every accepted steer emits `prompt.steer.accepted`, and `prompt.steer.applied`, after the `message.*` events of its user message, once the run took it in; `run.finished` lists it in `unappliedSteerIds` unless it is in the model context of later turns. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part emits exactly one `part.completed` event before the corresponding `run.finished` event. Every `part.started`, `part.delta`, `part.updated`, and `part.completed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
1034
1036
 
1037
+ `getSessionView(scopeId, sessionId)` reads a session's metadata, visible messages and pending permission requests in one step, together with the instance's event `sequence` they include. The messages include what a running turn has streamed so far, so a consumer that applies every event of the instance with a greater `sequence` follows the session exactly from the view on; events up to `sequence` are already in it. The read waits while a queued write or a change that emits events for the session is in progress; a session that keeps changing through 64 consecutive waits is refused with `FlexHarnessSessionBusyError`, and the read can be repeated. Session metadata is current: status changes such as `waiting_permission` emit no event. A permission request its run leaves unanswered leaves `pendingPermissions` without an event of its own; the run's `run.finished` follows.
1038
+
1035
1039
  `part.started`, `part.updated`, and `part.completed` are snapshot events with a complete immutable `part`. A newly streamed text part emits `part.started` with empty text before its first delta; reasoning parts also start empty. `part.delta` is a separate delta-only event: it has no cumulative `part`, and carries `partType`, the required exact source `delta`, and `baseTextUtf8Bytes`/`textUtf8Bytes` for the cumulative text before and after that delta. The counters remain correct when a UTF-16 surrogate pair is split across callbacks. Exact deltas are never truncated, including a single delta above the 96 KiB message-transfer text limit. `part.updated` remains a cumulative replacement snapshot for running tool output or metadata, not a text delta. `session.history.changed` carries `direction: 'undo' | 'redo' | 'branch'`; `runId` is present for undo and redo. Events contain public IDs, exact delta payloads, and public snapshots only; they do not expose prompt payloads, the resolved scope object, storage key, model object, provider options, or raw storage key.
1036
1040
 
1037
1041
  Part events narrow through the exported `TFlexPartEvent` union. `IFlexPartEventBase` contains their shared coordinates, `IFlexPartSnapshotEvent` owns snapshot events and their complete `part`, and `IFlexPartDeltaEvent` owns exact delta-only events and their UTF-8 counters.
@@ -1068,6 +1072,8 @@ After every harness using a `JsonFileFlexHarnessStores` instance has been dispos
1068
1072
 
1069
1073
  The JSON stores are explicitly not cross-process safe. When several processes can access the same storage namespace, every core `IFlexHarnessStores` domain and `stores.projectManagement` must use database-backed or equivalent cross-process CAS. Process-local CAS for the core stores or for PM alone is insufficient: session generation creation, cleanup tombstones, PM replacement, and stale-writer rejection must all retain their respective atomic preconditions across processes.
1070
1074
 
1075
+ `NoSqlFlexHarnessStores` from `/stores/nosqldb` is that database-backed bundle for every domain. Pass it an initialized `SmartdataDb` and `await stores.prepare()` at startup, before the first harness uses it; the subpath readme describes chunked snapshots and failure reporting.
1076
+
1071
1077
  Direct store operations and non-run session mutations surface conflicts as `FlexHarnessStoreConflictError` or the corresponding AgentSession store conflict. Malformed, wrong-schema, or non-JSON snapshots are surfaced as `FlexHarnessStoreFormatError`. A write or deletion that changed its target but cannot confirm parent-directory durability surfaces `FlexHarnessStoreCommitUncertainError` with the affected path, operation, and cause. Run persistence failures cross the external error boundary and therefore become `FlexHarnessExternalError`. FlexHarness does not merge conflicts.
1072
1078
 
1073
1079
  Each domain serializes its own mutations. A successful run first persists a hidden completed projection, then finalizes the canonical Agent generation as `accepted`, then promotes the hidden projection publicly. Recovery uses the canonical generation outcome to promote an accepted stage or publish a failed/cancelled projection. Failed and cancelled generations remain auditable but never enter future model context.
@@ -0,0 +1,177 @@
1
+ import type * as plugins from './plugins.js';
2
+ import {
3
+ FlexHarnessStoreConflictError,
4
+ FlexHarnessStoreFormatError,
5
+ FlexHarnessValidationError,
6
+ } from './errors.js';
7
+ import type {
8
+ IFlexPermissionSnapshot,
9
+ IFlexProjectManagementSnapshot,
10
+ IFlexProjectManagementTombstone,
11
+ IFlexProjectManagementWriteContext,
12
+ IFlexScopeSnapshot,
13
+ TFlexProjectionSnapshot,
14
+ TFlexProjectManagementRecord,
15
+ } from './interfaces.js';
16
+ import { assertJsonSerializable, cloneSerializable } from './utils.json.js';
17
+ import { assertExactKeys, assertRecord } from './utils.storecodecs.js';
18
+
19
+ /**
20
+ * Store-independent admission rules every `IFlexHarnessStores` implementation applies: argument
21
+ * validation, the next-revision rule, exact session keys, and the project-management generation
22
+ * fence. Backends own only how they read, compare and write.
23
+ */
24
+
25
+ type TSnapshot =
26
+ | IFlexScopeSnapshot
27
+ | TFlexProjectionSnapshot
28
+ | IFlexPermissionSnapshot
29
+ | TFlexProjectManagementRecord;
30
+ type TSnapshotValidator<TSnapshotValue extends TSnapshot> = (
31
+ value: unknown,
32
+ ) => asserts value is TSnapshotValue;
33
+
34
+ export function validateIdentifier(value: string, name: string): void {
35
+ if (typeof value !== 'string' || !value.trim()) {
36
+ throw new FlexHarnessValidationError(`${name} must be a non-empty string.`);
37
+ }
38
+ }
39
+
40
+ export function validateExpectedRevision(expectedRevision: number): void {
41
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
42
+ throw new FlexHarnessValidationError('expectedRevision must be a non-negative integer.');
43
+ }
44
+ }
45
+
46
+ export function validateProjectManagementWriteContext(
47
+ writeContext: IFlexProjectManagementWriteContext,
48
+ ): void {
49
+ assertJsonSerializable(writeContext, '$writeContext');
50
+ assertRecord(writeContext, 'writeContext');
51
+ assertExactKeys(writeContext, ['actor', 'runId', 'agent', 'toolCallId'], 'writeContext');
52
+ if (writeContext.actor !== 'agent' && writeContext.actor !== 'application') {
53
+ throw new FlexHarnessValidationError('writeContext.actor must be agent or application.');
54
+ }
55
+ for (const key of ['runId', 'agent', 'toolCallId'] as const) {
56
+ if (writeContext[key] !== undefined) validateIdentifier(writeContext[key], `writeContext.${key}`);
57
+ }
58
+ }
59
+
60
+ export function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
61
+ snapshot: TSnapshotValue,
62
+ expectedRevision: number,
63
+ validator: TSnapshotValidator<TSnapshotValue>,
64
+ ): TSnapshotValue {
65
+ validator(snapshot);
66
+ validateExpectedRevision(expectedRevision);
67
+ if (snapshot.revision !== expectedRevision + 1) {
68
+ throw new FlexHarnessStoreFormatError(
69
+ `Snapshot revision ${snapshot.revision} must equal expected revision ${expectedRevision} plus one.`,
70
+ );
71
+ }
72
+ return cloneSerializable(snapshot);
73
+ }
74
+
75
+ export function providerKey(storageKey: string, sessionId: string): string {
76
+ validateIdentifier(storageKey, 'storageKey');
77
+ validateIdentifier(sessionId, 'sessionId');
78
+ return JSON.stringify([storageKey, sessionId]);
79
+ }
80
+
81
+ export function assertProjectionSession(snapshot: TFlexProjectionSnapshot, sessionId: string): void {
82
+ for (const message of snapshot.messages) {
83
+ if (message.sessionId !== sessionId) {
84
+ throw new FlexHarnessStoreFormatError(
85
+ `Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
86
+ );
87
+ }
88
+ }
89
+ for (const terminal of snapshot.stagedTerminals) {
90
+ if (
91
+ terminal.userMessage.sessionId !== sessionId
92
+ || terminal.assistantMessage.sessionId !== sessionId
93
+ ) {
94
+ throw new FlexHarnessStoreFormatError(
95
+ `Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
96
+ );
97
+ }
98
+ }
99
+ }
100
+
101
+ function sameProjectManagementGeneration(
102
+ left: TFlexProjectManagementRecord,
103
+ right: TFlexProjectManagementRecord,
104
+ ): boolean {
105
+ return left.sessionGenerationId === right.sessionGenerationId
106
+ && left.sessionGenerationSequence === right.sessionGenerationSequence;
107
+ }
108
+
109
+ function canInitializeProjectManagementGeneration(
110
+ current: TFlexProjectManagementRecord,
111
+ next: TFlexProjectManagementRecord,
112
+ expectedRevision: number,
113
+ ): boolean {
114
+ return 'deletedAt' in current
115
+ && expectedRevision === 0
116
+ && next.sessionGenerationId !== current.sessionGenerationId
117
+ && next.sessionGenerationSequence > current.sessionGenerationSequence;
118
+ }
119
+
120
+ /**
121
+ * Admits a live project-management save over the stored record: a CAS save within the stored
122
+ * generation, or a newer generation that replaces a tombstone at expected revision 0.
123
+ */
124
+ export function assertProjectManagementSaveAdmitted(
125
+ key: string,
126
+ current: TFlexProjectManagementRecord | undefined,
127
+ snapshot: IFlexProjectManagementSnapshot,
128
+ expectedRevision: number,
129
+ ): void {
130
+ const actualRevision = current?.revision ?? 0;
131
+ if (current && !sameProjectManagementGeneration(current, snapshot)) {
132
+ if (!canInitializeProjectManagementGeneration(current, snapshot, expectedRevision)) {
133
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
134
+ }
135
+ } else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
136
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Admits a project-management tombstone over the stored record. Returns false when the same
142
+ * generation is already tombstoned, which makes a repeated cleanup idempotent.
143
+ */
144
+ export function isProjectManagementTombstoneAdmitted(
145
+ key: string,
146
+ current: TFlexProjectManagementRecord | undefined,
147
+ tombstone: IFlexProjectManagementTombstone,
148
+ expectedRevision: number,
149
+ ): boolean {
150
+ const actualRevision = current?.revision ?? 0;
151
+ if (current && sameProjectManagementGeneration(current, tombstone)) {
152
+ if ('deletedAt' in current) return false;
153
+ if (actualRevision !== expectedRevision) {
154
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
155
+ }
156
+ } else if (current) {
157
+ if (!canInitializeProjectManagementGeneration(current, tombstone, expectedRevision)) {
158
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
159
+ }
160
+ } else if (actualRevision !== expectedRevision) {
161
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
162
+ }
163
+ return true;
164
+ }
165
+
166
+ /** An archive is written once; repeating it with the same events is a no-op. */
167
+ export function assertSameArchiveEvents(
168
+ existing: plugins.IAgentEventArchiveV2,
169
+ next: plugins.IAgentEventArchiveV2,
170
+ ): void {
171
+ if (
172
+ JSON.stringify(existing.events.map((event) => event.id))
173
+ !== JSON.stringify(next.events.map((event) => event.id))
174
+ ) {
175
+ throw new Error(`Archive "${next.archiveId}" already exists with different events.`);
176
+ }
177
+ }