@modelprofile.com/flexharness 11.3.0 → 12.0.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 (65) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.core.d.ts +4 -0
  3. package/dist_ts/classes.flexharness.core.js +69 -25
  4. package/dist_ts/classes.flexharness.imports.js +4 -3
  5. package/dist_ts/classes.flexharness.reversions.d.ts +7 -1
  6. package/dist_ts/classes.flexharness.reversions.js +43 -29
  7. package/dist_ts/classes.flexharness.sessions.js +2 -6
  8. package/dist_ts/classes.stores.js +20 -18
  9. package/dist_ts/index.d.ts +1 -0
  10. package/dist_ts/index.js +2 -1
  11. package/dist_ts/interfaces.d.ts +24 -2
  12. package/dist_ts/interfaces.flexharness.d.ts +8 -1
  13. package/dist_ts/interfaces.js +1 -1
  14. package/dist_ts/plugins.d.ts +3 -3
  15. package/dist_ts/plugins.js +3 -3
  16. package/dist_ts/utils.projectionchange.d.ts +34 -0
  17. package/dist_ts/utils.projectionchange.js +135 -0
  18. package/dist_ts/utils.storecodecs.d.ts +10 -3
  19. package/dist_ts/utils.storecodecs.js +29 -4
  20. package/dist_ts/utils.storerules.d.ts +1 -1
  21. package/dist_ts/utils.storerules.js +1 -1
  22. package/dist_ts_migration/v3_legacyflexharness.js +6 -5
  23. package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +5 -4
  24. package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +56 -54
  25. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +176 -19
  26. package/dist_ts_stores_nosqldb/classes.recordstore.js +392 -67
  27. package/dist_ts_stores_nosqldb/index.d.ts +1 -1
  28. package/dist_ts_stores_nosqldb/index.js +2 -2
  29. package/dist_ts_stores_nosqldb/plugins.d.ts +1 -1
  30. package/dist_ts_stores_nosqldb/plugins.js +1 -1
  31. package/dist_ts_stores_testing/contract.d.ts +33 -0
  32. package/dist_ts_stores_testing/contract.js +566 -0
  33. package/dist_ts_stores_testing/fixtures.d.ts +20 -0
  34. package/dist_ts_stores_testing/fixtures.js +112 -0
  35. package/dist_ts_stores_testing/index.d.ts +2 -0
  36. package/dist_ts_stores_testing/index.js +3 -0
  37. package/dist_ts_stores_testing/plugins.d.ts +5 -0
  38. package/dist_ts_stores_testing/plugins.js +7 -0
  39. package/package.json +9 -3
  40. package/readme.md +6 -3
  41. package/ts/00_commitinfo_data.ts +1 -1
  42. package/ts/classes.flexharness.core.ts +74 -24
  43. package/ts/classes.flexharness.imports.ts +3 -2
  44. package/ts/classes.flexharness.reversions.ts +53 -28
  45. package/ts/classes.flexharness.sessions.ts +2 -5
  46. package/ts/classes.stores.ts +40 -37
  47. package/ts/index.ts +5 -0
  48. package/ts/interfaces.flexharness.ts +8 -1
  49. package/ts/interfaces.ts +27 -2
  50. package/ts/plugins.ts +12 -7
  51. package/ts/readme.md +6 -3
  52. package/ts/utils.projectionchange.ts +190 -0
  53. package/ts/utils.storecodecs.ts +42 -7
  54. package/ts/utils.storerules.ts +2 -2
  55. package/ts_migration/v3_legacyflexharness.ts +11 -6
  56. package/ts_stores_nosqldb/classes.nosqldbstores.ts +79 -71
  57. package/ts_stores_nosqldb/classes.recordstore.ts +491 -66
  58. package/ts_stores_nosqldb/index.ts +1 -0
  59. package/ts_stores_nosqldb/plugins.ts +4 -3
  60. package/ts_stores_nosqldb/readme.md +6 -2
  61. package/ts_stores_testing/contract.ts +776 -0
  62. package/ts_stores_testing/fixtures.ts +127 -0
  63. package/ts_stores_testing/index.ts +17 -0
  64. package/ts_stores_testing/plugins.ts +20 -0
  65. package/ts_stores_testing/readme.md +62 -0
