@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.
@@ -1,20 +1,53 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import {
3
+ FlexHarnessStoreCommitUncertainError,
3
4
  FlexHarnessStoreConflictError,
4
5
  FlexHarnessStoreFormatError,
5
6
  FlexHarnessValidationError,
6
7
  } from './errors.js';
7
- import type { IFlexHarnessSnapshot, IFlexHarnessStore } from './interfaces.js';
8
- import { assertFlexHarnessSnapshot, cloneSerializable } from './utils.json.js';
8
+ import type {
9
+ IFlexAgentEventStoreProvider,
10
+ IFlexHarnessStores,
11
+ IFlexPermissionSnapshot,
12
+ IFlexPermissionStore,
13
+ IFlexProjectionSnapshot,
14
+ IFlexProjectionStore,
15
+ IFlexScopeSnapshot,
16
+ IFlexScopeStore,
17
+ IFlexToolJobStoreProvider,
18
+ } from './interfaces.js';
19
+ import {
20
+ assertFlexPermissionSnapshot,
21
+ assertFlexProjectionSnapshot,
22
+ assertFlexScopeSnapshot,
23
+ assertJsonSerializable,
24
+ cloneSerializable,
25
+ } from './utils.json.js';
9
26
 
10
- function validateSave(
11
- snapshot: IFlexHarnessSnapshot,
12
- expectedRevision: number,
13
- ): IFlexHarnessSnapshot {
14
- assertFlexHarnessSnapshot(snapshot);
27
+ type TSnapshot = IFlexScopeSnapshot | IFlexProjectionSnapshot | IFlexPermissionSnapshot;
28
+ type TSnapshotValidator<TSnapshotValue extends TSnapshot> = (
29
+ value: unknown,
30
+ ) => asserts value is TSnapshotValue;
31
+
32
+ function validateIdentifier(value: string, name: string): void {
33
+ if (typeof value !== 'string' || !value.trim()) {
34
+ throw new FlexHarnessValidationError(`${name} must be a non-empty string.`);
35
+ }
36
+ }
37
+
38
+ function validateExpectedRevision(expectedRevision: number): void {
15
39
  if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
16
40
  throw new FlexHarnessValidationError('expectedRevision must be a non-negative integer.');
17
41
  }
42
+ }
43
+
44
+ function validateSnapshotSave<TSnapshotValue extends TSnapshot>(
45
+ snapshot: TSnapshotValue,
46
+ expectedRevision: number,
47
+ validator: TSnapshotValidator<TSnapshotValue>,
48
+ ): TSnapshotValue {
49
+ validator(snapshot);
50
+ validateExpectedRevision(expectedRevision);
18
51
  if (snapshot.revision !== expectedRevision + 1) {
19
52
  throw new FlexHarnessStoreFormatError(
20
53
  `Snapshot revision ${snapshot.revision} must equal expected revision ${expectedRevision} plus one.`,
@@ -23,20 +56,291 @@ function validateSave(
23
56
  return cloneSerializable(snapshot);
24
57
  }
25
58
 
26
- export class InMemoryFlexHarnessStore implements IFlexHarnessStore {
27
- private readonly snapshots = new Map<string, IFlexHarnessSnapshot>();
59
+ function providerKey(storageKey: string, sessionId: string): string {
60
+ validateIdentifier(storageKey, 'storageKey');
61
+ validateIdentifier(sessionId, 'sessionId');
62
+ return JSON.stringify([storageKey, sessionId]);
63
+ }
64
+
65
+ function assertProjectionSession(snapshot: IFlexProjectionSnapshot, sessionId: string): void {
66
+ for (const message of snapshot.messages) {
67
+ if (message.sessionId !== sessionId) {
68
+ throw new FlexHarnessStoreFormatError(
69
+ `Projection message "${message.messageId}" does not belong to session "${sessionId}".`,
70
+ );
71
+ }
72
+ }
73
+ for (const terminal of snapshot.stagedTerminals) {
74
+ if (
75
+ terminal.userMessage.sessionId !== sessionId
76
+ || terminal.assistantMessage.sessionId !== sessionId
77
+ ) {
78
+ throw new FlexHarnessStoreFormatError(
79
+ `Staged terminal "${terminal.runId}" does not belong to session "${sessionId}".`,
80
+ );
81
+ }
82
+ }
83
+ }
84
+
85
+ function normalizeJsonObjectUndefined<TValue>(value: TValue, path = '$'): TValue {
86
+ const seen = new WeakSet<object>();
87
+ const visit = (current: unknown, currentPath: string): unknown => {
88
+ if (
89
+ current === null
90
+ || typeof current === 'string'
91
+ || typeof current === 'boolean'
92
+ ) return current;
93
+ if (typeof current === 'number') {
94
+ if (!Number.isFinite(current)) {
95
+ throw new FlexHarnessStoreFormatError(`${currentPath} contains a non-finite number.`);
96
+ }
97
+ return current;
98
+ }
99
+ if (typeof current !== 'object') {
100
+ throw new FlexHarnessStoreFormatError(
101
+ `${currentPath} contains ${typeof current}, which is not JSON-safe.`,
102
+ );
103
+ }
104
+ if (seen.has(current)) {
105
+ throw new FlexHarnessStoreFormatError(`${currentPath} contains a circular reference.`);
106
+ }
107
+ const prototype = Object.getPrototypeOf(current);
108
+ if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) {
109
+ throw new FlexHarnessStoreFormatError(`${currentPath} contains a non-plain object.`);
110
+ }
111
+ if (Object.getOwnPropertySymbols(current).length > 0) {
112
+ throw new FlexHarnessStoreFormatError(`${currentPath} contains a symbol-keyed property.`);
113
+ }
114
+ seen.add(current);
115
+ if (Array.isArray(current)) {
116
+ const result = current.map((entry, index) => {
117
+ if (!(index in current) || entry === undefined) {
118
+ throw new FlexHarnessStoreFormatError(`${currentPath}[${index}] is not JSON-safe.`);
119
+ }
120
+ return visit(entry, `${currentPath}[${index}]`);
121
+ });
122
+ seen.delete(current);
123
+ return result;
124
+ }
125
+ const result: Record<string, unknown> = {};
126
+ for (const key of Object.getOwnPropertyNames(current)) {
127
+ const descriptor = Object.getOwnPropertyDescriptor(current, key)!;
128
+ if (!descriptor.enumerable || !('value' in descriptor)) {
129
+ throw new FlexHarnessStoreFormatError(`${currentPath}.${key} is not a plain JSON property.`);
130
+ }
131
+ if (descriptor.value !== undefined) {
132
+ result[key] = visit(descriptor.value, `${currentPath}.${key}`);
133
+ }
134
+ }
135
+ seen.delete(current);
136
+ return result;
137
+ };
138
+ const normalized = visit(value, path);
139
+ assertJsonSerializable(normalized, path);
140
+ return normalized as TValue;
141
+ }
142
+
143
+ function assertNonEmptyString(value: unknown, path: string): asserts value is string {
144
+ if (typeof value !== 'string' || !value.trim()) {
145
+ throw new FlexHarnessStoreFormatError(`${path} must be a non-empty string.`);
146
+ }
147
+ }
148
+
149
+ function assertNonNegativeInteger(value: unknown, path: string): asserts value is number {
150
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
151
+ throw new FlexHarnessStoreFormatError(`${path} must be a non-negative integer.`);
152
+ }
153
+ }
154
+
155
+ function assertExactKeys(
156
+ value: Record<string, unknown>,
157
+ keys: readonly string[],
158
+ path: string,
159
+ ): void {
160
+ const invalidKey = Object.keys(value).find((key) => !keys.includes(key));
161
+ if (invalidKey) {
162
+ throw new FlexHarnessStoreFormatError(`${path}.${invalidKey} is not supported.`);
163
+ }
164
+ }
165
+
166
+ function assertRecord(value: unknown, path: string): asserts value is Record<string, unknown> {
167
+ if (
168
+ !value
169
+ || typeof value !== 'object'
170
+ || Array.isArray(value)
171
+ || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
172
+ ) {
173
+ throw new FlexHarnessStoreFormatError(`${path} must be a plain object.`);
174
+ }
175
+ }
176
+
177
+ function createAgentEventSnapshot(
178
+ sessionId: string,
179
+ events: readonly plugins.TAgentEvent[],
180
+ revision: number,
181
+ ): plugins.IAgentEventSnapshotV2 {
182
+ return {
183
+ schemaVersion: 2,
184
+ sessionId,
185
+ revision,
186
+ updatedAt: events.reduce((latest, event) => Math.max(latest, event.timestamp), 0),
187
+ events: normalizeJsonObjectUndefined([...events], '$events'),
188
+ };
189
+ }
190
+
191
+ function validateAgentEventSnapshot(
192
+ value: unknown,
193
+ sessionId: string,
194
+ ): plugins.IAgentEventSnapshotV2 {
195
+ assertJsonSerializable(value, '$snapshot');
196
+ try {
197
+ return plugins.validateAgentEventSnapshotV2(value, sessionId);
198
+ } catch (error) {
199
+ throw new FlexHarnessStoreFormatError(
200
+ `$snapshot is not a canonical schema-2 Agent event snapshot: ${error instanceof Error ? error.message : String(error)}`,
201
+ { cause: error },
202
+ );
203
+ }
204
+ }
28
205
 
29
- public async load(storageKey: string): Promise<IFlexHarnessSnapshot | undefined> {
206
+ function validateAgentEventArchive(
207
+ value: unknown,
208
+ sessionId: string,
209
+ archiveId: string,
210
+ ): plugins.IAgentEventArchiveV2 {
211
+ assertJsonSerializable(value, '$archive');
212
+ try {
213
+ return plugins.validateAgentEventArchiveV2(value, sessionId, archiveId);
214
+ } catch (error) {
215
+ throw new FlexHarnessStoreFormatError(
216
+ `$archive is not a canonical schema-2 Agent event archive: ${error instanceof Error ? error.message : String(error)}`,
217
+ { cause: error },
218
+ );
219
+ }
220
+ }
221
+
222
+ function validateToolJobSnapshot(value: unknown): plugins.IToolJobSnapshot {
223
+ assertJsonSerializable(value, '$snapshot');
224
+ assertRecord(value, '$snapshot');
225
+ assertExactKeys(value, ['schemaVersion', 'revision', 'updatedAt', 'jobs'], '$snapshot');
226
+ if (value.schemaVersion !== 1) {
227
+ throw new FlexHarnessStoreFormatError('Tool job snapshot schemaVersion must be 1.');
228
+ }
229
+ assertNonNegativeInteger(value.revision, '$snapshot.revision');
230
+ assertNonNegativeInteger(value.updatedAt, '$snapshot.updatedAt');
231
+ if (!Array.isArray(value.jobs)) {
232
+ throw new FlexHarnessStoreFormatError('$snapshot.jobs must be an array.');
233
+ }
234
+ const executionIds = new Set<string>();
235
+ for (let index = 0; index < value.jobs.length; index++) {
236
+ const path = `$snapshot.jobs[${index}]`;
237
+ assertRecord(value.jobs[index], path);
238
+ const job = value.jobs[index];
239
+ assertExactKeys(
240
+ job,
241
+ [
242
+ 'executionId',
243
+ 'type',
244
+ 'state',
245
+ 'request',
246
+ 'exitCode',
247
+ 'stdout',
248
+ 'stderr',
249
+ 'signal',
250
+ 'startedAt',
251
+ 'updatedAt',
252
+ 'finishedAt',
253
+ ],
254
+ path,
255
+ );
256
+ assertNonEmptyString(job.executionId, `${path}.executionId`);
257
+ if (executionIds.has(job.executionId)) {
258
+ throw new FlexHarnessStoreFormatError(`$snapshot.jobs contains duplicate job "${job.executionId}".`);
259
+ }
260
+ executionIds.add(job.executionId);
261
+ assertNonEmptyString(job.type, `${path}.type`);
262
+ if (!['running', 'finished', 'failed', 'aborted'].includes(String(job.state))) {
263
+ throw new FlexHarnessStoreFormatError(`${path}.state is invalid.`);
264
+ }
265
+ if (job.request !== undefined) {
266
+ const requestPath = `${path}.request`;
267
+ assertRecord(job.request, requestPath);
268
+ assertExactKeys(
269
+ job.request,
270
+ ['type', 'command', 'cwd', 'timeoutMs', 'metadata'],
271
+ requestPath,
272
+ );
273
+ assertNonEmptyString(job.request.type, `${requestPath}.type`);
274
+ if (job.request.command !== undefined) {
275
+ const commandPath = `${requestPath}.command`;
276
+ assertRecord(job.request.command, commandPath);
277
+ assertExactKeys(job.request.command, ['executable', 'args'], commandPath);
278
+ assertNonEmptyString(job.request.command.executable, `${commandPath}.executable`);
279
+ if (!Array.isArray(job.request.command.args)) {
280
+ throw new FlexHarnessStoreFormatError(`${commandPath}.args must be an array.`);
281
+ }
282
+ for (let argumentIndex = 0; argumentIndex < job.request.command.args.length; argumentIndex++) {
283
+ if (typeof job.request.command.args[argumentIndex] !== 'string') {
284
+ throw new FlexHarnessStoreFormatError(
285
+ `${commandPath}.args[${argumentIndex}] must be a string.`,
286
+ );
287
+ }
288
+ }
289
+ }
290
+ if (job.request.cwd !== undefined && typeof job.request.cwd !== 'string') {
291
+ throw new FlexHarnessStoreFormatError(`${requestPath}.cwd must be a string.`);
292
+ }
293
+ if (job.request.timeoutMs !== undefined) {
294
+ assertNonNegativeInteger(job.request.timeoutMs, `${requestPath}.timeoutMs`);
295
+ }
296
+ if (job.request.metadata !== undefined) {
297
+ assertRecord(job.request.metadata, `${requestPath}.metadata`);
298
+ }
299
+ }
300
+ if (job.exitCode !== undefined && job.exitCode !== null && !Number.isSafeInteger(job.exitCode)) {
301
+ throw new FlexHarnessStoreFormatError(`${path}.exitCode must be an integer or null.`);
302
+ }
303
+ for (const key of ['stdout', 'stderr', 'signal'] as const) {
304
+ if (job[key] !== undefined && typeof job[key] !== 'string') {
305
+ throw new FlexHarnessStoreFormatError(`${path}.${key} must be a string.`);
306
+ }
307
+ }
308
+ for (const key of ['startedAt', 'updatedAt', 'finishedAt'] as const) {
309
+ if (job[key] !== undefined) assertNonNegativeInteger(job[key], `${path}.${key}`);
310
+ }
311
+ }
312
+ return value as unknown as plugins.IToolJobSnapshot;
313
+ }
314
+
315
+ function createToolJobSnapshot(
316
+ jobs: readonly plugins.IToolJobState[],
317
+ revision: number,
318
+ ): plugins.IToolJobSnapshot {
319
+ const snapshot: plugins.IToolJobSnapshot = {
320
+ schemaVersion: 1,
321
+ revision,
322
+ updatedAt: Date.now(),
323
+ jobs: normalizeJsonObjectUndefined([...jobs], '$jobs'),
324
+ };
325
+ return validateToolJobSnapshot(snapshot);
326
+ }
327
+
328
+ class InMemoryScopeStore implements IFlexScopeStore {
329
+ private readonly snapshots = new Map<string, IFlexScopeSnapshot>();
330
+
331
+ public async load(storageKey: string): Promise<IFlexScopeSnapshot | undefined> {
332
+ validateIdentifier(storageKey, 'storageKey');
30
333
  const snapshot = this.snapshots.get(storageKey);
31
334
  return snapshot ? cloneSerializable(snapshot) : undefined;
32
335
  }
33
336
 
34
337
  public async save(
35
338
  storageKey: string,
36
- snapshot: IFlexHarnessSnapshot,
339
+ snapshot: IFlexScopeSnapshot,
37
340
  expectedRevision: number,
38
341
  ): Promise<void> {
39
- const validated = validateSave(snapshot, expectedRevision);
342
+ validateIdentifier(storageKey, 'storageKey');
343
+ const validated = validateSnapshotSave(snapshot, expectedRevision, assertFlexScopeSnapshot);
40
344
  const actualRevision = this.snapshots.get(storageKey)?.revision ?? 0;
41
345
  if (actualRevision !== expectedRevision) {
42
346
  throw new FlexHarnessStoreConflictError(storageKey, expectedRevision, actualRevision);
@@ -45,151 +349,976 @@ export class InMemoryFlexHarnessStore implements IFlexHarnessStore {
45
349
  }
46
350
  }
47
351
 
48
- export interface IJsonFileFlexHarnessStoreOptions {
49
- directory: string;
50
- }
352
+ class InMemoryProjectionStore implements IFlexProjectionStore {
353
+ private readonly snapshots = new Map<string, IFlexProjectionSnapshot>();
51
354
 
52
- /**
53
- * Atomic and CAS-safe across instances in this process. It intentionally does not
54
- * claim cross-process safety because no operating-system lock is held.
55
- */
56
- export class JsonFileFlexHarnessStore implements IFlexHarnessStore {
57
- private static readonly fileQueues = new Map<string, Promise<void>>();
58
- private readonly directory: string;
355
+ public async load(
356
+ storageKey: string,
357
+ sessionId: string,
358
+ ): Promise<IFlexProjectionSnapshot | undefined> {
359
+ const key = providerKey(storageKey, sessionId);
360
+ const snapshot = this.snapshots.get(key);
361
+ return snapshot ? cloneSerializable(snapshot) : undefined;
362
+ }
59
363
 
60
- constructor(options: IJsonFileFlexHarnessStoreOptions) {
61
- if (!options.directory) {
62
- throw new FlexHarnessValidationError('JsonFileFlexHarnessStore requires a directory.');
364
+ public async save(
365
+ storageKey: string,
366
+ sessionId: string,
367
+ snapshot: IFlexProjectionSnapshot,
368
+ expectedRevision: number,
369
+ ): Promise<void> {
370
+ const key = providerKey(storageKey, sessionId);
371
+ const validated = validateSnapshotSave(snapshot, expectedRevision, assertFlexProjectionSnapshot);
372
+ assertProjectionSession(validated, sessionId);
373
+ const actualRevision = this.snapshots.get(key)?.revision ?? 0;
374
+ if (actualRevision !== expectedRevision) {
375
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
63
376
  }
64
- this.directory = plugins.path.resolve(options.directory);
377
+ this.snapshots.set(key, validated);
65
378
  }
66
379
 
67
- public async load(storageKey: string): Promise<IFlexHarnessSnapshot | undefined> {
68
- const filePath = this.filePath(storageKey);
69
- return JsonFileFlexHarnessStore.inFileQueue(filePath, async () => {
70
- await this.preparePath(filePath);
71
- return this.readSnapshot(filePath);
72
- });
380
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
381
+ this.snapshots.delete(providerKey(storageKey, sessionId));
382
+ }
383
+ }
384
+
385
+ class InMemoryPermissionStore implements IFlexPermissionStore {
386
+ private readonly snapshots = new Map<string, IFlexPermissionSnapshot>();
387
+
388
+ public async load(
389
+ storageKey: string,
390
+ sessionId: string,
391
+ ): Promise<IFlexPermissionSnapshot | undefined> {
392
+ const snapshot = this.snapshots.get(providerKey(storageKey, sessionId));
393
+ return snapshot ? cloneSerializable(snapshot) : undefined;
73
394
  }
74
395
 
75
396
  public async save(
76
397
  storageKey: string,
77
- snapshot: IFlexHarnessSnapshot,
398
+ sessionId: string,
399
+ snapshot: IFlexPermissionSnapshot,
78
400
  expectedRevision: number,
79
401
  ): Promise<void> {
80
- const validated = validateSave(snapshot, expectedRevision);
81
- const filePath = this.filePath(storageKey);
82
- await JsonFileFlexHarnessStore.inFileQueue(filePath, async () => {
83
- await this.preparePath(filePath);
84
- const current = await this.readSnapshot(filePath);
85
- const actualRevision = current?.revision ?? 0;
86
- if (actualRevision !== expectedRevision) {
87
- throw new FlexHarnessStoreConflictError(storageKey, expectedRevision, actualRevision);
402
+ const key = providerKey(storageKey, sessionId);
403
+ const validated = validateSnapshotSave(snapshot, expectedRevision, assertFlexPermissionSnapshot);
404
+ const actualRevision = this.snapshots.get(key)?.revision ?? 0;
405
+ if (actualRevision !== expectedRevision) {
406
+ throw new FlexHarnessStoreConflictError(key, expectedRevision, actualRevision);
407
+ }
408
+ this.snapshots.set(key, validated);
409
+ }
410
+
411
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
412
+ this.snapshots.delete(providerKey(storageKey, sessionId));
413
+ }
414
+ }
415
+
416
+ class InMemoryAgentEventStore implements plugins.IAgentEventStoreV2 {
417
+ public readonly eventSchemaVersion = 2 as const;
418
+ private snapshot: plugins.IAgentEventSnapshotV2 | undefined;
419
+ private readonly archives = new Map<string, plugins.IAgentEventArchiveV2>();
420
+
421
+ constructor(private readonly sessionId: string) {}
422
+
423
+ private assertSession(sessionId: string): void {
424
+ if (sessionId !== this.sessionId) {
425
+ throw new FlexHarnessValidationError(
426
+ `Agent event store is bound to session "${this.sessionId}", not "${sessionId}".`,
427
+ );
428
+ }
429
+ }
430
+
431
+ public async load(sessionId: string): Promise<plugins.IAgentEventSnapshotV2 | undefined> {
432
+ this.assertSession(sessionId);
433
+ return this.snapshot ? cloneSerializable(this.snapshot) : undefined;
434
+ }
435
+
436
+ public async save(
437
+ sessionId: string,
438
+ events: readonly plugins.TAgentEvent[],
439
+ expectedRevision: number,
440
+ ): Promise<number> {
441
+ this.assertSession(sessionId);
442
+ validateExpectedRevision(expectedRevision);
443
+ const snapshot = createAgentEventSnapshot(sessionId, events, expectedRevision + 1);
444
+ validateAgentEventSnapshot(snapshot, sessionId);
445
+ const actualRevision = this.snapshot?.revision ?? 0;
446
+ if (actualRevision !== expectedRevision) {
447
+ throw new plugins.AgentEventStoreConflictError(sessionId, expectedRevision, actualRevision);
448
+ }
449
+ this.snapshot = cloneSerializable(snapshot);
450
+ return snapshot.revision;
451
+ }
452
+
453
+ public async archive(archive: plugins.IAgentEventArchiveV2): Promise<void> {
454
+ this.assertSession(archive.sessionId);
455
+ const normalized = normalizeJsonObjectUndefined(archive, '$archive');
456
+ const validated = validateAgentEventArchive(normalized, this.sessionId, archive.archiveId);
457
+ const existing = this.archives.get(archive.archiveId);
458
+ if (existing) {
459
+ if (
460
+ JSON.stringify(existing.events.map((event) => event.id))
461
+ !== JSON.stringify(validated.events.map((event) => event.id))
462
+ ) {
463
+ throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
88
464
  }
89
- await this.writeSnapshot(filePath, validated);
90
- });
465
+ return;
466
+ }
467
+ this.archives.set(archive.archiveId, cloneSerializable(validated));
468
+ }
469
+
470
+ public async loadArchive(
471
+ sessionId: string,
472
+ archiveId: string,
473
+ ): Promise<plugins.IAgentEventArchiveV2 | undefined> {
474
+ this.assertSession(sessionId);
475
+ validateIdentifier(archiveId, 'archiveId');
476
+ const archive = this.archives.get(archiveId);
477
+ return archive ? cloneSerializable(archive) : undefined;
478
+ }
479
+
480
+ public clear(): void {
481
+ this.snapshot = undefined;
482
+ this.archives.clear();
483
+ }
484
+ }
485
+
486
+ class InMemoryAgentEventStoreProvider implements IFlexAgentEventStoreProvider {
487
+ private readonly stores = new Map<string, InMemoryAgentEventStore>();
488
+
489
+ public getStore(storageKey: string, sessionId: string): InMemoryAgentEventStore {
490
+ const key = providerKey(storageKey, sessionId);
491
+ let store = this.stores.get(key);
492
+ if (!store) {
493
+ store = new InMemoryAgentEventStore(sessionId);
494
+ this.stores.set(key, store);
495
+ }
496
+ return store;
497
+ }
498
+
499
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
500
+ const key = providerKey(storageKey, sessionId);
501
+ const store = this.stores.get(key);
502
+ if (!store) return;
503
+ store.clear();
504
+ this.stores.delete(key);
505
+ }
506
+ }
507
+
508
+ class InMemoryToolJobStore implements plugins.IToolJobStore {
509
+ private snapshot: plugins.IToolJobSnapshot | undefined;
510
+
511
+ public async load(abortSignal?: AbortSignal): Promise<plugins.IToolJobSnapshot | undefined> {
512
+ abortSignal?.throwIfAborted();
513
+ return this.snapshot ? cloneSerializable(this.snapshot) : undefined;
514
+ }
515
+
516
+ public async save(
517
+ jobs: readonly plugins.IToolJobState[],
518
+ expectedRevision: number,
519
+ abortSignal?: AbortSignal,
520
+ ): Promise<number> {
521
+ abortSignal?.throwIfAborted();
522
+ validateExpectedRevision(expectedRevision);
523
+ const snapshot = createToolJobSnapshot(jobs, expectedRevision + 1);
524
+ const actualRevision = this.snapshot?.revision ?? 0;
525
+ if (actualRevision !== expectedRevision) {
526
+ throw new plugins.ToolJobStoreConflictError(expectedRevision, actualRevision);
527
+ }
528
+ this.snapshot = cloneSerializable(snapshot);
529
+ return snapshot.revision;
530
+ }
531
+
532
+ public clear(): void {
533
+ this.snapshot = undefined;
534
+ }
535
+ }
536
+
537
+ class InMemoryToolJobStoreProvider implements IFlexToolJobStoreProvider {
538
+ private readonly stores = new Map<string, InMemoryToolJobStore>();
539
+
540
+ public getStore(storageKey: string, sessionId: string): InMemoryToolJobStore {
541
+ const key = providerKey(storageKey, sessionId);
542
+ let store = this.stores.get(key);
543
+ if (!store) {
544
+ store = new InMemoryToolJobStore();
545
+ this.stores.set(key, store);
546
+ }
547
+ return store;
91
548
  }
92
549
 
93
- private static inFileQueue<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
94
- const previous = JsonFileFlexHarnessStore.fileQueues.get(filePath) ?? Promise.resolve();
550
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
551
+ const key = providerKey(storageKey, sessionId);
552
+ const store = this.stores.get(key);
553
+ if (!store) return;
554
+ store.clear();
555
+ this.stores.delete(key);
556
+ }
557
+ }
558
+
559
+ export class InMemoryFlexHarnessStores implements IFlexHarnessStores {
560
+ public readonly scopes: IFlexScopeStore = new InMemoryScopeStore();
561
+ public readonly projections: IFlexProjectionStore = new InMemoryProjectionStore();
562
+ public readonly permissions: IFlexPermissionStore = new InMemoryPermissionStore();
563
+ public readonly agentEvents: IFlexAgentEventStoreProvider = new InMemoryAgentEventStoreProvider();
564
+ public readonly jobs: IFlexToolJobStoreProvider = new InMemoryToolJobStoreProvider();
565
+ }
566
+
567
+ const jsonDomains = ['scopes', 'projections', 'permissions', 'events', 'archives', 'jobs'] as const;
568
+ type TJsonDomain = (typeof jsonDomains)[number];
569
+
570
+ interface IDirectorySyncAttempt {
571
+ syncError?: unknown;
572
+ closeError?: unknown;
573
+ }
574
+
575
+ function aggregateStoreErrors(errors: unknown[], message: string): unknown {
576
+ return errors.length === 1 ? errors[0] : new AggregateError(errors, message);
577
+ }
578
+
579
+ class JsonFileRoot {
580
+ private static readonly fileQueues = new Map<string, Promise<void>>();
581
+ public readonly directory: string;
582
+ private readonly retainedFileHandles = new Set<plugins.fs.FileHandle>();
583
+ private readonly fileHandleClosures = new Map<plugins.fs.FileHandle, Promise<void>>();
584
+
585
+ constructor(directory: string) {
586
+ this.directory = plugins.path.resolve(directory);
587
+ }
588
+
589
+ public static inQueue<T>(key: string, operation: () => Promise<T>): Promise<T> {
590
+ const previous = JsonFileRoot.fileQueues.get(key) ?? Promise.resolve();
95
591
  const result = previous.catch(() => undefined).then(operation);
96
592
  const barrier = result.then(
97
593
  () => undefined,
98
594
  () => undefined,
99
595
  );
100
- JsonFileFlexHarnessStore.fileQueues.set(filePath, barrier);
596
+ JsonFileRoot.fileQueues.set(key, barrier);
101
597
  void barrier.then(() => {
102
- if (JsonFileFlexHarnessStore.fileQueues.get(filePath) === barrier) {
103
- JsonFileFlexHarnessStore.fileQueues.delete(filePath);
104
- }
598
+ if (JsonFileRoot.fileQueues.get(key) === barrier) JsonFileRoot.fileQueues.delete(key);
105
599
  });
106
600
  return result;
107
601
  }
108
602
 
109
- private filePath(storageKey: string): string {
110
- const digest = plugins.crypto.createHash('sha256').update(storageKey).digest('hex');
111
- return plugins.path.join(this.directory, `${digest}.json`);
603
+ public digest(value: string): string {
604
+ return plugins.crypto.createHash('sha256').update(value).digest('hex');
605
+ }
606
+
607
+ public scopePath(domain: TJsonDomain, storageKey: string): string {
608
+ validateIdentifier(storageKey, 'storageKey');
609
+ return plugins.path.join(this.directory, domain, `${this.digest(storageKey)}.json`);
610
+ }
611
+
612
+ public sessionPath(domain: TJsonDomain, storageKey: string, sessionId: string): string {
613
+ validateIdentifier(storageKey, 'storageKey');
614
+ validateIdentifier(sessionId, 'sessionId');
615
+ return plugins.path.join(
616
+ this.directory,
617
+ domain,
618
+ this.digest(storageKey),
619
+ `${this.digest(sessionId)}.json`,
620
+ );
112
621
  }
113
622
 
114
- private async preparePath(filePath: string): Promise<void> {
623
+ public archivePath(storageKey: string, sessionId: string, archiveId: string): string {
624
+ validateIdentifier(archiveId, 'archiveId');
625
+ return plugins.path.join(
626
+ this.archiveSessionPath(storageKey, sessionId),
627
+ `${this.digest(archiveId)}.json`,
628
+ );
629
+ }
630
+
631
+ public archiveSessionPath(storageKey: string, sessionId: string): string {
632
+ validateIdentifier(storageKey, 'storageKey');
633
+ validateIdentifier(sessionId, 'sessionId');
634
+ return plugins.path.join(
635
+ this.directory,
636
+ 'archives',
637
+ this.digest(storageKey),
638
+ this.digest(sessionId),
639
+ );
640
+ }
641
+
642
+ public eventQueueKey(storageKey: string, sessionId: string): string {
643
+ return `event:${this.sessionPath('events', storageKey, sessionId)}`;
644
+ }
645
+
646
+ private async prepareRoot(): Promise<void> {
647
+ await this.retryRetainedFileHandles();
115
648
  await plugins.fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
116
649
  await plugins.fs.chmod(this.directory, 0o700);
117
- await this.cleanupTemps(filePath);
650
+ await Promise.all(jsonDomains.map(async (domain) => {
651
+ const path = plugins.path.join(this.directory, domain);
652
+ await plugins.fs.mkdir(path, { recursive: true, mode: 0o700 });
653
+ await plugins.fs.chmod(path, 0o700);
654
+ }));
655
+ }
656
+
657
+ private closeFileHandle(handle: plugins.fs.FileHandle): Promise<void> {
658
+ const existing = this.fileHandleClosures.get(handle);
659
+ if (existing) return existing;
660
+ let closure!: Promise<void>;
661
+ closure = handle.close().then(() => {
662
+ this.retainedFileHandles.delete(handle);
663
+ }, (error: unknown) => {
664
+ this.retainedFileHandles.add(handle);
665
+ throw error;
666
+ }).finally(() => {
667
+ if (this.fileHandleClosures.get(handle) === closure) {
668
+ this.fileHandleClosures.delete(handle);
669
+ }
670
+ });
671
+ this.fileHandleClosures.set(handle, closure);
672
+ return closure;
673
+ }
674
+
675
+ private async retryRetainedFileHandles(): Promise<void> {
676
+ const results = await Promise.allSettled(
677
+ [...this.retainedFileHandles].map((handle) => this.closeFileHandle(handle)),
678
+ );
679
+ const errors = results
680
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
681
+ .map((result) => result.reason);
682
+ if (errors.length > 0) {
683
+ throw aggregateStoreErrors(errors, 'Failed to close retained JSON store file handles.');
684
+ }
685
+ }
686
+
687
+ public async dispose(): Promise<void> {
688
+ await Promise.allSettled([...this.fileHandleClosures.values()]);
689
+ await this.retryRetainedFileHandles();
690
+ }
691
+
692
+ private async prepareDirectory(directory: string): Promise<void> {
693
+ await this.prepareRoot();
694
+ await plugins.fs.mkdir(directory, { recursive: true, mode: 0o700 });
695
+ let current = directory;
696
+ while (current !== this.directory && current.startsWith(`${this.directory}${plugins.path.sep}`)) {
697
+ await plugins.fs.chmod(current, 0o700);
698
+ current = plugins.path.dirname(current);
699
+ }
118
700
  }
119
701
 
120
702
  private async cleanupTemps(filePath: string): Promise<void> {
703
+ const directory = plugins.path.dirname(filePath);
121
704
  const prefix = `${plugins.path.basename(filePath)}.`;
122
- const entries = await plugins.fs.readdir(this.directory, { withFileTypes: true });
123
- await Promise.all(
124
- entries
125
- .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.tmp'))
126
- .map(async (entry) => {
127
- try {
128
- await plugins.fs.unlink(plugins.path.join(this.directory, entry.name));
129
- } catch (error) {
130
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
131
- throw error;
132
- }
133
- }
134
- }),
135
- );
705
+ const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
706
+ const cleanupResults = await Promise.allSettled(entries
707
+ .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.tmp'))
708
+ .map(async (entry) => {
709
+ try {
710
+ await plugins.fs.unlink(plugins.path.join(directory, entry.name));
711
+ } catch (error) {
712
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
713
+ }
714
+ }));
715
+ const cleanupErrors = cleanupResults
716
+ .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
717
+ .map((result) => result.reason);
718
+ if (cleanupErrors.length > 0) {
719
+ throw aggregateStoreErrors(cleanupErrors, `Failed to clean temporary files for ${filePath}.`);
720
+ }
136
721
  }
137
722
 
138
- private async readSnapshot(filePath: string): Promise<IFlexHarnessSnapshot | undefined> {
723
+ private async attemptDirectorySync(directory: string): Promise<IDirectorySyncAttempt> {
724
+ let handle: plugins.fs.FileHandle | undefined;
725
+ let syncError: unknown;
726
+ let closeError: unknown;
727
+ try {
728
+ handle = await plugins.fs.open(directory, 'r');
729
+ await handle.sync();
730
+ } catch (error) {
731
+ syncError = error;
732
+ }
733
+ if (handle) {
734
+ try {
735
+ await this.closeFileHandle(handle);
736
+ } catch (error) {
737
+ closeError = error;
738
+ }
739
+ }
740
+ return { syncError, closeError };
741
+ }
742
+
743
+ private async writeExact(handle: plugins.fs.FileHandle, serialized: Buffer): Promise<void> {
744
+ let offset = 0;
745
+ while (offset < serialized.length) {
746
+ const { bytesWritten } = await handle.write(
747
+ serialized,
748
+ offset,
749
+ serialized.length - offset,
750
+ offset,
751
+ );
752
+ if (bytesWritten === 0) {
753
+ throw new Error('Writing the store temporary file made no progress.');
754
+ }
755
+ offset += bytesWritten;
756
+ }
757
+ }
758
+
759
+ private async syncParentAfterWrite(filePath: string, serialized: Buffer): Promise<void> {
760
+ const directory = plugins.path.dirname(filePath);
761
+ const firstAttempt = await this.attemptDirectorySync(directory);
762
+ if (firstAttempt.syncError === undefined && firstAttempt.closeError === undefined) return;
763
+
764
+ let comparisonError: unknown;
765
+ try {
766
+ const persisted = await plugins.fs.readFile(filePath);
767
+ if (!persisted.equals(serialized)) {
768
+ throw new Error(`Destination bytes at ${filePath} do not match the committed write.`);
769
+ }
770
+ } catch (error) {
771
+ comparisonError = error;
772
+ }
773
+ const retryAttempt = await this.attemptDirectorySync(directory);
774
+ if (
775
+ comparisonError === undefined
776
+ && retryAttempt.syncError === undefined
777
+ && firstAttempt.closeError === undefined
778
+ && retryAttempt.closeError === undefined
779
+ ) return;
780
+
781
+ const errors = [
782
+ firstAttempt.syncError,
783
+ firstAttempt.closeError,
784
+ comparisonError,
785
+ retryAttempt.syncError,
786
+ retryAttempt.closeError,
787
+ ].filter((error) => error !== undefined);
788
+ throw new FlexHarnessStoreCommitUncertainError(filePath, 'write', {
789
+ cause: aggregateStoreErrors(errors, `Could not confirm the store write at ${filePath}.`),
790
+ });
791
+ }
792
+
793
+ private async syncParentAfterDelete(
794
+ deletedPath: string,
795
+ operation: 'delete-file' | 'delete-directory',
796
+ ): Promise<void> {
797
+ const directory = plugins.path.dirname(deletedPath);
798
+ const firstAttempt = await this.attemptDirectorySync(directory);
799
+ if (firstAttempt.syncError === undefined && firstAttempt.closeError === undefined) return;
800
+
801
+ let comparisonError: unknown;
802
+ try {
803
+ await plugins.fs.stat(deletedPath);
804
+ comparisonError = new Error(`Deleted path ${deletedPath} still exists.`);
805
+ } catch (error) {
806
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') comparisonError = error;
807
+ }
808
+ const retryAttempt = await this.attemptDirectorySync(directory);
809
+ if (
810
+ comparisonError === undefined
811
+ && retryAttempt.syncError === undefined
812
+ && firstAttempt.closeError === undefined
813
+ && retryAttempt.closeError === undefined
814
+ ) return;
815
+
816
+ const errors = [
817
+ firstAttempt.syncError,
818
+ firstAttempt.closeError,
819
+ comparisonError,
820
+ retryAttempt.syncError,
821
+ retryAttempt.closeError,
822
+ ]
823
+ .filter((error) => error !== undefined);
824
+ throw new FlexHarnessStoreCommitUncertainError(deletedPath, operation, {
825
+ cause: aggregateStoreErrors(errors, `Could not confirm the store deletion at ${deletedPath}.`),
826
+ });
827
+ }
828
+
829
+ public async read<TValue>(
830
+ filePath: string,
831
+ validate: (value: unknown) => TValue,
832
+ ): Promise<TValue | undefined> {
833
+ await this.prepareDirectory(plugins.path.dirname(filePath));
139
834
  let serialized: string;
140
835
  try {
141
836
  await plugins.fs.chmod(filePath, 0o600);
142
837
  serialized = await plugins.fs.readFile(filePath, 'utf8');
143
838
  } catch (error) {
144
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
145
- return undefined;
146
- }
839
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
147
840
  throw error;
148
841
  }
149
-
150
842
  let parsed: unknown;
151
843
  try {
152
844
  parsed = JSON.parse(serialized);
153
845
  } catch (error) {
154
- throw new FlexHarnessStoreFormatError(`Malformed JSON snapshot at ${filePath}.`, {
155
- cause: error,
156
- });
846
+ throw new FlexHarnessStoreFormatError(`Malformed JSON at ${filePath}.`, { cause: error });
157
847
  }
158
848
  try {
159
- assertFlexHarnessSnapshot(parsed);
849
+ return validate(parsed);
160
850
  } catch (error) {
161
851
  if (error instanceof FlexHarnessStoreFormatError) {
162
- throw new FlexHarnessStoreFormatError(`Invalid snapshot at ${filePath}: ${error.message}`, {
852
+ throw new FlexHarnessStoreFormatError(`Invalid JSON at ${filePath}: ${error.message}`, {
163
853
  cause: error,
164
854
  });
165
855
  }
166
856
  throw error;
167
857
  }
168
- return cloneSerializable(parsed);
169
858
  }
170
859
 
171
- private async writeSnapshot(
172
- filePath: string,
173
- snapshot: IFlexHarnessSnapshot,
174
- ): Promise<void> {
860
+ public async write(filePath: string, value: unknown): Promise<void> {
861
+ assertJsonSerializable(value);
862
+ const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
863
+ const directory = plugins.path.dirname(filePath);
864
+ await this.prepareDirectory(directory);
865
+ await this.cleanupTemps(filePath);
175
866
  const tempPath = `${filePath}.${process.pid}.${plugins.crypto.randomUUID()}.tmp`;
176
- let fileHandle: plugins.fs.FileHandle | undefined;
867
+ let handle: plugins.fs.FileHandle | undefined;
868
+ let operationError: unknown;
869
+ let operationFailed = false;
870
+ let renamed = false;
177
871
  try {
178
- fileHandle = await plugins.fs.open(tempPath, 'wx', 0o600);
179
- await fileHandle.writeFile(`${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
180
- await fileHandle.sync();
181
- await fileHandle.close();
182
- fileHandle = undefined;
183
- await plugins.fs.chmod(tempPath, 0o600);
872
+ handle = await plugins.fs.open(tempPath, 'wx', 0o600);
873
+ await this.writeExact(handle, serialized);
874
+ await handle.sync();
875
+ await this.closeFileHandle(handle);
876
+ handle = undefined;
184
877
  await plugins.fs.rename(tempPath, filePath);
185
- await plugins.fs.chmod(filePath, 0o600);
186
- } finally {
187
- await fileHandle?.close().catch(() => undefined);
188
- await plugins.fs.unlink(tempPath).catch((error: NodeJS.ErrnoException) => {
189
- if (error.code !== 'ENOENT') {
190
- throw error;
191
- }
878
+ renamed = true;
879
+ await this.syncParentAfterWrite(filePath, serialized);
880
+ } catch (error) {
881
+ operationError = error;
882
+ operationFailed = true;
883
+ }
884
+
885
+ const cleanupErrors: unknown[] = [];
886
+ if (handle) {
887
+ try {
888
+ await this.closeFileHandle(handle);
889
+ } catch (error) {
890
+ cleanupErrors.push(error);
891
+ }
892
+ }
893
+ try {
894
+ await plugins.fs.unlink(tempPath);
895
+ } catch (error) {
896
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') cleanupErrors.push(error);
897
+ }
898
+
899
+ if (!operationFailed && cleanupErrors.length === 0) return;
900
+ if (
901
+ renamed
902
+ && operationError instanceof FlexHarnessStoreCommitUncertainError
903
+ && cleanupErrors.length === 0
904
+ ) {
905
+ throw operationError;
906
+ }
907
+ const errors: unknown[] = [];
908
+ if (operationFailed) errors.push(operationError);
909
+ errors.push(...cleanupErrors);
910
+ const cause = aggregateStoreErrors(errors, `Store write or cleanup failed at ${filePath}.`);
911
+ if (renamed) {
912
+ throw new FlexHarnessStoreCommitUncertainError(filePath, 'write', { cause });
913
+ }
914
+ throw cause;
915
+ }
916
+
917
+ public async deleteFile(filePath: string): Promise<void> {
918
+ await this.prepareDirectory(plugins.path.dirname(filePath));
919
+ try {
920
+ await plugins.fs.unlink(filePath);
921
+ } catch (error) {
922
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
923
+ }
924
+ await this.syncParentAfterDelete(filePath, 'delete-file');
925
+ }
926
+
927
+ public async deleteDirectory(directory: string): Promise<void> {
928
+ await this.prepareDirectory(plugins.path.dirname(directory));
929
+ await plugins.fs.rm(directory, { recursive: true, force: true });
930
+ await this.syncParentAfterDelete(directory, 'delete-directory');
931
+ }
932
+ }
933
+
934
+ class JsonScopeStore implements IFlexScopeStore {
935
+ constructor(private readonly root: JsonFileRoot) {}
936
+
937
+ public async load(storageKey: string): Promise<IFlexScopeSnapshot | undefined> {
938
+ const filePath = this.root.scopePath('scopes', storageKey);
939
+ return JsonFileRoot.inQueue(filePath, async () => {
940
+ const snapshot = await this.root.read(filePath, (value) => {
941
+ assertFlexScopeSnapshot(value);
942
+ return value;
943
+ });
944
+ return snapshot ? cloneSerializable(snapshot) : undefined;
945
+ });
946
+ }
947
+
948
+ public async save(
949
+ storageKey: string,
950
+ snapshot: IFlexScopeSnapshot,
951
+ expectedRevision: number,
952
+ ): Promise<void> {
953
+ const validated = validateSnapshotSave(snapshot, expectedRevision, assertFlexScopeSnapshot);
954
+ const filePath = this.root.scopePath('scopes', storageKey);
955
+ await JsonFileRoot.inQueue(filePath, async () => {
956
+ const current = await this.root.read(filePath, (value) => {
957
+ assertFlexScopeSnapshot(value);
958
+ return value;
959
+ });
960
+ const actualRevision = current?.revision ?? 0;
961
+ if (actualRevision !== expectedRevision) {
962
+ throw new FlexHarnessStoreConflictError(storageKey, expectedRevision, actualRevision);
963
+ }
964
+ await this.root.write(filePath, validated);
965
+ });
966
+ }
967
+ }
968
+
969
+ class JsonProjectionStore implements IFlexProjectionStore {
970
+ constructor(private readonly root: JsonFileRoot) {}
971
+
972
+ public async load(
973
+ storageKey: string,
974
+ sessionId: string,
975
+ ): Promise<IFlexProjectionSnapshot | undefined> {
976
+ const filePath = this.root.sessionPath('projections', storageKey, sessionId);
977
+ return JsonFileRoot.inQueue(filePath, async () => {
978
+ const snapshot = await this.root.read(filePath, (value) => {
979
+ assertFlexProjectionSnapshot(value);
980
+ assertProjectionSession(value, sessionId);
981
+ return value;
982
+ });
983
+ return snapshot ? cloneSerializable(snapshot) : undefined;
984
+ });
985
+ }
986
+
987
+ public async save(
988
+ storageKey: string,
989
+ sessionId: string,
990
+ snapshot: IFlexProjectionSnapshot,
991
+ expectedRevision: number,
992
+ ): Promise<void> {
993
+ const validated = validateSnapshotSave(snapshot, expectedRevision, assertFlexProjectionSnapshot);
994
+ assertProjectionSession(validated, sessionId);
995
+ const filePath = this.root.sessionPath('projections', storageKey, sessionId);
996
+ await JsonFileRoot.inQueue(filePath, async () => {
997
+ const current = await this.root.read(filePath, (value) => {
998
+ assertFlexProjectionSnapshot(value);
999
+ assertProjectionSession(value, sessionId);
1000
+ return value;
1001
+ });
1002
+ const actualRevision = current?.revision ?? 0;
1003
+ if (actualRevision !== expectedRevision) {
1004
+ throw new FlexHarnessStoreConflictError(providerKey(storageKey, sessionId), expectedRevision, actualRevision);
1005
+ }
1006
+ await this.root.write(filePath, validated);
1007
+ });
1008
+ }
1009
+
1010
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
1011
+ const filePath = this.root.sessionPath('projections', storageKey, sessionId);
1012
+ await JsonFileRoot.inQueue(filePath, () => this.root.deleteFile(filePath));
1013
+ }
1014
+ }
1015
+
1016
+ class JsonPermissionStore implements IFlexPermissionStore {
1017
+ constructor(private readonly root: JsonFileRoot) {}
1018
+
1019
+ public async load(
1020
+ storageKey: string,
1021
+ sessionId: string,
1022
+ ): Promise<IFlexPermissionSnapshot | undefined> {
1023
+ const filePath = this.root.sessionPath('permissions', storageKey, sessionId);
1024
+ return JsonFileRoot.inQueue(filePath, async () => {
1025
+ const snapshot = await this.root.read(filePath, (value) => {
1026
+ assertFlexPermissionSnapshot(value);
1027
+ return value;
192
1028
  });
1029
+ return snapshot ? cloneSerializable(snapshot) : undefined;
1030
+ });
1031
+ }
1032
+
1033
+ public async save(
1034
+ storageKey: string,
1035
+ sessionId: string,
1036
+ snapshot: IFlexPermissionSnapshot,
1037
+ expectedRevision: number,
1038
+ ): Promise<void> {
1039
+ const validated = validateSnapshotSave(snapshot, expectedRevision, assertFlexPermissionSnapshot);
1040
+ const filePath = this.root.sessionPath('permissions', storageKey, sessionId);
1041
+ await JsonFileRoot.inQueue(filePath, async () => {
1042
+ const current = await this.root.read(filePath, (value) => {
1043
+ assertFlexPermissionSnapshot(value);
1044
+ return value;
1045
+ });
1046
+ const actualRevision = current?.revision ?? 0;
1047
+ if (actualRevision !== expectedRevision) {
1048
+ throw new FlexHarnessStoreConflictError(providerKey(storageKey, sessionId), expectedRevision, actualRevision);
1049
+ }
1050
+ await this.root.write(filePath, validated);
1051
+ });
1052
+ }
1053
+
1054
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
1055
+ const filePath = this.root.sessionPath('permissions', storageKey, sessionId);
1056
+ await JsonFileRoot.inQueue(filePath, () => this.root.deleteFile(filePath));
1057
+ }
1058
+ }
1059
+
1060
+ async function clearJsonAgentEventSession(
1061
+ root: JsonFileRoot,
1062
+ storageKey: string,
1063
+ sessionId: string,
1064
+ ): Promise<void> {
1065
+ const filePath = root.sessionPath('events', storageKey, sessionId);
1066
+ const archiveDirectory = root.archiveSessionPath(storageKey, sessionId);
1067
+ await JsonFileRoot.inQueue(root.eventQueueKey(storageKey, sessionId), async () => {
1068
+ await root.deleteFile(filePath);
1069
+ await root.deleteDirectory(archiveDirectory);
1070
+ });
1071
+ }
1072
+
1073
+ class JsonAgentEventStore implements plugins.IAgentEventStoreV2 {
1074
+ public readonly eventSchemaVersion = 2 as const;
1075
+
1076
+ constructor(
1077
+ private readonly root: JsonFileRoot,
1078
+ private readonly storageKey: string,
1079
+ private readonly sessionId: string,
1080
+ ) {}
1081
+
1082
+ private assertSession(sessionId: string): void {
1083
+ if (sessionId !== this.sessionId) {
1084
+ throw new FlexHarnessValidationError(
1085
+ `Agent event store is bound to session "${this.sessionId}", not "${sessionId}".`,
1086
+ );
1087
+ }
1088
+ }
1089
+
1090
+ public async load(sessionId: string): Promise<plugins.IAgentEventSnapshotV2 | undefined> {
1091
+ this.assertSession(sessionId);
1092
+ const filePath = this.root.sessionPath('events', this.storageKey, this.sessionId);
1093
+ return JsonFileRoot.inQueue(this.root.eventQueueKey(this.storageKey, this.sessionId), async () => {
1094
+ const snapshot = await this.root.read(
1095
+ filePath,
1096
+ (value) => validateAgentEventSnapshot(value, this.sessionId),
1097
+ );
1098
+ return snapshot ? cloneSerializable(snapshot) : undefined;
1099
+ });
1100
+ }
1101
+
1102
+ public async save(
1103
+ sessionId: string,
1104
+ events: readonly plugins.TAgentEvent[],
1105
+ expectedRevision: number,
1106
+ ): Promise<number> {
1107
+ this.assertSession(sessionId);
1108
+ validateExpectedRevision(expectedRevision);
1109
+ const snapshot = createAgentEventSnapshot(sessionId, events, expectedRevision + 1);
1110
+ validateAgentEventSnapshot(snapshot, sessionId);
1111
+ const filePath = this.root.sessionPath('events', this.storageKey, this.sessionId);
1112
+ return JsonFileRoot.inQueue(this.root.eventQueueKey(this.storageKey, this.sessionId), async () => {
1113
+ const current = await this.root.read(
1114
+ filePath,
1115
+ (value) => validateAgentEventSnapshot(value, this.sessionId),
1116
+ );
1117
+ const actualRevision = current?.revision ?? 0;
1118
+ if (actualRevision !== expectedRevision) {
1119
+ throw new plugins.AgentEventStoreConflictError(sessionId, expectedRevision, actualRevision);
1120
+ }
1121
+ await this.root.write(filePath, snapshot);
1122
+ return snapshot.revision;
1123
+ });
1124
+ }
1125
+
1126
+ public async archive(archive: plugins.IAgentEventArchiveV2): Promise<void> {
1127
+ this.assertSession(archive.sessionId);
1128
+ const normalized = normalizeJsonObjectUndefined(archive, '$archive');
1129
+ const validated = validateAgentEventArchive(normalized, this.sessionId, archive.archiveId);
1130
+ const filePath = this.root.archivePath(
1131
+ this.storageKey,
1132
+ this.sessionId,
1133
+ archive.archiveId,
1134
+ );
1135
+ await JsonFileRoot.inQueue(this.root.eventQueueKey(this.storageKey, this.sessionId), async () => {
1136
+ const current = await this.root.read(
1137
+ filePath,
1138
+ (value) => validateAgentEventArchive(value, this.sessionId, archive.archiveId),
1139
+ );
1140
+ if (current) {
1141
+ if (
1142
+ JSON.stringify(current.events.map((event) => event.id))
1143
+ !== JSON.stringify(validated.events.map((event) => event.id))
1144
+ ) {
1145
+ throw new Error(`Archive "${archive.archiveId}" already exists with different events.`);
1146
+ }
1147
+ return;
1148
+ }
1149
+ await this.root.write(filePath, validated);
1150
+ });
1151
+ }
1152
+
1153
+ public async loadArchive(
1154
+ sessionId: string,
1155
+ archiveId: string,
1156
+ ): Promise<plugins.IAgentEventArchiveV2 | undefined> {
1157
+ this.assertSession(sessionId);
1158
+ const filePath = this.root.archivePath(this.storageKey, this.sessionId, archiveId);
1159
+ return JsonFileRoot.inQueue(this.root.eventQueueKey(this.storageKey, this.sessionId), async () => {
1160
+ const archive = await this.root.read(
1161
+ filePath,
1162
+ (value) => validateAgentEventArchive(value, this.sessionId, archiveId),
1163
+ );
1164
+ return archive ? cloneSerializable(archive) : undefined;
1165
+ });
1166
+ }
1167
+
1168
+ public async clear(): Promise<void> {
1169
+ await clearJsonAgentEventSession(this.root, this.storageKey, this.sessionId);
1170
+ }
1171
+ }
1172
+
1173
+ class JsonAgentEventStoreProvider implements IFlexAgentEventStoreProvider {
1174
+ private readonly stores = new Map<string, JsonAgentEventStore>();
1175
+
1176
+ constructor(private readonly root: JsonFileRoot) {}
1177
+
1178
+ public getStore(storageKey: string, sessionId: string): JsonAgentEventStore {
1179
+ const key = providerKey(storageKey, sessionId);
1180
+ let store = this.stores.get(key);
1181
+ if (!store) {
1182
+ store = new JsonAgentEventStore(this.root, storageKey, sessionId);
1183
+ this.stores.set(key, store);
1184
+ }
1185
+ return store;
1186
+ }
1187
+
1188
+ public releaseSession(storageKey: string, sessionId: string): void {
1189
+ this.stores.delete(providerKey(storageKey, sessionId));
1190
+ }
1191
+
1192
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
1193
+ const key = providerKey(storageKey, sessionId);
1194
+ const store = this.stores.get(key);
1195
+ if (store) {
1196
+ await store.clear();
1197
+ } else {
1198
+ await clearJsonAgentEventSession(this.root, storageKey, sessionId);
193
1199
  }
1200
+ this.stores.delete(key);
1201
+ }
1202
+ }
1203
+
1204
+ async function clearJsonToolJobSession(
1205
+ root: JsonFileRoot,
1206
+ storageKey: string,
1207
+ sessionId: string,
1208
+ ): Promise<void> {
1209
+ const filePath = root.sessionPath('jobs', storageKey, sessionId);
1210
+ await JsonFileRoot.inQueue(filePath, () => root.deleteFile(filePath));
1211
+ }
1212
+
1213
+ class JsonToolJobStore implements plugins.IToolJobStore {
1214
+ constructor(
1215
+ private readonly root: JsonFileRoot,
1216
+ private readonly storageKey: string,
1217
+ private readonly sessionId: string,
1218
+ ) {}
1219
+
1220
+ private filePath(): string {
1221
+ return this.root.sessionPath('jobs', this.storageKey, this.sessionId);
1222
+ }
1223
+
1224
+ public async load(abortSignal?: AbortSignal): Promise<plugins.IToolJobSnapshot | undefined> {
1225
+ abortSignal?.throwIfAborted();
1226
+ const filePath = this.filePath();
1227
+ return JsonFileRoot.inQueue(filePath, async () => {
1228
+ abortSignal?.throwIfAborted();
1229
+ const snapshot = await this.root.read(filePath, validateToolJobSnapshot);
1230
+ abortSignal?.throwIfAborted();
1231
+ return snapshot ? cloneSerializable(snapshot) : undefined;
1232
+ });
1233
+ }
1234
+
1235
+ public async save(
1236
+ jobs: readonly plugins.IToolJobState[],
1237
+ expectedRevision: number,
1238
+ abortSignal?: AbortSignal,
1239
+ ): Promise<number> {
1240
+ abortSignal?.throwIfAborted();
1241
+ validateExpectedRevision(expectedRevision);
1242
+ const snapshot = createToolJobSnapshot(jobs, expectedRevision + 1);
1243
+ const filePath = this.filePath();
1244
+ return JsonFileRoot.inQueue(filePath, async () => {
1245
+ abortSignal?.throwIfAborted();
1246
+ const current = await this.root.read(filePath, validateToolJobSnapshot);
1247
+ const actualRevision = current?.revision ?? 0;
1248
+ if (actualRevision !== expectedRevision) {
1249
+ throw new plugins.ToolJobStoreConflictError(expectedRevision, actualRevision);
1250
+ }
1251
+ abortSignal?.throwIfAborted();
1252
+ await this.root.write(filePath, snapshot);
1253
+ return snapshot.revision;
1254
+ });
1255
+ }
1256
+
1257
+ public async clear(): Promise<void> {
1258
+ await clearJsonToolJobSession(this.root, this.storageKey, this.sessionId);
1259
+ }
1260
+ }
1261
+
1262
+ class JsonToolJobStoreProvider implements IFlexToolJobStoreProvider {
1263
+ private readonly stores = new Map<string, JsonToolJobStore>();
1264
+
1265
+ constructor(private readonly root: JsonFileRoot) {}
1266
+
1267
+ public getStore(storageKey: string, sessionId: string): JsonToolJobStore {
1268
+ const key = providerKey(storageKey, sessionId);
1269
+ let store = this.stores.get(key);
1270
+ if (!store) {
1271
+ store = new JsonToolJobStore(this.root, storageKey, sessionId);
1272
+ this.stores.set(key, store);
1273
+ }
1274
+ return store;
1275
+ }
1276
+
1277
+ public releaseSession(storageKey: string, sessionId: string): void {
1278
+ this.stores.delete(providerKey(storageKey, sessionId));
1279
+ }
1280
+
1281
+ public async deleteSession(storageKey: string, sessionId: string): Promise<void> {
1282
+ const key = providerKey(storageKey, sessionId);
1283
+ const store = this.stores.get(key);
1284
+ if (store) {
1285
+ await store.clear();
1286
+ } else {
1287
+ await clearJsonToolJobSession(this.root, storageKey, sessionId);
1288
+ }
1289
+ this.stores.delete(key);
1290
+ }
1291
+ }
1292
+
1293
+ export interface IJsonFileFlexHarnessStoresOptions {
1294
+ directory: string;
1295
+ }
1296
+
1297
+ /**
1298
+ * Atomic and CAS-safe across instances in this process. No operating-system lock
1299
+ * is held, so this store intentionally does not claim cross-process safety.
1300
+ */
1301
+ export class JsonFileFlexHarnessStores implements IFlexHarnessStores {
1302
+ public readonly scopes: IFlexScopeStore;
1303
+ public readonly projections: IFlexProjectionStore;
1304
+ public readonly permissions: IFlexPermissionStore;
1305
+ public readonly agentEvents: IFlexAgentEventStoreProvider;
1306
+ public readonly jobs: IFlexToolJobStoreProvider;
1307
+ private readonly root: JsonFileRoot;
1308
+
1309
+ constructor(options: IJsonFileFlexHarnessStoresOptions) {
1310
+ if (!options?.directory?.trim()) {
1311
+ throw new FlexHarnessValidationError('JsonFileFlexHarnessStores requires a directory.');
1312
+ }
1313
+ this.root = new JsonFileRoot(options.directory);
1314
+ this.scopes = new JsonScopeStore(this.root);
1315
+ this.projections = new JsonProjectionStore(this.root);
1316
+ this.permissions = new JsonPermissionStore(this.root);
1317
+ this.agentEvents = new JsonAgentEventStoreProvider(this.root);
1318
+ this.jobs = new JsonToolJobStoreProvider(this.root);
1319
+ }
1320
+
1321
+ public dispose(): Promise<void> {
1322
+ return this.root.dispose();
194
1323
  }
195
1324
  }