@modelprofile.com/flexharness 3.7.0 → 3.8.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.
@@ -10,7 +10,7 @@ import type {
10
10
  IFlexHarnessStores,
11
11
  IFlexPermissionSnapshot,
12
12
  IFlexPermissionStore,
13
- IFlexProjectionSnapshotV2,
13
+ IFlexProjectionSnapshotV3,
14
14
  IFlexProjectionStore,
15
15
  IFlexScopeSnapshot,
16
16
  IFlexScopeStore,
@@ -365,7 +365,7 @@ class InMemoryProjectionStore implements IFlexProjectionStore {
365
365
  public async save(
366
366
  storageKey: string,
367
367
  sessionId: string,
368
- snapshot: IFlexProjectionSnapshotV2,
368
+ snapshot: IFlexProjectionSnapshotV3,
369
369
  expectedRevision: number,
370
370
  ): Promise<void> {
371
371
  const key = providerKey(storageKey, sessionId);
@@ -988,7 +988,7 @@ class JsonProjectionStore implements IFlexProjectionStore {
988
988
  public async save(
989
989
  storageKey: string,
990
990
  sessionId: string,
991
- snapshot: IFlexProjectionSnapshotV2,
991
+ snapshot: IFlexProjectionSnapshotV3,
992
992
  expectedRevision: number,
993
993
  ): Promise<void> {
994
994
  const validated = validateSnapshotSave(snapshot, expectedRevision, assertFlexProjectionSnapshot);
package/ts/interfaces.ts CHANGED
@@ -460,8 +460,16 @@ export interface IFlexProjectionSnapshotV1 {
460
460
  }
461
461
 
462
462
  export type TFlexReversionSegmentStatus = 'capturing' | 'completed' | 'failed' | 'cancelled';
463
+ export type TFlexReversionProtocolVersion = 1 | 2;
464
+ export type TFlexReversionProvenance = 'transcript' | 'workspace';
465
+ export type TFlexReversionDisposition = 'pending' | 'revertible' | 'no-change' | 'nonrevertible';
463
466
 
464
- export interface IFlexReversionSegment {
467
+ export interface IFlexAffectedWorkspace {
468
+ readonly id: string;
469
+ readonly label: string;
470
+ }
471
+
472
+ interface IFlexReversionSegmentV2 {
465
473
  runId: string;
466
474
  userMessageId: string;
467
475
  status: TFlexReversionSegmentStatus;
@@ -472,13 +480,25 @@ export interface IFlexReversionSegment {
472
480
  workspaceReference?: TJsonValue;
473
481
  }
474
482
 
475
- export interface IFlexPendingCaptureReversion {
483
+ export interface IFlexReversionSegment extends IFlexReversionSegmentV2 {
484
+ protocolVersion: TFlexReversionProtocolVersion;
485
+ provenance: TFlexReversionProvenance;
486
+ disposition?: TFlexReversionDisposition;
487
+ affectedWorkspaces?: IFlexAffectedWorkspace[];
488
+ reasonCode?: string;
489
+ }
490
+
491
+ interface IFlexPendingCaptureReversionV2 {
476
492
  kind: 'capture';
477
493
  runId: string;
478
494
  captureId: string;
479
495
  state: 'preparing' | 'prepared' | 'finalizing';
480
496
  }
481
497
 
498
+ export interface IFlexPendingCaptureReversion extends IFlexPendingCaptureReversionV2 {
499
+ protocolVersion: TFlexReversionProtocolVersion;
500
+ }
501
+
482
502
  export interface IFlexPendingApplyReversion {
483
503
  kind: 'apply';
484
504
  operationId: string;
@@ -489,21 +509,41 @@ export interface IFlexPendingApplyReversion {
489
509
  appliedRunIds: string[];
490
510
  }
491
511
 
512
+ type TFlexPendingReversionV2 =
513
+ | IFlexPendingCaptureReversionV2
514
+ | IFlexPendingApplyReversion;
515
+
492
516
  export type TFlexPendingReversion =
493
517
  | IFlexPendingCaptureReversion
494
518
  | IFlexPendingApplyReversion;
495
519
 
496
- export interface IFlexPendingReversionRelease {
520
+ interface IFlexPendingReversionReleaseV2 {
497
521
  runId: string;
498
522
  captureId: string;
499
523
  reference: TJsonValue;
500
524
  }
501
525
 
526
+ export interface IFlexPendingReversionRelease extends IFlexPendingReversionReleaseV2 {
527
+ protocolVersion: TFlexReversionProtocolVersion;
528
+ }
529
+
502
530
  export interface IFlexProjectionSnapshotV2 {
503
531
  schemaVersion: 2;
504
532
  revision: number;
505
533
  messages: IFlexMessage[];
506
534
  stagedTerminals: IFlexTerminalProjection[];
535
+ reversionSegments: IFlexReversionSegmentV2[];
536
+ revertCursor: number;
537
+ excludedRunIds: string[];
538
+ pendingReversion?: TFlexPendingReversionV2;
539
+ pendingReversionReleases: IFlexPendingReversionReleaseV2[];
540
+ }
541
+
542
+ export interface IFlexProjectionSnapshotV3 {
543
+ schemaVersion: 3;
544
+ revision: number;
545
+ messages: IFlexMessage[];
546
+ stagedTerminals: IFlexTerminalProjection[];
507
547
  reversionSegments: IFlexReversionSegment[];
508
548
  revertCursor: number;
509
549
  excludedRunIds: string[];
@@ -511,9 +551,12 @@ export interface IFlexProjectionSnapshotV2 {
511
551
  pendingReversionReleases: IFlexPendingReversionRelease[];
512
552
  }
513
553
 
514
- export type IFlexProjectionSnapshot = IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2;
554
+ export type IFlexProjectionSnapshot =
555
+ | IFlexProjectionSnapshotV1
556
+ | IFlexProjectionSnapshotV2
557
+ | IFlexProjectionSnapshotV3;
515
558
  export type TFlexProjectionSnapshot = IFlexProjectionSnapshot;
516
- export type IFlexProjectionSnapshotCurrent = IFlexProjectionSnapshotV2;
559
+ export type IFlexProjectionSnapshotCurrent = IFlexProjectionSnapshotV3;
517
560
 
518
561
  export interface IFlexPermissionSnapshot {
519
562
  schemaVersion: 1;
@@ -619,6 +662,10 @@ export const FLEX_REVERSION_MAXIMUM_LIMITS = Object.freeze({
619
662
  });
620
663
 
621
664
  export const FLEX_REVERSION_REFERENCE_MAX_BYTES = 256 * 1024;
665
+ export const FLEX_REVERSION_MAX_AFFECTED_WORKSPACES = 64;
666
+ export const FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES = 512;
667
+ export const FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES = 2048;
668
+ export const FLEX_REVERSION_REASON_CODE_MAX_BYTES = 128;
622
669
 
623
670
  export interface IFlexTurnReversionBaseContext<TScope> {
624
671
  readonly scopeId: string;
@@ -672,6 +719,77 @@ export interface IFlexTurnReversionProvider<TScope> {
672
719
  ): Promise<void> | void;
673
720
  }
674
721
 
722
+ export interface IFlexTurnReversionRevertibleOutcome {
723
+ readonly disposition: 'revertible';
724
+ readonly reference: TJsonValue;
725
+ readonly affectedWorkspaces: readonly IFlexAffectedWorkspace[];
726
+ }
727
+
728
+ export interface IFlexTurnReversionNoChangeOutcome {
729
+ readonly disposition: 'no-change';
730
+ readonly reference: TJsonValue;
731
+ readonly affectedWorkspaces?: readonly [];
732
+ }
733
+
734
+ export interface IFlexTurnReversionNonrevertibleOutcome {
735
+ readonly disposition: 'nonrevertible';
736
+ readonly reference: TJsonValue;
737
+ readonly reasonCode: string;
738
+ readonly affectedWorkspaces?: readonly IFlexAffectedWorkspace[];
739
+ }
740
+
741
+ export type TFlexTurnReversionFinalizedOutcome =
742
+ | IFlexTurnReversionRevertibleOutcome
743
+ | IFlexTurnReversionNoChangeOutcome
744
+ | IFlexTurnReversionNonrevertibleOutcome;
745
+
746
+ export type TFlexTurnReversionCaptureInspectionV2 =
747
+ | { readonly status: 'missing' | 'prepared' | 'unknown' }
748
+ | {
749
+ readonly status: 'finalized';
750
+ readonly outcome: TFlexTurnReversionFinalizedOutcome;
751
+ };
752
+
753
+ export interface IFlexTurnReversionProviderV2<TScope> {
754
+ readonly protocolVersion: 2;
755
+ prepare(
756
+ context: IFlexTurnReversionBaseContext<TScope>,
757
+ ): Promise<void> | void;
758
+ inspectCapture(
759
+ context: IFlexTurnReversionBaseContext<TScope>,
760
+ ): Promise<TFlexTurnReversionCaptureInspectionV2> | TFlexTurnReversionCaptureInspectionV2;
761
+ finalize(
762
+ context: IFlexTurnReversionBaseContext<TScope>,
763
+ ): Promise<TFlexTurnReversionFinalizedOutcome> | TFlexTurnReversionFinalizedOutcome;
764
+ apply(
765
+ context: IFlexTurnReversionApplyContext<TScope>,
766
+ ): Promise<void> | void;
767
+ inspectApply(
768
+ context: IFlexTurnReversionApplyContext<TScope>,
769
+ ): Promise<IFlexTurnReversionApplyInspection> | IFlexTurnReversionApplyInspection;
770
+ release(
771
+ context: IFlexTurnReversionReleaseContext<TScope>,
772
+ ): Promise<void> | void;
773
+ }
774
+
775
+ export type TFlexReversionPolicy = 'transcript-optional' | 'workspace-required';
776
+
777
+ export type TFlexSessionReversionGroupKind = 'candidate' | 'barrier' | 'no-change';
778
+
779
+ export interface IFlexSessionReversionGroup {
780
+ readonly runId: string;
781
+ readonly kind: TFlexSessionReversionGroupKind;
782
+ readonly visibility: 'visible' | 'hidden';
783
+ readonly affectedWorkspaces: readonly IFlexAffectedWorkspace[];
784
+ readonly affectedWorkspacesTruncated: boolean;
785
+ }
786
+
787
+ export interface IFlexSessionReversionInfo {
788
+ readonly undoAvailable: boolean;
789
+ readonly redoAvailable: boolean;
790
+ readonly groups: readonly IFlexSessionReversionGroup[];
791
+ }
792
+
675
793
  export interface IFlexUndoSessionResult {
676
794
  revertedRunId: string;
677
795
  }
@@ -769,7 +887,8 @@ export interface IFlexHarnessOptions<TScope> {
769
887
  callbackLimits?: IFlexCallbackLimits;
770
888
  promptQueueLimits?: IFlexPromptQueueLimits;
771
889
  reversionLimits?: IFlexReversionLimits;
772
- turnReversionProvider?: IFlexTurnReversionProvider<TScope>;
890
+ turnReversionProvider?: IFlexTurnReversionProvider<TScope> | IFlexTurnReversionProviderV2<TScope>;
891
+ reversionPolicy?: TFlexReversionPolicy;
773
892
  externalErrorProjector?: TFlexExternalErrorProjector;
774
893
  subagents?: IFlexSubagentDefinition[];
775
894
  slashCommands?: readonly TFlexSlashCommandRegistration<TScope>[];
package/ts/utils.json.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  import { FlexHarnessStoreFormatError, FlexHarnessValidationError } from './errors.js';
2
2
  import {
3
+ FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
3
4
  FLEX_REVERSION_MAXIMUM_LIMITS,
5
+ FLEX_REVERSION_REASON_CODE_MAX_BYTES,
4
6
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
7
+ FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
8
+ FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
5
9
  } from './interfaces.js';
6
10
  import type {
7
11
  IFlexJsonLimits,
8
12
  IFlexPermissionSnapshot,
9
13
  IFlexProjectionSnapshotV1,
10
14
  IFlexProjectionSnapshotV2,
15
+ IFlexProjectionSnapshotV3,
11
16
  IFlexScopeSnapshot,
12
17
  TFlexAgentModelMessage,
13
18
  TJsonValue,
@@ -1148,18 +1153,25 @@ export function assertFlexProjectionSnapshotV2(
1148
1153
  const fromCursor = Number(pending.fromCursor);
1149
1154
  const toCursor = Number(pending.toCursor);
1150
1155
  const direction = pending.direction as 'undo' | 'redo';
1151
- if (fromCursor !== Number(snapshot.revertCursor) || toCursor !== fromCursor + (direction === 'undo' ? -1 : 1)) {
1152
- throw new FlexHarnessStoreFormatError('Snapshot pending apply cursor movement is invalid.');
1153
- }
1154
1156
  const completed = segments.filter((segment) =>
1155
1157
  (segment as Record<string, unknown>).status === 'completed');
1156
- const target = direction === 'undo' ? completed[fromCursor - 1] : completed[fromCursor];
1157
- if (!target) throw new FlexHarnessStoreFormatError('Snapshot pending apply has no target candidate.');
1158
- const targetRunId = (target as Record<string, unknown>).runId;
1159
- const targetIndex = segments.findIndex((segment) =>
1160
- (segment as Record<string, unknown>).runId === targetRunId);
1161
- const start = target === completed[0] ? 0 : targetIndex;
1162
- const next = completed[direction === 'undo' ? fromCursor : fromCursor + 1];
1158
+ if (
1159
+ fromCursor !== Number(snapshot.revertCursor)
1160
+ || (direction === 'undo' && !(toCursor >= 0 && toCursor < fromCursor))
1161
+ || (direction === 'redo' && !(toCursor > fromCursor && toCursor <= completed.length))
1162
+ ) {
1163
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply cursor movement is invalid.');
1164
+ }
1165
+ const rangeStart = Math.min(fromCursor, toCursor);
1166
+ const rangeEnd = Math.max(fromCursor, toCursor);
1167
+ const first = completed[rangeStart];
1168
+ if (!first) throw new FlexHarnessStoreFormatError('Snapshot pending apply has no target candidate.');
1169
+ const firstRunId = (first as Record<string, unknown>).runId;
1170
+ const start = rangeStart === 0
1171
+ ? 0
1172
+ : segments.findIndex((segment) =>
1173
+ (segment as Record<string, unknown>).runId === firstRunId);
1174
+ const next = completed[rangeEnd];
1163
1175
  const end = next
1164
1176
  ? segments.findIndex((segment) =>
1165
1177
  (segment as Record<string, unknown>).runId === (next as Record<string, unknown>).runId)
@@ -1211,12 +1223,259 @@ export function assertFlexProjectionSnapshotV2(
1211
1223
  }
1212
1224
  }
1213
1225
 
1226
+ function requireBoundedString(value: unknown, path: string, maxBytes: number): string {
1227
+ const result = requireString(value, path);
1228
+ if (Buffer.byteLength(result, 'utf8') > maxBytes) {
1229
+ throw new FlexHarnessStoreFormatError(`${path} exceeds its byte limit.`);
1230
+ }
1231
+ return result;
1232
+ }
1233
+
1234
+ function validateAffectedWorkspaces(value: unknown, path: string): void {
1235
+ if (!Array.isArray(value)) {
1236
+ throw new FlexHarnessStoreFormatError(`${path} must be an array.`);
1237
+ }
1238
+ if (value.length > FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
1239
+ throw new FlexHarnessStoreFormatError(`${path} exceeds its item limit.`);
1240
+ }
1241
+ const ids = new Set<string>();
1242
+ for (let index = 0; index < value.length; index++) {
1243
+ const workspacePath = `${path}[${index}]`;
1244
+ const workspace = requireRecord(value[index], workspacePath);
1245
+ requireOnlyKeys(workspace, ['id', 'label'], workspacePath);
1246
+ const id = requireBoundedString(
1247
+ workspace.id,
1248
+ `${workspacePath}.id`,
1249
+ FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
1250
+ );
1251
+ if (!id.trim()) {
1252
+ throw new FlexHarnessStoreFormatError(`${workspacePath}.id must contain non-whitespace characters.`);
1253
+ }
1254
+ const label = requireBoundedString(
1255
+ workspace.label,
1256
+ `${workspacePath}.label`,
1257
+ FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
1258
+ );
1259
+ if (!label.trim()) {
1260
+ throw new FlexHarnessStoreFormatError(`${workspacePath}.label must contain non-whitespace characters.`);
1261
+ }
1262
+ if (ids.has(id)) {
1263
+ throw new FlexHarnessStoreFormatError(`${path} contains duplicate workspace "${id}".`);
1264
+ }
1265
+ ids.add(id);
1266
+ }
1267
+ }
1268
+
1269
+ export function assertFlexProjectionSnapshotV3(
1270
+ value: unknown,
1271
+ ): asserts value is IFlexProjectionSnapshotV3 {
1272
+ assertJsonSerializable(value, '$snapshot');
1273
+ const snapshot = requireRecord(value, '$snapshot');
1274
+ requireOnlyKeys(snapshot, [
1275
+ 'schemaVersion',
1276
+ 'revision',
1277
+ 'messages',
1278
+ 'stagedTerminals',
1279
+ 'reversionSegments',
1280
+ 'revertCursor',
1281
+ 'excludedRunIds',
1282
+ 'pendingReversion',
1283
+ 'pendingReversionReleases',
1284
+ ], '$snapshot');
1285
+ if (snapshot.schemaVersion !== 3) {
1286
+ throw new FlexHarnessStoreFormatError('Projection snapshot schemaVersion must be 3.');
1287
+ }
1288
+ if (!Array.isArray(snapshot.reversionSegments)) {
1289
+ throw new FlexHarnessStoreFormatError('Snapshot reversionSegments must be an array.');
1290
+ }
1291
+ const retainedCaptureIds = new Set<string>();
1292
+ for (let index = 0; index < snapshot.reversionSegments.length; index++) {
1293
+ const path = `$snapshot.reversionSegments[${index}]`;
1294
+ const segment = requireRecord(snapshot.reversionSegments[index], path);
1295
+ requireOnlyKeys(segment, [
1296
+ 'runId',
1297
+ 'userMessageId',
1298
+ 'status',
1299
+ 'contextAvailable',
1300
+ 'eventIds',
1301
+ 'workspaceCaptured',
1302
+ 'captureId',
1303
+ 'workspaceReference',
1304
+ 'protocolVersion',
1305
+ 'provenance',
1306
+ 'disposition',
1307
+ 'affectedWorkspaces',
1308
+ 'reasonCode',
1309
+ ], path);
1310
+ const protocolVersion = segment.protocolVersion;
1311
+ if (protocolVersion !== 1 && protocolVersion !== 2) {
1312
+ throw new FlexHarnessStoreFormatError(`${path}.protocolVersion is invalid.`);
1313
+ }
1314
+ if (segment.provenance !== 'transcript' && segment.provenance !== 'workspace') {
1315
+ throw new FlexHarnessStoreFormatError(`${path}.provenance is invalid.`);
1316
+ }
1317
+ if (segment.workspaceCaptured !== (segment.provenance === 'workspace')) {
1318
+ throw new FlexHarnessStoreFormatError(`${path}.workspaceCaptured does not match its provenance.`);
1319
+ }
1320
+ if (protocolVersion === 2 && segment.provenance !== 'workspace') {
1321
+ throw new FlexHarnessStoreFormatError(`${path} uses protocol 2 without workspace provenance.`);
1322
+ }
1323
+ if (protocolVersion === 1 && segment.affectedWorkspaces !== undefined) {
1324
+ throw new FlexHarnessStoreFormatError(`${path} protocol 1 contains affected workspace metadata.`);
1325
+ }
1326
+ const status = String(segment.status);
1327
+ const disposition = segment.disposition;
1328
+ const captureId = segment.captureId;
1329
+ if (captureId !== undefined) retainedCaptureIds.add(String(captureId));
1330
+ if (segment.affectedWorkspaces !== undefined) {
1331
+ validateAffectedWorkspaces(segment.affectedWorkspaces, `${path}.affectedWorkspaces`);
1332
+ }
1333
+ if (segment.reasonCode !== undefined) {
1334
+ const reasonCode = requireBoundedString(
1335
+ segment.reasonCode,
1336
+ `${path}.reasonCode`,
1337
+ FLEX_REVERSION_REASON_CODE_MAX_BYTES,
1338
+ );
1339
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(reasonCode)) {
1340
+ throw new FlexHarnessStoreFormatError(`${path}.reasonCode is invalid.`);
1341
+ }
1342
+ }
1343
+ if (segment.provenance === 'transcript') {
1344
+ if (
1345
+ disposition !== undefined
1346
+ || captureId !== undefined
1347
+ || segment.workspaceReference !== undefined
1348
+ || segment.affectedWorkspaces !== undefined
1349
+ || segment.reasonCode !== undefined
1350
+ ) throw new FlexHarnessStoreFormatError(`${path} transcript provenance contains workspace data.`);
1351
+ continue;
1352
+ }
1353
+ if (status === 'capturing') {
1354
+ if (disposition !== 'pending' || captureId === undefined || segment.workspaceReference !== undefined) {
1355
+ throw new FlexHarnessStoreFormatError(`${path} has invalid pending workspace ownership.`);
1356
+ }
1357
+ if (segment.affectedWorkspaces !== undefined || segment.reasonCode !== undefined) {
1358
+ throw new FlexHarnessStoreFormatError(`${path} pending workspace capture has terminal metadata.`);
1359
+ }
1360
+ continue;
1361
+ }
1362
+ if (protocolVersion === 1 && disposition !== 'revertible') {
1363
+ throw new FlexHarnessStoreFormatError(`${path} terminal protocol-1 workspace capture is not revertible.`);
1364
+ }
1365
+ if (protocolVersion === 2 && !['revertible', 'no-change', 'nonrevertible'].includes(String(disposition))) {
1366
+ throw new FlexHarnessStoreFormatError(`${path} terminal protocol-2 capture lacks a conclusive disposition.`);
1367
+ }
1368
+ if (disposition === 'revertible') {
1369
+ if (captureId === undefined || segment.workspaceReference === undefined) {
1370
+ throw new FlexHarnessStoreFormatError(`${path} revertible capture lacks retained ownership.`);
1371
+ }
1372
+ if (protocolVersion === 2 && segment.affectedWorkspaces === undefined) {
1373
+ throw new FlexHarnessStoreFormatError(`${path} revertible protocol-2 capture lacks affected workspaces.`);
1374
+ }
1375
+ if (segment.reasonCode !== undefined) {
1376
+ throw new FlexHarnessStoreFormatError(`${path} revertible capture has a reason code.`);
1377
+ }
1378
+ } else {
1379
+ if (captureId !== undefined || segment.workspaceReference !== undefined) {
1380
+ throw new FlexHarnessStoreFormatError(`${path} terminal disposition retains segment ownership.`);
1381
+ }
1382
+ if (disposition === 'no-change') {
1383
+ if (Array.isArray(segment.affectedWorkspaces) && segment.affectedWorkspaces.length > 0) {
1384
+ throw new FlexHarnessStoreFormatError(`${path} no-change capture affects a workspace.`);
1385
+ }
1386
+ if (segment.reasonCode !== undefined) {
1387
+ throw new FlexHarnessStoreFormatError(`${path} no-change capture has a reason code.`);
1388
+ }
1389
+ }
1390
+ if (disposition === 'nonrevertible' && segment.reasonCode === undefined) {
1391
+ throw new FlexHarnessStoreFormatError(`${path} nonrevertible capture lacks a reason code.`);
1392
+ }
1393
+ }
1394
+ }
1395
+ if (snapshot.pendingReversion !== undefined) {
1396
+ const pending = requireRecord(snapshot.pendingReversion, '$snapshot.pendingReversion');
1397
+ if (pending.kind === 'capture') {
1398
+ requireOnlyKeys(
1399
+ pending,
1400
+ ['kind', 'runId', 'captureId', 'state', 'protocolVersion'],
1401
+ '$snapshot.pendingReversion',
1402
+ );
1403
+ if (pending.protocolVersion !== 1 && pending.protocolVersion !== 2) {
1404
+ throw new FlexHarnessStoreFormatError('$snapshot.pendingReversion.protocolVersion is invalid.');
1405
+ }
1406
+ const matching = snapshot.reversionSegments.filter((entry) => {
1407
+ const segment = entry as Record<string, unknown>;
1408
+ return segment.runId === pending.runId
1409
+ && segment.captureId === pending.captureId
1410
+ && segment.protocolVersion === pending.protocolVersion
1411
+ && segment.status === 'capturing'
1412
+ && segment.disposition === 'pending';
1413
+ });
1414
+ if (matching.length !== 1) {
1415
+ throw new FlexHarnessStoreFormatError('Snapshot pending capture does not match exactly one segment.');
1416
+ }
1417
+ }
1418
+ }
1419
+ if (!Array.isArray(snapshot.pendingReversionReleases)) {
1420
+ throw new FlexHarnessStoreFormatError('Snapshot pendingReversionReleases must be an array.');
1421
+ }
1422
+ const releaseCaptureIds = new Set<string>();
1423
+ for (let index = 0; index < snapshot.pendingReversionReleases.length; index++) {
1424
+ const path = `$snapshot.pendingReversionReleases[${index}]`;
1425
+ const release = requireRecord(snapshot.pendingReversionReleases[index], path);
1426
+ requireOnlyKeys(release, ['runId', 'captureId', 'reference', 'protocolVersion'], path);
1427
+ const captureId = requireString(release.captureId, `${path}.captureId`);
1428
+ if (release.protocolVersion !== 1 && release.protocolVersion !== 2) {
1429
+ throw new FlexHarnessStoreFormatError(`${path}.protocolVersion is invalid.`);
1430
+ }
1431
+ if (releaseCaptureIds.has(captureId) || retainedCaptureIds.has(captureId)) {
1432
+ throw new FlexHarnessStoreFormatError(`${path} has duplicate durable capture ownership.`);
1433
+ }
1434
+ releaseCaptureIds.add(captureId);
1435
+ }
1436
+ const compatibleV2 = {
1437
+ ...snapshot,
1438
+ schemaVersion: 2,
1439
+ reversionSegments: snapshot.reversionSegments.map((entry) => {
1440
+ const segment = entry as Record<string, unknown>;
1441
+ return {
1442
+ runId: segment.runId,
1443
+ userMessageId: segment.userMessageId,
1444
+ status: segment.status,
1445
+ contextAvailable: segment.contextAvailable,
1446
+ eventIds: segment.eventIds,
1447
+ workspaceCaptured: segment.provenance === 'workspace'
1448
+ && (segment.status === 'capturing' || segment.disposition === 'revertible'),
1449
+ ...(segment.captureId === undefined ? {} : { captureId: segment.captureId }),
1450
+ ...(segment.workspaceReference === undefined
1451
+ ? {}
1452
+ : { workspaceReference: segment.workspaceReference }),
1453
+ };
1454
+ }),
1455
+ ...(snapshot.pendingReversion === undefined
1456
+ ? {}
1457
+ : {
1458
+ pendingReversion: (() => {
1459
+ const { protocolVersion: _protocolVersion, ...pending } = snapshot.pendingReversion as
1460
+ Record<string, unknown>;
1461
+ return pending;
1462
+ })(),
1463
+ }),
1464
+ pendingReversionReleases: snapshot.pendingReversionReleases.map((entry) => {
1465
+ const { protocolVersion: _protocolVersion, ...release } = entry as Record<string, unknown>;
1466
+ return release;
1467
+ }),
1468
+ };
1469
+ assertFlexProjectionSnapshotV2(compatibleV2);
1470
+ }
1471
+
1214
1472
  export function assertFlexProjectionSnapshot(
1215
1473
  value: unknown,
1216
- ): asserts value is IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 {
1474
+ ): asserts value is IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 | IFlexProjectionSnapshotV3 {
1217
1475
  const record = requireRecord(value, '$snapshot');
1218
1476
  if (record.schemaVersion === 1) assertFlexProjectionSnapshotV1(value);
1219
- else assertFlexProjectionSnapshotV2(value);
1477
+ else if (record.schemaVersion === 2) assertFlexProjectionSnapshotV2(value);
1478
+ else assertFlexProjectionSnapshotV3(value);
1220
1479
  }
1221
1480
 
1222
1481
  export function assertFlexPermissionSnapshot(
@@ -405,7 +405,7 @@ function createMigrationPlan(
405
405
  tombstones: [],
406
406
  };
407
407
  const projectionSnapshot: IFlexProjectionSnapshotCurrent = {
408
- schemaVersion: 2,
408
+ schemaVersion: 3,
409
409
  revision: 1,
410
410
  messages: stored.messages,
411
411
  stagedTerminals: [],