@@ -14,7 +14,7 @@ import type {
14
14
  IFlexProjectManagementStore,
15
15
  IFlexProjectManagementTombstone,
16
16
  IFlexProjectManagementWriteContext,
17
- IFlexProjectionSnapshotV3,
17
+ IFlexProjectionChange,
18
18
  IFlexProjectionStore,
19
19
  IFlexResultStore,
20
20
  IFlexScopeSnapshot,
@@ -36,10 +36,15 @@ import {
36
36
  assertFlexProjectManagementSnapshot,
37
37
  assertFlexProjectManagementTombstone,
38
38
  } from './utils.projectmanagement.js';
39
+ import {
40
+ createChangedProjectionSnapshot,
41
+ validateProjectionChangeSave,
42
+ } from './utils.projectionchange.js';
39
43
  import {
40
44
  assertFlexToolJobSnapshot,
41
- createAgentEventSnapshot,
45
+ createChangedAgentEventSnapshot,
42
46
  createToolJobSnapshot,
47
+ normalizeAgentEventChange,
43
48
  normalizeJsonObjectUndefined,
44
49
  validateAgentEventArchive,
45
50
  validateAgentEventSnapshot,
@@ -95,20 +100,17 @@ class InMemoryProjectionStore implements IFlexProjectionStore {
95
100
  public async save(
96
101
  storageKey: string,
97
102
  sessionId: string,
98
- snapshot: IFlexProjectionSnapshotV3,
103
+ change: IFlexProjectionChange,
99
104
  expectedRevision: number,
100
105
  ): Promise<void> {
101
106
  const key = providerKey(storageKey, sessionId);
102
- const validated = validateSnapshotSave(
103
- snapshot,
104
- expectedRevision,
105
- (value) => assertFlexProjectionSnapshot(value, sessionId),
106
- );
107
- const actualRevision = this.snapshots.get(key)?.revision ?? 0;
107
+ const validated = validateProjectionChangeSave(change, expectedRevision);
108
+ const current = this.snapshots.get(key);
109
+ const actualRevision = current?.revision ?? 0;
108
110
  if (actualRevision !== expectedRevision) {
109
111
  throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
110
112
  }
111
- this.snapshots.set(key, validated);
113
+ this.snapshots.set(key, createChangedProjectionSnapshot(current, validated, sessionId));
112
114
  }
113
115
 
114
116
  public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
@@ -203,10 +205,9 @@ export class InMemoryFlexProjectManagementStore implements IFlexProjectManagemen
203
205
  }
204
206
  }
205
207
 
206
- class InMemoryAgentEventStore implements plugins.IAgentEventStoreV2 {
207
- public readonly eventSchemaVersion = 2 as const;
208
- private snapshot: plugins.IAgentEventSnapshotV2 | undefined;
209
- private readonly archives = new Map<string, plugins.IAgentEventArchiveV2>();
208
+ class InMemoryAgentEventStore implements plugins.IAgentEventStore {
209
+ private snapshot: plugins.IAgentEventSnapshot | undefined;
210
+ private readonly archives = new Map<string, plugins.IAgentEventArchive>();
210
211
 
211
212
  constructor(private readonly sessionId: string) {}
212
213
 
@@ -218,29 +219,32 @@ class InMemoryAgentEventStore implements plugins.IAgentEventStoreV2 {
218
219
  }
219
220
  }
220
221
 
221
- public async load(sessionId: string): Promise<plugins.IAgentEventSnapshotV2 | undefined> {
222
+ public async load(sessionId: string): Promise<plugins.IAgentEventSnapshot | undefined> {
222
223
  this.assertSession(sessionId);
223
224
  return this.snapshot ? cloneSerializable(this.snapshot) : undefined;
224
225
  }
225
226
 
226
227
  public async save(
227
228
  sessionId: string,
228
- events: readonly plugins.TAgentEvent[],
229
+ change: plugins.IAgentEventChange,
229
230
  expectedRevision: number,
230
231
  ): Promise<number> {
231
232
  this.assertSession(sessionId);
232
233
  validateExpectedRevision(expectedRevision);
233
- const snapshot = createAgentEventSnapshot(sessionId, events, expectedRevision + 1);
234
- validateAgentEventSnapshot(snapshot, sessionId);
234
+ const normalized = normalizeAgentEventChange(change);
235
235
  const actualRevision = this.snapshot?.revision ?? 0;
236
236
  if (actualRevision !== expectedRevision) {
237
237
  throw new plugins.AgentEventStoreConflictError(sessionId, expectedRevision, actualRevision);
238
238
  }
239
- this.snapshot = cloneSerializable(snapshot);
239
+ const snapshot = validateAgentEventSnapshot(
240
+ createChangedAgentEventSnapshot(sessionId, this.snapshot, normalized),
241
+ sessionId,
242
+ );
243
+ this.snapshot = snapshot;
240
244
  return snapshot.revision;
241
245
  }
242
246
 
243
- public async archive(archive: plugins.IAgentEventArchiveV2): Promise<void> {
247
+ public async archive(archive: plugins.IAgentEventArchive): Promise<void> {
244
248
  this.assertSession(archive.sessionId);
245
249
  const normalized = normalizeJsonObjectUndefined(archive, '$archive');
246
250
  const validated = validateAgentEventArchive(normalized, this.sessionId, archive.archiveId);
@@ -255,7 +259,7 @@ class InMemoryAgentEventStore implements plugins.IAgentEventStoreV2 {
255
259
  public async loadArchive(
256
260
  sessionId: string,
257
261
  archiveId: string,
258
- ): Promise<plugins.IAgentEventArchiveV2 | undefined> {
262
+ ): Promise<plugins.IAgentEventArchive | undefined> {
259
263
  this.assertSession(sessionId);
260
264
  validateIdentifier(archiveId, 'archiveId');
261
265
  const archive = this.archives.get(archiveId);
@@ -829,17 +833,14 @@ class JsonProjectionStore implements IFlexProjectionStore {
829
833
  });
830
834
  }
831
835
 
836
+ /** Applies the change and writes the whole projection again. */
832
837
  public async save(
833
838
  storageKey: string,
834
839
  sessionId: string,
835
- snapshot: IFlexProjectionSnapshotV3,
840
+ change: IFlexProjectionChange,
836
841
  expectedRevision: number,
837
842
  ): Promise<void> {
838
- const validated = validateSnapshotSave(
839
- snapshot,
840
- expectedRevision,
841
- (value) => assertFlexProjectionSnapshot(value, sessionId),
842
- );
843
+ const validated = validateProjectionChangeSave(change, expectedRevision);
843
844
  const filePath = this.root.sessionPath('projections', storageKey, sessionId);
844
845
  await JsonFileRoot.inQueue(filePath, async () => {
845
846
  const current = await this.root.read(filePath, (value) => {
@@ -850,7 +851,7 @@ class JsonProjectionStore implements IFlexProjectionStore {
850
851
  if (actualRevision !== expectedRevision) {
851
852
  throw new FlexHarnessStoreConflictError(providerKey(storageKey, sessionId), expectedRevision, actualRevision);
852
853
  }
853
- await this.root.write(filePath, validated);
854
+ await this.root.write(filePath, createChangedProjectionSnapshot(current, validated, sessionId));
854
855
  });
855
856
  }
856
857
 
@@ -1066,9 +1067,8 @@ async function clearJsonAgentEventSession(
1066
1067
  });
1067
1068
  }
1068
1069
 
1069
- class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
1070
- public readonly eventSchemaVersion = 2 as const;
1071
-
1070
+ /** Stores a session's event log in one file; a save rewrites the whole log. */
1071
+ class JsonAgentEventStore implements plugins.IAgentEventStore {
1072
1072
  constructor(
1073
1073
  private readonly root: JsonFileRoot,
1074
1074
  private readonly storageKey: string,
@@ -1083,7 +1083,7 @@ class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
1083
1083
  }
1084
1084
  }
1085
1085
 
1086
- public async load(sessionId: string): Promise<plugins.IAgentEventSnapshotV2 | undefined> {
1086
+ public async load(sessionId: string): Promise<plugins.IAgentEventSnapshot | undefined> {
1087
1087
  this.assertSession(sessionId);
1088
1088
  const filePath = this.root.sessionPath('events', this.storageKey, this.sessionId);
1089
1089
  return JsonFileRoot.inQueue(this.root.eventQueueKey(this.storageKey, this.sessionId), async () => {
@@ -1097,13 +1097,12 @@ class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
1097
1097
 
1098
1098
  public async save(
1099
1099
  sessionId: string,
1100
- events: readonly plugins.TAgentEvent[],
1100
+ change: plugins.IAgentEventChange,
1101
1101
  expectedRevision: number,
1102
1102
  ): Promise<number> {
1103
1103
  this.assertSession(sessionId);
1104
1104
  validateExpectedRevision(expectedRevision);
1105
- const snapshot = createAgentEventSnapshot(sessionId, events, expectedRevision + 1);
1106
- validateAgentEventSnapshot(snapshot, sessionId);
1105
+ const normalized = normalizeAgentEventChange(change);
1107
1106
  const filePath = this.root.sessionPath('events', this.storageKey, this.sessionId);
1108
1107
  return JsonFileRoot.inQueue(this.root.eventQueueKey(this.storageKey, this.sessionId), async () => {
1109
1108
  const current = await this.root.read(
@@ -1114,12 +1113,16 @@ class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
1114
1113
  if (actualRevision !== expectedRevision) {
1115
1114
  throw new plugins.AgentEventStoreConflictError(sessionId, expectedRevision, actualRevision);
1116
1115
  }
1116
+ const snapshot = validateAgentEventSnapshot(
1117
+ createChangedAgentEventSnapshot(sessionId, current, normalized),
1118
+ sessionId,
1119
+ );
1117
1120
  await this.root.write(filePath, snapshot);
1118
1121
  return snapshot.revision;
1119
1122
  });
1120
1123
  }
1121
1124
 
1122
- public async archive(archive: plugins.IAgentEventArchiveV2): Promise<void> {
1125
+ public async archive(archive: plugins.IAgentEventArchive): Promise<void> {
1123
1126
  this.assertSession(archive.sessionId);
1124
1127
  const normalized = normalizeJsonObjectUndefined(archive, '$archive');
1125
1128
  const validated = validateAgentEventArchive(normalized, this.sessionId, archive.archiveId);
@@ -1144,7 +1147,7 @@ class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
1144
1147
  public async loadArchive(
1145
1148
  sessionId: string,
1146
1149
  archiveId: string,
1147
- ): Promise<plugins.IAgentEventArchiveV2 | undefined> {
1150
+ ): Promise<plugins.IAgentEventArchive | undefined> {
1148
1151
  this.assertSession(sessionId);
1149
1152
  const filePath = this.root.archivePath(this.storageKey, this.sessionId, archiveId);
1150
1153
  return JsonFileRoot.inQueue(this.root.eventQueueKey(this.storageKey, this.sessionId), async () => {
package/ts/index.ts CHANGED
@@ -21,6 +21,11 @@ export {
21
21
  assertFlexProjectManagementTombstone,
22
22
  createEmptyFlexProjectManagementSnapshot,
23
23
  } from './utils.projectmanagement.js';
24
+ export {
25
+ applyFlexProjectionChange,
26
+ assertFlexProjectionChange,
27
+ createWholeProjectionChange,
28
+ } from './utils.projectionchange.js';
24
29
  export { assertFlexToolJobSnapshot } from './utils.storecodecs.js';
25
30
  export {
26
31
  FLEX_SLASH_COMMAND_MAX_INPUT_BYTES,
@@ -2,6 +2,7 @@ import type * as plugins from './plugins.js';
2
2
  import type { FlexHarnessAbortError } from './errors.js';
3
3
  import type { FlexToolCatalog } from './classes.toolcatalog.js';
4
4
  import type { FlexCodeSandbox } from './classes.codesandbox.js';
5
+ import type { IFlexProjectionLists } from './utils.projectionchange.js';
5
6
  import type {
6
7
  IFlexAgentContextInvocation,
7
8
  IFlexAffectedWorkspace,
@@ -69,6 +70,12 @@ export interface IStoredSessionState {
69
70
  pendingReversionReleases: IFlexPendingReversionRelease[];
70
71
  projectionSchemaVersion: 1 | 2 | 3;
71
72
  projectionBaseline?: TFlexProjectionSnapshot;
73
+ /**
74
+ * The lists as the store holds them, so a save sends only the entries that differ. Entries are
75
+ * frozen from their first save on and replaced rather than edited. Absent while the stored lists
76
+ * are unknown or of an older schema; the next save then sends whole lists.
77
+ */
78
+ savedProjectionLists?: IFlexProjectionLists;
72
79
  projectionReconciliationRequired: boolean;
73
80
  projectionRevision: number;
74
81
  projectionQueue: Promise<void>;
@@ -79,7 +86,7 @@ export interface IStoredSessionState {
79
86
  rememberedPermissionKeys: Set<string>;
80
87
  permissionRevision: number;
81
88
  permissionQueue: Promise<void>;
82
- eventStore: plugins.IAgentEventStoreV2;
89
+ eventStore: plugins.IAgentEventStore;
83
90
  agentSession: plugins.IAgentSession;
84
91
  agentSessionAbortCompleted: boolean;
85
92
  agentSessionCloseCompleted: boolean;
package/ts/interfaces.ts CHANGED
@@ -1231,6 +1231,27 @@ export interface IFlexProjectionSnapshotV3 {
1231
1231
  pendingReversionReleases: IFlexPendingReversionRelease[];
1232
1232
  }
1233
1233
 
1234
+ /**
1235
+ * How a stored list changed: the new list is the stored entries from `start` up to `end`,
1236
+ * followed by `items`. `{ start: 0, end: 0 }` carries the whole list.
1237
+ */
1238
+ export interface IFlexListChange<TItem> {
1239
+ start: number;
1240
+ end: number;
1241
+ items: TItem[];
1242
+ }
1243
+
1244
+ /**
1245
+ * How a session's projection changed since the store's last save: the current projection with
1246
+ * its two growing lists as list changes. A change applies only to the current schema; to any
1247
+ * other stored projection only whole lists apply.
1248
+ */
1249
+ export interface IFlexProjectionChange
1250
+ extends Omit<IFlexProjectionSnapshotV3, 'messages' | 'reversionSegments'> {
1251
+ messages: IFlexListChange<IFlexMessage>;
1252
+ reversionSegments: IFlexListChange<IFlexReversionSegment>;
1253
+ }
1254
+
1234
1255
  export type IFlexProjectionSnapshot =
1235
1256
  | IFlexProjectionSnapshotV1
1236
1257
  | IFlexProjectionSnapshotV2
@@ -1436,10 +1457,14 @@ export interface IFlexScopeStore {
1436
1457
 
1437
1458
  export interface IFlexProjectionStore {
1438
1459
  load(storageKey: string, sessionId: string): Promise<TFlexProjectionSnapshot | undefined>;
1460
+ /**
1461
+ * Applies `change` to the stored projection while its revision is `expectedRevision`; the
1462
+ * change's revision is the next one.
1463
+ */
1439
1464
  save(
1440
1465
  storageKey: string,
1441
1466
  sessionId: string,
1442
- snapshot: IFlexProjectionSnapshotCurrent,
1467
+ change: IFlexProjectionChange,
1443
1468
  expectedRevision: number,
1444
1469
  ): Promise<void>;
1445
1470
  deleteSession(storageKey: string, sessionId: string): Promise<void>;
@@ -1460,7 +1485,7 @@ export interface IFlexAgentEventStoreProvider {
1460
1485
  getStore(
1461
1486
  storageKey: string,
1462
1487
  sessionId: string,
1463
- ): Promise<plugins.IAgentEventStoreV2> | plugins.IAgentEventStoreV2;
1488
+ ): Promise<plugins.IAgentEventStore> | plugins.IAgentEventStore;
1464
1489
  releaseSession?(storageKey: string, sessionId: string): Promise<void> | void;
1465
1490
  deleteSession(storageKey: string, sessionId: string): Promise<void>;
1466
1491
  }
package/ts/plugins.ts CHANGED
@@ -94,6 +94,8 @@ export const importCodeSandboxEngine = async (): Promise<ICodeSandboxEngine> =>
94
94
  import {
95
95
  AgentEventStoreConflictError,
96
96
  AgentPendingInputQueue,
97
+ applyAgentEventChange,
98
+ assertAgentEventChange,
97
99
  AgentPendingInputRejectedError,
98
100
  AgentSession,
99
101
  AgentToolCallScheduler,
@@ -105,14 +107,16 @@ import {
105
107
  isGenerationInModelContext,
106
108
  modelMessagesToAgentEvents,
107
109
  tool,
108
- validateAgentEventArchiveV2,
109
- validateAgentEventSnapshotV2,
110
+ validateAgentEventArchive,
111
+ validateAgentEventSnapshot,
110
112
  z,
111
113
  } from '@modelprofile.com/flexharness-agent';
112
114
 
113
115
  export {
114
116
  AgentEventStoreConflictError,
115
117
  AgentPendingInputQueue,
118
+ applyAgentEventChange,
119
+ assertAgentEventChange,
116
120
  AgentPendingInputRejectedError,
117
121
  AgentSession,
118
122
  AgentToolCallScheduler,
@@ -124,17 +128,18 @@ export {
124
128
  isGenerationInModelContext,
125
129
  modelMessagesToAgentEvents,
126
130
  tool,
127
- validateAgentEventArchiveV2,
128
- validateAgentEventSnapshotV2,
131
+ validateAgentEventArchive,
132
+ validateAgentEventSnapshot,
129
133
  z,
130
134
  };
131
135
  export type {
132
136
  IAgentAppliedInput,
133
137
  ISmartAiCacheDispatch,
134
- IAgentEventArchiveV2,
138
+ IAgentEventArchive,
139
+ IAgentEventChange,
135
140
  IAgentContextCompactionOptions,
136
- IAgentEventSnapshotV2,
137
- IAgentEventStoreV2,
141
+ IAgentEventSnapshot,
142
+ IAgentEventStore,
138
143
  IAgentGenerateResult,
139
144
  IAgentGenerationHandle,
140
145
  IAgentGenerationLease,
package/ts/readme.md CHANGED
@@ -68,6 +68,7 @@ or provider SDKs.
68
68
  | `/mcp` | MCP clients, AI SDK tool conversion and `createMcpToolCatalog`, the tool catalog of MCP tools | `@modelcontextprotocol/sdk` |
69
69
  | `/migration` | Existing versioned harness migrations | None |
70
70
  | `/stores/nosqldb` | `NoSqlFlexHarnessStores`: cross-process CAS stores on `@lossless.org/client/nosqldb` | `@lossless.org/client` |
71
+ | `/stores/testing` | `createFlexStoreContractTests`: the contract every built-in `IFlexHarnessStores` passes, to run against a custom store with any test runner | None |
71
72
 
72
73
  The integrations in the last column use optional peer dependencies. Install them
73
74
  only for the entrypoints your project imports, for example:
@@ -1128,7 +1129,9 @@ Current FlexHarness persistence is separated by trust and lifecycle domain throu
1128
1129
 
1129
1130
  `InMemoryFlexHarnessStores` implements all six required domains with revision-based compare-and-swap behavior for tests and ephemeral processes. It is the default when `stores` is omitted. Custom `IFlexHarnessStores` implementations must provide `projectManagement` even when project-management tools are disabled, because deletion cleanup always writes the generation fence.
1130
1131
 
1131
- A custom store validates what it loads and saves with the validators the built-in stores apply. Each throws `FlexHarnessStoreFormatError` for a malformed or wrong-schema snapshot: `assertFlexScopeSnapshot()`, `assertFlexProjectionSnapshot(value, sessionId)`, which also requires every message and staged terminal to belong to `sessionId`, `assertFlexPermissionSnapshot()`, `assertFlexProjectManagementRecord()` and `assertFlexToolJobSnapshot()`. Agent event snapshots and archives have `validateAgentEventSnapshotV2()` and `validateAgentEventArchiveV2()` in `@modelprofile.com/flexharness-agent`.
1132
+ A custom store validates what it loads and saves with the validators the built-in stores apply. Each throws `FlexHarnessStoreFormatError` for a malformed or wrong-schema snapshot: `assertFlexScopeSnapshot()`, `assertFlexProjectionSnapshot(value, sessionId)`, which also requires every message and staged terminal to belong to `sessionId`, `assertFlexPermissionSnapshot()`, `assertFlexProjectManagementRecord()` and `assertFlexToolJobSnapshot()`. Agent event snapshots and archives have `validateAgentEventSnapshot()` and `validateAgentEventArchive()` in `@modelprofile.com/flexharness-agent`.
1133
+
1134
+ Projections and Agent event histories are saved as changes, so a save costs what changed rather than the whole session. `IFlexProjectionStore.save(storageKey, sessionId, change, expectedRevision)` receives an `IFlexProjectionChange`: the schema-3 projection with `messages` and `reversionSegments` given as `IFlexListChange`s `{ start, end, items }`, each of which keeps the stored entries from `start` up to `end` and appends `items`. A list change with `start` and `end` at 0 replaces the list whole; FlexHarness sends whole lists when it cannot know which entries the store holds: for a projection it loaded in an older schema and after a save with an uncertain outcome. The Agent event store of each session receives an `IAgentEventChange` of removed, replaced and appended events, as the `@modelprofile.com/flexharness-agent` readme describes. A store that keeps snapshots checks a change's form with `assertFlexProjectionChange()` or `assertAgentEventChange()`, applies it with `applyFlexProjectionChange()` or `applyAgentEventChange()`, and validates the result as above; `createWholeProjectionChange(snapshot)` makes the change that stores a projection whole. FlexHarness freezes every message and reversion segment it saved and replaces an entry instead of editing it, which is how it tells the entries the store already holds. `/stores/testing` exports `createFlexStoreContractTests(backend)`, the cases every built-in store passes, for a custom store to run with any test runner; its readme shows how.
1132
1135
 
1133
1136
  Custom Agent event and job providers may implement `releaseSession(storageKey, sessionId)` to release session-bound wrappers, handles, or caches without deleting durable data. FlexHarness calls these hooks only after the corresponding AgentSession or execution context has released runtime ownership. A failed release remains owned for a later retirement or disposal retry. `deleteSession()` and `deleteSessionGenerationCohort()` are the separate destructive operations for durable session data.
1134
1137
 
@@ -1145,13 +1148,13 @@ After every harness using a `JsonFileFlexHarnessStores` instance has been dispos
1145
1148
 
1146
1149
  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.
1147
1150
 
1148
- `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.
1151
+ `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, change logs and failure reporting.
1149
1152
 
1150
1153
  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.
1151
1154
 
1152
1155
  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.
1153
1156
 
1154
- Projection stores accept schema version 1, 2, or 3 from `load()`, but every `save()` receives the current schema-3 shape. Schema 3 records explicit reversion protocol, transcript/workspace provenance, and V2 disposition. A terminal V2 segment must be conclusively `revertible`, `no-change`, or `nonrevertible`; a pending V2 capture remains owned by its capture WAL and is never treated as transcript history.
1157
+ Projection stores accept schema version 1, 2, or 3 from `load()`, but every `save()` receives a change that makes the current schema-3 shape. A change to a loaded schema-1 or schema-2 projection carries both lists whole. Schema 3 records explicit reversion protocol, transcript/workspace provenance, and V2 disposition. A terminal V2 segment must be conclusively `revertible`, `no-change`, or `nonrevertible`; a pending V2 capture remains owned by its capture WAL and is never treated as transcript history.
1155
1158
 
1156
1159
  Current schema-3 projections also persist optional `metrics` state. Older strict readers that do not recognize this key, or the token breakdown keys inside it (`reportedLifetimeInputTokens`, `reportedLifetimeCacheReadTokens`, `reportedLifetimeCacheWriteTokens`, `lifetimeTokenBreakdownComplete`), cannot reopen a projection after they have been written; downgrade or mixed-version access to that session is unsupported.
1157
1160
 
@@ -0,0 +1,190 @@
1
+ import { FlexHarnessStoreFormatError } from './errors.js';
2
+ import type {
3
+ IFlexListChange,
4
+ IFlexMessage,
5
+ IFlexProjectionChange,
6
+ IFlexProjectionSnapshotCurrent,
7
+ IFlexReversionSegment,
8
+ TFlexProjectionSnapshot,
9
+ } from './interfaces.js';
10
+ import {
11
+ assertFlexProjectionSnapshot,
12
+ assertJsonSerializable,
13
+ cloneSerializable,
14
+ deepFreeze,
15
+ } from './utils.json.js';
16
+ import { assertExactKeys, assertRecord } from './utils.storecodecs.js';
17
+ import { validateExpectedRevision } from './utils.storerules.js';
18
+
19
+ const projectionChangeKeys = [
20
+ 'schemaVersion',
21
+ 'revision',
22
+ 'metrics',
23
+ 'messages',
24
+ 'stagedTerminals',
25
+ 'reversionSegments',
26
+ 'revertCursor',
27
+ 'excludedRunIds',
28
+ 'pendingReversion',
29
+ 'pendingReversionReleases',
30
+ ];
31
+
32
+ function assertListChange(value: unknown, path: string): void {
33
+ assertRecord(value, path);
34
+ assertExactKeys(value, ['start', 'end', 'items'], path);
35
+ if (!Number.isSafeInteger(value.start) || Number(value.start) < 0) {
36
+ throw new FlexHarnessStoreFormatError(`${path}.start must be a non-negative integer.`);
37
+ }
38
+ if (!Number.isSafeInteger(value.end) || Number(value.end) < Number(value.start)) {
39
+ throw new FlexHarnessStoreFormatError(`${path}.end must be an integer from start.`);
40
+ }
41
+ if (!Array.isArray(value.items)) {
42
+ throw new FlexHarnessStoreFormatError(`${path}.items must be an array.`);
43
+ }
44
+ for (let index = 0; index < value.items.length; index++) {
45
+ assertRecord(value.items[index], `${path}.items[${index}]`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Checks the form of a projection change: its fields, a positive revision and two list changes
51
+ * of records. Whether its content makes a valid projection is decided on the snapshot it
52
+ * produces, by `assertFlexProjectionSnapshot`.
53
+ */
54
+ export function assertFlexProjectionChange(value: unknown): asserts value is IFlexProjectionChange {
55
+ assertJsonSerializable(value, '$change');
56
+ assertRecord(value, '$change');
57
+ assertExactKeys(value, projectionChangeKeys, '$change');
58
+ if (value.schemaVersion !== 3) {
59
+ throw new FlexHarnessStoreFormatError('Projection change schemaVersion must be 3.');
60
+ }
61
+ if (!Number.isSafeInteger(value.revision) || Number(value.revision) < 1) {
62
+ throw new FlexHarnessStoreFormatError('$change.revision must be a positive integer.');
63
+ }
64
+ assertListChange(value.messages, '$change.messages');
65
+ assertListChange(value.reversionSegments, '$change.reversionSegments');
66
+ }
67
+
68
+ function applyListChange<TItem>(
69
+ stored: readonly TItem[],
70
+ change: IFlexListChange<TItem>,
71
+ path: string,
72
+ ): TItem[] {
73
+ if (change.end > stored.length) {
74
+ throw new FlexHarnessStoreFormatError(
75
+ `${path} keeps entries up to ${change.end}, but the store holds ${stored.length}.`,
76
+ );
77
+ }
78
+ const next = stored.slice(change.start, change.end);
79
+ for (const item of change.items) next.push(item);
80
+ return next;
81
+ }
82
+
83
+ /**
84
+ * Applies `change` to a stored projection and returns the new snapshot; neither input is
85
+ * modified. A stored projection of an older schema offers no entries to keep, so only whole
86
+ * lists apply to it. It rejects a change that keeps entries the stored lists lack; the change's
87
+ * form is `assertFlexProjectionChange`'s to check and the result's content
88
+ * `assertFlexProjectionSnapshot`'s.
89
+ */
90
+ export function applyFlexProjectionChange(
91
+ stored: TFlexProjectionSnapshot | undefined,
92
+ change: IFlexProjectionChange,
93
+ ): IFlexProjectionSnapshotCurrent {
94
+ const lists = stored?.schemaVersion === 3 ? stored : undefined;
95
+ return {
96
+ schemaVersion: 3,
97
+ revision: change.revision,
98
+ ...(change.metrics === undefined ? {} : { metrics: change.metrics }),
99
+ messages: applyListChange(lists?.messages ?? [], change.messages, '$change.messages'),
100
+ stagedTerminals: change.stagedTerminals,
101
+ reversionSegments: applyListChange(
102
+ lists?.reversionSegments ?? [],
103
+ change.reversionSegments,
104
+ '$change.reversionSegments',
105
+ ),
106
+ revertCursor: change.revertCursor,
107
+ excludedRunIds: change.excludedRunIds,
108
+ ...(change.pendingReversion === undefined ? {} : { pendingReversion: change.pendingReversion }),
109
+ pendingReversionReleases: change.pendingReversionReleases,
110
+ };
111
+ }
112
+
113
+ /** A copy of a change a store was asked to save at `expectedRevision`, after checking its form. */
114
+ export function validateProjectionChangeSave(
115
+ change: IFlexProjectionChange,
116
+ expectedRevision: number,
117
+ ): IFlexProjectionChange {
118
+ assertFlexProjectionChange(change);
119
+ validateExpectedRevision(expectedRevision);
120
+ if (change.revision !== expectedRevision + 1) {
121
+ throw new FlexHarnessStoreFormatError(
122
+ `Change revision ${change.revision} must equal expected revision ${expectedRevision} plus one.`,
123
+ );
124
+ }
125
+ return cloneSerializable(change);
126
+ }
127
+
128
+ /** The snapshot `change` makes of a stored projection, validated in full for `sessionId`. */
129
+ export function createChangedProjectionSnapshot(
130
+ stored: TFlexProjectionSnapshot | undefined,
131
+ change: IFlexProjectionChange,
132
+ sessionId: string,
133
+ ): IFlexProjectionSnapshotCurrent {
134
+ const snapshot = applyFlexProjectionChange(stored, change);
135
+ assertFlexProjectionSnapshot(snapshot, sessionId);
136
+ return snapshot;
137
+ }
138
+
139
+ /** A projection's message and reversion segment lists, entry by entry. */
140
+ export interface IFlexProjectionLists {
141
+ messages: readonly IFlexMessage[];
142
+ reversionSegments: readonly IFlexReversionSegment[];
143
+ }
144
+
145
+ /** Freezes the lists' entries, which a change refers to by identity once they are saved. */
146
+ export function freezeProjectionEntries(lists: IFlexProjectionLists): void {
147
+ for (const message of lists.messages) deepFreeze(message);
148
+ for (const segment of lists.reversionSegments) deepFreeze(segment);
149
+ }
150
+
151
+ /**
152
+ * The list change that turns `saved` into `current`. It keeps the run of entries the two share by
153
+ * identity, starting where `current` starts, and sends the rest; without such a run it sends the
154
+ * whole list.
155
+ */
156
+ function createListChange<TItem>(
157
+ saved: readonly TItem[] | undefined,
158
+ current: readonly TItem[],
159
+ ): IFlexListChange<TItem> {
160
+ const start = saved === undefined || current.length === 0 ? -1 : saved.indexOf(current[0]);
161
+ if (saved === undefined || start < 0) return { start: 0, end: 0, items: current.slice() };
162
+ let kept = 0;
163
+ while (start + kept < saved.length && kept < current.length && saved[start + kept] === current[kept]) {
164
+ kept++;
165
+ }
166
+ return { start, end: start + kept, items: current.slice(kept) };
167
+ }
168
+
169
+ /**
170
+ * The change that turns the stored projection into `snapshot`, given the lists the store holds
171
+ * (`saved`). Entries are compared by identity, so they must be replaced, never edited, once
172
+ * saved. Without `saved` both lists are sent whole.
173
+ */
174
+ export function createProjectionChange(
175
+ saved: IFlexProjectionLists | undefined,
176
+ snapshot: IFlexProjectionSnapshotCurrent,
177
+ ): IFlexProjectionChange {
178
+ return {
179
+ ...snapshot,
180
+ messages: createListChange(saved?.messages, snapshot.messages),
181
+ reversionSegments: createListChange(saved?.reversionSegments, snapshot.reversionSegments),
182
+ };
183
+ }
184
+
185
+ /** The change that stores `snapshot` whole. */
186
+ export function createWholeProjectionChange(
187
+ snapshot: IFlexProjectionSnapshotCurrent,
188
+ ): IFlexProjectionChange {
189
+ return createProjectionChange(undefined, snapshot);
190
+ }
@@ -96,25 +96,25 @@ export function assertRecord(value: unknown, path: string): asserts value is Rec
96
96
 
97
97
  export function createAgentEventSnapshot(
98
98
  sessionId: string,
99
- events: readonly plugins.TAgentEvent[],
99
+ events: plugins.TAgentEvent[],
100
100
  revision: number,
101
- ): plugins.IAgentEventSnapshotV2 {
101
+ ): plugins.IAgentEventSnapshot {
102
102
  return {
103
103
  schemaVersion: 2,
104
104
  sessionId,
105
105
  revision,
106
106
  updatedAt: events.reduce((latest, event) => Math.max(latest, event.timestamp), 0),
107
- events: normalizeJsonObjectUndefined([...events], '$events'),
107
+ events,
108
108
  };
109
109
  }
110
110
 
111
111
  export function validateAgentEventSnapshot(
112
112
  value: unknown,
113
113
  sessionId: string,
114
- ): plugins.IAgentEventSnapshotV2 {
114
+ ): plugins.IAgentEventSnapshot {
115
115
  assertJsonSerializable(value, '$snapshot');
116
116
  try {
117
- return plugins.validateAgentEventSnapshotV2(value, sessionId);
117
+ return plugins.validateAgentEventSnapshot(value, sessionId);
118
118
  } catch (error) {
119
119
  throw new FlexHarnessStoreFormatError(
120
120
  `$snapshot is not a canonical schema-2 Agent event snapshot: ${error instanceof Error ? error.message : String(error)}`,
@@ -127,10 +127,10 @@ export function validateAgentEventArchive(
127
127
  value: unknown,
128
128
  sessionId: string,
129
129
  archiveId: string,
130
- ): plugins.IAgentEventArchiveV2 {
130
+ ): plugins.IAgentEventArchive {
131
131
  assertJsonSerializable(value, '$archive');
132
132
  try {
133
- return plugins.validateAgentEventArchiveV2(value, sessionId, archiveId);
133
+ return plugins.validateAgentEventArchive(value, sessionId, archiveId);
134
134
  } catch (error) {
135
135
  throw new FlexHarnessStoreFormatError(
136
136
  `$archive is not a canonical schema-2 Agent event archive: ${error instanceof Error ? error.message : String(error)}`,
@@ -139,6 +139,41 @@ export function validateAgentEventArchive(
139
139
  }
140
140
  }
141
141
 
142
+ /** A JSON copy of `change`, checked by `assertAgentEventChange`. */
143
+ export function normalizeAgentEventChange(change: unknown): plugins.IAgentEventChange {
144
+ const normalized = normalizeJsonObjectUndefined(change, '$change');
145
+ try {
146
+ plugins.assertAgentEventChange(normalized);
147
+ } catch (error) {
148
+ throw new FlexHarnessStoreFormatError(
149
+ `$change is not a valid Agent event change: ${error instanceof Error ? error.message : String(error)}`,
150
+ { cause: error },
151
+ );
152
+ }
153
+ return normalized;
154
+ }
155
+
156
+ /**
157
+ * The next snapshot of `current` after a normalized `change`; the store validates it before it
158
+ * keeps it.
159
+ */
160
+ export function createChangedAgentEventSnapshot(
161
+ sessionId: string,
162
+ current: plugins.IAgentEventSnapshot | undefined,
163
+ change: plugins.IAgentEventChange,
164
+ ): plugins.IAgentEventSnapshot {
165
+ let events: plugins.TAgentEvent[];
166
+ try {
167
+ events = plugins.applyAgentEventChange(current?.events ?? [], change);
168
+ } catch (error) {
169
+ throw new FlexHarnessStoreFormatError(
170
+ `$change does not apply to the stored Agent event log: ${error instanceof Error ? error.message : String(error)}`,
171
+ { cause: error },
172
+ );
173
+ }
174
+ return createAgentEventSnapshot(sessionId, events, (current?.revision ?? 0) + 1);
175
+ }
176
+
142
177
  export function assertFlexToolJobSnapshot(value: unknown): asserts value is plugins.IToolJobSnapshot {
143
178
  assertJsonSerializable(value, '$snapshot');
144
179
  assertRecord(value, '$snapshot');