@modelprofile.com/flexharness 3.7.0 → 4.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.
package/ts/utils.json.ts CHANGED
@@ -1,13 +1,19 @@
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,
9
+ FLEX_SESSION_GENERATION_ID_MAX_BYTES,
5
10
  } from './interfaces.js';
6
11
  import type {
7
12
  IFlexJsonLimits,
8
13
  IFlexPermissionSnapshot,
9
14
  IFlexProjectionSnapshotV1,
10
15
  IFlexProjectionSnapshotV2,
16
+ IFlexProjectionSnapshotV3,
11
17
  IFlexScopeSnapshot,
12
18
  TFlexAgentModelMessage,
13
19
  TJsonValue,
@@ -427,6 +433,39 @@ const activityStatuses = [
427
433
  'cancelled',
428
434
  ] as const;
429
435
 
436
+ function validateOptionalSessionGeneration(
437
+ value: Record<string, unknown>,
438
+ path: string,
439
+ ): { sessionGenerationId?: string; sessionGenerationSequence?: number } {
440
+ const hasId = value.sessionGenerationId !== undefined;
441
+ const hasSequence = value.sessionGenerationSequence !== undefined;
442
+ if (hasId !== hasSequence) {
443
+ throw new FlexHarnessStoreFormatError(`${path} has partial session generation fields.`);
444
+ }
445
+ if (!hasId) return {};
446
+ const sessionGenerationId = requireString(
447
+ value.sessionGenerationId,
448
+ `${path}.sessionGenerationId`,
449
+ );
450
+ if (Buffer.byteLength(sessionGenerationId, 'utf8') > FLEX_SESSION_GENERATION_ID_MAX_BYTES) {
451
+ throw new FlexHarnessStoreFormatError(
452
+ `${path}.sessionGenerationId exceeds ${FLEX_SESSION_GENERATION_ID_MAX_BYTES} UTF-8 bytes.`,
453
+ );
454
+ }
455
+ if (
456
+ !Number.isSafeInteger(value.sessionGenerationSequence)
457
+ || Number(value.sessionGenerationSequence) < 1
458
+ ) {
459
+ throw new FlexHarnessStoreFormatError(
460
+ `${path}.sessionGenerationSequence must be a positive integer.`,
461
+ );
462
+ }
463
+ return {
464
+ sessionGenerationId,
465
+ sessionGenerationSequence: value.sessionGenerationSequence as number,
466
+ };
467
+ }
468
+
430
469
  function validateSession(value: unknown, path: string): IValidatedSessionIdentity {
431
470
  const session = requireRecord(value, path);
432
471
  requireOnlyKeys(
@@ -434,6 +473,8 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
434
473
  [
435
474
  'scopeId',
436
475
  'sessionId',
476
+ 'sessionGenerationId',
477
+ 'sessionGenerationSequence',
437
478
  'title',
438
479
  'createdAt',
439
480
  'updatedAt',
@@ -450,6 +491,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
450
491
  );
451
492
  requireString(session.scopeId, `${path}.scopeId`);
452
493
  const sessionId = requireString(session.sessionId, `${path}.sessionId`);
494
+ const generation = validateOptionalSessionGeneration(session, path);
453
495
  requireOptionalString(session.title, `${path}.title`);
454
496
  requireString(session.createdAt, `${path}.createdAt`);
455
497
  requireString(session.updatedAt, `${path}.updatedAt`);
@@ -491,6 +533,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
491
533
  }
492
534
  return {
493
535
  sessionId,
536
+ ...generation,
494
537
  ...(relationshipCount === 0
495
538
  ? {}
496
539
  : {
@@ -536,6 +579,8 @@ interface IValidatedMessageIdentity {
536
579
 
537
580
  interface IValidatedSessionIdentity {
538
581
  sessionId: string;
582
+ sessionGenerationId?: string;
583
+ sessionGenerationSequence?: number;
539
584
  parentSessionId?: string;
540
585
  parentRunId?: string;
541
586
  parentToolCallId?: string;
@@ -775,10 +820,19 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
775
820
  const tombstone = requireRecord(snapshot.tombstones[index], path);
776
821
  requireOnlyKeys(
777
822
  tombstone,
778
- ['sessionId', 'deletedAt', 'rootSessionId', 'depth', 'parentSessionId'],
823
+ [
824
+ 'sessionId',
825
+ 'sessionGenerationId',
826
+ 'sessionGenerationSequence',
827
+ 'deletedAt',
828
+ 'rootSessionId',
829
+ 'depth',
830
+ 'parentSessionId',
831
+ ],
779
832
  path,
780
833
  );
781
834
  const sessionId = requireString(tombstone.sessionId, `${path}.sessionId`);
835
+ validateOptionalSessionGeneration(tombstone, path);
782
836
  requireString(tombstone.deletedAt, `${path}.deletedAt`);
783
837
  const parentSessionId = tombstone.parentSessionId === undefined
784
838
  ? undefined
@@ -1148,18 +1202,25 @@ export function assertFlexProjectionSnapshotV2(
1148
1202
  const fromCursor = Number(pending.fromCursor);
1149
1203
  const toCursor = Number(pending.toCursor);
1150
1204
  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
1205
  const completed = segments.filter((segment) =>
1155
1206
  (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];
1207
+ if (
1208
+ fromCursor !== Number(snapshot.revertCursor)
1209
+ || (direction === 'undo' && !(toCursor >= 0 && toCursor < fromCursor))
1210
+ || (direction === 'redo' && !(toCursor > fromCursor && toCursor <= completed.length))
1211
+ ) {
1212
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply cursor movement is invalid.');
1213
+ }
1214
+ const rangeStart = Math.min(fromCursor, toCursor);
1215
+ const rangeEnd = Math.max(fromCursor, toCursor);
1216
+ const first = completed[rangeStart];
1217
+ if (!first) throw new FlexHarnessStoreFormatError('Snapshot pending apply has no target candidate.');
1218
+ const firstRunId = (first as Record<string, unknown>).runId;
1219
+ const start = rangeStart === 0
1220
+ ? 0
1221
+ : segments.findIndex((segment) =>
1222
+ (segment as Record<string, unknown>).runId === firstRunId);
1223
+ const next = completed[rangeEnd];
1163
1224
  const end = next
1164
1225
  ? segments.findIndex((segment) =>
1165
1226
  (segment as Record<string, unknown>).runId === (next as Record<string, unknown>).runId)
@@ -1211,12 +1272,259 @@ export function assertFlexProjectionSnapshotV2(
1211
1272
  }
1212
1273
  }
1213
1274
 
1275
+ function requireBoundedString(value: unknown, path: string, maxBytes: number): string {
1276
+ const result = requireString(value, path);
1277
+ if (Buffer.byteLength(result, 'utf8') > maxBytes) {
1278
+ throw new FlexHarnessStoreFormatError(`${path} exceeds its byte limit.`);
1279
+ }
1280
+ return result;
1281
+ }
1282
+
1283
+ function validateAffectedWorkspaces(value: unknown, path: string): void {
1284
+ if (!Array.isArray(value)) {
1285
+ throw new FlexHarnessStoreFormatError(`${path} must be an array.`);
1286
+ }
1287
+ if (value.length > FLEX_REVERSION_MAX_AFFECTED_WORKSPACES) {
1288
+ throw new FlexHarnessStoreFormatError(`${path} exceeds its item limit.`);
1289
+ }
1290
+ const ids = new Set<string>();
1291
+ for (let index = 0; index < value.length; index++) {
1292
+ const workspacePath = `${path}[${index}]`;
1293
+ const workspace = requireRecord(value[index], workspacePath);
1294
+ requireOnlyKeys(workspace, ['id', 'label'], workspacePath);
1295
+ const id = requireBoundedString(
1296
+ workspace.id,
1297
+ `${workspacePath}.id`,
1298
+ FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
1299
+ );
1300
+ if (!id.trim()) {
1301
+ throw new FlexHarnessStoreFormatError(`${workspacePath}.id must contain non-whitespace characters.`);
1302
+ }
1303
+ const label = requireBoundedString(
1304
+ workspace.label,
1305
+ `${workspacePath}.label`,
1306
+ FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
1307
+ );
1308
+ if (!label.trim()) {
1309
+ throw new FlexHarnessStoreFormatError(`${workspacePath}.label must contain non-whitespace characters.`);
1310
+ }
1311
+ if (ids.has(id)) {
1312
+ throw new FlexHarnessStoreFormatError(`${path} contains duplicate workspace "${id}".`);
1313
+ }
1314
+ ids.add(id);
1315
+ }
1316
+ }
1317
+
1318
+ export function assertFlexProjectionSnapshotV3(
1319
+ value: unknown,
1320
+ ): asserts value is IFlexProjectionSnapshotV3 {
1321
+ assertJsonSerializable(value, '$snapshot');
1322
+ const snapshot = requireRecord(value, '$snapshot');
1323
+ requireOnlyKeys(snapshot, [
1324
+ 'schemaVersion',
1325
+ 'revision',
1326
+ 'messages',
1327
+ 'stagedTerminals',
1328
+ 'reversionSegments',
1329
+ 'revertCursor',
1330
+ 'excludedRunIds',
1331
+ 'pendingReversion',
1332
+ 'pendingReversionReleases',
1333
+ ], '$snapshot');
1334
+ if (snapshot.schemaVersion !== 3) {
1335
+ throw new FlexHarnessStoreFormatError('Projection snapshot schemaVersion must be 3.');
1336
+ }
1337
+ if (!Array.isArray(snapshot.reversionSegments)) {
1338
+ throw new FlexHarnessStoreFormatError('Snapshot reversionSegments must be an array.');
1339
+ }
1340
+ const retainedCaptureIds = new Set<string>();
1341
+ for (let index = 0; index < snapshot.reversionSegments.length; index++) {
1342
+ const path = `$snapshot.reversionSegments[${index}]`;
1343
+ const segment = requireRecord(snapshot.reversionSegments[index], path);
1344
+ requireOnlyKeys(segment, [
1345
+ 'runId',
1346
+ 'userMessageId',
1347
+ 'status',
1348
+ 'contextAvailable',
1349
+ 'eventIds',
1350
+ 'workspaceCaptured',
1351
+ 'captureId',
1352
+ 'workspaceReference',
1353
+ 'protocolVersion',
1354
+ 'provenance',
1355
+ 'disposition',
1356
+ 'affectedWorkspaces',
1357
+ 'reasonCode',
1358
+ ], path);
1359
+ const protocolVersion = segment.protocolVersion;
1360
+ if (protocolVersion !== 1 && protocolVersion !== 2) {
1361
+ throw new FlexHarnessStoreFormatError(`${path}.protocolVersion is invalid.`);
1362
+ }
1363
+ if (segment.provenance !== 'transcript' && segment.provenance !== 'workspace') {
1364
+ throw new FlexHarnessStoreFormatError(`${path}.provenance is invalid.`);
1365
+ }
1366
+ if (segment.workspaceCaptured !== (segment.provenance === 'workspace')) {
1367
+ throw new FlexHarnessStoreFormatError(`${path}.workspaceCaptured does not match its provenance.`);
1368
+ }
1369
+ if (protocolVersion === 2 && segment.provenance !== 'workspace') {
1370
+ throw new FlexHarnessStoreFormatError(`${path} uses protocol 2 without workspace provenance.`);
1371
+ }
1372
+ if (protocolVersion === 1 && segment.affectedWorkspaces !== undefined) {
1373
+ throw new FlexHarnessStoreFormatError(`${path} protocol 1 contains affected workspace metadata.`);
1374
+ }
1375
+ const status = String(segment.status);
1376
+ const disposition = segment.disposition;
1377
+ const captureId = segment.captureId;
1378
+ if (captureId !== undefined) retainedCaptureIds.add(String(captureId));
1379
+ if (segment.affectedWorkspaces !== undefined) {
1380
+ validateAffectedWorkspaces(segment.affectedWorkspaces, `${path}.affectedWorkspaces`);
1381
+ }
1382
+ if (segment.reasonCode !== undefined) {
1383
+ const reasonCode = requireBoundedString(
1384
+ segment.reasonCode,
1385
+ `${path}.reasonCode`,
1386
+ FLEX_REVERSION_REASON_CODE_MAX_BYTES,
1387
+ );
1388
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(reasonCode)) {
1389
+ throw new FlexHarnessStoreFormatError(`${path}.reasonCode is invalid.`);
1390
+ }
1391
+ }
1392
+ if (segment.provenance === 'transcript') {
1393
+ if (
1394
+ disposition !== undefined
1395
+ || captureId !== undefined
1396
+ || segment.workspaceReference !== undefined
1397
+ || segment.affectedWorkspaces !== undefined
1398
+ || segment.reasonCode !== undefined
1399
+ ) throw new FlexHarnessStoreFormatError(`${path} transcript provenance contains workspace data.`);
1400
+ continue;
1401
+ }
1402
+ if (status === 'capturing') {
1403
+ if (disposition !== 'pending' || captureId === undefined || segment.workspaceReference !== undefined) {
1404
+ throw new FlexHarnessStoreFormatError(`${path} has invalid pending workspace ownership.`);
1405
+ }
1406
+ if (segment.affectedWorkspaces !== undefined || segment.reasonCode !== undefined) {
1407
+ throw new FlexHarnessStoreFormatError(`${path} pending workspace capture has terminal metadata.`);
1408
+ }
1409
+ continue;
1410
+ }
1411
+ if (protocolVersion === 1 && disposition !== 'revertible') {
1412
+ throw new FlexHarnessStoreFormatError(`${path} terminal protocol-1 workspace capture is not revertible.`);
1413
+ }
1414
+ if (protocolVersion === 2 && !['revertible', 'no-change', 'nonrevertible'].includes(String(disposition))) {
1415
+ throw new FlexHarnessStoreFormatError(`${path} terminal protocol-2 capture lacks a conclusive disposition.`);
1416
+ }
1417
+ if (disposition === 'revertible') {
1418
+ if (captureId === undefined || segment.workspaceReference === undefined) {
1419
+ throw new FlexHarnessStoreFormatError(`${path} revertible capture lacks retained ownership.`);
1420
+ }
1421
+ if (protocolVersion === 2 && segment.affectedWorkspaces === undefined) {
1422
+ throw new FlexHarnessStoreFormatError(`${path} revertible protocol-2 capture lacks affected workspaces.`);
1423
+ }
1424
+ if (segment.reasonCode !== undefined) {
1425
+ throw new FlexHarnessStoreFormatError(`${path} revertible capture has a reason code.`);
1426
+ }
1427
+ } else {
1428
+ if (captureId !== undefined || segment.workspaceReference !== undefined) {
1429
+ throw new FlexHarnessStoreFormatError(`${path} terminal disposition retains segment ownership.`);
1430
+ }
1431
+ if (disposition === 'no-change') {
1432
+ if (Array.isArray(segment.affectedWorkspaces) && segment.affectedWorkspaces.length > 0) {
1433
+ throw new FlexHarnessStoreFormatError(`${path} no-change capture affects a workspace.`);
1434
+ }
1435
+ if (segment.reasonCode !== undefined) {
1436
+ throw new FlexHarnessStoreFormatError(`${path} no-change capture has a reason code.`);
1437
+ }
1438
+ }
1439
+ if (disposition === 'nonrevertible' && segment.reasonCode === undefined) {
1440
+ throw new FlexHarnessStoreFormatError(`${path} nonrevertible capture lacks a reason code.`);
1441
+ }
1442
+ }
1443
+ }
1444
+ if (snapshot.pendingReversion !== undefined) {
1445
+ const pending = requireRecord(snapshot.pendingReversion, '$snapshot.pendingReversion');
1446
+ if (pending.kind === 'capture') {
1447
+ requireOnlyKeys(
1448
+ pending,
1449
+ ['kind', 'runId', 'captureId', 'state', 'protocolVersion'],
1450
+ '$snapshot.pendingReversion',
1451
+ );
1452
+ if (pending.protocolVersion !== 1 && pending.protocolVersion !== 2) {
1453
+ throw new FlexHarnessStoreFormatError('$snapshot.pendingReversion.protocolVersion is invalid.');
1454
+ }
1455
+ const matching = snapshot.reversionSegments.filter((entry) => {
1456
+ const segment = entry as Record<string, unknown>;
1457
+ return segment.runId === pending.runId
1458
+ && segment.captureId === pending.captureId
1459
+ && segment.protocolVersion === pending.protocolVersion
1460
+ && segment.status === 'capturing'
1461
+ && segment.disposition === 'pending';
1462
+ });
1463
+ if (matching.length !== 1) {
1464
+ throw new FlexHarnessStoreFormatError('Snapshot pending capture does not match exactly one segment.');
1465
+ }
1466
+ }
1467
+ }
1468
+ if (!Array.isArray(snapshot.pendingReversionReleases)) {
1469
+ throw new FlexHarnessStoreFormatError('Snapshot pendingReversionReleases must be an array.');
1470
+ }
1471
+ const releaseCaptureIds = new Set<string>();
1472
+ for (let index = 0; index < snapshot.pendingReversionReleases.length; index++) {
1473
+ const path = `$snapshot.pendingReversionReleases[${index}]`;
1474
+ const release = requireRecord(snapshot.pendingReversionReleases[index], path);
1475
+ requireOnlyKeys(release, ['runId', 'captureId', 'reference', 'protocolVersion'], path);
1476
+ const captureId = requireString(release.captureId, `${path}.captureId`);
1477
+ if (release.protocolVersion !== 1 && release.protocolVersion !== 2) {
1478
+ throw new FlexHarnessStoreFormatError(`${path}.protocolVersion is invalid.`);
1479
+ }
1480
+ if (releaseCaptureIds.has(captureId) || retainedCaptureIds.has(captureId)) {
1481
+ throw new FlexHarnessStoreFormatError(`${path} has duplicate durable capture ownership.`);
1482
+ }
1483
+ releaseCaptureIds.add(captureId);
1484
+ }
1485
+ const compatibleV2 = {
1486
+ ...snapshot,
1487
+ schemaVersion: 2,
1488
+ reversionSegments: snapshot.reversionSegments.map((entry) => {
1489
+ const segment = entry as Record<string, unknown>;
1490
+ return {
1491
+ runId: segment.runId,
1492
+ userMessageId: segment.userMessageId,
1493
+ status: segment.status,
1494
+ contextAvailable: segment.contextAvailable,
1495
+ eventIds: segment.eventIds,
1496
+ workspaceCaptured: segment.provenance === 'workspace'
1497
+ && (segment.status === 'capturing' || segment.disposition === 'revertible'),
1498
+ ...(segment.captureId === undefined ? {} : { captureId: segment.captureId }),
1499
+ ...(segment.workspaceReference === undefined
1500
+ ? {}
1501
+ : { workspaceReference: segment.workspaceReference }),
1502
+ };
1503
+ }),
1504
+ ...(snapshot.pendingReversion === undefined
1505
+ ? {}
1506
+ : {
1507
+ pendingReversion: (() => {
1508
+ const { protocolVersion: _protocolVersion, ...pending } = snapshot.pendingReversion as
1509
+ Record<string, unknown>;
1510
+ return pending;
1511
+ })(),
1512
+ }),
1513
+ pendingReversionReleases: snapshot.pendingReversionReleases.map((entry) => {
1514
+ const { protocolVersion: _protocolVersion, ...release } = entry as Record<string, unknown>;
1515
+ return release;
1516
+ }),
1517
+ };
1518
+ assertFlexProjectionSnapshotV2(compatibleV2);
1519
+ }
1520
+
1214
1521
  export function assertFlexProjectionSnapshot(
1215
1522
  value: unknown,
1216
- ): asserts value is IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 {
1523
+ ): asserts value is IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 | IFlexProjectionSnapshotV3 {
1217
1524
  const record = requireRecord(value, '$snapshot');
1218
1525
  if (record.schemaVersion === 1) assertFlexProjectionSnapshotV1(value);
1219
- else assertFlexProjectionSnapshotV2(value);
1526
+ else if (record.schemaVersion === 2) assertFlexProjectionSnapshotV2(value);
1527
+ else assertFlexProjectionSnapshotV3(value);
1220
1528
  }
1221
1529
 
1222
1530
  export function assertFlexPermissionSnapshot(
@@ -0,0 +1,226 @@
1
+ import {
2
+ FlexHarnessStoreFormatError,
3
+ } from './errors.js';
4
+ import {
5
+ FLEX_PROJECT_MANAGEMENT_LIMITS,
6
+ FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION,
7
+ FLEX_SESSION_GENERATION_ID_MAX_BYTES,
8
+ } from './interfaces.js';
9
+ import type {
10
+ IFlexProjectManagementSnapshot,
11
+ IFlexProjectManagementTombstone,
12
+ IFlexProjectTask,
13
+ TFlexProjectManagementRecord,
14
+ } from './interfaces.js';
15
+ import { assertJsonSerializable } from './utils.json.js';
16
+
17
+ function requireRecord(value: unknown, path: string): Record<string, unknown> {
18
+ if (
19
+ !value
20
+ || typeof value !== 'object'
21
+ || Array.isArray(value)
22
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
23
+ ) {
24
+ throw new FlexHarnessStoreFormatError(`${path} must be a plain object.`);
25
+ }
26
+ return value as Record<string, unknown>;
27
+ }
28
+
29
+ function requireOnlyKeys(
30
+ value: Record<string, unknown>,
31
+ keys: readonly string[],
32
+ path: string,
33
+ ): void {
34
+ const unsupported = Object.keys(value).find((key) => !keys.includes(key));
35
+ if (unsupported) {
36
+ throw new FlexHarnessStoreFormatError(`${path}.${unsupported} is not supported.`);
37
+ }
38
+ }
39
+
40
+ function requireBoundedString(
41
+ value: unknown,
42
+ path: string,
43
+ maxBytes: number,
44
+ nonEmpty = false,
45
+ ): asserts value is string {
46
+ if (
47
+ typeof value !== 'string'
48
+ || (nonEmpty && !value.trim())
49
+ || Buffer.byteLength(value, 'utf8') > maxBytes
50
+ ) {
51
+ throw new FlexHarnessStoreFormatError(
52
+ `${path} must be ${nonEmpty ? 'a non-empty ' : 'a '}string of at most ${maxBytes} UTF-8 bytes.`,
53
+ );
54
+ }
55
+ }
56
+
57
+ function requireTimestamp(value: unknown, path: string): asserts value is string {
58
+ if (typeof value !== 'string') {
59
+ throw new FlexHarnessStoreFormatError(`${path} must be a canonical ISO timestamp.`);
60
+ }
61
+ const timestamp = Date.parse(value);
62
+ if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== value) {
63
+ throw new FlexHarnessStoreFormatError(`${path} must be a canonical ISO timestamp.`);
64
+ }
65
+ }
66
+
67
+ function requireSessionGeneration(value: Record<string, unknown>, path: string): void {
68
+ requireBoundedString(
69
+ value.sessionGenerationId,
70
+ `${path}.sessionGenerationId`,
71
+ FLEX_SESSION_GENERATION_ID_MAX_BYTES,
72
+ true,
73
+ );
74
+ if (
75
+ !Number.isSafeInteger(value.sessionGenerationSequence)
76
+ || Number(value.sessionGenerationSequence) < 1
77
+ ) {
78
+ throw new FlexHarnessStoreFormatError(
79
+ `${path}.sessionGenerationSequence must be a positive integer.`,
80
+ );
81
+ }
82
+ }
83
+
84
+ function validateTask(value: unknown, path: string): IFlexProjectTask {
85
+ const task = requireRecord(value, path);
86
+ requireOnlyKeys(
87
+ task,
88
+ ['id', 'content', 'status', 'priority', 'createdAt', 'updatedAt'],
89
+ path,
90
+ );
91
+ requireBoundedString(task.id, `${path}.id`, FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskIdBytes, true);
92
+ requireBoundedString(
93
+ task.content,
94
+ `${path}.content`,
95
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskContentBytes,
96
+ true,
97
+ );
98
+ if (!['pending', 'in_progress', 'completed', 'cancelled'].includes(String(task.status))) {
99
+ throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
100
+ }
101
+ if (!['high', 'medium', 'low'].includes(String(task.priority))) {
102
+ throw new FlexHarnessStoreFormatError(`${path}.priority is invalid.`);
103
+ }
104
+ requireTimestamp(task.createdAt, `${path}.createdAt`);
105
+ requireTimestamp(task.updatedAt, `${path}.updatedAt`);
106
+ if (task.updatedAt < task.createdAt) {
107
+ throw new FlexHarnessStoreFormatError(`${path}.updatedAt cannot precede createdAt.`);
108
+ }
109
+ return task as unknown as IFlexProjectTask;
110
+ }
111
+
112
+ export function createEmptyFlexProjectManagementSnapshot(
113
+ sessionGenerationId: string,
114
+ sessionGenerationSequence: number,
115
+ ): IFlexProjectManagementSnapshot {
116
+ const snapshot: IFlexProjectManagementSnapshot = {
117
+ schemaVersion: FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION,
118
+ revision: 0,
119
+ sessionGenerationId,
120
+ sessionGenerationSequence,
121
+ scratchpad: '',
122
+ tasks: [],
123
+ };
124
+ assertFlexProjectManagementSnapshot(snapshot);
125
+ return snapshot;
126
+ }
127
+
128
+ export function assertFlexProjectManagementSnapshot(
129
+ value: unknown,
130
+ ): asserts value is IFlexProjectManagementSnapshot {
131
+ assertJsonSerializable(value, '$snapshot');
132
+ const snapshot = requireRecord(value, '$snapshot');
133
+ requireOnlyKeys(
134
+ snapshot,
135
+ [
136
+ 'schemaVersion',
137
+ 'revision',
138
+ 'sessionGenerationId',
139
+ 'sessionGenerationSequence',
140
+ 'goal',
141
+ 'scratchpad',
142
+ 'tasks',
143
+ ],
144
+ '$snapshot',
145
+ );
146
+ if (snapshot.schemaVersion !== FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION) {
147
+ throw new FlexHarnessStoreFormatError(
148
+ `Project management snapshot schemaVersion must be ${FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION}.`,
149
+ );
150
+ }
151
+ if (!Number.isSafeInteger(snapshot.revision) || Number(snapshot.revision) < 0) {
152
+ throw new FlexHarnessStoreFormatError('Project management snapshot revision must be a non-negative integer.');
153
+ }
154
+ requireSessionGeneration(snapshot, '$snapshot');
155
+ if (snapshot.goal !== undefined) {
156
+ requireBoundedString(
157
+ snapshot.goal,
158
+ '$snapshot.goal',
159
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
160
+ true,
161
+ );
162
+ }
163
+ requireBoundedString(
164
+ snapshot.scratchpad,
165
+ '$snapshot.scratchpad',
166
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
167
+ );
168
+ if (!Array.isArray(snapshot.tasks)) {
169
+ throw new FlexHarnessStoreFormatError('$snapshot.tasks must be an array.');
170
+ }
171
+ if (snapshot.tasks.length > FLEX_PROJECT_MANAGEMENT_LIMITS.maxTasks) {
172
+ throw new FlexHarnessStoreFormatError('$snapshot.tasks exceeds its task-count limit.');
173
+ }
174
+ const taskIds = new Set<string>();
175
+ for (let index = 0; index < snapshot.tasks.length; index++) {
176
+ const task = validateTask(snapshot.tasks[index], `$snapshot.tasks[${index}]`);
177
+ if (taskIds.has(task.id)) {
178
+ throw new FlexHarnessStoreFormatError(`$snapshot.tasks contains duplicate task "${task.id}".`);
179
+ }
180
+ taskIds.add(task.id);
181
+ }
182
+ if (Buffer.byteLength(JSON.stringify(snapshot), 'utf8') > FLEX_PROJECT_MANAGEMENT_LIMITS.maxSnapshotBytes) {
183
+ throw new FlexHarnessStoreFormatError('Project management snapshot exceeds its serialized byte limit.');
184
+ }
185
+ }
186
+
187
+ export function assertFlexProjectManagementTombstone(
188
+ value: unknown,
189
+ ): asserts value is IFlexProjectManagementTombstone {
190
+ assertJsonSerializable(value, '$tombstone');
191
+ const tombstone = requireRecord(value, '$tombstone');
192
+ requireOnlyKeys(
193
+ tombstone,
194
+ [
195
+ 'schemaVersion',
196
+ 'revision',
197
+ 'sessionGenerationId',
198
+ 'sessionGenerationSequence',
199
+ 'deletedAt',
200
+ ],
201
+ '$tombstone',
202
+ );
203
+ if (tombstone.schemaVersion !== FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION) {
204
+ throw new FlexHarnessStoreFormatError(
205
+ `Project management tombstone schemaVersion must be ${FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION}.`,
206
+ );
207
+ }
208
+ if (!Number.isSafeInteger(tombstone.revision) || Number(tombstone.revision) < 1) {
209
+ throw new FlexHarnessStoreFormatError(
210
+ 'Project management tombstone revision must be a positive integer.',
211
+ );
212
+ }
213
+ requireSessionGeneration(tombstone, '$tombstone');
214
+ requireTimestamp(tombstone.deletedAt, '$tombstone.deletedAt');
215
+ }
216
+
217
+ export function assertFlexProjectManagementRecord(
218
+ value: unknown,
219
+ ): asserts value is TFlexProjectManagementRecord {
220
+ const record = requireRecord(value, '$record');
221
+ if (Object.prototype.hasOwnProperty.call(record, 'deletedAt')) {
222
+ assertFlexProjectManagementTombstone(value);
223
+ } else {
224
+ assertFlexProjectManagementSnapshot(value);
225
+ }
226
+ }
@@ -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: [],