@modelprofile.com/flexharness 10.16.0 → 10.17.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +3 -0
- package/dist_ts/classes.flexharness.js +36 -15
- package/dist_ts/classes.stores.js +40 -8
- package/dist_ts/classes.toolcatalog.d.ts +9 -0
- package/dist_ts/classes.toolcatalog.js +98 -8
- package/dist_ts/interfaces.d.ts +9 -1
- package/dist_ts/interfaces.js +4 -2
- package/dist_ts/utils.storerules.d.ts +5 -0
- package/dist_ts/utils.storerules.js +10 -1
- package/dist_ts_stores_nosqldb/classes.nosqldbstores.js +27 -2
- package/dist_ts_stores_nosqldb/classes.recordstore.d.ts +2 -0
- package/dist_ts_stores_nosqldb/classes.recordstore.js +7 -1
- package/dist_ts_typedrequest/catalog.d.ts +28 -0
- package/dist_ts_typedrequest/catalog.js +79 -0
- package/dist_ts_typedrequest/describe.d.ts +10 -0
- package/dist_ts_typedrequest/describe.js +189 -0
- package/dist_ts_typedrequest/index.d.ts +3 -0
- package/dist_ts_typedrequest/index.js +4 -0
- package/dist_ts_typedrequest/interfaces.d.ts +107 -0
- package/dist_ts_typedrequest/interfaces.js +2 -0
- package/dist_ts_typedrequest/plugins.d.ts +7 -0
- package/dist_ts_typedrequest/plugins.js +11 -0
- package/package.json +14 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +40 -14
- package/ts/classes.stores.ts +49 -6
- package/ts/classes.toolcatalog.ts +90 -7
- package/ts/interfaces.ts +10 -2
- package/ts/utils.storerules.ts +15 -0
- package/ts_stores_nosqldb/classes.nosqldbstores.ts +29 -0
- package/ts_stores_nosqldb/classes.recordstore.ts +8 -0
- package/ts_typedrequest/catalog.ts +91 -0
- package/ts_typedrequest/describe.ts +211 -0
- package/ts_typedrequest/index.ts +3 -0
- package/ts_typedrequest/interfaces.ts +124 -0
- package/ts_typedrequest/plugins.ts +15 -0
- package/ts_typedrequest/readme.md +66 -0
|
@@ -907,10 +907,13 @@ class FlexToolCatalogCore {
|
|
|
907
907
|
if (cached) return cached;
|
|
908
908
|
let rendered: IRenderedDeclaration;
|
|
909
909
|
if (entry.declaration !== undefined) {
|
|
910
|
-
|
|
911
|
-
//
|
|
912
|
-
|
|
913
|
-
this.
|
|
910
|
+
// A host's declaration names its parameters' types in its parameter list and its result's after
|
|
911
|
+
// it; text that is no plain function declaration declares every type it names with it.
|
|
912
|
+
const parts = signatureParts(entry.declaration, entry.name);
|
|
913
|
+
const input = this.scanTypes(parts?.params ?? entry.declaration);
|
|
914
|
+
const output = parts ? this.scanTypes(parts.result) : [];
|
|
915
|
+
rendered = { text: entry.declaration.trim(), types: [...new Set([...input, ...output])].sort(compareCodePoints) };
|
|
916
|
+
this.methodTypeSets.set(entry.path, { input, output });
|
|
914
917
|
} else {
|
|
915
918
|
const inputWriter = new SchemaDeclarationWriter(this.scopeContext(entry.inputScope));
|
|
916
919
|
const members = inputWriter.objectMembers(entry.input.json);
|
|
@@ -944,9 +947,10 @@ class FlexToolCatalogCore {
|
|
|
944
947
|
return rendered;
|
|
945
948
|
}
|
|
946
949
|
|
|
950
|
+
/** The catalog types a declaration's code names; its comments and string literals name none. */
|
|
947
951
|
private scanTypes(text: string, self?: string): string[] {
|
|
948
952
|
const found = new Set<string>();
|
|
949
|
-
for (const match of text.matchAll(identifierScanPattern)) {
|
|
953
|
+
for (const match of codeOf(text).matchAll(identifierScanPattern)) {
|
|
950
954
|
if (match[0] !== self && this.types.has(match[0])) found.add(match[0]);
|
|
951
955
|
}
|
|
952
956
|
return [...found].sort(compareCodePoints);
|
|
@@ -1045,6 +1049,16 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
1045
1049
|
private readonly describeTopMatch: boolean,
|
|
1046
1050
|
) {}
|
|
1047
1051
|
|
|
1052
|
+
/**
|
|
1053
|
+
* The identifier a name from elsewhere, such as a file or an API's method, is declared under as a
|
|
1054
|
+
* path segment or a type: every other character `_`, `_` before a leading digit and after a
|
|
1055
|
+
* reserved word (`export-jobs` becomes `export_jobs`, `delete` becomes `delete_`). The catalog
|
|
1056
|
+
* maps type names that are no identifiers the same way.
|
|
1057
|
+
*/
|
|
1058
|
+
public static identifierOf(name: string): string {
|
|
1059
|
+
return typeIdentifierOf(name);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1048
1062
|
/**
|
|
1049
1063
|
* Prepares `catalog`. Preparing the same object again returns the cached preparation, so a host
|
|
1050
1064
|
* that returns one catalog object for every run pays for it once.
|
|
@@ -1290,7 +1304,7 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
1290
1304
|
const unknownTypes = requestedTypes.filter((name) => identifierOf(name) === undefined).sort(compareCodePoints);
|
|
1291
1305
|
const notes = [
|
|
1292
1306
|
...unknownMethods.map((path) => {
|
|
1293
|
-
const suggestions = closeMatches(path, this.
|
|
1307
|
+
const suggestions = closeMatches(path, this.listedMethods());
|
|
1294
1308
|
return `Unknown method ${path}.${suggestions.length ? ` Did you mean ${suggestions.join(', ')}?` : ' Find methods with search_tools.'}`;
|
|
1295
1309
|
}),
|
|
1296
1310
|
...unknownTypes.map((name) => {
|
|
@@ -1336,7 +1350,7 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
1336
1350
|
error: {
|
|
1337
1351
|
error: 'unknown_method',
|
|
1338
1352
|
method: path,
|
|
1339
|
-
didYouMean: isNamespace ? [] : closeMatches(path, this.
|
|
1353
|
+
didYouMean: isNamespace ? [] : closeMatches(path, this.listedMethods()),
|
|
1340
1354
|
hint: isNamespace
|
|
1341
1355
|
? `${path} is a namespace; list it with search_tools({"namespace":"${path}"}).`
|
|
1342
1356
|
: 'Find methods with search_tools.',
|
|
@@ -1393,6 +1407,11 @@ export class FlexToolCatalog<TScope = unknown> {
|
|
|
1393
1407
|
return { method: input.method, params: input.params === undefined ? {} : input.params };
|
|
1394
1408
|
}
|
|
1395
1409
|
|
|
1410
|
+
/** The method paths search and suggestions may name: built-ins such as `results.get` stay out. */
|
|
1411
|
+
private listedMethods(): string[] {
|
|
1412
|
+
return this.methods.filter((path) => !this.core.unlisted.has(path));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1396
1415
|
private isAdmitted(path: string): boolean {
|
|
1397
1416
|
return this.admitted === undefined || this.admitted.has(path);
|
|
1398
1417
|
}
|
|
@@ -1780,6 +1799,70 @@ export const catalogToolInputSchema = <TInput>(definition: IFlexCatalogToolDefin
|
|
|
1780
1799
|
});
|
|
1781
1800
|
};
|
|
1782
1801
|
|
|
1802
|
+
/** Declaration text with its comments and the contents of its string literals blanked. */
|
|
1803
|
+
const codeOf = (text: string): string => {
|
|
1804
|
+
let code = '';
|
|
1805
|
+
for (let index = 0; index < text.length; index++) {
|
|
1806
|
+
const char = text[index];
|
|
1807
|
+
if (char === '/' && text[index + 1] === '/') {
|
|
1808
|
+
const end = text.indexOf('\n', index);
|
|
1809
|
+
index = (end === -1 ? text.length : end) - 1;
|
|
1810
|
+
code += ' ';
|
|
1811
|
+
} else if (char === '/' && text[index + 1] === '*') {
|
|
1812
|
+
const end = text.indexOf('*/', index + 2);
|
|
1813
|
+
index = end === -1 ? text.length : end + 1;
|
|
1814
|
+
code += ' ';
|
|
1815
|
+
} else if (char === '"' || char === "'" || char === '`') {
|
|
1816
|
+
let end = index + 1;
|
|
1817
|
+
while (end < text.length && text[end] !== char) end += text[end] === '\\' ? 2 : 1;
|
|
1818
|
+
code += `${char}${char}`;
|
|
1819
|
+
index = end;
|
|
1820
|
+
} else {
|
|
1821
|
+
code += char;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return code;
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
/** The index of the bracket that closes the one at `open`, or -1. */
|
|
1828
|
+
const closingBracket = (code: string, open: number): number => {
|
|
1829
|
+
const pairs: Record<string, string> = { '(': ')', '<': '>', '{': '}', '[': ']' };
|
|
1830
|
+
const stack: string[] = [];
|
|
1831
|
+
for (let index = open; index < code.length; index++) {
|
|
1832
|
+
const char = code[index];
|
|
1833
|
+
if (char === '=' && code[index + 1] === '>') {
|
|
1834
|
+
index++;
|
|
1835
|
+
} else if (pairs[char] !== undefined && (char !== '<' || stack.length === 0 || stack[stack.length - 1] === '>')) {
|
|
1836
|
+
stack.push(pairs[char]);
|
|
1837
|
+
} else if (char === stack[stack.length - 1]) {
|
|
1838
|
+
stack.pop();
|
|
1839
|
+
if (stack.length === 0) return index;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return -1;
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* A host method declaration's parameter list and the result after it, as code without comments, or
|
|
1847
|
+
* undefined where the text is no plain `function name(…): …` declaration.
|
|
1848
|
+
*/
|
|
1849
|
+
const signatureParts = (declaration: string, name: string): { params: string; result: string } | undefined => {
|
|
1850
|
+
const code = codeOf(declaration);
|
|
1851
|
+
const start = new RegExp(`\\bfunction\\s+${name}\\s*`).exec(code);
|
|
1852
|
+
if (!start) return undefined;
|
|
1853
|
+
let open = start.index + start[0].length;
|
|
1854
|
+
if (code[open] === '<') {
|
|
1855
|
+
const typeParameters = closingBracket(code, open);
|
|
1856
|
+
if (typeParameters === -1) return undefined;
|
|
1857
|
+
open = typeParameters + 1;
|
|
1858
|
+
while (/\s/.test(code[open] ?? '')) open++;
|
|
1859
|
+
}
|
|
1860
|
+
if (code[open] !== '(') return undefined;
|
|
1861
|
+
const close = closingBracket(code, open);
|
|
1862
|
+
if (close === -1) return undefined;
|
|
1863
|
+
return { params: code.slice(open + 1, close), result: code.slice(close + 1) };
|
|
1864
|
+
};
|
|
1865
|
+
|
|
1783
1866
|
/** A root-level method declaration gets `declare`, as a namespace member does not need it. */
|
|
1784
1867
|
const withDeclare = (text: string): string => {
|
|
1785
1868
|
const lines = text.split('\n');
|
package/ts/interfaces.ts
CHANGED
|
@@ -688,6 +688,8 @@ export const FLEX_RESULT_METHOD = 'results.get';
|
|
|
688
688
|
export const FLEX_RESULT_LIMITS = Object.freeze({
|
|
689
689
|
/** The most of one output a result stores; beyond it the stored value is truncated. */
|
|
690
690
|
maxStoredBytes: 8 * 1024 * 1024,
|
|
691
|
+
/** The results a session keeps; a new one beyond them deletes the oldest. */
|
|
692
|
+
maxResultsPerSession: 32,
|
|
691
693
|
/** The size of the summary the model gets in place of the output. */
|
|
692
694
|
summaryBytes: 2 * 1024,
|
|
693
695
|
});
|
|
@@ -720,7 +722,7 @@ export const FLEX_TOOL_CATALOG_LIMITS = Object.freeze({
|
|
|
720
722
|
maxIdentifierCharacters: 128,
|
|
721
723
|
maxSummaryCharacters: 120,
|
|
722
724
|
maxDescriptionBytes: 16 * 1024,
|
|
723
|
-
maxDeclarationBytes:
|
|
725
|
+
maxDeclarationBytes: 64 * 1024,
|
|
724
726
|
maxTags: 16,
|
|
725
727
|
maxTagCharacters: 48,
|
|
726
728
|
maxAliases: 16,
|
|
@@ -1432,11 +1434,17 @@ export interface IFlexToolJobStoreProvider {
|
|
|
1432
1434
|
|
|
1433
1435
|
/**
|
|
1434
1436
|
* Tool outputs over the output budget, stored whole for the session that produced them. A result
|
|
1435
|
-
* is written once under its handle and read by `results.get
|
|
1437
|
+
* is written once under its handle and read by `results.get`; FlexHarness keeps a session's latest
|
|
1438
|
+
* `FLEX_RESULT_LIMITS.maxResultsPerSession` results and deletes older ones. A FlexHarness writes a
|
|
1439
|
+
* session's results one operation at a time, so a store need not order concurrent writes of one
|
|
1440
|
+
* session.
|
|
1436
1441
|
*/
|
|
1437
1442
|
export interface IFlexResultStore {
|
|
1438
1443
|
save(storageKey: string, sessionId: string, handle: string, value: TJsonValue): Promise<void>;
|
|
1439
1444
|
load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined>;
|
|
1445
|
+
/** The handles of the session's results, oldest first. */
|
|
1446
|
+
list(storageKey: string, sessionId: string): Promise<string[]>;
|
|
1447
|
+
delete(storageKey: string, sessionId: string, handle: string): Promise<void>;
|
|
1440
1448
|
deleteSession(storageKey: string, sessionId: string): Promise<void>;
|
|
1441
1449
|
}
|
|
1442
1450
|
|
package/ts/utils.storerules.ts
CHANGED
|
@@ -175,3 +175,18 @@ export function assertSameArchiveEvents(
|
|
|
175
175
|
throw new Error(`Archive "${next.archiveId}" already exists with different events.`);
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
|
|
179
|
+
/** The index of a session's stored results: their handles, oldest first. */
|
|
180
|
+
export interface IFlexResultIndex {
|
|
181
|
+
handles: string[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function validateResultIndex(value: unknown): IFlexResultIndex {
|
|
185
|
+
assertRecord(value, '$resultIndex');
|
|
186
|
+
assertExactKeys(value, ['handles'], '$resultIndex');
|
|
187
|
+
const handles = value.handles;
|
|
188
|
+
if (!Array.isArray(handles) || !handles.every((handle) => typeof handle === 'string')) {
|
|
189
|
+
throw new FlexHarnessStoreFormatError('$resultIndex.handles must list handles.');
|
|
190
|
+
}
|
|
191
|
+
return { handles: [...handles] };
|
|
192
|
+
}
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
validateExpectedRevision,
|
|
48
48
|
validateIdentifier,
|
|
49
49
|
validateProjectManagementWriteContext,
|
|
50
|
+
validateResultIndex,
|
|
50
51
|
validateSnapshotSave,
|
|
51
52
|
} from '../ts/utils.storerules.js';
|
|
52
53
|
import { FlexRecordStore, type IFlexRecordWrite } from './classes.recordstore.js';
|
|
@@ -431,6 +432,11 @@ export interface INoSqlFlexHarnessStoresOptions {
|
|
|
431
432
|
operationTimeoutMs?: number;
|
|
432
433
|
}
|
|
433
434
|
|
|
435
|
+
/**
|
|
436
|
+
* One record per result and an index record of the session's handles, oldest first, changed with
|
|
437
|
+
* compare-and-swap. A result whose handle the index lost to a failed write stays until its session
|
|
438
|
+
* is deleted.
|
|
439
|
+
*/
|
|
434
440
|
class NoSqlResultStore implements IFlexResultStore {
|
|
435
441
|
constructor(private readonly records: FlexRecordStore) {}
|
|
436
442
|
|
|
@@ -446,6 +452,7 @@ class NoSqlResultStore implements IFlexResultStore {
|
|
|
446
452
|
},
|
|
447
453
|
contention: conflict,
|
|
448
454
|
});
|
|
455
|
+
await this.changeIndex(storageKey, sessionId, (handles) => [...handles, handle]);
|
|
449
456
|
}
|
|
450
457
|
|
|
451
458
|
public load(storageKey: string, sessionId: string, handle: string): Promise<TJsonValue | undefined> {
|
|
@@ -453,9 +460,31 @@ class NoSqlResultStore implements IFlexResultStore {
|
|
|
453
460
|
return this.records.read({ domain: 'result', storageKey, sessionId, entryId: handle }, validateResult);
|
|
454
461
|
}
|
|
455
462
|
|
|
463
|
+
public async list(storageKey: string, sessionId: string): Promise<string[]> {
|
|
464
|
+
const index = await this.records.read({ domain: 'result', storageKey, sessionId }, validateResultIndex);
|
|
465
|
+
return index?.handles ?? [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
public async delete(storageKey: string, sessionId: string, handle: string): Promise<void> {
|
|
469
|
+
validateIdentifier(handle, 'handle');
|
|
470
|
+
await this.changeIndex(storageKey, sessionId, (handles) => handles.filter((entry) => entry !== handle));
|
|
471
|
+
await this.records.delete({ domain: 'result', storageKey, sessionId, entryId: handle });
|
|
472
|
+
}
|
|
473
|
+
|
|
456
474
|
public deleteSession(storageKey: string, sessionId: string): Promise<void> {
|
|
457
475
|
return this.records.deleteOwner(storageKey, sessionId, ['result']);
|
|
458
476
|
}
|
|
477
|
+
|
|
478
|
+
/** The session's index record: the result records' handles, without an entry of its own. */
|
|
479
|
+
private changeIndex(storageKey: string, sessionId: string, change: (handles: string[]) => string[]): Promise<void> {
|
|
480
|
+
const contention = () => new Error(`The result index of session "${sessionId}" is contended by concurrent writers.`);
|
|
481
|
+
return this.records.mutate({ domain: 'result', storageKey, sessionId }, {
|
|
482
|
+
validate: validateResultIndex,
|
|
483
|
+
// The write token fences concurrent writers; the index keeps no revision of its own.
|
|
484
|
+
decide: (current) => ({ revision: 1, value: { handles: change(current?.handles ?? []) } }),
|
|
485
|
+
contention,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
459
488
|
}
|
|
460
489
|
|
|
461
490
|
function validateResult(value: unknown): TJsonValue {
|
|
@@ -297,6 +297,13 @@ export class FlexRecordStore {
|
|
|
297
297
|
throw mutation.contention(actualRevision);
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/** Deletes one record and its chunks. */
|
|
301
|
+
public async delete(address: IFlexRecordAddress): Promise<void> {
|
|
302
|
+
await this.prepare();
|
|
303
|
+
const resolved = this.resolve(address);
|
|
304
|
+
await this.deleteInTransaction(resolved.path, { recordId: resolved.recordId, domain: resolved.domain });
|
|
305
|
+
}
|
|
306
|
+
|
|
300
307
|
/** Deletes every record and chunk one session owns in the given domains. */
|
|
301
308
|
public async deleteOwner(
|
|
302
309
|
storageKey: string,
|
|
@@ -324,6 +331,7 @@ export class FlexRecordStore {
|
|
|
324
331
|
private async deleteInTransaction(
|
|
325
332
|
path: string,
|
|
326
333
|
selector:
|
|
334
|
+
| { recordId: string; domain: TFlexRecordDomain }
|
|
327
335
|
| { ownerKey: string; domain: { $in: TFlexRecordDomain[] } }
|
|
328
336
|
| { namespaceKey: string; domain: TFlexRecordDomain },
|
|
329
337
|
): Promise<void> {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { IFlexCatalogMethod, IFlexCatalogRunContext, IFlexToolCatalog } from '@modelprofile.com/flexharness';
|
|
3
|
+
import type {
|
|
4
|
+
IFlexTypedRequestCatalogOptions,
|
|
5
|
+
IFlexTypedRequestMethod,
|
|
6
|
+
IFlexTypedRequestSurface,
|
|
7
|
+
} from './interfaces.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A typed request the router refused: the `text` and `data` of its error, as a client of the API
|
|
11
|
+
* would receive them. A host's `externalErrorProjector` decides what of it the model sees.
|
|
12
|
+
*/
|
|
13
|
+
export class FlexTypedRequestError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
/** The typed method: `getVoucher`. */
|
|
16
|
+
public readonly method: string,
|
|
17
|
+
/** The error's text, such as a `TypedResponseError`'s message. */
|
|
18
|
+
public readonly text: string,
|
|
19
|
+
/** The error's data. */
|
|
20
|
+
public readonly data?: unknown,
|
|
21
|
+
) {
|
|
22
|
+
super(`${method} was refused: ${text}`);
|
|
23
|
+
this.name = 'FlexTypedRequestError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The tool catalog of a typedrequest surface: every included method becomes a catalog method of
|
|
29
|
+
* its path, declared as its contract writes it and validated against its schema, and runs through
|
|
30
|
+
* `router.routeAndAddResponse()` in process, its request the model's parameters with the omitted
|
|
31
|
+
* properties `request` fills. A refusal rejects with `FlexTypedRequestError`.
|
|
32
|
+
*/
|
|
33
|
+
export function catalogFromTypedRequests<TScope = unknown>(
|
|
34
|
+
surface: IFlexTypedRequestSurface,
|
|
35
|
+
options: IFlexTypedRequestCatalogOptions<TScope>,
|
|
36
|
+
): IFlexToolCatalog<TScope> {
|
|
37
|
+
if (typeof options?.router?.routeAndAddResponse !== 'function') {
|
|
38
|
+
throw new plugins.FlexHarnessValidationError('catalogFromTypedRequests needs a router with routeAndAddResponse().');
|
|
39
|
+
}
|
|
40
|
+
const aliases = options.aliases ?? {};
|
|
41
|
+
const known = new Set(surface.methods.map((method) => method.method));
|
|
42
|
+
const unknown = Object.keys(aliases).filter((method) => !known.has(method));
|
|
43
|
+
if (unknown.length > 0) {
|
|
44
|
+
throw new plugins.FlexHarnessValidationError(`The surface has no method ${unknown.join(', ')} to give aliases.`);
|
|
45
|
+
}
|
|
46
|
+
if (surface.omit.length > 0 && !options.request) {
|
|
47
|
+
throw new plugins.FlexHarnessValidationError(
|
|
48
|
+
`The surface leaves ${surface.omit.join(', ')} out of every request; the options' request() must fill them.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const include = options.include ?? ((method: IFlexTypedRequestMethod) => !method.deprecated);
|
|
52
|
+
const { router } = options;
|
|
53
|
+
const call = async (
|
|
54
|
+
method: IFlexTypedRequestMethod,
|
|
55
|
+
params: Record<string, unknown>,
|
|
56
|
+
context: IFlexCatalogRunContext<TScope>,
|
|
57
|
+
): Promise<unknown> => {
|
|
58
|
+
const filled = options.request ? await options.request(method, context) : {};
|
|
59
|
+
const localData = options.localData ? await options.localData(method, context) : undefined;
|
|
60
|
+
const answer = await router.routeAndAddResponse(
|
|
61
|
+
{
|
|
62
|
+
method: method.method,
|
|
63
|
+
request: { ...params, ...filled },
|
|
64
|
+
response: {},
|
|
65
|
+
correlation: { id: plugins.crypto.randomUUID(), phase: 'request' },
|
|
66
|
+
},
|
|
67
|
+
{ ...(localData ? { trustedLocalData: localData } : {}), trustedAbortSignal: context.abortSignal },
|
|
68
|
+
);
|
|
69
|
+
if (!answer) throw new FlexTypedRequestError(method.method, 'The router answered nothing.');
|
|
70
|
+
if (answer.error) throw new FlexTypedRequestError(method.method, answer.error.text, answer.error.data);
|
|
71
|
+
return answer.response;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
methods: surface.methods.filter((method) => include(method)).map((method): IFlexCatalogMethod<TScope> => ({
|
|
75
|
+
method: method.path,
|
|
76
|
+
summary: method.summary,
|
|
77
|
+
...(method.description ? { description: method.description } : {}),
|
|
78
|
+
...(aliases[method.method] ? { aliases: aliases[method.method] } : {}),
|
|
79
|
+
inputSchema: method.inputSchema,
|
|
80
|
+
outputSchema: method.outputSchema,
|
|
81
|
+
declaration: method.declaration,
|
|
82
|
+
run: (params: Record<string, unknown>, context) => call(method, params, context),
|
|
83
|
+
...(options.onCall
|
|
84
|
+
? { onCall: (params: Record<string, unknown>, context: IFlexCatalogRunContext<TScope>) => options.onCall!(method, params, context) }
|
|
85
|
+
: {}),
|
|
86
|
+
})),
|
|
87
|
+
types: surface.types,
|
|
88
|
+
...(options.vocabulary ? { vocabulary: options.vocabulary } : {}),
|
|
89
|
+
...(options.onProposals ? { onProposals: options.onProposals } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IFlexTypedRequestMethod,
|
|
4
|
+
IFlexTypedRequestSurface,
|
|
5
|
+
IFlexTypedRequestSurfaceOptions,
|
|
6
|
+
TFlexTypedRequestSchema,
|
|
7
|
+
} from './interfaces.js';
|
|
8
|
+
|
|
9
|
+
const componentsPrefix = '#/components/schemas/';
|
|
10
|
+
const definitionsPrefix = '#/$defs/';
|
|
11
|
+
const maxSummaryCharacters = 120;
|
|
12
|
+
|
|
13
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
14
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
/** A schema whose references name `#/$defs/<type>` instead of the OpenAPI components. */
|
|
17
|
+
const withDefinitionRefs = (value: unknown): unknown => {
|
|
18
|
+
if (Array.isArray(value)) return value.map(withDefinitionRefs);
|
|
19
|
+
if (!isRecord(value)) return value;
|
|
20
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
21
|
+
key,
|
|
22
|
+
key === '$ref' && typeof child === 'string' && child.startsWith(componentsPrefix)
|
|
23
|
+
? `${definitionsPrefix}${child.slice(componentsPrefix.length)}`
|
|
24
|
+
: withDefinitionRefs(child),
|
|
25
|
+
]));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The type a `#/$defs/<type>` reference names. */
|
|
29
|
+
const referencedType = (schema: TFlexTypedRequestSchema): string | undefined =>
|
|
30
|
+
typeof schema.$ref === 'string' && schema.$ref.startsWith(definitionsPrefix)
|
|
31
|
+
? schema.$ref.slice(definitionsPrefix.length)
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A request schema without the omitted properties: an object loses them, and a reference to a
|
|
36
|
+
* named type that holds one becomes the type's own schema without them.
|
|
37
|
+
*/
|
|
38
|
+
const withoutOmitted = (
|
|
39
|
+
schema: TFlexTypedRequestSchema,
|
|
40
|
+
omit: ReadonlySet<string>,
|
|
41
|
+
types: Readonly<Record<string, TFlexTypedRequestSchema>>,
|
|
42
|
+
): TFlexTypedRequestSchema => {
|
|
43
|
+
const target = referencedType(schema);
|
|
44
|
+
if (target !== undefined) {
|
|
45
|
+
const named = types[target];
|
|
46
|
+
return named !== undefined && holdsOmitted(named, omit, types, new Set([target]))
|
|
47
|
+
? withoutOmitted(named, omit, types)
|
|
48
|
+
: schema;
|
|
49
|
+
}
|
|
50
|
+
const stripped: TFlexTypedRequestSchema = { ...schema };
|
|
51
|
+
if (isRecord(stripped.properties)) {
|
|
52
|
+
stripped.properties = Object.fromEntries(Object.entries(stripped.properties).filter(([key]) => !omit.has(key)));
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(stripped.required)) {
|
|
55
|
+
stripped.required = stripped.required.filter((key) => typeof key !== 'string' || !omit.has(key));
|
|
56
|
+
}
|
|
57
|
+
for (const key of ['allOf', 'anyOf', 'oneOf'] as const) {
|
|
58
|
+
const members = stripped[key];
|
|
59
|
+
if (Array.isArray(members)) {
|
|
60
|
+
stripped[key] = members.map((member) => isRecord(member) ? withoutOmitted(member, omit, types) : member);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return stripped;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Whether a schema has an omitted property, itself or through the schemas it composes or refers to. */
|
|
67
|
+
const holdsOmitted = (
|
|
68
|
+
schema: TFlexTypedRequestSchema,
|
|
69
|
+
omit: ReadonlySet<string>,
|
|
70
|
+
types: Readonly<Record<string, TFlexTypedRequestSchema>>,
|
|
71
|
+
seen: Set<string>,
|
|
72
|
+
): boolean => {
|
|
73
|
+
if (isRecord(schema.properties) && Object.keys(schema.properties).some((key) => omit.has(key))) return true;
|
|
74
|
+
const target = referencedType(schema);
|
|
75
|
+
if (target !== undefined) {
|
|
76
|
+
if (seen.has(target) || types[target] === undefined) return false;
|
|
77
|
+
seen.add(target);
|
|
78
|
+
return holdsOmitted(types[target], omit, types, seen);
|
|
79
|
+
}
|
|
80
|
+
return (['allOf', 'anyOf', 'oneOf'] as const).some((key) => Array.isArray(schema[key])
|
|
81
|
+
&& (schema[key] as unknown[]).some((member) => isRecord(member) && holdsOmitted(member, omit, types, seen)));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Whether a call may leave out every parameter: its schema requires none. */
|
|
85
|
+
const requiresNothing = (
|
|
86
|
+
schema: TFlexTypedRequestSchema,
|
|
87
|
+
types: Readonly<Record<string, TFlexTypedRequestSchema>>,
|
|
88
|
+
): boolean => {
|
|
89
|
+
const target = referencedType(schema);
|
|
90
|
+
if (target !== undefined) return types[target] === undefined || requiresNothing(types[target], types);
|
|
91
|
+
if (Array.isArray(schema.required) && schema.required.length > 0) return false;
|
|
92
|
+
return !Array.isArray(schema.allOf)
|
|
93
|
+
|| schema.allOf.every((member) => !isRecord(member) || requiresNothing(member, types));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The named types a schema refers to. */
|
|
97
|
+
const referencesIn = (value: unknown, found: Set<string>): Set<string> => {
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
for (const entry of value) referencesIn(entry, found);
|
|
100
|
+
} else if (isRecord(value)) {
|
|
101
|
+
const target = referencedType(value);
|
|
102
|
+
if (target !== undefined) found.add(target);
|
|
103
|
+
for (const child of Object.values(value)) referencesIn(child, found);
|
|
104
|
+
}
|
|
105
|
+
return found;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** The first sentence of a contract's JSDoc, its tags left out, within the summary's length. */
|
|
109
|
+
const summaryOf = (documentation: string | undefined, method: string): string => {
|
|
110
|
+
const prose = (documentation ?? '').split(/\n(?=@)/)[0].replace(/\s+/g, ' ').trim();
|
|
111
|
+
const sentence = prose.split(/(?<=[.!?])\s/)[0] ?? '';
|
|
112
|
+
if (!sentence) return method;
|
|
113
|
+
return [...sentence].length <= maxSummaryCharacters
|
|
114
|
+
? sentence
|
|
115
|
+
: `${[...sentence].slice(0, maxSummaryCharacters - 1).join('')}…`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** A JSDoc block of the given lines; `*\/` in them cannot end it early. */
|
|
119
|
+
const docBlock = (lines: string[]): string => {
|
|
120
|
+
const text = lines.join('\n').replace(/\*\//g, '*\\/');
|
|
121
|
+
return `/**\n${text.split('\n').map((line) => (line ? ` * ${line}` : ' *')).join('\n')}\n */\n`;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The area of a contract when the options name none: its file's base name, an `index` file's folder. */
|
|
125
|
+
const defaultArea = (file: string): string => {
|
|
126
|
+
const parsed = plugins.path.posix.parse(file);
|
|
127
|
+
return parsed.name === 'index' ? plugins.path.posix.basename(parsed.dir) || parsed.name : parsed.name;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Reads a typedrequest API's contracts from their TypeScript sources: each typed method's path
|
|
132
|
+
* (`<area>.<method>`), its summary and JSDoc, its request and response as JSON Schema for
|
|
133
|
+
* validation, and its TypeScript declaration as the contract writes it, for the model to read; and
|
|
134
|
+
* the named types they reach, each with its schema, its declaration, or both. Needs the optional
|
|
135
|
+
* peer `@api.global/typedopenapi`. Run it at build time and keep the JSON, or at startup where the
|
|
136
|
+
* sources are at hand.
|
|
137
|
+
*/
|
|
138
|
+
export async function describeTypedRequests(options: IFlexTypedRequestSurfaceOptions): Promise<IFlexTypedRequestSurface> {
|
|
139
|
+
let typedopenapi: Awaited<ReturnType<typeof plugins.importTypedOpenApi>>;
|
|
140
|
+
try {
|
|
141
|
+
typedopenapi = await plugins.importTypedOpenApi();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new plugins.FlexHarnessValidationError(
|
|
144
|
+
'describeTypedRequests needs the optional peer @api.global/typedopenapi (0.8.0 or later).',
|
|
145
|
+
{ cause: error },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const omit = [...new Set(options.omit ?? [])].sort();
|
|
149
|
+
const source = {
|
|
150
|
+
tsconfig: options.tsconfig,
|
|
151
|
+
contracts: options.contracts,
|
|
152
|
+
...(options.contractNamePattern ? { contractNamePattern: options.contractNamePattern } : {}),
|
|
153
|
+
};
|
|
154
|
+
const schemaSet = typedopenapi.generateContractSchemas(source);
|
|
155
|
+
const declarationSet = typedopenapi.generateContractDeclarations({ ...source, omit });
|
|
156
|
+
const schemas = withDefinitionRefs(schemaSet.schemas) as Record<string, TFlexTypedRequestSchema>;
|
|
157
|
+
const declarations = new Map(declarationSet.contracts.map((contract) => [contract.name, contract]));
|
|
158
|
+
const omitted = new Set(omit);
|
|
159
|
+
|
|
160
|
+
const paths = new Set<string>();
|
|
161
|
+
const methods: IFlexTypedRequestMethod[] = schemaSet.contracts.map((contract) => {
|
|
162
|
+
const declared = declarations.get(contract.name);
|
|
163
|
+
if (!declared) {
|
|
164
|
+
throw new plugins.FlexHarnessValidationError(`@api.global/typedopenapi declared no contract ${contract.name}, whose schemas it generated.`);
|
|
165
|
+
}
|
|
166
|
+
const area = options.area ? options.area({ name: contract.name, method: contract.method, file: declared.file }) : defaultArea(declared.file);
|
|
167
|
+
const name = plugins.FlexToolCatalog.identifierOf(contract.method);
|
|
168
|
+
const base = `${plugins.FlexToolCatalog.identifierOf(area)}.${name}`;
|
|
169
|
+
let path = base;
|
|
170
|
+
for (let suffix = 2; paths.has(path); suffix++) path = `${base}_${suffix}`;
|
|
171
|
+
paths.add(path);
|
|
172
|
+
const inputSchema = withoutOmitted(withDefinitionRefs(contract.request) as TFlexTypedRequestSchema, omitted, schemas);
|
|
173
|
+
const docLines = [
|
|
174
|
+
...(declared.documentation ? [declared.documentation] : []),
|
|
175
|
+
...(declared.request.documentation ? [`@param params ${declared.request.documentation}`] : []),
|
|
176
|
+
...(declared.response.documentation ? [`@returns ${declared.response.documentation}`] : []),
|
|
177
|
+
];
|
|
178
|
+
const functionName = path.slice(path.lastIndexOf('.') + 1);
|
|
179
|
+
const optional = requiresNothing(inputSchema, schemas) ? '?' : '';
|
|
180
|
+
return {
|
|
181
|
+
method: contract.method,
|
|
182
|
+
path,
|
|
183
|
+
summary: summaryOf(declared.documentation, contract.method),
|
|
184
|
+
...(declared.documentation ? { description: declared.documentation } : {}),
|
|
185
|
+
...(contract.deprecated ? { deprecated: true } : {}),
|
|
186
|
+
inputSchema,
|
|
187
|
+
outputSchema: withDefinitionRefs(contract.response) as TFlexTypedRequestSchema,
|
|
188
|
+
declaration: `${docLines.length ? docBlock(docLines) : ''}function ${functionName}(params${optional}: ${declared.request.type}): Promise<${declared.response.type}>;`,
|
|
189
|
+
};
|
|
190
|
+
}).sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
|
191
|
+
|
|
192
|
+
// Only the schemas the methods still reach: an omitted property's types are gone with it.
|
|
193
|
+
const reached = new Set<string>();
|
|
194
|
+
const pending = [...referencesIn(methods.map((method) => [method.inputSchema, method.outputSchema]), new Set())];
|
|
195
|
+
while (pending.length) {
|
|
196
|
+
const name = pending.pop()!;
|
|
197
|
+
if (reached.has(name) || schemas[name] === undefined) continue;
|
|
198
|
+
reached.add(name);
|
|
199
|
+
pending.push(...referencesIn(schemas[name], new Set()));
|
|
200
|
+
}
|
|
201
|
+
const names = [...new Set([...reached, ...Object.keys(declarationSet.types)])].sort();
|
|
202
|
+
return {
|
|
203
|
+
methods,
|
|
204
|
+
types: names.map((name) => ({
|
|
205
|
+
name,
|
|
206
|
+
...(reached.has(name) ? { schema: schemas[name] } : {}),
|
|
207
|
+
...(declarationSet.types[name] !== undefined ? { declaration: declarationSet.types[name] } : {}),
|
|
208
|
+
})),
|
|
209
|
+
omit,
|
|
210
|
+
};
|
|
211
|
+
}
|