@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
package/ts/classes.stores.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
)
|
|
14
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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:
|
|
339
|
+
snapshot: IFlexScopeSnapshot,
|
|
37
340
|
expectedRevision: number,
|
|
38
341
|
): Promise<void> {
|
|
39
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
352
|
+
class InMemoryProjectionStore implements IFlexProjectionStore {
|
|
353
|
+
private readonly snapshots = new Map<string, IFlexProjectionSnapshot>();
|
|
51
354
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
377
|
+
this.snapshots.set(key, validated);
|
|
65
378
|
}
|
|
66
379
|
|
|
67
|
-
public async
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
398
|
+
sessionId: string,
|
|
399
|
+
snapshot: IFlexPermissionSnapshot,
|
|
78
400
|
expectedRevision: number,
|
|
79
401
|
): Promise<void> {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
const
|
|
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
|
-
|
|
596
|
+
JsonFileRoot.fileQueues.set(key, barrier);
|
|
101
597
|
void barrier.then(() => {
|
|
102
|
-
if (
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
123
|
-
await Promise.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
155
|
-
cause: error,
|
|
156
|
-
});
|
|
846
|
+
throw new FlexHarnessStoreFormatError(`Malformed JSON at ${filePath}.`, { cause: error });
|
|
157
847
|
}
|
|
158
848
|
try {
|
|
159
|
-
|
|
849
|
+
return validate(parsed);
|
|
160
850
|
} catch (error) {
|
|
161
851
|
if (error instanceof FlexHarnessStoreFormatError) {
|
|
162
|
-
throw new FlexHarnessStoreFormatError(`Invalid
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
867
|
+
let handle: plugins.fs.FileHandle | undefined;
|
|
868
|
+
let operationError: unknown;
|
|
869
|
+
let operationFailed = false;
|
|
870
|
+
let renamed = false;
|
|
177
871
|
try {
|
|
178
|
-
|
|
179
|
-
await
|
|
180
|
-
await
|
|
181
|
-
await
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
}
|