@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/changelog.md +21 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +65 -2
- package/dist_ts/classes.flexharness.js +1401 -222
- package/dist_ts/classes.stores.d.ts +10 -1
- package/dist_ts/classes.stores.js +164 -2
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.d.ts +184 -6
- package/dist_ts/interfaces.js +16 -1
- package/dist_ts/utils.json.d.ts +3 -2
- package/dist_ts/utils.json.js +278 -12
- package/dist_ts/utils.projectmanagement.d.ts +5 -0
- package/dist_ts/utils.projectmanagement.js +141 -0
- package/dist_ts_migration/v3_legacyflexharness.js +1 -1
- package/package.json +1 -1
- package/readme.hints.md +7 -4
- package/readme.md +207 -25
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +2024 -255
- package/ts/classes.stores.ts +265 -6
- package/ts/index.ts +6 -0
- package/ts/interfaces.ts +254 -6
- package/ts/utils.json.ts +321 -13
- package/ts/utils.projectmanagement.ts +226 -0
- package/ts_migration/v3_legacyflexharness.ts +1 -1
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
|
-
[
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
+
}
|