feat(catalog): opt-in lazy loading of action schemas (#489)

Signed-off-by: Kevin Cui <[email protected]>
This commit is contained in:
Kevin Cui
2026-09-04 08:34:02 +08:00
committed by GitHub
parent a3708655b3
commit d11f0e5ff8
9 changed files with 503 additions and 35 deletions
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+147
View File
@@ -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
View File
@@ -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
View File
@@ -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 ?? []);
+3 -3
View File
@@ -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),
});
+6
View File
@@ -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({