mirror of
https://gh-proxy.org/https://github.com/klinsmaya/open-connector.git
synced 2026-11-08 01:16:52 +08:00
feat(catalog): opt-in lazy loading of action schemas (#489)
Signed-off-by: Kevin Cui <[email protected]>
This commit is contained in:
@@ -13,9 +13,10 @@ name: Build Binaries
|
||||
# ~80 MB runtime per target and compiling is a matter of seconds, whereas
|
||||
# cross-compiling ON a Windows runner is broken upstream (oven-sh/bun#11198,
|
||||
# the workspace and the Bun cache sit on different drives). The Linux job also
|
||||
# smoke-tests its own linux-x64 output twice, once against SQLite and once
|
||||
# against the PostgreSQL service container (service containers are Linux-only,
|
||||
# which is one reason the smoke matrix is a separate job).
|
||||
# smoke-tests its own linux-x64 output three times: against SQLite, against
|
||||
# SQLite with the lazy catalog schemas, and against the PostgreSQL service
|
||||
# container (service containers are Linux-only, which is one reason the smoke
|
||||
# matrix is a separate job).
|
||||
#
|
||||
# The smoke matrix mixes runner vendors because Blacksmith offers no Windows
|
||||
# ARM64 and no macOS Intel runners; those two targets run on GitHub-hosted
|
||||
@@ -121,6 +122,13 @@ jobs:
|
||||
- name: Smoke test linux-x64 (SQLite)
|
||||
run: node scripts/smoke-binary.ts dist/open-connector-linux-x64
|
||||
|
||||
# Lazy catalog mode: action schemas are read from the binary's embedded catalog
|
||||
# file when an action is requested, which no other smoke run exercises.
|
||||
- name: Smoke test linux-x64 (lazy catalog schemas)
|
||||
env:
|
||||
OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS: "true"
|
||||
run: node scripts/smoke-binary.ts dist/open-connector-linux-x64
|
||||
|
||||
# PostgreSQL mode: the binary's own `migrate` subcommand applies the embedded
|
||||
# postgresql/ migrations, then the server must pass its schema check.
|
||||
- name: Migrate PostgreSQL with linux-x64
|
||||
|
||||
+2
-1
@@ -175,7 +175,8 @@ Compression Rule matching `text/markdown` if your deployment serves agent guides
|
||||
## Configuration
|
||||
|
||||
Cloudflare uses the same environment variable names for origin, auth tokens, action policy, transit
|
||||
file limits, and credential encryption. `PORT`, `HOST`, and `OOMOL_CONNECT_DATA_DIR` are local
|
||||
file limits, and credential encryption. `PORT`, `HOST`, `OOMOL_CONNECT_DATA_DIR`,
|
||||
`OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS`, and `OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES` are local
|
||||
Node-only settings on Workers.
|
||||
|
||||
See [configuration.md](configuration.md) for all runtime environment variables.
|
||||
|
||||
+38
-2
@@ -39,6 +39,8 @@ OpenConnector is configured with environment variables.
|
||||
| `OOMOL_CONNECT_S3_SECRET_ACCESS_KEY` | SDK credential chain | Explicit S3 secret access key. Configure it with the access key ID, or omit both. |
|
||||
| `OOMOL_CONNECT_S3_SESSION_TOKEN` | unset | Optional session token used with explicit S3 credentials. |
|
||||
| `OOMOL_CONNECT_RUN_LIMIT` | `5000` | Maximum number of recent action run audit records to retain. |
|
||||
| `OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS` | `false` | Read action JSON schemas from the catalog files on demand instead of keeping them in memory. See below. |
|
||||
| `OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES` | `64` | Provider files whose action schemas stay cached when lazy schemas are on. |
|
||||
|
||||
Example:
|
||||
|
||||
@@ -225,11 +227,45 @@ multicast, and other unsafe special-use targets remain blocked.
|
||||
> a deployment-wide egress exception, not per-connection authorization. Keep
|
||||
> entries narrowly scoped to domains you trust.
|
||||
|
||||
## Lazy catalog schemas
|
||||
|
||||
The generated catalog ships one JSON file per provider, and the `inputSchema` and `outputSchema` of
|
||||
their actions are most of those bytes. The server keeps the whole catalog in memory, which is the
|
||||
largest allocation it makes and the one that matters on a 256 MB class machine such as the Helm
|
||||
chart's default `256Mi` request or a small Fly machine.
|
||||
|
||||
Set `OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS=true` to keep those schemas on disk instead:
|
||||
|
||||
```bash
|
||||
OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS=true npm run dev
|
||||
```
|
||||
|
||||
Action metadata stays in memory: ids, names, descriptions, required scopes, provider permissions,
|
||||
follow-up and async lifecycle links, and execution flags. Only the two schema fields are read back
|
||||
from the provider file when something asks for them, and the schemas of the 64 most recently used
|
||||
provider files stay cached. `OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES` tunes that number: each
|
||||
cached file holds every schema of one provider, typically tens to a few hundred KB parsed, so 64
|
||||
files is on the order of a few MB to a few tens of MB. Values that are not positive integers fall
|
||||
back to the default. Set it higher when one server serves many providers repeatedly, and lower on
|
||||
the tightest machines. Responses are byte-identical to the default mode, so this is a
|
||||
memory-for-reads trade and not an API change.
|
||||
|
||||
What changes operationally:
|
||||
|
||||
- The catalog directory must stay readable while the server runs. Replacing or deleting it under a
|
||||
running server makes later schema reads fail. The single-file executable reads the catalog from
|
||||
its own embedded filesystem, so it needs nothing extra.
|
||||
- Reading or running one action reads one provider file. `GET /api/actions`, which serializes every
|
||||
action, and action search across many services re-read files as they go.
|
||||
- This is a Node and single-binary setting. Cloudflare Workers load the catalog from asset chunks
|
||||
and ignore it.
|
||||
|
||||
## Cloudflare Workers
|
||||
|
||||
Cloudflare uses the same environment variable names for origin, static auth tokens, execution
|
||||
policy, transit file limits, and data encryption. The JWT settings above, `PORT`, `HOST`, and
|
||||
`OOMOL_CONNECT_DATA_DIR` are Node-only settings.
|
||||
policy, transit file limits, and data encryption. The JWT settings above, `PORT`, `HOST`,
|
||||
`OOMOL_CONNECT_DATA_DIR`, `OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS`, and
|
||||
`OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES` are Node-only settings.
|
||||
|
||||
The Worker runtime also requires these bindings in `wrangler.local.jsonc`. Copy
|
||||
`wrangler.example.jsonc` to `wrangler.local.jsonc` and fill in your own Cloudflare resource IDs
|
||||
|
||||
+20
-2
@@ -12,8 +12,8 @@ import { setTimeout as sleep } from "node:timers/promises";
|
||||
// web console, migrations and shutdown path work.
|
||||
//
|
||||
// Usage: `node scripts/smoke-binary.ts <path-to-binary>`. Set OOMOL_CONNECT_DATABASE_URL to run the PostgreSQL
|
||||
// mode; every other OOMOL_CONNECT_* variable is removed from the server's environment. Uses only Node built-ins so
|
||||
// the smoke runners need no `npm ci`.
|
||||
// mode and OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS to run the lazy catalog mode; every other OOMOL_CONNECT_* variable is
|
||||
// removed from the server's environment. Uses only Node built-ins so the smoke runners need no `npm ci`.
|
||||
|
||||
interface ProcessExit {
|
||||
code: number | null;
|
||||
@@ -122,6 +122,7 @@ try {
|
||||
const indexHtml = await checkConsoleIndex(baseUrl);
|
||||
await checkConsoleAssets(baseUrl, indexHtml);
|
||||
await checkProviders(baseUrl);
|
||||
await checkActionSchema(baseUrl);
|
||||
await checkApps(baseUrl);
|
||||
await checkDatabaseBackend(server, dataDir, databaseUrl);
|
||||
await checkGracefulShutdown(server);
|
||||
@@ -171,6 +172,11 @@ function buildServerEnvironment(options: ServerProcessOptions): NodeJS.ProcessEn
|
||||
if (options.databaseUrl) {
|
||||
env.OOMOL_CONNECT_DATABASE_URL = options.databaseUrl;
|
||||
}
|
||||
// Forwarded on purpose: it is what makes a smoke run read action schemas from the embedded catalog on demand.
|
||||
const lazyCatalogSchemas = process.env.OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS;
|
||||
if (lazyCatalogSchemas !== undefined) {
|
||||
env.OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS = lazyCatalogSchemas;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
@@ -275,6 +281,18 @@ async function checkProviders(baseUrl: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* With OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS the schemas are read from the embedded catalog file when the action is
|
||||
* requested, so one action detail proves that on-demand read; slack is already known to be in /v1/providers.
|
||||
*/
|
||||
async function checkActionSchema(baseUrl: string): Promise<void> {
|
||||
const actionId = "slack.add_reaction";
|
||||
const data = await fetchEnvelopeData(baseUrl, `/v1/actions/${actionId}`);
|
||||
assert(isRecord(data), `/v1/actions/${actionId} data is not an object`);
|
||||
assert(isRecord(data.inputSchema), `/v1/actions/${actionId} inputSchema is not an object`);
|
||||
assert(Object.keys(data.inputSchema).length > 0, `/v1/actions/${actionId} inputSchema is empty`);
|
||||
}
|
||||
|
||||
async function checkApps(baseUrl: string): Promise<void> {
|
||||
const data = await fetchEnvelopeData(baseUrl, "/v1/apps");
|
||||
assert(Array.isArray(data), "/v1/apps data is not an array");
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { ActionDefinition, JsonSchema, ProviderDefinition } from "./core/types.ts";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
interface ActionSchemas {
|
||||
inputSchema: JsonSchema;
|
||||
outputSchema: JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-enumerable link from a metadata-only action back to the catalog file that still owns its
|
||||
* schemas. It is a symbol so it never reaches `Object.keys`, `JSON.stringify` or a spread copy,
|
||||
* while `Object.getOwnPropertyDescriptors` still carries it onto the runtime action.
|
||||
*/
|
||||
const actionSchemaSource = Symbol("catalog action schema source");
|
||||
|
||||
interface FileBackedActionDefinition extends ActionDefinition {
|
||||
[actionSchemaSource]: FileActionSchemaSource;
|
||||
}
|
||||
|
||||
/** Provider files whose parsed action schemas stay cached at once (`OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES`). */
|
||||
export const defaultLazySchemaCacheFiles = 64;
|
||||
|
||||
/**
|
||||
* Read provider catalog files while keeping every `inputSchema`/`outputSchema` on disk.
|
||||
*
|
||||
* Files are read one at a time so peak memory holds a single fully parsed provider, and each action
|
||||
* keeps schema-shaped accessors at their original key positions, so a lazily loaded catalog
|
||||
* serializes byte-for-byte like an eagerly loaded one.
|
||||
*/
|
||||
export async function readProvidersWithLazySchemas(
|
||||
filePaths: string[],
|
||||
cacheFiles: number = defaultLazySchemaCacheFiles,
|
||||
): Promise<ProviderDefinition[]> {
|
||||
const loader = new FileActionSchemaLoader(cacheFiles);
|
||||
const providers: ProviderDefinition[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
const provider = JSON.parse(await readFile(filePath, "utf8")) as ProviderDefinition;
|
||||
const source = new FileActionSchemaSource(filePath, loader);
|
||||
providers.push({
|
||||
...provider,
|
||||
actions: provider.actions.map((action) => toFileBackedAction(action, source)),
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one parsed action without its schemas, replacing them with enumerable accessors defined at
|
||||
* the key position they had in the file. Real catalog actions carry `followUpActions` and
|
||||
* `asyncLifecycle` after the schemas, so appending the accessors instead would reorder the keys and
|
||||
* change every serialized response.
|
||||
*/
|
||||
function toFileBackedAction(action: ActionDefinition, source: FileActionSchemaSource): ActionDefinition {
|
||||
const fileBacked: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(action)) {
|
||||
if (key === "inputSchema" || key === "outputSchema") {
|
||||
Object.defineProperty(fileBacked, key, {
|
||||
get: key === "inputSchema" ? readInputSchema : readOutputSchema,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} else {
|
||||
fileBacked[key] = action[key as keyof ActionDefinition];
|
||||
}
|
||||
}
|
||||
Object.defineProperty(fileBacked, actionSchemaSource, { value: source });
|
||||
|
||||
return fileBacked as ActionDefinition;
|
||||
}
|
||||
|
||||
function readInputSchema(this: FileBackedActionDefinition): JsonSchema {
|
||||
return this[actionSchemaSource].read(this.id).inputSchema;
|
||||
}
|
||||
|
||||
function readOutputSchema(this: FileBackedActionDefinition): JsonSchema {
|
||||
return this[actionSchemaSource].read(this.id).outputSchema;
|
||||
}
|
||||
|
||||
/** The one catalog file that backs every action loaded from it. */
|
||||
class FileActionSchemaSource {
|
||||
private readonly filePath: string;
|
||||
private readonly loader: FileActionSchemaLoader;
|
||||
|
||||
constructor(filePath: string, loader: FileActionSchemaLoader) {
|
||||
this.filePath = filePath;
|
||||
this.loader = loader;
|
||||
}
|
||||
|
||||
read(actionId: string): ActionSchemas {
|
||||
return this.loader.read(this.filePath, actionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Least-recently-used cache of parsed catalog files, shared by every action of one loaded catalog.
|
||||
*
|
||||
* Reads are synchronous because they answer property getters. A miss re-reads the whole file, so an
|
||||
* uncached file that changed on disk is picked up on its next access.
|
||||
*/
|
||||
class FileActionSchemaLoader {
|
||||
private readonly cache = new Map<string, Map<string, ActionSchemas>>();
|
||||
private readonly maxCachedFiles: number;
|
||||
|
||||
constructor(maxCachedFiles: number) {
|
||||
// A size below 1 would evict every entry as soon as it is inserted, and NaN or Infinity would
|
||||
// never evict, turning the bounded cache into one that retains every provider file.
|
||||
if (!Number.isInteger(maxCachedFiles) || maxCachedFiles < 1) {
|
||||
throw new Error(`lazySchemaCacheFiles must be a positive integer, got ${maxCachedFiles}`);
|
||||
}
|
||||
this.maxCachedFiles = maxCachedFiles;
|
||||
}
|
||||
|
||||
read(filePath: string, actionId: string): ActionSchemas {
|
||||
const cached = this.cache.get(filePath);
|
||||
// Re-inserting a hit moves it to the end of the insertion order, so the first key is the least
|
||||
// recently used file.
|
||||
this.cache.delete(filePath);
|
||||
const schemas = cached ?? readActionSchemas(filePath);
|
||||
this.cache.set(filePath, schemas);
|
||||
while (this.cache.size > this.maxCachedFiles) {
|
||||
const leastRecent = this.cache.keys().next().value;
|
||||
if (leastRecent === undefined) {
|
||||
break;
|
||||
}
|
||||
this.cache.delete(leastRecent);
|
||||
}
|
||||
|
||||
const actionSchemas = schemas.get(actionId);
|
||||
if (!actionSchemas) {
|
||||
throw new Error(`Catalog action ${actionId} is missing from ${filePath}`);
|
||||
}
|
||||
return actionSchemas;
|
||||
}
|
||||
}
|
||||
|
||||
function readActionSchemas(filePath: string): Map<string, ActionSchemas> {
|
||||
const provider = JSON.parse(readFileSync(filePath, "utf8")) as ProviderDefinition;
|
||||
return new Map(
|
||||
provider.actions.map((action) => [
|
||||
action.id,
|
||||
{ inputSchema: action.inputSchema, outputSchema: action.outputSchema },
|
||||
]),
|
||||
);
|
||||
}
|
||||
+219
-7
@@ -1,8 +1,18 @@
|
||||
import type { ProviderSummaryDefinition } from "./catalog-store.ts";
|
||||
import type { ProviderDefinition } from "./core/types.ts";
|
||||
import type { CatalogStore, ProviderSummaryDefinition } from "./catalog-store.ts";
|
||||
import type { JsonSchema, ProviderDefinition } from "./core/types.ts";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCatalogStore, resolveExecutableActionIds } from "./catalog-store.ts";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { defaultLazySchemaCacheFiles } from "./catalog-lazy-schemas.ts";
|
||||
import { createCatalogStore, loadCatalog, resolveExecutableActionIds } from "./catalog-store.ts";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("catalog store", () => {
|
||||
it("preserves optional provider descriptions without defaulting missing ones", () => {
|
||||
@@ -90,7 +100,200 @@ describe("catalog store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function providerFixture(service: string, actionNames: string[]): ProviderDefinition {
|
||||
describe("loadCatalog", () => {
|
||||
it("keeps schemas as plain data properties by default", async () => {
|
||||
const catalogDir = await writeCatalogDir([providerFixture("example", ["ping", "pong"])]);
|
||||
|
||||
const catalog = await loadCatalog(catalogDir);
|
||||
const action = catalog.actionsById.get("example.ping")!;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(action, "inputSchema")!;
|
||||
|
||||
expect(descriptor.get).toBeUndefined();
|
||||
expect(descriptor.value).toEqual(schemaFor("ping", 1));
|
||||
});
|
||||
|
||||
it("reads lazy schemas from disk on access, so a change to an uncached file is picked up", async () => {
|
||||
const catalogDir = await writeCatalogDir([providerFixture("example", ["ping"])]);
|
||||
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true });
|
||||
const action = catalog.actionsById.get("example.ping")!;
|
||||
expect(Object.getOwnPropertyDescriptor(action, "inputSchema")?.get).toBeTypeOf("function");
|
||||
// The link back to the catalog file must stay invisible to key iteration and to spread copies.
|
||||
const [schemaSource] = Object.getOwnPropertySymbols(action);
|
||||
expect(Object.getOwnPropertyDescriptor(action, schemaSource!)?.enumerable).toBe(false);
|
||||
await writeProviderFile(catalogDir, providerFixture("example", ["ping"], 2));
|
||||
|
||||
expect(action.inputSchema).toEqual(schemaFor("ping", 2));
|
||||
expect(action.outputSchema).toEqual({ type: "object", properties: { ok: { type: "boolean" } } });
|
||||
expect(Object.getOwnPropertySymbols({ ...action })).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-reads a lazy schema file only after the cache evicts it", async () => {
|
||||
const catalogDir = await writeCatalogDir([
|
||||
providerFixture("example", ["ping"]),
|
||||
providerFixture("remote", ["ping"]),
|
||||
]);
|
||||
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true, lazySchemaCacheFiles: 1 });
|
||||
const example = catalog.actionsById.get("example.ping")!;
|
||||
const remote = catalog.actionsById.get("remote.ping")!;
|
||||
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
// Caching "remote" evicts "example", so the next access re-reads the edited file.
|
||||
expect(remote.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
await writeProviderFile(catalogDir, providerFixture("example", ["ping"], 2));
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 2));
|
||||
|
||||
// "example" is cached again, so a further edit stays invisible until it is evicted.
|
||||
await writeProviderFile(catalogDir, providerFixture("example", ["ping"], 3));
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 2));
|
||||
expect(remote.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 3));
|
||||
});
|
||||
|
||||
it("keeps a file cached when a hit refreshes its recency", async () => {
|
||||
const catalogDir = await writeCatalogDir([
|
||||
providerFixture("example", ["ping"]),
|
||||
providerFixture("remote", ["ping"]),
|
||||
providerFixture("third", ["ping"]),
|
||||
]);
|
||||
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true, lazySchemaCacheFiles: 2 });
|
||||
const example = catalog.actionsById.get("example.ping")!;
|
||||
const remote = catalog.actionsById.get("remote.ping")!;
|
||||
const third = catalog.actionsById.get("third.ping")!;
|
||||
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
expect(remote.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
// Re-reading "example" makes it the most recent, so caching "third" must evict "remote".
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
expect(third.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
await writeProviderFile(catalogDir, providerFixture("example", ["ping"], 2));
|
||||
await writeProviderFile(catalogDir, providerFixture("remote", ["ping"], 2));
|
||||
|
||||
// Staleness is the only way to observe which file the cache kept: "example" is still cached and
|
||||
// answers from the parse it already has, while the evicted "remote" re-reads the edited file.
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
expect(remote.inputSchema).toEqual(schemaFor("ping", 2));
|
||||
});
|
||||
|
||||
it("caches the default number of provider files when no cache size is given", async () => {
|
||||
const services = Array.from({ length: defaultLazySchemaCacheFiles + 1 }, (_, index) => `service${index}`);
|
||||
const catalogDir = await writeCatalogDir(services.map((service) => providerFixture(service, ["ping"])));
|
||||
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true });
|
||||
const actionFor = (service: string) => catalog.actionsById.get(`${service}.ping`)!;
|
||||
// One file more than the cache holds, read oldest first, so only the first service is evicted.
|
||||
for (const service of services) {
|
||||
expect(actionFor(service).inputSchema).toEqual(schemaFor("ping", 1));
|
||||
}
|
||||
await writeProviderFile(catalogDir, providerFixture(services[0]!, ["ping"], 2));
|
||||
await writeProviderFile(catalogDir, providerFixture(services[1]!, ["ping"], 2));
|
||||
|
||||
// The second service is checked first because re-reading the evicted first file would cache it
|
||||
// again and push the second one out in turn.
|
||||
expect(actionFor(services[1]!).inputSchema).toEqual(schemaFor("ping", 1));
|
||||
expect(actionFor(services[0]!).inputSchema).toEqual(schemaFor("ping", 2));
|
||||
});
|
||||
|
||||
it("rejects a lazy schema cache size that is not a positive integer", async () => {
|
||||
const catalogDir = await writeCatalogDir([providerFixture("example", ["ping"])]);
|
||||
|
||||
for (const value of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
await expect(loadCatalog(catalogDir, { lazySchemas: true, lazySchemaCacheFiles: value })).rejects.toThrow(
|
||||
"lazySchemaCacheFiles must be a positive integer",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the action and file when a lazy schema file no longer contains the action", async () => {
|
||||
const catalogDir = await writeCatalogDir([
|
||||
providerFixture("example", ["ping"]),
|
||||
providerFixture("remote", ["ping"]),
|
||||
]);
|
||||
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true, lazySchemaCacheFiles: 1 });
|
||||
const example = catalog.actionsById.get("example.ping")!;
|
||||
expect(example.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
expect(catalog.actionsById.get("remote.ping")!.inputSchema).toEqual(schemaFor("ping", 1));
|
||||
await writeProviderFile(catalogDir, providerFixture("example", ["pong"]));
|
||||
|
||||
expect(() => example.inputSchema).toThrow(
|
||||
`Catalog action example.ping is missing from ${join(catalogDir, "example.json")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes a lazily loaded catalog exactly like an eagerly loaded one", async () => {
|
||||
const catalogDir = await writeCatalogDir([
|
||||
providerFixture("example", ["ping", "pong"]),
|
||||
providerFixture("remote", ["ping"]),
|
||||
]);
|
||||
|
||||
const eager = await loadCatalog(catalogDir, { executableServices: ["example"] });
|
||||
const lazy = await loadCatalog(catalogDir, { executableServices: ["example"], lazySchemas: true });
|
||||
|
||||
expect(lazy.providerSummariesJson).toBe(eager.providerSummariesJson);
|
||||
expect(lazy.providerSummariesEtag).toBe(eager.providerSummariesEtag);
|
||||
expect(JSON.stringify(lazy.actions)).toBe(JSON.stringify(eager.actions));
|
||||
expect([...lazy.actionsById.keys()]).toEqual([...eager.actionsById.keys()]);
|
||||
expect(JSON.stringify([...lazy.actionsById.values()])).toBe(JSON.stringify([...eager.actionsById.values()]));
|
||||
expect(actionKeys(lazy)).toEqual(actionKeys(eager));
|
||||
expect(actionKeys(lazy)[0]).toEqual([
|
||||
"id",
|
||||
"service",
|
||||
"name",
|
||||
"description",
|
||||
"requiredScopes",
|
||||
"providerPermissions",
|
||||
"inputSchema",
|
||||
"outputSchema",
|
||||
"followUpActions",
|
||||
"asyncLifecycle",
|
||||
"execution",
|
||||
]);
|
||||
for (const summaries of [eager.providerSummariesJson, lazy.providerSummariesJson]) {
|
||||
expect(summaries).not.toContain("inputSchema");
|
||||
expect(summaries).not.toContain("outputSchema");
|
||||
}
|
||||
});
|
||||
|
||||
it("builds provider summaries without reading any lazy schema", async () => {
|
||||
const catalogDir = await writeCatalogDir([providerFixture("example", ["ping"])]);
|
||||
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true });
|
||||
await rm(catalogDir, { recursive: true, force: true });
|
||||
|
||||
const [summary] = JSON.parse(catalog.providerSummariesJson) as ProviderSummaryDefinition[];
|
||||
expect(summary?.actions[0]?.id).toBe("example.ping");
|
||||
// Nothing was cached during store construction, so the first schema access has to hit the
|
||||
// deleted file.
|
||||
expect(() => catalog.actionsById.get("example.ping")!.inputSchema).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
function actionKeys(catalog: CatalogStore): string[][] {
|
||||
return catalog.actions.map((action) => Object.keys(action));
|
||||
}
|
||||
|
||||
async function writeCatalogDir(providers: ProviderDefinition[]): Promise<string> {
|
||||
const catalogDir = await mkdtemp(join(tmpdir(), "catalog-store-"));
|
||||
temporaryDirectories.push(catalogDir);
|
||||
for (const provider of providers) {
|
||||
await writeProviderFile(catalogDir, provider);
|
||||
}
|
||||
|
||||
return catalogDir;
|
||||
}
|
||||
|
||||
function writeProviderFile(catalogDir: string, provider: ProviderDefinition): Promise<void> {
|
||||
return writeFile(join(catalogDir, `${provider.service}.json`), JSON.stringify(provider));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated catalog actions carry `followUpActions` and `asyncLifecycle` after the schemas, so the
|
||||
* fixture keeps keys on both sides of them.
|
||||
*/
|
||||
function providerFixture(service: string, actionNames: string[], revision = 1): ProviderDefinition {
|
||||
return {
|
||||
service,
|
||||
displayName: service,
|
||||
@@ -104,8 +307,17 @@ function providerFixture(service: string, actionNames: string[]): ProviderDefini
|
||||
description: `${name} action.`,
|
||||
requiredScopes: [],
|
||||
providerPermissions: [],
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
inputSchema: schemaFor(name, revision),
|
||||
outputSchema: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||
followUpActions: [`${service}.${actionNames[0]}`],
|
||||
asyncLifecycle: {
|
||||
startActionId: `${service}.${actionNames[0]}`,
|
||||
statusActionId: `${service}.${name}`,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function schemaFor(name: string, revision: number): JsonSchema {
|
||||
return { type: "object", properties: { [`${name}${revision}`]: { type: "string" } } };
|
||||
}
|
||||
|
||||
+57
-17
@@ -2,6 +2,7 @@ import type { ActionDefinition, AuthType, ProviderDefinition, ProviderScenario }
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { readProvidersWithLazySchemas } from "./catalog-lazy-schemas.ts";
|
||||
import { sortProviders } from "./core/catalog.ts";
|
||||
import { resolveProviderScenario } from "./core/provider-scenarios.ts";
|
||||
|
||||
@@ -73,11 +74,23 @@ export interface CreateCatalogStoreOptions {
|
||||
executableActionIds?: Iterable<string>;
|
||||
}
|
||||
|
||||
export interface LoadCatalogOptions extends CreateCatalogStoreOptions {
|
||||
/** Which actions of a catalog the runtime may execute locally, independent of where the catalog is read from. */
|
||||
export interface ExecutableActionOptions extends CreateCatalogStoreOptions {
|
||||
/** Mark every catalog action owned by these locally loaded provider services as executable. */
|
||||
executableServices?: Iterable<string>;
|
||||
}
|
||||
|
||||
export interface LoadCatalogOptions extends ExecutableActionOptions {
|
||||
/** Keep action schemas on disk and read them back on demand (see `OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS`). */
|
||||
lazySchemas?: boolean;
|
||||
/**
|
||||
* Provider files whose schemas stay cached at once in lazy mode. Must be a positive integer. Defaults to
|
||||
* `defaultLazySchemaCacheFiles` from `catalog-lazy-schemas.ts`, which the server reads from
|
||||
* `OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES`.
|
||||
*/
|
||||
lazySchemaCacheFiles?: number;
|
||||
}
|
||||
|
||||
export function createCatalogStore(
|
||||
providers: ProviderDefinition[],
|
||||
options: CreateCatalogStoreOptions = {},
|
||||
@@ -85,12 +98,17 @@ export function createCatalogStore(
|
||||
const sortedProviders = sortProviders(providers);
|
||||
const executableActions = new Set(options.executableActionIds ?? []);
|
||||
const runtimeProviders = sortedProviders.map((provider): RuntimeProviderDefinition => {
|
||||
const actions = provider.actions.map(
|
||||
(action): RuntimeActionDefinition => ({
|
||||
...action,
|
||||
execution: createActionExecutionStatus(provider, action, executableActions),
|
||||
}),
|
||||
);
|
||||
const actions = provider.actions.map((action): RuntimeActionDefinition => {
|
||||
// A descriptor copy instead of a spread: a lazily loaded action exposes its schemas as
|
||||
// accessors, and spreading would read them all back into memory here. Plain JSON actions copy
|
||||
// to the same keys, in the same order, with the same attributes.
|
||||
const runtimeAction = Object.defineProperties(
|
||||
{},
|
||||
Object.getOwnPropertyDescriptors(action),
|
||||
) as RuntimeActionDefinition;
|
||||
runtimeAction.execution = createActionExecutionStatus(provider, action, executableActions);
|
||||
return runtimeAction;
|
||||
});
|
||||
|
||||
return {
|
||||
...provider,
|
||||
@@ -136,23 +154,45 @@ function weakEtag(content: string): string {
|
||||
function toProviderSummary(provider: RuntimeProviderDefinition): ProviderSummaryDefinition {
|
||||
return {
|
||||
...provider,
|
||||
actions: provider.actions.map(({ inputSchema: _inputSchema, outputSchema: _outputSchema, ...action }) => action),
|
||||
actions: provider.actions.map(toActionSummary),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the two schema keys by copying the others in order.
|
||||
*
|
||||
* Rest destructuring or a spread would read both schemas, which pulls every file-backed schema of a
|
||||
* lazily loaded catalog into memory while the summaries are built.
|
||||
*/
|
||||
function toActionSummary(action: RuntimeActionDefinition): ActionSummaryDefinition {
|
||||
const summary: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(action) as (keyof RuntimeActionDefinition)[]) {
|
||||
if (key === "inputSchema" || key === "outputSchema") {
|
||||
continue;
|
||||
}
|
||||
summary[key] = action[key];
|
||||
}
|
||||
|
||||
return summary as ActionSummaryDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load generated provider catalog files from disk.
|
||||
*
|
||||
* With `lazySchemas`, files are read one at a time and each action keeps only its metadata; the
|
||||
* schemas stay on disk behind accessors backed by a small per-file cache, which trades a
|
||||
* synchronous read on schema access for a much smaller resident catalog.
|
||||
*/
|
||||
export async function loadCatalog(catalogDir: string, options: LoadCatalogOptions = {}): Promise<CatalogStore> {
|
||||
const entries = await readdir(catalogDir, { withFileTypes: true });
|
||||
const providers = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map(async (entry) => {
|
||||
const content = await readFile(join(catalogDir, entry.name), "utf8");
|
||||
return JSON.parse(content) as ProviderDefinition;
|
||||
}),
|
||||
);
|
||||
const filePaths = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map((entry) => join(catalogDir, entry.name));
|
||||
const providers = options.lazySchemas
|
||||
? await readProvidersWithLazySchemas(filePaths, options.lazySchemaCacheFiles)
|
||||
: await Promise.all(
|
||||
filePaths.map(async (filePath) => JSON.parse(await readFile(filePath, "utf8")) as ProviderDefinition),
|
||||
);
|
||||
return createCatalogStore(providers, {
|
||||
executableActionIds: resolveExecutableActionIds(providers, options),
|
||||
});
|
||||
@@ -161,7 +201,7 @@ export async function loadCatalog(catalogDir: string, options: LoadCatalogOption
|
||||
/** Resolve provider-level executable services into the exact action ids present in a loaded catalog. */
|
||||
export function resolveExecutableActionIds(
|
||||
providers: ProviderDefinition[],
|
||||
options: LoadCatalogOptions = {},
|
||||
options: ExecutableActionOptions = {},
|
||||
): Set<string> {
|
||||
const actionIds = new Set(options.executableActionIds ?? []);
|
||||
const services = new Set(options.executableServices ?? []);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CatalogStore, LoadCatalogOptions } from "../../catalog-store.ts";
|
||||
import type { CatalogStore, ExecutableActionOptions } from "../../catalog-store.ts";
|
||||
import type { ProviderDefinition } from "../../core/types.ts";
|
||||
import type { AssetsBinding } from "./cloudflare-bindings.ts";
|
||||
|
||||
@@ -16,7 +16,7 @@ interface CatalogAssetIndex {
|
||||
|
||||
export async function loadCatalogFromAssets(
|
||||
assets: AssetsBinding,
|
||||
options: LoadCatalogOptions = {},
|
||||
options: ExecutableActionOptions = {},
|
||||
): Promise<CatalogStore> {
|
||||
const index = parseCatalogIndex(await readJsonAsset(assets, catalogIndexPath), catalogIndexPath);
|
||||
const chunks = await Promise.all(index.chunks.map((chunk) => requireProviderArrayAsset(assets, `/catalog/${chunk}`)));
|
||||
@@ -30,7 +30,7 @@ export async function loadCatalogFromAssets(
|
||||
return createCatalog(providers, options);
|
||||
}
|
||||
|
||||
function createCatalog(providers: ProviderDefinition[], options: LoadCatalogOptions): CatalogStore {
|
||||
function createCatalog(providers: ProviderDefinition[], options: ExecutableActionOptions): CatalogStore {
|
||||
return createCatalogStore(providers, {
|
||||
executableActionIds: resolveExecutableActionIds(providers, options),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { defaultLazySchemaCacheFiles } from "../catalog-lazy-schemas.ts";
|
||||
import { loadCatalog } from "../catalog-store.ts";
|
||||
import { ActionPolicyService, parseActionPolicyList } from "../core/action-policy.ts";
|
||||
import {
|
||||
@@ -85,6 +86,11 @@ async function main(): Promise<void> {
|
||||
const assets = await resolveServerAssets();
|
||||
const catalog = await loadCatalog(assets.catalogDir, {
|
||||
executableServices: Object.keys(executorModules),
|
||||
lazySchemas: parseBooleanEnv("OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS"),
|
||||
lazySchemaCacheFiles: readPositiveIntegerEnv(
|
||||
"OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES",
|
||||
defaultLazySchemaCacheFiles,
|
||||
),
|
||||
});
|
||||
const runtimeDatabase = databaseUrl
|
||||
? await createNodeRuntimeDatabase({
|
||||
|
||||
Reference in New Issue
Block a user