@modelprofile.com/flexharness 11.3.1 → 12.1.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 (73) 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 +44 -25
  25. package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +152 -20
  26. package/dist_ts_stores_nosqldb/classes.recordstore.js +369 -66
  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/dist_ts_typedrequest/catalog.d.ts +4 -4
  40. package/dist_ts_typedrequest/catalog.js +25 -7
  41. package/dist_ts_typedrequest/describe.js +23 -11
  42. package/dist_ts_typedrequest/interfaces.d.ts +17 -1
  43. package/package.json +9 -3
  44. package/readme.md +6 -3
  45. package/ts/00_commitinfo_data.ts +1 -1
  46. package/ts/classes.flexharness.core.ts +74 -24
  47. package/ts/classes.flexharness.imports.ts +3 -2
  48. package/ts/classes.flexharness.reversions.ts +53 -28
  49. package/ts/classes.flexharness.sessions.ts +2 -5
  50. package/ts/classes.stores.ts +40 -37
  51. package/ts/index.ts +5 -0
  52. package/ts/interfaces.flexharness.ts +8 -1
  53. package/ts/interfaces.ts +27 -2
  54. package/ts/plugins.ts +12 -7
  55. package/ts/readme.md +6 -3
  56. package/ts/utils.projectionchange.ts +190 -0
  57. package/ts/utils.storecodecs.ts +42 -7
  58. package/ts/utils.storerules.ts +2 -2
  59. package/ts_migration/v3_legacyflexharness.ts +11 -6
  60. package/ts_stores_nosqldb/classes.nosqldbstores.ts +59 -46
  61. package/ts_stores_nosqldb/classes.recordstore.ts +445 -64
  62. package/ts_stores_nosqldb/index.ts +1 -0
  63. package/ts_stores_nosqldb/plugins.ts +4 -3
  64. package/ts_stores_nosqldb/readme.md +6 -2
  65. package/ts_stores_testing/contract.ts +776 -0
  66. package/ts_stores_testing/fixtures.ts +127 -0
  67. package/ts_stores_testing/index.ts +17 -0
  68. package/ts_stores_testing/plugins.ts +20 -0
  69. package/ts_stores_testing/readme.md +62 -0
  70. package/ts_typedrequest/catalog.ts +24 -6
  71. package/ts_typedrequest/describe.ts +19 -10
  72. package/ts_typedrequest/interfaces.ts +21 -1
  73. package/ts_typedrequest/readme.md +5 -3
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');
@@ -144,8 +144,8 @@ export function isProjectManagementTombstoneAdmitted(
144
144
 
145
145
  /** An archive is written once; repeating it with the same events is a no-op. */
146
146
  export function assertSameArchiveEvents(
147
- existing: plugins.IAgentEventArchiveV2,
148
- next: plugins.IAgentEventArchiveV2,
147
+ existing: plugins.IAgentEventArchive,
148
+ next: plugins.IAgentEventArchive,
149
149
  ): void {
150
150
  if (
151
151
  JSON.stringify(existing.events.map((event) => event.id))
@@ -18,6 +18,7 @@ import {
18
18
  assertJsonSerializable,
19
19
  cloneSerializable,
20
20
  } from '../ts/utils.json.js';
21
+ import { createWholeProjectionChange } from '../ts/utils.projectionchange.js';
21
22
 
22
23
  export interface IFlexLegacyStoredSessionSnapshot {
23
24
  session: IFlexSession;
@@ -59,8 +60,8 @@ interface IInspectedSessionDestination {
59
60
  plan: ILegacySessionMigrationPlan;
60
61
  projection: TFlexProjectionSnapshot | undefined;
61
62
  permission: IFlexPermissionSnapshot | undefined;
62
- eventStore: plugins.IAgentEventStoreV2;
63
- eventSnapshot: plugins.IAgentEventSnapshotV2 | undefined;
63
+ eventStore: plugins.IAgentEventStore;
64
+ eventSnapshot: plugins.IAgentEventSnapshot | undefined;
64
65
  jobSnapshot: plugins.IToolJobSnapshot | undefined;
65
66
  }
66
67
 
@@ -341,7 +342,7 @@ function createSessionAgentEvents(
341
342
 
342
343
  const jsonEvents = JSON.parse(JSON.stringify(events)) as plugins.TAgentEvent[];
343
344
  try {
344
- plugins.validateAgentEventSnapshotV2({
345
+ plugins.validateAgentEventSnapshot({
345
346
  schemaVersion: 2,
346
347
  sessionId,
347
348
  revision: 1,
@@ -508,7 +509,7 @@ async function inspectDestinations(
508
509
  projection,
509
510
  permission,
510
511
  eventStore,
511
- eventSnapshot: eventSnapshot as plugins.IAgentEventSnapshotV2 | undefined,
512
+ eventSnapshot,
512
513
  jobSnapshot,
513
514
  });
514
515
  }
@@ -596,7 +597,7 @@ export async function migrateLegacyFlexHarnessSnapshot(
596
597
  await stores.projections.save(
597
598
  storageKey,
598
599
  session.sessionId,
599
- session.projectionSnapshot,
600
+ createWholeProjectionChange(session.projectionSnapshot),
600
601
  0,
601
602
  );
602
603
  }
@@ -609,7 +610,11 @@ export async function migrateLegacyFlexHarnessSnapshot(
609
610
  );
610
611
  }
611
612
  if (!destination.eventSnapshot) {
612
- await destination.eventStore.save(session.sessionId, session.agentEvents, 0);
613
+ await destination.eventStore.save(
614
+ session.sessionId,
615
+ { remove: [], replace: [], append: session.agentEvents },
616
+ 0,
617
+ );
613
618
  }
614
619
  }
615
620
  if (!destinations.scope) {