@modelprofile.com/flexharness 2.1.0 → 3.0.1
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 +66 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +86 -30
- package/dist_ts/classes.flexharness.js +2593 -1131
- package/dist_ts/classes.stores.d.ts +19 -20
- package/dist_ts/classes.stores.js +968 -75
- package/dist_ts/errors.d.ts +5 -0
- package/dist_ts/errors.js +11 -1
- package/dist_ts/interfaces.d.ts +130 -23
- package/dist_ts/plugins.d.ts +3 -3
- package/dist_ts/plugins.js +3 -3
- package/dist_ts/utils.json.d.ts +4 -2
- package/dist_ts/utils.json.js +283 -140
- package/dist_ts/utils.prompt.d.ts +1 -2
- package/dist_ts/utils.prompt.js +3 -26
- package/dist_ts_migration/index.d.ts +1 -0
- package/dist_ts_migration/index.js +2 -0
- package/dist_ts_migration/v3_legacyflexharness.d.ts +17 -0
- package/dist_ts_migration/v3_legacyflexharness.js +426 -0
- package/package.json +17 -3
- package/readme.hints.md +13 -11
- package/readme.md +94 -27
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +2966 -1421
- package/ts/classes.stores.ts +1226 -97
- package/ts/errors.ts +20 -0
- package/ts/interfaces.ts +168 -22
- package/ts/plugins.ts +46 -3
- package/ts/utils.json.ts +341 -154
- package/ts/utils.prompt.ts +6 -27
- package/ts_migration/index.ts +1 -0
- package/ts_migration/v3_legacyflexharness.ts +620 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import * as plugins from '../ts/plugins.js';
|
|
2
|
+
import { FlexHarnessStoreFormatError, FlexHarnessValidationError } from '../ts/errors.js';
|
|
3
|
+
import type {
|
|
4
|
+
IFlexHarnessStores,
|
|
5
|
+
IFlexMessage,
|
|
6
|
+
IFlexPermissionSnapshot,
|
|
7
|
+
IFlexProjectionSnapshot,
|
|
8
|
+
IFlexScopeSnapshot,
|
|
9
|
+
IFlexSession,
|
|
10
|
+
TFlexAgentModelMessage,
|
|
11
|
+
TJsonValue,
|
|
12
|
+
} from '../ts/interfaces.js';
|
|
13
|
+
import {
|
|
14
|
+
assertFlexPermissionSnapshot,
|
|
15
|
+
assertFlexProjectionSnapshot,
|
|
16
|
+
assertFlexScopeSnapshot,
|
|
17
|
+
assertJsonSerializable,
|
|
18
|
+
cloneSerializable,
|
|
19
|
+
} from '../ts/utils.json.js';
|
|
20
|
+
|
|
21
|
+
export interface IFlexLegacyStoredSessionSnapshot {
|
|
22
|
+
session: IFlexSession;
|
|
23
|
+
messages: IFlexMessage[];
|
|
24
|
+
modelHistory: TJsonValue[];
|
|
25
|
+
rememberedPermissionKeys: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IFlexLegacyHarnessSnapshot {
|
|
29
|
+
schemaVersion: 1;
|
|
30
|
+
revision: number;
|
|
31
|
+
sessions: IFlexLegacyStoredSessionSnapshot[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IFlexLegacyHarnessStore {
|
|
35
|
+
load(storageKey: string): Promise<IFlexLegacyHarnessSnapshot | undefined>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ILegacyRun {
|
|
39
|
+
runId: string;
|
|
40
|
+
status: 'completed' | 'failed' | 'cancelled';
|
|
41
|
+
userMessage: IFlexMessage;
|
|
42
|
+
assistantMessage: IFlexMessage;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ILegacySessionMigrationPlan {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
projectionSnapshot: IFlexProjectionSnapshot;
|
|
48
|
+
permissionSnapshot: IFlexPermissionSnapshot;
|
|
49
|
+
agentEvents: plugins.TAgentEvent[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ILegacyMigrationPlan {
|
|
53
|
+
scopeSnapshot: IFlexScopeSnapshot;
|
|
54
|
+
sessions: ILegacySessionMigrationPlan[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface IInspectedSessionDestination {
|
|
58
|
+
plan: ILegacySessionMigrationPlan;
|
|
59
|
+
projection: IFlexProjectionSnapshot | undefined;
|
|
60
|
+
permission: IFlexPermissionSnapshot | undefined;
|
|
61
|
+
eventStore: plugins.IAgentEventStoreV2;
|
|
62
|
+
eventSnapshot: plugins.IAgentEventSnapshotV2 | undefined;
|
|
63
|
+
jobSnapshot: plugins.IToolJobSnapshot | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface IMigrationProviderOwnership {
|
|
67
|
+
agentEvents: Set<string>;
|
|
68
|
+
jobs: Set<string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requireRecord(value: unknown, path: string): Record<string, unknown> {
|
|
72
|
+
if (
|
|
73
|
+
!value
|
|
74
|
+
|| typeof value !== 'object'
|
|
75
|
+
|| Array.isArray(value)
|
|
76
|
+
|| (Object.getPrototypeOf(value) !== Object.prototype
|
|
77
|
+
&& Object.getPrototypeOf(value) !== null)
|
|
78
|
+
) {
|
|
79
|
+
throw new FlexHarnessStoreFormatError(`${path} must be a plain object.`);
|
|
80
|
+
}
|
|
81
|
+
return value as Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requireOnlyKeys(
|
|
85
|
+
value: Record<string, unknown>,
|
|
86
|
+
allowedKeys: readonly string[],
|
|
87
|
+
path: string,
|
|
88
|
+
): void {
|
|
89
|
+
const unexpectedKey = Object.keys(value).find((key) => !allowedKeys.includes(key));
|
|
90
|
+
if (unexpectedKey) {
|
|
91
|
+
throw new FlexHarnessStoreFormatError(`${path}.${unexpectedKey} is not supported.`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requireNonNegativeInteger(value: unknown, path: string): void {
|
|
96
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0) {
|
|
97
|
+
throw new FlexHarnessStoreFormatError(`${path} must be a non-negative integer.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function deterministicDigest(...values: unknown[]): string {
|
|
102
|
+
return plugins.crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function deterministicEventId(
|
|
106
|
+
storageKey: string,
|
|
107
|
+
sessionId: string,
|
|
108
|
+
...coordinates: unknown[]
|
|
109
|
+
): string {
|
|
110
|
+
return `legacy_${deterministicDigest(storageKey, sessionId, ...coordinates)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function deterministicTimestamp(value: string, path: string): number {
|
|
114
|
+
if (!/(?:Z|[+-]\d{2}:\d{2})$/i.test(value)) {
|
|
115
|
+
throw new FlexHarnessStoreFormatError(`${path} must include an explicit timezone.`);
|
|
116
|
+
}
|
|
117
|
+
const timestamp = Date.parse(value);
|
|
118
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
119
|
+
throw new FlexHarnessStoreFormatError(`${path} is invalid.`);
|
|
120
|
+
}
|
|
121
|
+
return timestamp;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function canonicalJson(value: unknown): string {
|
|
125
|
+
const canonicalize = (current: unknown): unknown => {
|
|
126
|
+
if (Array.isArray(current)) return current.map(canonicalize);
|
|
127
|
+
if (current && typeof current === 'object') {
|
|
128
|
+
return Object.fromEntries(
|
|
129
|
+
Object.entries(current)
|
|
130
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
131
|
+
.map(([key, entry]) => [key, canonicalize(entry)]),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return current;
|
|
135
|
+
};
|
|
136
|
+
return JSON.stringify(canonicalize(value));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function withoutRevision<TSnapshot extends { revision: number }>(
|
|
140
|
+
snapshot: TSnapshot,
|
|
141
|
+
): Omit<TSnapshot, 'revision'> {
|
|
142
|
+
const { revision: _revision, ...content } = snapshot;
|
|
143
|
+
return content;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertSameContent(actual: unknown, expected: unknown, destination: string): void {
|
|
147
|
+
if (canonicalJson(actual) !== canonicalJson(expected)) {
|
|
148
|
+
throw new FlexHarnessStoreFormatError(
|
|
149
|
+
`Legacy migration destination ${destination} already contains different content.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const interruptedMessageError = 'The process stopped before this message completed.';
|
|
155
|
+
const interruptedToolError = 'The process stopped before this tool call completed.';
|
|
156
|
+
|
|
157
|
+
function repairLegacyInterruptedSession(
|
|
158
|
+
stored: IFlexLegacyStoredSessionSnapshot,
|
|
159
|
+
path: string,
|
|
160
|
+
): void {
|
|
161
|
+
const timestampValues = [
|
|
162
|
+
stored.session.createdAt,
|
|
163
|
+
stored.session.updatedAt,
|
|
164
|
+
stored.session.archivedAt,
|
|
165
|
+
...stored.messages.flatMap((message) => [message.createdAt, message.completedAt]),
|
|
166
|
+
].flatMap((value) => value === undefined ? [] : [value]);
|
|
167
|
+
const recoveredAt = new Date(Math.max(...timestampValues.map((value, index) => (
|
|
168
|
+
deterministicTimestamp(value, `${path}.recoveryTimestamps[${index}]`)
|
|
169
|
+
)))).toISOString();
|
|
170
|
+
if (
|
|
171
|
+
stored.session.status === 'running'
|
|
172
|
+
|| stored.session.status === 'waiting_permission'
|
|
173
|
+
) {
|
|
174
|
+
stored.session.status = 'idle';
|
|
175
|
+
stored.session.activity = { status: 'idle' };
|
|
176
|
+
stored.session.updatedAt = recoveredAt;
|
|
177
|
+
} else if (
|
|
178
|
+
stored.session.activity.status === 'running'
|
|
179
|
+
|| stored.session.activity.status === 'waiting_permission'
|
|
180
|
+
) {
|
|
181
|
+
stored.session.activity = { status: 'idle' };
|
|
182
|
+
stored.session.updatedAt = recoveredAt;
|
|
183
|
+
}
|
|
184
|
+
for (const message of stored.messages) {
|
|
185
|
+
if (message.status === 'streaming') {
|
|
186
|
+
message.status = 'cancelled';
|
|
187
|
+
message.completedAt = recoveredAt;
|
|
188
|
+
message.error = interruptedMessageError;
|
|
189
|
+
}
|
|
190
|
+
for (const part of message.parts) {
|
|
191
|
+
if (part.type === 'reasoning' && part.status === 'running') {
|
|
192
|
+
part.status = 'cancelled';
|
|
193
|
+
}
|
|
194
|
+
if (part.type === 'tool' && part.status === 'running') {
|
|
195
|
+
part.status = 'cancelled';
|
|
196
|
+
part.error = interruptedToolError;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function groupLegacyRuns(
|
|
203
|
+
messages: readonly IFlexMessage[],
|
|
204
|
+
sessionId: string,
|
|
205
|
+
path: string,
|
|
206
|
+
): ILegacyRun[] {
|
|
207
|
+
const groups = new Map<string, Array<{ message: IFlexMessage; index: number; timestamp: number }>>();
|
|
208
|
+
let priorTimestamp = -1;
|
|
209
|
+
for (const [index, message] of messages.entries()) {
|
|
210
|
+
const messagePath = `${path}[${index}]`;
|
|
211
|
+
if (message.sessionId !== sessionId) {
|
|
212
|
+
throw new FlexHarnessStoreFormatError(`${messagePath}.sessionId does not match its session.`);
|
|
213
|
+
}
|
|
214
|
+
const timestamp = deterministicTimestamp(message.createdAt, `${messagePath}.createdAt`);
|
|
215
|
+
if (timestamp < priorTimestamp) {
|
|
216
|
+
throw new FlexHarnessStoreFormatError(`${path} must be in chronological order.`);
|
|
217
|
+
}
|
|
218
|
+
priorTimestamp = timestamp;
|
|
219
|
+
const group = groups.get(message.runId) ?? [];
|
|
220
|
+
group.push({ message, index, timestamp });
|
|
221
|
+
groups.set(message.runId, group);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const runs: ILegacyRun[] = [];
|
|
225
|
+
let expectedIndex = 0;
|
|
226
|
+
for (const [runId, group] of groups) {
|
|
227
|
+
if (group.length !== 2) {
|
|
228
|
+
throw new FlexHarnessStoreFormatError(
|
|
229
|
+
`${path} run "${runId}" must contain exactly one user and one assistant message.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const [user, assistant] = group;
|
|
233
|
+
if (user.index !== expectedIndex || assistant.index !== expectedIndex + 1) {
|
|
234
|
+
throw new FlexHarnessStoreFormatError(`${path} contains ambiguous interleaved run groups.`);
|
|
235
|
+
}
|
|
236
|
+
expectedIndex += 2;
|
|
237
|
+
if (user.message.role !== 'user' || assistant.message.role !== 'assistant') {
|
|
238
|
+
throw new FlexHarnessStoreFormatError(
|
|
239
|
+
`${path} run "${runId}" must contain a user message followed by an assistant message.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (user.message.runId !== runId || assistant.message.runId !== runId) {
|
|
243
|
+
throw new FlexHarnessStoreFormatError(`${path} run "${runId}" has mismatched run IDs.`);
|
|
244
|
+
}
|
|
245
|
+
if (user.timestamp > assistant.timestamp) {
|
|
246
|
+
throw new FlexHarnessStoreFormatError(`${path} run "${runId}" is not chronological.`);
|
|
247
|
+
}
|
|
248
|
+
if (user.message.status === 'streaming' || assistant.message.status === 'streaming') {
|
|
249
|
+
throw new FlexHarnessStoreFormatError(`${path} run "${runId}" is not terminal.`);
|
|
250
|
+
}
|
|
251
|
+
if (user.message.status !== assistant.message.status) {
|
|
252
|
+
throw new FlexHarnessStoreFormatError(`${path} run "${runId}" has mismatched statuses.`);
|
|
253
|
+
}
|
|
254
|
+
runs.push({
|
|
255
|
+
runId,
|
|
256
|
+
status: user.message.status,
|
|
257
|
+
userMessage: user.message,
|
|
258
|
+
assistantMessage: assistant.message,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return runs;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function generationOutcome(status: ILegacyRun['status']): plugins.TAgentGenerationOutcome {
|
|
265
|
+
if (status === 'completed') return 'accepted';
|
|
266
|
+
if (status === 'failed') return 'rejected';
|
|
267
|
+
return 'interrupted';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function createSessionAgentEvents(
|
|
271
|
+
storageKey: string,
|
|
272
|
+
stored: IFlexLegacyStoredSessionSnapshot,
|
|
273
|
+
runs: readonly ILegacyRun[],
|
|
274
|
+
path: string,
|
|
275
|
+
): plugins.TAgentEvent[] {
|
|
276
|
+
const { sessionId } = stored.session;
|
|
277
|
+
const baseTimestamp = deterministicTimestamp(stored.session.createdAt, `${path}.session.createdAt`);
|
|
278
|
+
let sequence = 0;
|
|
279
|
+
const nextIdentity = () => {
|
|
280
|
+
sequence++;
|
|
281
|
+
const timestamp = baseTimestamp + sequence - 1;
|
|
282
|
+
if (!Number.isSafeInteger(timestamp)) {
|
|
283
|
+
throw new FlexHarnessStoreFormatError(
|
|
284
|
+
`${path} contains too many events for its createdAt timestamp.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return { sequence, timestamp };
|
|
288
|
+
};
|
|
289
|
+
const converted = plugins.modelMessagesToAgentEvents(
|
|
290
|
+
stored.modelHistory as unknown as TFlexAgentModelMessage[],
|
|
291
|
+
{
|
|
292
|
+
identityFactory: (coordinates) => deterministicEventId(
|
|
293
|
+
storageKey,
|
|
294
|
+
sessionId,
|
|
295
|
+
'model-history',
|
|
296
|
+
coordinates.messageIndex,
|
|
297
|
+
coordinates.partIndex ?? null,
|
|
298
|
+
coordinates.eventKind,
|
|
299
|
+
),
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
const events: plugins.TAgentEvent[] = converted.map((event) => ({
|
|
303
|
+
...event,
|
|
304
|
+
...nextIdentity(),
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
for (const run of runs) {
|
|
308
|
+
const begun = plugins.createAgentEvent<plugins.IGenerationBegunEvent>({
|
|
309
|
+
type: 'generation-begun',
|
|
310
|
+
id: deterministicEventId(storageKey, sessionId, 'run', run.runId, 'begun'),
|
|
311
|
+
...nextIdentity(),
|
|
312
|
+
generationId: run.runId,
|
|
313
|
+
claimTokenDigest: deterministicDigest(storageKey, sessionId, run.runId, 'claim-token'),
|
|
314
|
+
...(events.length > 0 ? { parentIds: [events[events.length - 1].id] } : {}),
|
|
315
|
+
});
|
|
316
|
+
const started = plugins.createAgentEvent<plugins.IGenerationExecutionStartedEvent>({
|
|
317
|
+
type: 'generation-execution-started',
|
|
318
|
+
id: deterministicEventId(storageKey, sessionId, 'run', run.runId, 'execution-started'),
|
|
319
|
+
...nextIdentity(),
|
|
320
|
+
generationId: run.runId,
|
|
321
|
+
parentIds: [begun.id],
|
|
322
|
+
});
|
|
323
|
+
const completed = plugins.createAgentEvent<plugins.IGenerationExecutionCompletedEvent>({
|
|
324
|
+
type: 'generation-execution-completed',
|
|
325
|
+
id: deterministicEventId(storageKey, sessionId, 'run', run.runId, 'execution-completed'),
|
|
326
|
+
...nextIdentity(),
|
|
327
|
+
generationId: run.runId,
|
|
328
|
+
parentIds: [started.id],
|
|
329
|
+
});
|
|
330
|
+
const outcome = plugins.createAgentEvent<plugins.IGenerationOutcomeEvent>({
|
|
331
|
+
type: 'generation-outcome',
|
|
332
|
+
id: deterministicEventId(storageKey, sessionId, 'run', run.runId, 'outcome'),
|
|
333
|
+
...nextIdentity(),
|
|
334
|
+
generationId: run.runId,
|
|
335
|
+
outcome: generationOutcome(run.status),
|
|
336
|
+
parentIds: [completed.id],
|
|
337
|
+
});
|
|
338
|
+
events.push(begun, started, completed, outcome);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const jsonEvents = JSON.parse(JSON.stringify(events)) as plugins.TAgentEvent[];
|
|
342
|
+
try {
|
|
343
|
+
plugins.validateAgentEventSnapshotV2({
|
|
344
|
+
schemaVersion: 2,
|
|
345
|
+
sessionId,
|
|
346
|
+
revision: 1,
|
|
347
|
+
updatedAt: baseTimestamp,
|
|
348
|
+
events: jsonEvents,
|
|
349
|
+
}, sessionId);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw new FlexHarnessStoreFormatError(
|
|
352
|
+
`${path}.modelHistory cannot be converted to valid canonical Agent events: ${
|
|
353
|
+
error instanceof Error ? error.message : String(error)
|
|
354
|
+
}`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const transactions = plugins.getAgentGenerationTransactions(jsonEvents);
|
|
358
|
+
if (transactions.size !== runs.length) {
|
|
359
|
+
throw new FlexHarnessStoreFormatError(`${path}.messages do not produce unambiguous transactions.`);
|
|
360
|
+
}
|
|
361
|
+
for (const run of runs) {
|
|
362
|
+
if (transactions.get(run.runId)?.state !== generationOutcome(run.status)) {
|
|
363
|
+
throw new FlexHarnessStoreFormatError(
|
|
364
|
+
`${path}.messages run "${run.runId}" produces an invalid transaction lifecycle.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return jsonEvents;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function createMigrationPlan(
|
|
372
|
+
storageKey: string,
|
|
373
|
+
legacySnapshot: IFlexLegacyHarnessSnapshot,
|
|
374
|
+
): ILegacyMigrationPlan {
|
|
375
|
+
if (typeof storageKey !== 'string' || !storageKey.trim()) {
|
|
376
|
+
throw new FlexHarnessValidationError('storageKey must be a non-empty string.');
|
|
377
|
+
}
|
|
378
|
+
assertJsonSerializable(legacySnapshot, '$legacySnapshot');
|
|
379
|
+
const snapshot = requireRecord(legacySnapshot, '$legacySnapshot');
|
|
380
|
+
requireOnlyKeys(snapshot, ['schemaVersion', 'revision', 'sessions'], '$legacySnapshot');
|
|
381
|
+
if (snapshot.schemaVersion !== 1) {
|
|
382
|
+
throw new FlexHarnessStoreFormatError('Legacy snapshot schemaVersion must be 1.');
|
|
383
|
+
}
|
|
384
|
+
requireNonNegativeInteger(snapshot.revision, '$legacySnapshot.revision');
|
|
385
|
+
if (!Array.isArray(snapshot.sessions)) {
|
|
386
|
+
throw new FlexHarnessStoreFormatError('$legacySnapshot.sessions must be an array.');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const legacy = cloneSerializable(legacySnapshot);
|
|
390
|
+
const sessionIds = new Set<string>();
|
|
391
|
+
const eventIds = new Set<string>();
|
|
392
|
+
const sessions = legacy.sessions.map((stored, index): ILegacySessionMigrationPlan => {
|
|
393
|
+
const path = `$legacySnapshot.sessions[${index}]`;
|
|
394
|
+
const storedRecord = requireRecord(stored, path);
|
|
395
|
+
requireOnlyKeys(
|
|
396
|
+
storedRecord,
|
|
397
|
+
['session', 'messages', 'modelHistory', 'rememberedPermissionKeys'],
|
|
398
|
+
path,
|
|
399
|
+
);
|
|
400
|
+
const scopeSnapshot: IFlexScopeSnapshot = {
|
|
401
|
+
schemaVersion: 1,
|
|
402
|
+
revision: 1,
|
|
403
|
+
sessions: [stored.session],
|
|
404
|
+
tombstones: [],
|
|
405
|
+
};
|
|
406
|
+
const projectionSnapshot: IFlexProjectionSnapshot = {
|
|
407
|
+
schemaVersion: 1,
|
|
408
|
+
revision: 1,
|
|
409
|
+
messages: stored.messages,
|
|
410
|
+
stagedTerminals: [],
|
|
411
|
+
};
|
|
412
|
+
const permissionSnapshot: IFlexPermissionSnapshot = {
|
|
413
|
+
schemaVersion: 1,
|
|
414
|
+
revision: 1,
|
|
415
|
+
rememberedPermissionKeys: stored.rememberedPermissionKeys,
|
|
416
|
+
};
|
|
417
|
+
assertFlexScopeSnapshot(scopeSnapshot);
|
|
418
|
+
assertFlexProjectionSnapshot(projectionSnapshot);
|
|
419
|
+
assertFlexPermissionSnapshot(permissionSnapshot);
|
|
420
|
+
repairLegacyInterruptedSession(stored, path);
|
|
421
|
+
assertFlexScopeSnapshot(scopeSnapshot);
|
|
422
|
+
assertFlexProjectionSnapshot(projectionSnapshot);
|
|
423
|
+
const { sessionId } = stored.session;
|
|
424
|
+
if (sessionIds.has(sessionId)) {
|
|
425
|
+
throw new FlexHarnessStoreFormatError(
|
|
426
|
+
`$legacySnapshot contains duplicate session "${sessionId}".`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
sessionIds.add(sessionId);
|
|
430
|
+
const runs = groupLegacyRuns(stored.messages, sessionId, `${path}.messages`);
|
|
431
|
+
const agentEvents = createSessionAgentEvents(storageKey, stored, runs, path);
|
|
432
|
+
for (const event of agentEvents) {
|
|
433
|
+
if (eventIds.has(event.id)) {
|
|
434
|
+
throw new FlexHarnessStoreFormatError(
|
|
435
|
+
`$legacySnapshot produces duplicate Agent event ID "${event.id}".`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
eventIds.add(event.id);
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
sessionId,
|
|
442
|
+
projectionSnapshot: cloneSerializable(projectionSnapshot),
|
|
443
|
+
permissionSnapshot: cloneSerializable(permissionSnapshot),
|
|
444
|
+
agentEvents,
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
const scopeSnapshot: IFlexScopeSnapshot = {
|
|
448
|
+
schemaVersion: 1,
|
|
449
|
+
revision: 1,
|
|
450
|
+
sessions: legacy.sessions.map((stored) => cloneSerializable(stored.session)),
|
|
451
|
+
tombstones: [],
|
|
452
|
+
};
|
|
453
|
+
assertFlexScopeSnapshot(scopeSnapshot);
|
|
454
|
+
return { scopeSnapshot, sessions };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function assertCompatibleJobSnapshot(
|
|
458
|
+
snapshot: plugins.IToolJobSnapshot | undefined,
|
|
459
|
+
sessionId: string,
|
|
460
|
+
): void {
|
|
461
|
+
if (!snapshot) return;
|
|
462
|
+
if (
|
|
463
|
+
snapshot.schemaVersion !== 1
|
|
464
|
+
|| !Number.isSafeInteger(snapshot.revision)
|
|
465
|
+
|| snapshot.revision < 0
|
|
466
|
+
|| !Number.isSafeInteger(snapshot.updatedAt)
|
|
467
|
+
|| snapshot.updatedAt < 0
|
|
468
|
+
|| !Array.isArray(snapshot.jobs)
|
|
469
|
+
|| snapshot.jobs.length > 0
|
|
470
|
+
) {
|
|
471
|
+
throw new FlexHarnessStoreFormatError(
|
|
472
|
+
`Legacy migration destination jobs for session "${sessionId}" is not an empty job snapshot.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function inspectDestinations(
|
|
478
|
+
storageKey: string,
|
|
479
|
+
plan: ILegacyMigrationPlan,
|
|
480
|
+
stores: IFlexHarnessStores,
|
|
481
|
+
ownership: IMigrationProviderOwnership,
|
|
482
|
+
): Promise<{
|
|
483
|
+
scope: IFlexScopeSnapshot | undefined;
|
|
484
|
+
sessions: IInspectedSessionDestination[];
|
|
485
|
+
}> {
|
|
486
|
+
const scope = await stores.scopes.load(storageKey);
|
|
487
|
+
const sessions: IInspectedSessionDestination[] = [];
|
|
488
|
+
for (const session of plan.sessions) {
|
|
489
|
+
const [projection, permission] = await Promise.all([
|
|
490
|
+
stores.projections.load(storageKey, session.sessionId),
|
|
491
|
+
stores.permissions.load(storageKey, session.sessionId),
|
|
492
|
+
]);
|
|
493
|
+
const eventStore = await stores.agentEvents.getStore(storageKey, session.sessionId);
|
|
494
|
+
ownership.agentEvents.add(session.sessionId);
|
|
495
|
+
const jobStore = await stores.jobs.getStore(storageKey, session.sessionId);
|
|
496
|
+
ownership.jobs.add(session.sessionId);
|
|
497
|
+
const [eventSnapshot, jobSnapshot] = await Promise.all([
|
|
498
|
+
eventStore.load(session.sessionId),
|
|
499
|
+
jobStore.load(),
|
|
500
|
+
]);
|
|
501
|
+
sessions.push({
|
|
502
|
+
plan: session,
|
|
503
|
+
projection,
|
|
504
|
+
permission,
|
|
505
|
+
eventStore,
|
|
506
|
+
eventSnapshot: eventSnapshot as plugins.IAgentEventSnapshotV2 | undefined,
|
|
507
|
+
jobSnapshot,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (scope) {
|
|
512
|
+
assertSameContent(withoutRevision(scope), withoutRevision(plan.scopeSnapshot), 'scope snapshot');
|
|
513
|
+
}
|
|
514
|
+
for (const session of sessions) {
|
|
515
|
+
if (session.projection) {
|
|
516
|
+
assertSameContent(
|
|
517
|
+
withoutRevision(session.projection),
|
|
518
|
+
withoutRevision(session.plan.projectionSnapshot),
|
|
519
|
+
`projection snapshot for session "${session.plan.sessionId}"`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (session.permission) {
|
|
523
|
+
assertSameContent(
|
|
524
|
+
withoutRevision(session.permission),
|
|
525
|
+
withoutRevision(session.plan.permissionSnapshot),
|
|
526
|
+
`permission snapshot for session "${session.plan.sessionId}"`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (session.eventSnapshot) {
|
|
530
|
+
assertSameContent(
|
|
531
|
+
{
|
|
532
|
+
schemaVersion: session.eventSnapshot.schemaVersion,
|
|
533
|
+
sessionId: session.eventSnapshot.sessionId,
|
|
534
|
+
events: session.eventSnapshot.events,
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
schemaVersion: 2,
|
|
538
|
+
sessionId: session.plan.sessionId,
|
|
539
|
+
events: session.plan.agentEvents,
|
|
540
|
+
},
|
|
541
|
+
`Agent events for session "${session.plan.sessionId}"`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
assertCompatibleJobSnapshot(session.jobSnapshot, session.plan.sessionId);
|
|
545
|
+
}
|
|
546
|
+
return { scope, sessions };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function releaseMigrationProviders(
|
|
550
|
+
storageKey: string,
|
|
551
|
+
stores: IFlexHarnessStores,
|
|
552
|
+
ownership: IMigrationProviderOwnership,
|
|
553
|
+
): Promise<unknown[]> {
|
|
554
|
+
const releases: Promise<void>[] = [];
|
|
555
|
+
if (stores.agentEvents.releaseSession) {
|
|
556
|
+
for (const sessionId of ownership.agentEvents) {
|
|
557
|
+
releases.push(Promise.resolve().then(() =>
|
|
558
|
+
stores.agentEvents.releaseSession!(storageKey, sessionId)));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (stores.jobs.releaseSession) {
|
|
562
|
+
for (const sessionId of ownership.jobs) {
|
|
563
|
+
releases.push(Promise.resolve().then(() =>
|
|
564
|
+
stores.jobs.releaseSession!(storageKey, sessionId)));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const results = await Promise.allSettled(releases);
|
|
568
|
+
return results
|
|
569
|
+
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
|
570
|
+
.map((result) => result.reason);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Migrates one stopped, exclusively owned 2.x storage namespace into the 3.x stores. */
|
|
574
|
+
export async function migrateLegacyFlexHarnessSnapshot(
|
|
575
|
+
storageKey: string,
|
|
576
|
+
legacySnapshot: IFlexLegacyHarnessSnapshot,
|
|
577
|
+
stores: IFlexHarnessStores,
|
|
578
|
+
): Promise<void> {
|
|
579
|
+
const plan = createMigrationPlan(storageKey, legacySnapshot);
|
|
580
|
+
const ownership: IMigrationProviderOwnership = {
|
|
581
|
+
agentEvents: new Set(),
|
|
582
|
+
jobs: new Set(),
|
|
583
|
+
};
|
|
584
|
+
let operationError: unknown;
|
|
585
|
+
try {
|
|
586
|
+
const destinations = await inspectDestinations(storageKey, plan, stores, ownership);
|
|
587
|
+
|
|
588
|
+
for (const destination of destinations.sessions) {
|
|
589
|
+
const session = destination.plan;
|
|
590
|
+
if (!destination.projection) {
|
|
591
|
+
await stores.projections.save(
|
|
592
|
+
storageKey,
|
|
593
|
+
session.sessionId,
|
|
594
|
+
session.projectionSnapshot,
|
|
595
|
+
0,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
if (!destination.permission) {
|
|
599
|
+
await stores.permissions.save(
|
|
600
|
+
storageKey,
|
|
601
|
+
session.sessionId,
|
|
602
|
+
session.permissionSnapshot,
|
|
603
|
+
0,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (!destination.eventSnapshot) {
|
|
607
|
+
await destination.eventStore.save(session.sessionId, session.agentEvents, 0);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (!destinations.scope) {
|
|
611
|
+
await stores.scopes.save(storageKey, plan.scopeSnapshot, 0);
|
|
612
|
+
}
|
|
613
|
+
} catch (error) {
|
|
614
|
+
operationError = error;
|
|
615
|
+
}
|
|
616
|
+
const errors = await releaseMigrationProviders(storageKey, stores, ownership);
|
|
617
|
+
if (operationError !== undefined) errors.unshift(operationError);
|
|
618
|
+
if (errors.length === 1) throw errors[0];
|
|
619
|
+
if (errors.length > 1) throw new AggregateError(errors, 'Legacy FlexHarness migration failed.');
|
|
620
|
+
}
|