@modelprofile.com/flexharness 3.6.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.
package/ts/utils.json.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  import { FlexHarnessStoreFormatError, FlexHarnessValidationError } from './errors.js';
2
+ import {
3
+ FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
4
+ FLEX_REVERSION_MAXIMUM_LIMITS,
5
+ FLEX_REVERSION_REASON_CODE_MAX_BYTES,
6
+ FLEX_REVERSION_REFERENCE_MAX_BYTES,
7
+ FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
8
+ FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
9
+ } from './interfaces.js';
2
10
  import type {
3
11
  IFlexJsonLimits,
4
12
  IFlexPermissionSnapshot,
5
- IFlexProjectionSnapshot,
13
+ IFlexProjectionSnapshotV1,
14
+ IFlexProjectionSnapshotV2,
15
+ IFlexProjectionSnapshotV3,
6
16
  IFlexScopeSnapshot,
7
17
  TFlexAgentModelMessage,
8
18
  TJsonValue,
@@ -390,8 +400,8 @@ function requireString(value: unknown, path: string): string {
390
400
  return value;
391
401
  }
392
402
 
393
- function requireOptionalString(value: unknown, path: string): void {
394
- if (value !== undefined) requireString(value, path);
403
+ function requireOptionalString(value: unknown, path: string): string | undefined {
404
+ return value === undefined ? undefined : requireString(value, path);
395
405
  }
396
406
 
397
407
  function requireNonNegativeNumber(value: unknown, path: string): void {
@@ -873,15 +883,12 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
873
883
  }
874
884
  }
875
885
 
876
- export function assertFlexProjectionSnapshot(
877
- value: unknown,
878
- ): asserts value is IFlexProjectionSnapshot {
879
- const snapshot = validateSnapshotHeader(value, [
880
- 'schemaVersion',
881
- 'revision',
882
- 'messages',
883
- 'stagedTerminals',
884
- ]);
886
+ interface IValidatedProjectionBody {
887
+ messages: IValidatedMessageIdentity[];
888
+ stagedUsers: IValidatedMessageIdentity[];
889
+ }
890
+
891
+ function validateProjectionBody(snapshot: Record<string, unknown>): IValidatedProjectionBody {
885
892
  const visibleMessages = validateMessages(snapshot.messages, '$snapshot.messages');
886
893
  let projectionSessionId = visibleMessages[0]?.sessionId;
887
894
  if (visibleMessages.some((message) => message.sessionId !== projectionSessionId)) {
@@ -892,6 +899,7 @@ export function assertFlexProjectionSnapshot(
892
899
  }
893
900
  const runIds = new Set<string>();
894
901
  const stagedMessageIds = new Set<string>();
902
+ const stagedUsers: IValidatedMessageIdentity[] = [];
895
903
  for (let index = 0; index < snapshot.stagedTerminals.length; index++) {
896
904
  const path = `$snapshot.stagedTerminals[${index}]`;
897
905
  const terminal = requireRecord(snapshot.stagedTerminals[index], path);
@@ -928,6 +936,10 @@ export function assertFlexProjectionSnapshot(
928
936
  if (assistant.status !== terminal.status) {
929
937
  throw new FlexHarnessStoreFormatError(`${path}.status does not match its assistant message.`);
930
938
  }
939
+ if (user.status !== terminal.status) {
940
+ throw new FlexHarnessStoreFormatError(`${path}.status does not match its user message.`);
941
+ }
942
+ stagedUsers.push(user);
931
943
  projectionSessionId ??= user.sessionId;
932
944
  if (user.sessionId !== projectionSessionId) {
933
945
  throw new FlexHarnessStoreFormatError(`${path} belongs to another session.`);
@@ -945,6 +957,525 @@ export function assertFlexProjectionSnapshot(
945
957
  requireOptionalString(terminal.finishReason, `${path}.finishReason`);
946
958
  if (terminal.steps !== undefined) requireNonNegativeInteger(terminal.steps, `${path}.steps`);
947
959
  }
960
+ return { messages: visibleMessages, stagedUsers };
961
+ }
962
+
963
+ export function assertFlexProjectionSnapshotV1(
964
+ value: unknown,
965
+ ): asserts value is IFlexProjectionSnapshotV1 {
966
+ const snapshot = validateSnapshotHeader(value, [
967
+ 'schemaVersion',
968
+ 'revision',
969
+ 'messages',
970
+ 'stagedTerminals',
971
+ ]);
972
+ validateProjectionBody(snapshot);
973
+ }
974
+
975
+ export function assertFlexProjectionSnapshotV2(
976
+ value: unknown,
977
+ ): asserts value is IFlexProjectionSnapshotV2 {
978
+ assertJsonSerializable(value, '$snapshot');
979
+ const snapshot = requireRecord(value, '$snapshot');
980
+ requireOnlyKeys(snapshot, [
981
+ 'schemaVersion',
982
+ 'revision',
983
+ 'messages',
984
+ 'stagedTerminals',
985
+ 'reversionSegments',
986
+ 'revertCursor',
987
+ 'excludedRunIds',
988
+ 'pendingReversion',
989
+ 'pendingReversionReleases',
990
+ ], '$snapshot');
991
+ if (snapshot.schemaVersion !== 2) {
992
+ throw new FlexHarnessStoreFormatError('Projection snapshot schemaVersion must be 2.');
993
+ }
994
+ requireNonNegativeInteger(snapshot.revision, 'Snapshot revision');
995
+ const projectionBody = validateProjectionBody(snapshot);
996
+ const segments = Array.isArray(snapshot.reversionSegments)
997
+ ? snapshot.reversionSegments
998
+ : (() => { throw new FlexHarnessStoreFormatError('Snapshot reversionSegments must be an array.'); })();
999
+ if (segments.length > FLEX_REVERSION_MAXIMUM_LIMITS.maxSegments) {
1000
+ throw new FlexHarnessStoreFormatError('Snapshot reversionSegments exceeds its hard limit.');
1001
+ }
1002
+ const segmentRunIds = new Set<string>();
1003
+ const segmentCaptureIds = new Set<string>();
1004
+ const segmentEventIds = new Set<string>();
1005
+ const capturingWorkspaceSegments = new Map<string, string>();
1006
+ for (let index = 0; index < segments.length; index++) {
1007
+ const path = `$snapshot.reversionSegments[${index}]`;
1008
+ const segment = requireRecord(segments[index], path);
1009
+ requireOnlyKeys(segment, [
1010
+ 'runId',
1011
+ 'userMessageId',
1012
+ 'status',
1013
+ 'contextAvailable',
1014
+ 'eventIds',
1015
+ 'workspaceCaptured',
1016
+ 'captureId',
1017
+ 'workspaceReference',
1018
+ ], path);
1019
+ const runId = requireString(segment.runId, `${path}.runId`);
1020
+ const userMessageId = requireString(segment.userMessageId, `${path}.userMessageId`);
1021
+ if (segmentRunIds.has(runId)) {
1022
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate reversion run "${runId}".`);
1023
+ }
1024
+ segmentRunIds.add(runId);
1025
+ if (!['capturing', 'completed', 'failed', 'cancelled'].includes(String(segment.status))) {
1026
+ throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
1027
+ }
1028
+ if (typeof segment.contextAvailable !== 'boolean') {
1029
+ throw new FlexHarnessStoreFormatError(`${path}.contextAvailable must be a boolean.`);
1030
+ }
1031
+ const eventIds = requireStringArray(segment.eventIds, `${path}.eventIds`);
1032
+ if (eventIds.some((id) => !id) || new Set(eventIds).size !== eventIds.length) {
1033
+ throw new FlexHarnessStoreFormatError(`${path}.eventIds is invalid.`);
1034
+ }
1035
+ for (const eventId of eventIds) {
1036
+ if (segmentEventIds.has(eventId)) {
1037
+ throw new FlexHarnessStoreFormatError(`Snapshot reuses reversion event "${eventId}".`);
1038
+ }
1039
+ segmentEventIds.add(eventId);
1040
+ }
1041
+ if (typeof segment.workspaceCaptured !== 'boolean') {
1042
+ throw new FlexHarnessStoreFormatError(`${path}.workspaceCaptured must be a boolean.`);
1043
+ }
1044
+ const captureId = requireOptionalString(segment.captureId, `${path}.captureId`);
1045
+ if (segment.workspaceCaptured && captureId === undefined) {
1046
+ throw new FlexHarnessStoreFormatError(`${path} is capture-backed without a capture ID.`);
1047
+ }
1048
+ if (captureId !== undefined) {
1049
+ if (segmentCaptureIds.has(captureId)) {
1050
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate capture "${captureId}".`);
1051
+ }
1052
+ segmentCaptureIds.add(captureId);
1053
+ if (segment.status === 'capturing') capturingWorkspaceSegments.set(runId, captureId);
1054
+ }
1055
+ if (!segment.workspaceCaptured && (captureId !== undefined || segment.workspaceReference !== undefined)) {
1056
+ throw new FlexHarnessStoreFormatError(`${path} is transcript-only but contains workspace capture data.`);
1057
+ }
1058
+ if (
1059
+ segment.workspaceCaptured
1060
+ && segment.status !== 'capturing'
1061
+ && segment.workspaceReference === undefined
1062
+ ) {
1063
+ throw new FlexHarnessStoreFormatError(`${path} is terminal but has no workspace reference.`);
1064
+ }
1065
+ if (segment.status === 'capturing' && segment.workspaceReference !== undefined) {
1066
+ throw new FlexHarnessStoreFormatError(`${path} is capturing but already has a workspace reference.`);
1067
+ }
1068
+ if (segment.workspaceReference !== undefined) {
1069
+ if (captureId === undefined) {
1070
+ throw new FlexHarnessStoreFormatError(`${path} has a workspace reference without a capture ID.`);
1071
+ }
1072
+ if (Buffer.byteLength(JSON.stringify(segment.workspaceReference)) > FLEX_REVERSION_REFERENCE_MAX_BYTES) {
1073
+ throw new FlexHarnessStoreFormatError(`${path}.workspaceReference exceeds its byte limit.`);
1074
+ }
1075
+ }
1076
+ const correlatedUsers = new Set([...projectionBody.messages, ...projectionBody.stagedUsers]
1077
+ .filter((message) =>
1078
+ message.role === 'user' && message.runId === runId && message.messageId === userMessageId)
1079
+ .map((message) => `${message.runId}:${message.messageId}`));
1080
+ if (correlatedUsers.size !== 1) {
1081
+ throw new FlexHarnessStoreFormatError(`${path} does not match exactly one projected user message.`);
1082
+ }
1083
+ }
1084
+ requireNonNegativeInteger(snapshot.revertCursor, '$snapshot.revertCursor');
1085
+ const candidateCount = segments.filter((segment) => {
1086
+ const record = segment as Record<string, unknown>;
1087
+ return record.status === 'completed';
1088
+ }).length;
1089
+ if (candidateCount > FLEX_REVERSION_MAXIMUM_LIMITS.maxCompletedTurns) {
1090
+ throw new FlexHarnessStoreFormatError('Snapshot completed reversion turns exceed their hard limit.');
1091
+ }
1092
+ if (Number(snapshot.revertCursor) > candidateCount) {
1093
+ throw new FlexHarnessStoreFormatError('Snapshot revertCursor exceeds its candidate count.');
1094
+ }
1095
+ const excludedRunIds = requireStringArray(snapshot.excludedRunIds, '$snapshot.excludedRunIds');
1096
+ if (
1097
+ excludedRunIds.some((id) => !id)
1098
+ || new Set(excludedRunIds).size !== excludedRunIds.length
1099
+ ) throw new FlexHarnessStoreFormatError('Snapshot excludedRunIds is invalid.');
1100
+ if (excludedRunIds.length > FLEX_REVERSION_MAXIMUM_LIMITS.maxExcludedRunIds) {
1101
+ throw new FlexHarnessStoreFormatError('Snapshot excludedRunIds exceeds its hard limit.');
1102
+ }
1103
+ if (snapshot.pendingReversion !== undefined) {
1104
+ const pending = requireRecord(snapshot.pendingReversion, '$snapshot.pendingReversion');
1105
+ if (pending.kind === 'capture') {
1106
+ requireOnlyKeys(pending, ['kind', 'runId', 'captureId', 'state'], '$snapshot.pendingReversion');
1107
+ const pendingRunId = requireString(pending.runId, '$snapshot.pendingReversion.runId');
1108
+ const pendingCaptureId = requireString(pending.captureId, '$snapshot.pendingReversion.captureId');
1109
+ if (!['preparing', 'prepared', 'finalizing'].includes(String(pending.state))) {
1110
+ throw new FlexHarnessStoreFormatError('Snapshot pending capture state is invalid.');
1111
+ }
1112
+ const matches = segments.filter((segment) => {
1113
+ const record = segment as Record<string, unknown>;
1114
+ return record.runId === pendingRunId
1115
+ && record.captureId === pendingCaptureId
1116
+ && record.workspaceCaptured === true
1117
+ && record.status === 'capturing';
1118
+ });
1119
+ if (matches.length !== 1) {
1120
+ throw new FlexHarnessStoreFormatError('Snapshot pending capture does not match exactly one segment.');
1121
+ }
1122
+ capturingWorkspaceSegments.delete(pendingRunId);
1123
+ } else if (pending.kind === 'apply') {
1124
+ requireOnlyKeys(pending, [
1125
+ 'kind',
1126
+ 'operationId',
1127
+ 'direction',
1128
+ 'fromCursor',
1129
+ 'toCursor',
1130
+ 'segmentRunIds',
1131
+ 'appliedRunIds',
1132
+ ], '$snapshot.pendingReversion');
1133
+ requireString(pending.operationId, '$snapshot.pendingReversion.operationId');
1134
+ if (!['undo', 'redo'].includes(String(pending.direction))) {
1135
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply direction is invalid.');
1136
+ }
1137
+ requireNonNegativeInteger(pending.fromCursor, '$snapshot.pendingReversion.fromCursor');
1138
+ requireNonNegativeInteger(pending.toCursor, '$snapshot.pendingReversion.toCursor');
1139
+ const pendingRuns = requireStringArray(
1140
+ pending.segmentRunIds,
1141
+ '$snapshot.pendingReversion.segmentRunIds',
1142
+ );
1143
+ const appliedRuns = requireStringArray(
1144
+ pending.appliedRunIds,
1145
+ '$snapshot.pendingReversion.appliedRunIds',
1146
+ );
1147
+ if (new Set(pendingRuns).size !== pendingRuns.length || new Set(appliedRuns).size !== appliedRuns.length) {
1148
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply run IDs are invalid.');
1149
+ }
1150
+ if (appliedRuns.some((runId) => !pendingRuns.includes(runId))) {
1151
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply progress is invalid.');
1152
+ }
1153
+ const fromCursor = Number(pending.fromCursor);
1154
+ const toCursor = Number(pending.toCursor);
1155
+ const direction = pending.direction as 'undo' | 'redo';
1156
+ const completed = segments.filter((segment) =>
1157
+ (segment as Record<string, unknown>).status === 'completed');
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];
1175
+ const end = next
1176
+ ? segments.findIndex((segment) =>
1177
+ (segment as Record<string, unknown>).runId === (next as Record<string, unknown>).runId)
1178
+ : segments.length;
1179
+ const exactRuns = segments.slice(start, end).map((segment) =>
1180
+ String((segment as Record<string, unknown>).runId));
1181
+ if (JSON.stringify(pendingRuns) !== JSON.stringify(exactRuns)) {
1182
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply segment unit is invalid.');
1183
+ }
1184
+ const applyOrder = direction === 'undo' ? [...exactRuns].reverse() : exactRuns;
1185
+ if (JSON.stringify(appliedRuns) !== JSON.stringify(applyOrder.slice(0, appliedRuns.length))) {
1186
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply progress is out of order.');
1187
+ }
1188
+ } else {
1189
+ throw new FlexHarnessStoreFormatError('Snapshot pendingReversion kind is invalid.');
1190
+ }
1191
+ }
1192
+ if (capturingWorkspaceSegments.size > 0) {
1193
+ throw new FlexHarnessStoreFormatError(
1194
+ 'Snapshot contains a capture-backed capturing segment without exact pending ownership.',
1195
+ );
1196
+ }
1197
+ if (!Array.isArray(snapshot.pendingReversionReleases)) {
1198
+ throw new FlexHarnessStoreFormatError('Snapshot pendingReversionReleases must be an array.');
1199
+ }
1200
+ if (
1201
+ snapshot.pendingReversionReleases.length
1202
+ > FLEX_REVERSION_MAXIMUM_LIMITS.maxPendingReversionReleases
1203
+ ) {
1204
+ throw new FlexHarnessStoreFormatError('Snapshot pending reversion releases exceed their hard limit.');
1205
+ }
1206
+ const releaseCaptureIds = new Set<string>();
1207
+ for (let index = 0; index < snapshot.pendingReversionReleases.length; index++) {
1208
+ const path = `$snapshot.pendingReversionReleases[${index}]`;
1209
+ const release = requireRecord(snapshot.pendingReversionReleases[index], path);
1210
+ requireOnlyKeys(release, ['runId', 'captureId', 'reference'], path);
1211
+ requireString(release.runId, `${path}.runId`);
1212
+ const captureId = requireString(release.captureId, `${path}.captureId`);
1213
+ if (releaseCaptureIds.has(captureId)) {
1214
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate release capture "${captureId}".`);
1215
+ }
1216
+ releaseCaptureIds.add(captureId);
1217
+ if (!Object.prototype.hasOwnProperty.call(release, 'reference')) {
1218
+ throw new FlexHarnessStoreFormatError(`${path}.reference is required.`);
1219
+ }
1220
+ if (Buffer.byteLength(JSON.stringify(release.reference)) > FLEX_REVERSION_REFERENCE_MAX_BYTES) {
1221
+ throw new FlexHarnessStoreFormatError(`${path}.reference exceeds its byte limit.`);
1222
+ }
1223
+ }
1224
+ }
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
+
1472
+ export function assertFlexProjectionSnapshot(
1473
+ value: unknown,
1474
+ ): asserts value is IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 | IFlexProjectionSnapshotV3 {
1475
+ const record = requireRecord(value, '$snapshot');
1476
+ if (record.schemaVersion === 1) assertFlexProjectionSnapshotV1(value);
1477
+ else if (record.schemaVersion === 2) assertFlexProjectionSnapshotV2(value);
1478
+ else assertFlexProjectionSnapshotV3(value);
948
1479
  }
949
1480
 
950
1481
  export function assertFlexPermissionSnapshot(