@modelprofile.com/flexharness 10.0.0 → 10.2.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +13 -1
- package/dist_ts/classes.flexharness.js +302 -188
- package/dist_ts/classes.stores.js +11 -119
- package/dist_ts/errors.d.ts +7 -2
- package/dist_ts/errors.js +1 -1
- package/dist_ts/interfaces.d.ts +18 -0
- package/dist_ts/interfaces.js +1 -1
- package/dist_ts/utils.storerules.d.ts +28 -0
- package/dist_ts/utils.storerules.js +107 -0
- package/dist_ts_chat_harness/classes.transcript.d.ts +103 -0
- package/dist_ts_chat_harness/classes.transcript.js +296 -0
- package/dist_ts_chat_harness/functions.intents.d.ts +62 -0
- package/dist_ts_chat_harness/functions.intents.js +96 -0
- package/dist_ts_chat_harness/functions.mapping.d.ts +33 -0
- package/dist_ts_chat_harness/functions.mapping.js +220 -0
- package/dist_ts_chat_harness/index.d.ts +3 -0
- package/dist_ts_chat_harness/index.js +4 -0
- package/dist_ts_chat_harness/plugins.d.ts +1 -0
- package/dist_ts_chat_harness/plugins.js +2 -0
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.d.ts +31 -0
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +312 -0
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +110 -0
- package/dist_ts_stores_nosqldb/classes.recordstore.js +437 -0
- package/dist_ts_stores_nosqldb/index.d.ts +2 -0
- package/dist_ts_stores_nosqldb/index.js +3 -0
- package/dist_ts_stores_nosqldb/plugins.d.ts +7 -0
- package/dist_ts_stores_nosqldb/plugins.js +10 -0
- package/package.json +24 -4
- package/readme.md +6 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +324 -205
- package/ts/classes.stores.ts +28 -172
- package/ts/errors.ts +8 -2
- package/ts/interfaces.ts +19 -0
- package/ts/readme.md +6 -0
- package/ts/utils.storerules.ts +177 -0
- package/ts_chat_harness/classes.transcript.ts +399 -0
- package/ts_chat_harness/functions.intents.ts +169 -0
- package/ts_chat_harness/functions.mapping.ts +267 -0
- package/ts_chat_harness/index.ts +33 -0
- package/ts_chat_harness/plugins.ts +18 -0
- package/ts_chat_harness/readme.md +117 -0
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +465 -0
- package/ts_stores_nosqldb/classes.recordstore.ts +560 -0
- package/ts_stores_nosqldb/index.ts +9 -0
- package/ts_stores_nosqldb/plugins.ts +38 -0
- package/ts_stores_nosqldb/readme.md +53 -0
package/ts/classes.stores.ts
CHANGED
|
@@ -35,8 +35,6 @@ import {
|
|
|
35
35
|
assertFlexProjectManagementTombstone,
|
|
36
36
|
} from './utils.projectmanagement.js';
|
|
37
37
|
import {
|
|
38
|
-
assertExactKeys,
|
|
39
|
-
assertRecord,
|
|
40
38
|
createAgentEventSnapshot,
|
|
41
39
|
createToolJobSnapshot,
|
|
42
40
|
normalizeJsonObjectUndefined,
|
|
@@ -44,101 +42,17 @@ import {
|
|
|
44
42
|
validateAgentEventSnapshot,
|
|
45
43
|
validateToolJobSnapshot,
|
|
46
44
|
} from './utils.storecodecs.js';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (typeof value !== 'string' || !value.trim()) {
|
|
59
|
-
throw new FlexHarnessValidationError(`${name} must be a non-empty string.`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function validateExpectedRevision(expectedRevision: number): void {
|
|
64
|
-
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
65
|
-
throw new FlexHarnessValidationError('expectedRevision must be a non-negative integer.');
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function validateProjectManagementWriteContext(
|
|
70
|
-
writeContext: IFlexProjectManagementWriteContext,
|
|
71
|
-
): void {
|
|
72
|
-
assertJsonSerializable(writeContext, '$writeContext');
|
|
73
|
-
assertRecord(writeContext, 'writeContext');
|
|
74
|
-
assertExactKeys(writeContext, ['actor', 'runId', 'agent', 'toolCallId'], 'writeContext');
|
|
75
|
-
if (writeContext.actor !== 'agent' && writeContext.actor !== 'application') {
|
|
76
|
-
throw new FlexHarnessValidationError('writeContext.actor must be agent or application.');
|
|
77
|
-
}
|
|
78
|
-
for (const key of ['runId', 'agent', 'toolCallId'] as const) {
|
|
79
|
-
if (writeContext[key] !== undefined) validateIdentifier(writeContext[key], `writeContext.${key}`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
|
|
84
|
-
snapshot: TSnapshotValue,
|
|
85
|
-
expectedRevision: number,
|
|
86
|
-
validator: TSnapshotValidator<TSnapshotValue>,
|
|
87
|
-
): TSnapshotValue {
|
|
88
|
-
validator(snapshot);
|
|
89
|
-
validateExpectedRevision(expectedRevision);
|
|
90
|
-
if (snapshot.revision !== expectedRevision + 1) {
|
|
91
|
-
throw new FlexHarnessStoreFormatError(
|
|
92
|
-
`Snapshot revision ${snapshot.revision} must equal expected revision ${expectedRevision} plus one.`,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
return cloneSerializable(snapshot);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function providerKey(storageKey: string, sessionId: string): string {
|
|
99
|
-
validateIdentifier(storageKey, 'storageKey');
|
|
100
|
-
validateIdentifier(sessionId, 'sessionId');
|
|
101
|
-
return JSON.stringify([storageKey, sessionId]);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function sameProjectManagementGeneration(
|
|
105
|
-
left: TFlexProjectManagementRecord,
|
|
106
|
-
right: TFlexProjectManagementRecord,
|
|
107
|
-
): boolean {
|
|
108
|
-
return left.sessionGenerationId === right.sessionGenerationId
|
|
109
|
-
&& left.sessionGenerationSequence === right.sessionGenerationSequence;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function canInitializeProjectManagementGeneration(
|
|
113
|
-
current: TFlexProjectManagementRecord,
|
|
114
|
-
next: TFlexProjectManagementRecord,
|
|
115
|
-
expectedRevision: number,
|
|
116
|
-
): boolean {
|
|
117
|
-
return 'deletedAt' in current
|
|
118
|
-
&& expectedRevision === 0
|
|
119
|
-
&& next.sessionGenerationId !== current.sessionGenerationId
|
|
120
|
-
&& next.sessionGenerationSequence > current.sessionGenerationSequence;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function assertProjectionSession(snapshot: TFlexProjectionSnapshot, sessionId: string): void {
|
|
124
|
-
for (const message of snapshot.messages) {
|
|
125
|
-
if (message.sessionId !== sessionId) {
|
|
126
|
-
throw new FlexHarnessStoreFormatError(
|
|
127
|
-
`Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
for (const terminal of snapshot.stagedTerminals) {
|
|
132
|
-
if (
|
|
133
|
-
terminal.userMessage.sessionId !== sessionId
|
|
134
|
-
|| terminal.assistantMessage.sessionId !== sessionId
|
|
135
|
-
) {
|
|
136
|
-
throw new FlexHarnessStoreFormatError(
|
|
137
|
-
`Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
45
|
+
import {
|
|
46
|
+
assertProjectionSession,
|
|
47
|
+
assertProjectManagementSaveAdmitted,
|
|
48
|
+
assertSameArchiveEvents,
|
|
49
|
+
isProjectManagementTombstoneAdmitted,
|
|
50
|
+
providerKey,
|
|
51
|
+
validateExpectedRevision,
|
|
52
|
+
validateIdentifier,
|
|
53
|
+
validateProjectManagementWriteContext,
|
|
54
|
+
validateSnapshotSave,
|
|
55
|
+
} from './utils.storerules.js';
|
|
142
56
|
|
|
143
57
|
class InMemoryScopeStore implements IFlexScopeStore {
|
|
144
58
|
private readonly snapshots = new Map<string, IFlexScopeSnapshot>();
|
|
@@ -253,15 +167,7 @@ export class InMemoryFlexProjectManagementStore implements IFlexProjectManagemen
|
|
|
253
167
|
expectedRevision,
|
|
254
168
|
assertFlexProjectManagementSnapshot,
|
|
255
169
|
);
|
|
256
|
-
|
|
257
|
-
const actualRevision = current?.revision ?? 0;
|
|
258
|
-
if (current && !sameProjectManagementGeneration(current, validated)) {
|
|
259
|
-
if (!canInitializeProjectManagementGeneration(current, validated, expectedRevision)) {
|
|
260
|
-
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
261
|
-
}
|
|
262
|
-
} else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
|
|
263
|
-
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
264
|
-
}
|
|
170
|
+
assertProjectManagementSaveAdmitted(key, this.snapshots.get(key), validated, expectedRevision);
|
|
265
171
|
this.snapshots.set(key, validated);
|
|
266
172
|
}
|
|
267
173
|
|
|
@@ -277,19 +183,8 @@ export class InMemoryFlexProjectManagementStore implements IFlexProjectManagemen
|
|
|
277
183
|
expectedRevision,
|
|
278
184
|
assertFlexProjectManagementTombstone,
|
|
279
185
|
);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (current && sameProjectManagementGeneration(current, validated)) {
|
|
283
|
-
if ('deletedAt' in current) return;
|
|
284
|
-
if (actualRevision !== expectedRevision) {
|
|
285
|
-
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
286
|
-
}
|
|
287
|
-
} else if (current) {
|
|
288
|
-
if (!canInitializeProjectManagementGeneration(current, validated, expectedRevision)) {
|
|
289
|
-
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
290
|
-
}
|
|
291
|
-
} else if (actualRevision !== expectedRevision) {
|
|
292
|
-
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
186
|
+
if (!isProjectManagementTombstoneAdmitted(key, this.snapshots.get(key), validated, expectedRevision)) {
|
|
187
|
+
return;
|
|
293
188
|
}
|
|
294
189
|
this.snapshots.set(key, validated);
|
|
295
190
|
}
|
|
@@ -346,12 +241,7 @@ class InMemoryAgentEventStore implements plugins.IAgentEventStoreV2 {
|
|
|
346
241
|
const validated = validateAgentEventArchive(normalized, this.sessionId, archive.archiveId);
|
|
347
242
|
const existing = this.archives.get(archive.archiveId);
|
|
348
243
|
if (existing) {
|
|
349
|
-
|
|
350
|
-
JSON.stringify(existing.events.map((event) => event.id))
|
|
351
|
-
!== JSON.stringify(validated.events.map((event) => event.id))
|
|
352
|
-
) {
|
|
353
|
-
throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
|
|
354
|
-
}
|
|
244
|
+
assertSameArchiveEvents(existing, validated);
|
|
355
245
|
return;
|
|
356
246
|
}
|
|
357
247
|
this.archives.set(archive.archiveId, cloneSerializable(validated));
|
|
@@ -1007,22 +897,12 @@ class JsonProjectManagementStore implements IFlexProjectManagementStore {
|
|
|
1007
897
|
assertFlexProjectManagementRecord(value);
|
|
1008
898
|
return value;
|
|
1009
899
|
});
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
actualRevision,
|
|
1017
|
-
);
|
|
1018
|
-
}
|
|
1019
|
-
} else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
|
|
1020
|
-
throw new FlexHarnessStoreConflictError(
|
|
1021
|
-
providerKey(storageKey, sessionId),
|
|
1022
|
-
expectedRevision,
|
|
1023
|
-
actualRevision,
|
|
1024
|
-
);
|
|
1025
|
-
}
|
|
900
|
+
assertProjectManagementSaveAdmitted(
|
|
901
|
+
providerKey(storageKey, sessionId),
|
|
902
|
+
current,
|
|
903
|
+
validated,
|
|
904
|
+
expectedRevision,
|
|
905
|
+
);
|
|
1026
906
|
await this.root.write(filePath, validated);
|
|
1027
907
|
}),
|
|
1028
908
|
);
|
|
@@ -1046,31 +926,12 @@ class JsonProjectManagementStore implements IFlexProjectManagementStore {
|
|
|
1046
926
|
assertFlexProjectManagementRecord(value);
|
|
1047
927
|
return value;
|
|
1048
928
|
});
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
expectedRevision,
|
|
1056
|
-
actualRevision,
|
|
1057
|
-
);
|
|
1058
|
-
}
|
|
1059
|
-
} else if (current) {
|
|
1060
|
-
if (!canInitializeProjectManagementGeneration(current, validated, expectedRevision)) {
|
|
1061
|
-
throw new FlexHarnessStoreConflictError(
|
|
1062
|
-
providerKey(storageKey, sessionId),
|
|
1063
|
-
expectedRevision,
|
|
1064
|
-
actualRevision,
|
|
1065
|
-
);
|
|
1066
|
-
}
|
|
1067
|
-
} else if (actualRevision !== expectedRevision) {
|
|
1068
|
-
throw new FlexHarnessStoreConflictError(
|
|
1069
|
-
providerKey(storageKey, sessionId),
|
|
1070
|
-
expectedRevision,
|
|
1071
|
-
actualRevision,
|
|
1072
|
-
);
|
|
1073
|
-
}
|
|
929
|
+
if (!isProjectManagementTombstoneAdmitted(
|
|
930
|
+
providerKey(storageKey, sessionId),
|
|
931
|
+
current,
|
|
932
|
+
validated,
|
|
933
|
+
expectedRevision,
|
|
934
|
+
)) return;
|
|
1074
935
|
await this.root.write(filePath, validated);
|
|
1075
936
|
}),
|
|
1076
937
|
);
|
|
@@ -1163,12 +1024,7 @@ class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
|
|
|
1163
1024
|
(value) => validateAgentEventArchive(value, this.sessionId, archive.archiveId),
|
|
1164
1025
|
);
|
|
1165
1026
|
if (current) {
|
|
1166
|
-
|
|
1167
|
-
JSON.stringify(current.events.map((event) => event.id))
|
|
1168
|
-
!== JSON.stringify(validated.events.map((event) => event.id))
|
|
1169
|
-
) {
|
|
1170
|
-
throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
|
|
1171
|
-
}
|
|
1027
|
+
assertSameArchiveEvents(current, validated);
|
|
1172
1028
|
return;
|
|
1173
1029
|
}
|
|
1174
1030
|
await this.root.write(filePath, validated);
|
package/ts/errors.ts
CHANGED
|
@@ -173,13 +173,19 @@ export class FlexHarnessStoreFormatError extends FlexHarnessError {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* What a store was doing when it could not confirm the outcome: `write` and `delete` name a
|
|
178
|
+
* database record, `delete-file` and `delete-directory` a file-system path.
|
|
179
|
+
*/
|
|
180
|
+
export type TFlexStoreCommitOperation = 'write' | 'delete' | 'delete-file' | 'delete-directory';
|
|
181
|
+
|
|
176
182
|
export class FlexHarnessStoreCommitUncertainError extends FlexHarnessError {
|
|
177
183
|
public readonly path: string;
|
|
178
|
-
public readonly operation:
|
|
184
|
+
public readonly operation: TFlexStoreCommitOperation;
|
|
179
185
|
|
|
180
186
|
constructor(
|
|
181
187
|
path: string,
|
|
182
|
-
operation:
|
|
188
|
+
operation: TFlexStoreCommitOperation,
|
|
183
189
|
options: ErrorOptions,
|
|
184
190
|
) {
|
|
185
191
|
super(
|
package/ts/interfaces.ts
CHANGED
|
@@ -513,6 +513,25 @@ export interface IFlexMessagePage {
|
|
|
513
513
|
nextCursor?: string;
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
/**
|
|
517
|
+
* A session as the events up to `sequence` describe it, read in one step. Replaying the events of
|
|
518
|
+
* the same harness instance after `sequence` onto it gives the session's later states.
|
|
519
|
+
*/
|
|
520
|
+
export interface IFlexSessionView {
|
|
521
|
+
/** Current metadata. Its status can be ahead of `sequence`: status changes emit no event. */
|
|
522
|
+
session: IFlexSession;
|
|
523
|
+
/** The visible messages, including what a running turn has streamed so far. */
|
|
524
|
+
messages: IFlexMessage[];
|
|
525
|
+
/**
|
|
526
|
+
* The session's pending permission requests, oldest first. A request its run leaves unanswered,
|
|
527
|
+
* because the run was stopped or failed, leaves this list without an event of its own; the run's
|
|
528
|
+
* `run.finished` follows.
|
|
529
|
+
*/
|
|
530
|
+
pendingPermissions: IFlexPermissionRequest[];
|
|
531
|
+
/** The `sequence` of the instance's last event this view includes; 0 before the first event. */
|
|
532
|
+
sequence: number;
|
|
533
|
+
}
|
|
534
|
+
|
|
516
535
|
export interface IFlexResolvedScope<TScope> {
|
|
517
536
|
storageKey: string;
|
|
518
537
|
scope: TScope;
|
package/ts/readme.md
CHANGED
|
@@ -61,10 +61,12 @@ or provider SDKs.
|
|
|
61
61
|
| `/chat` | Portable streaming `ChatSession`, history and usage | None |
|
|
62
62
|
| `/chat/cli` | Ink/React terminal chat | `ink ink-text-input react`; TypeScript: `@types/react` |
|
|
63
63
|
| `/chat/web` | Lit `flexchat-window`, `flexchat-message`, `flexchat-input` | `lit` |
|
|
64
|
+
| `/chat/harness` | `FlexHarnessTranscript` and intents for the `@design.estate/dees-catalog` harness components, browser-safe | `@design.estate/dees-catalog` (types only) |
|
|
64
65
|
| `/kvm` | Browser control, terminal framing and OCR observation | `puppeteer` |
|
|
65
66
|
| `/documents` | PDF and image documents, `extractTextFromPdf` | `@push.rocks/smartpdf`; for image documents `sharp` |
|
|
66
67
|
| `/mcp` | MCP clients and AI SDK tool conversion | `@modelcontextprotocol/sdk` |
|
|
67
68
|
| `/migration` | Existing versioned harness migrations | None |
|
|
69
|
+
| `/stores/nosqldb` | `NoSqlFlexHarnessStores`: cross-process CAS stores on `@lossless.org/client/nosqldb` | `@lossless.org/client` |
|
|
68
70
|
|
|
69
71
|
The integrations in the last column use optional peer dependencies. Install them
|
|
70
72
|
only for the entrypoints your project imports, for example:
|
|
@@ -1032,6 +1034,8 @@ unsubscribe();
|
|
|
1032
1034
|
|
|
1033
1035
|
Events are discriminated, deeply immutable values with one global sequence within each `FlexHarness` instance. Every `IFlexEventBase` carries the exact source `sessionGenerationId` and `sessionGenerationSequence`; `session.deleted` retains the deleted generation after live metadata is removed. Listener exceptions are isolated from runs and other listeners. Every accepted queue entry emits `prompt.queued` and exactly one `prompt.finished`. Every accepted steer emits `prompt.steer.accepted`, and `prompt.steer.applied`, after the `message.*` events of its user message, once the run took it in; `run.finished` lists it in `unappliedSteerIds` unless it is in the model context of later turns. Durable promotion additionally emits `prompt.started`, and actual model preparation emits `prompt.running`; cancellation or failure can omit either intermediate event. Existing durable run/message terminal events precede `prompt.finished`. Every callback-backed streamed text part emits exactly one `part.completed` event before the corresponding `run.finished` event. Every `part.started`, `part.delta`, `part.updated`, and `part.completed` event carries zero-based `messageIndex` and `partIndex` coordinates from the session's authoritative message and part sequences.
|
|
1034
1036
|
|
|
1037
|
+
`getSessionView(scopeId, sessionId)` reads a session's metadata, visible messages and pending permission requests in one step, together with the instance's event `sequence` they include. The messages include what a running turn has streamed so far, so a consumer that applies every event of the instance with a greater `sequence` follows the session exactly from the view on; events up to `sequence` are already in it. The read waits while a queued write or a change that emits events for the session is in progress; a session that keeps changing through 64 consecutive waits is refused with `FlexHarnessSessionBusyError`, and the read can be repeated. Session metadata is current: status changes such as `waiting_permission` emit no event. A permission request its run leaves unanswered leaves `pendingPermissions` without an event of its own; the run's `run.finished` follows.
|
|
1038
|
+
|
|
1035
1039
|
`part.started`, `part.updated`, and `part.completed` are snapshot events with a complete immutable `part`. A newly streamed text part emits `part.started` with empty text before its first delta; reasoning parts also start empty. `part.delta` is a separate delta-only event: it has no cumulative `part`, and carries `partType`, the required exact source `delta`, and `baseTextUtf8Bytes`/`textUtf8Bytes` for the cumulative text before and after that delta. The counters remain correct when a UTF-16 surrogate pair is split across callbacks. Exact deltas are never truncated, including a single delta above the 96 KiB message-transfer text limit. `part.updated` remains a cumulative replacement snapshot for running tool output or metadata, not a text delta. `session.history.changed` carries `direction: 'undo' | 'redo' | 'branch'`; `runId` is present for undo and redo. Events contain public IDs, exact delta payloads, and public snapshots only; they do not expose prompt payloads, the resolved scope object, storage key, model object, provider options, or raw storage key.
|
|
1036
1040
|
|
|
1037
1041
|
Part events narrow through the exported `TFlexPartEvent` union. `IFlexPartEventBase` contains their shared coordinates, `IFlexPartSnapshotEvent` owns snapshot events and their complete `part`, and `IFlexPartDeltaEvent` owns exact delta-only events and their UTF-8 counters.
|
|
@@ -1068,6 +1072,8 @@ After every harness using a `JsonFileFlexHarnessStores` instance has been dispos
|
|
|
1068
1072
|
|
|
1069
1073
|
The JSON stores are explicitly not cross-process safe. When several processes can access the same storage namespace, every core `IFlexHarnessStores` domain and `stores.projectManagement` must use database-backed or equivalent cross-process CAS. Process-local CAS for the core stores or for PM alone is insufficient: session generation creation, cleanup tombstones, PM replacement, and stale-writer rejection must all retain their respective atomic preconditions across processes.
|
|
1070
1074
|
|
|
1075
|
+
`NoSqlFlexHarnessStores` from `/stores/nosqldb` is that database-backed bundle for every domain. Pass it an initialized `SmartdataDb` and `await stores.prepare()` at startup, before the first harness uses it; the subpath readme describes chunked snapshots and failure reporting.
|
|
1076
|
+
|
|
1071
1077
|
Direct store operations and non-run session mutations surface conflicts as `FlexHarnessStoreConflictError` or the corresponding AgentSession store conflict. Malformed, wrong-schema, or non-JSON snapshots are surfaced as `FlexHarnessStoreFormatError`. A write or deletion that changed its target but cannot confirm parent-directory durability surfaces `FlexHarnessStoreCommitUncertainError` with the affected path, operation, and cause. Run persistence failures cross the external error boundary and therefore become `FlexHarnessExternalError`. FlexHarness does not merge conflicts.
|
|
1072
1078
|
|
|
1073
1079
|
Each domain serializes its own mutations. A successful run first persists a hidden completed projection, then finalizes the canonical Agent generation as `accepted`, then promotes the hidden projection publicly. Recovery uses the canonical generation outcome to promote an accepted stage or publish a failed/cancelled projection. Failed and cancelled generations remain auditable but never enter future model context.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
FlexHarnessStoreConflictError,
|
|
4
|
+
FlexHarnessStoreFormatError,
|
|
5
|
+
FlexHarnessValidationError,
|
|
6
|
+
} from './errors.js';
|
|
7
|
+
import type {
|
|
8
|
+
IFlexPermissionSnapshot,
|
|
9
|
+
IFlexProjectManagementSnapshot,
|
|
10
|
+
IFlexProjectManagementTombstone,
|
|
11
|
+
IFlexProjectManagementWriteContext,
|
|
12
|
+
IFlexScopeSnapshot,
|
|
13
|
+
TFlexProjectionSnapshot,
|
|
14
|
+
TFlexProjectManagementRecord,
|
|
15
|
+
} from './interfaces.js';
|
|
16
|
+
import { assertJsonSerializable, cloneSerializable } from './utils.json.js';
|
|
17
|
+
import { assertExactKeys, assertRecord } from './utils.storecodecs.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Store-independent admission rules every `IFlexHarnessStores` implementation applies: argument
|
|
21
|
+
* validation, the next-revision rule, exact session keys, and the project-management generation
|
|
22
|
+
* fence. Backends own only how they read, compare and write.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type TSnapshot =
|
|
26
|
+
| IFlexScopeSnapshot
|
|
27
|
+
| TFlexProjectionSnapshot
|
|
28
|
+
| IFlexPermissionSnapshot
|
|
29
|
+
| TFlexProjectManagementRecord;
|
|
30
|
+
type TSnapshotValidator<TSnapshotValue extends TSnapshot> = (
|
|
31
|
+
value: unknown,
|
|
32
|
+
) => asserts value is TSnapshotValue;
|
|
33
|
+
|
|
34
|
+
export function validateIdentifier(value: string, name: string): void {
|
|
35
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
36
|
+
throw new FlexHarnessValidationError(`${name} must be a non-empty string.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateExpectedRevision(expectedRevision: number): void {
|
|
41
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
42
|
+
throw new FlexHarnessValidationError('expectedRevision must be a non-negative integer.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateProjectManagementWriteContext(
|
|
47
|
+
writeContext: IFlexProjectManagementWriteContext,
|
|
48
|
+
): void {
|
|
49
|
+
assertJsonSerializable(writeContext, '$writeContext');
|
|
50
|
+
assertRecord(writeContext, 'writeContext');
|
|
51
|
+
assertExactKeys(writeContext, ['actor', 'runId', 'agent', 'toolCallId'], 'writeContext');
|
|
52
|
+
if (writeContext.actor !== 'agent' && writeContext.actor !== 'application') {
|
|
53
|
+
throw new FlexHarnessValidationError('writeContext.actor must be agent or application.');
|
|
54
|
+
}
|
|
55
|
+
for (const key of ['runId', 'agent', 'toolCallId'] as const) {
|
|
56
|
+
if (writeContext[key] !== undefined) validateIdentifier(writeContext[key], `writeContext.${key}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
|
|
61
|
+
snapshot: TSnapshotValue,
|
|
62
|
+
expectedRevision: number,
|
|
63
|
+
validator: TSnapshotValidator<TSnapshotValue>,
|
|
64
|
+
): TSnapshotValue {
|
|
65
|
+
validator(snapshot);
|
|
66
|
+
validateExpectedRevision(expectedRevision);
|
|
67
|
+
if (snapshot.revision !== expectedRevision + 1) {
|
|
68
|
+
throw new FlexHarnessStoreFormatError(
|
|
69
|
+
`Snapshot revision ${snapshot.revision} must equal expected revision ${expectedRevision} plus one.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return cloneSerializable(snapshot);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function providerKey(storageKey: string, sessionId: string): string {
|
|
76
|
+
validateIdentifier(storageKey, 'storageKey');
|
|
77
|
+
validateIdentifier(sessionId, 'sessionId');
|
|
78
|
+
return JSON.stringify([storageKey, sessionId]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function assertProjectionSession(snapshot: TFlexProjectionSnapshot, sessionId: string): void {
|
|
82
|
+
for (const message of snapshot.messages) {
|
|
83
|
+
if (message.sessionId !== sessionId) {
|
|
84
|
+
throw new FlexHarnessStoreFormatError(
|
|
85
|
+
`Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const terminal of snapshot.stagedTerminals) {
|
|
90
|
+
if (
|
|
91
|
+
terminal.userMessage.sessionId !== sessionId
|
|
92
|
+
|| terminal.assistantMessage.sessionId !== sessionId
|
|
93
|
+
) {
|
|
94
|
+
throw new FlexHarnessStoreFormatError(
|
|
95
|
+
`Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sameProjectManagementGeneration(
|
|
102
|
+
left: TFlexProjectManagementRecord,
|
|
103
|
+
right: TFlexProjectManagementRecord,
|
|
104
|
+
): boolean {
|
|
105
|
+
return left.sessionGenerationId === right.sessionGenerationId
|
|
106
|
+
&& left.sessionGenerationSequence === right.sessionGenerationSequence;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function canInitializeProjectManagementGeneration(
|
|
110
|
+
current: TFlexProjectManagementRecord,
|
|
111
|
+
next: TFlexProjectManagementRecord,
|
|
112
|
+
expectedRevision: number,
|
|
113
|
+
): boolean {
|
|
114
|
+
return 'deletedAt' in current
|
|
115
|
+
&& expectedRevision === 0
|
|
116
|
+
&& next.sessionGenerationId !== current.sessionGenerationId
|
|
117
|
+
&& next.sessionGenerationSequence > current.sessionGenerationSequence;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Admits a live project-management save over the stored record: a CAS save within the stored
|
|
122
|
+
* generation, or a newer generation that replaces a tombstone at expected revision 0.
|
|
123
|
+
*/
|
|
124
|
+
export function assertProjectManagementSaveAdmitted(
|
|
125
|
+
key: string,
|
|
126
|
+
current: TFlexProjectManagementRecord | undefined,
|
|
127
|
+
snapshot: IFlexProjectManagementSnapshot,
|
|
128
|
+
expectedRevision: number,
|
|
129
|
+
): void {
|
|
130
|
+
const actualRevision = current?.revision ?? 0;
|
|
131
|
+
if (current && !sameProjectManagementGeneration(current, snapshot)) {
|
|
132
|
+
if (!canInitializeProjectManagementGeneration(current, snapshot, expectedRevision)) {
|
|
133
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
134
|
+
}
|
|
135
|
+
} else if ((current && 'deletedAt' in current) || actualRevision !== expectedRevision) {
|
|
136
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Admits a project-management tombstone over the stored record. Returns false when the same
|
|
142
|
+
* generation is already tombstoned, which makes a repeated cleanup idempotent.
|
|
143
|
+
*/
|
|
144
|
+
export function isProjectManagementTombstoneAdmitted(
|
|
145
|
+
key: string,
|
|
146
|
+
current: TFlexProjectManagementRecord | undefined,
|
|
147
|
+
tombstone: IFlexProjectManagementTombstone,
|
|
148
|
+
expectedRevision: number,
|
|
149
|
+
): boolean {
|
|
150
|
+
const actualRevision = current?.revision ?? 0;
|
|
151
|
+
if (current && sameProjectManagementGeneration(current, tombstone)) {
|
|
152
|
+
if ('deletedAt' in current) return false;
|
|
153
|
+
if (actualRevision !== expectedRevision) {
|
|
154
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
155
|
+
}
|
|
156
|
+
} else if (current) {
|
|
157
|
+
if (!canInitializeProjectManagementGeneration(current, tombstone, expectedRevision)) {
|
|
158
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
159
|
+
}
|
|
160
|
+
} else if (actualRevision !== expectedRevision) {
|
|
161
|
+
throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An archive is written once; repeating it with the same events is a no-op. */
|
|
167
|
+
export function assertSameArchiveEvents(
|
|
168
|
+
existing: plugins.IAgentEventArchiveV2,
|
|
169
|
+
next: plugins.IAgentEventArchiveV2,
|
|
170
|
+
): void {
|
|
171
|
+
if (
|
|
172
|
+
JSON.stringify(existing.events.map((event) => event.id))
|
|
173
|
+
!== JSON.stringify(next.events.map((event) => event.id))
|
|
174
|
+
) {
|
|
175
|
+
throw new Error(`Archive "${next.archiveId}" already exists with different events.`);
|
|
176
|
+
}
|
|
177
|
+
}
|