From 4d6fe25152873eaab534dde3eb3bf95714e93ba3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 05:41:46 +0000 Subject: [PATCH 1/8] feat: expose route contracts in Workbench --- .../src/dev/routes/route-manifest.ts | 24 ++++++ .../tests/route-manifest-routes.test.ts | 63 ++++++++++++++ .../src/routes/route-manifest-client.ts | 14 ++++ packages/workbench/src/routes/routes-model.ts | 84 ++++++++++++++----- packages/workbench/src/routes/routes-page.tsx | 17 ++++ .../tests/route-manifest-client.test.ts | 19 +++++ packages/workbench/tests/routes-model.test.ts | 29 +++++++ packages/workbench/tests/routes-page.test.ts | 20 +++++ 8 files changed, 250 insertions(+), 20 deletions(-) diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index 9a386a31d..28b62a9f6 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -49,8 +49,21 @@ export interface RouteManifestProvenance { readonly kind: 'conventional'; } +/** Mirrors one compiler route contract without exposing server-only module paths. */ +export interface RouteManifestContract { + readonly id: string; + readonly input: RouteInputSchema; + readonly origin: { + readonly binding: string; + readonly module: string; + }; + readonly routes: readonly string[]; +} + /** One compiled route projected for the browser catalog. */ export interface RouteManifestRoute { + /** Id of the compiler contract this route binds. */ + readonly contract?: string; readonly config: readonly RouteManifestConfigEntry[]; /** `config.description` when it is a string; the catalog's human label. */ readonly description?: string; @@ -128,6 +141,8 @@ export type RouteManifestState = StateDefinitionProjection; */ export interface RouteManifest { readonly cli?: RouteManifestCliSurface; + /** Present only when the compiler graph carries route contracts. */ + readonly contracts?: readonly RouteManifestContract[]; readonly diagnostics: readonly Diagnostic[]; /** The graph digest over project-relative route identity. */ readonly digest: string; @@ -185,6 +200,7 @@ const description = (config: Readonly>): string | undefi const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => { const summary = description(route.config); return { + ...(route.contract === undefined ? {} : { contract: route.contract }), config: configSummary(route.config), ...(summary === undefined ? {} : { description: summary }), ...(route.event === undefined ? {} : { event: route.event }), @@ -245,6 +261,14 @@ export const routeManifestFor = ( notices?: NormalizedNotices, ): RouteManifest => deepFreeze({ ...(graph.cli === undefined ? {} : { cli: manifestCli(graph.cli) }), + ...(graph.contracts === undefined ? {} : { + contracts: graph.contracts.map((contract) => ({ + id: contract.id, + input: contract.input, + origin: { ...contract.origin }, + routes: [...contract.routes], + })), + }), diagnostics: graph.diagnostics.map((diagnostic) => ({ ...diagnostic })), digest: graph.digest, events: graph.events.map(manifestRoute), diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index 207a205e4..115fb93f8 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -313,6 +313,69 @@ it('passes the bounded input schema through as the optional manifest wire field' expect(Object.isFrozen(manifest.scripts[0]?.inputSchema)).toBe(true); }); +it('projects shared route contracts and omits them from contract-free graphs', () => { + const input = Object.freeze({ + additionalProperties: false as const, + properties: Object.freeze({ + statuses: Object.freeze({ + items: Object.freeze({ enum: Object.freeze(['queued', 'running']), type: 'string' as const }), + type: 'array' as const, + }), + }), + type: 'object' as const, + }); + const contractId = 'contract:src/lib/protocol-schemas.ts#statusInputSchema'; + const cliRoute = { + config: {}, + contract: contractId, + id: 'cli:status', + inputSchema: input, + kind: 'cli' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/cli/status.ts' }, + source: '/project/src/cli/status.ts', + }; + const toolRoute = { + config: {}, + contract: contractId, + id: 'tool:hauler/hauler_status', + inputSchema: input, + kind: 'tool' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/mcp/hauler/tools/hauler_status.ts' }, + serverId: 'mcp:hauler', + source: '/project/src/mcp/hauler/tools/hauler_status.ts', + }; + const graph: CompiledRouteGraph = { + ...emptyCompiledRouteGraph, + cli: { mode: 'generated', routes: [cliRoute] }, + contracts: [{ + id: contractId, + input, + origin: { binding: 'statusInputSchema', module: 'src/lib/protocol-schemas.ts' }, + routes: ['cli:status', 'tool:hauler/hauler_status'], + }], + digest: 'c'.repeat(64), + servers: [{ + id: 'mcp:hauler', + mode: 'generated', + name: 'hauler', + routes: [toolRoute], + }], + }; + + const manifest = routeManifestFor(graph, revision); + + expect(manifest.contracts).toEqual([{ + id: contractId, + input, + origin: { binding: 'statusInputSchema', module: 'src/lib/protocol-schemas.ts' }, + routes: ['cli:status', 'tool:hauler/hauler_status'], + }]); + expect(manifest.cli?.routes[0]?.contract).toBe(contractId); + expect(manifest.servers[0]?.routes[0]?.contract).toBe(contractId); + expect(Object.isFrozen(manifest.contracts)).toBe(true); + expect(routeManifestFor(emptyCompiledRouteGraph, revision)).not.toHaveProperty('contracts'); +}); + it('summarizes an extracted route config without leaking non-scalar shapes', async () => { const graph = await compileRouteGraph(resolve(import.meta.dirname, '../fixtures/route-harness'), { targets: ['claude'] } as never); const manifest = routeManifestFor(graph, revision); diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index 5b51ce795..fc46087b3 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -87,7 +87,20 @@ const inputSchema: z.ZodType = z.strictObject({ type: z.literal('object'), }); +type RouteManifestContract = NonNullable[number]; + +const contractSchema: z.ZodType = z.strictObject({ + id: z.string(), + input: inputSchema, + origin: z.strictObject({ + binding: z.string(), + module: z.string(), + }), + routes: z.array(z.string()), +}); + const routeSchema: z.ZodType = z.strictObject({ + contract: z.string().optional(), config: z.array(configEntrySchema), description: z.string().optional(), event: z.string().optional(), @@ -181,6 +194,7 @@ const stateSchema: z.ZodType = z.strictObject({ const manifestSchema: z.ZodType = z.strictObject({ cli: cliSchema.optional(), + contracts: z.array(contractSchema).optional(), diagnostics: z.array(diagnosticSchema), digest: z.string(), events: z.array(routeSchema), diff --git a/packages/workbench/src/routes/routes-model.ts b/packages/workbench/src/routes/routes-model.ts index e3b62b403..a614ec5ad 100644 --- a/packages/workbench/src/routes/routes-model.ts +++ b/packages/workbench/src/routes/routes-model.ts @@ -34,6 +34,14 @@ export const routeCatalogKinds = Object.freeze([ export interface RouteCatalogEntry { readonly command?: RouteManifestCliCommand; readonly config: readonly RouteManifestConfigEntry[]; + readonly contract?: { + readonly id: string; + readonly origin: { + readonly binding: string; + readonly module: string; + }; + readonly sharedWith: readonly string[]; + }; readonly description?: string; readonly event?: string; readonly id: string; @@ -132,17 +140,33 @@ export const routeKindLabel = (kind: RouteManifestKind): string => kindLabels[ki const byId = (left: RouteCatalogEntry, right: RouteCatalogEntry): number => left.id.localeCompare(right.id); -const entryFor = (route: RouteManifestRoute, command?: RouteManifestCliCommand): RouteCatalogEntry => Object.freeze({ - ...(command === undefined ? {} : { command }), - config: route.config, - ...(route.description === undefined ? {} : { description: route.description }), - ...(route.event === undefined ? {} : { event: route.event }), - id: route.id, - ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), - kind: route.kind, - provenance: route.provenance.kind, - source: route.source, -}); +type ManifestContract = NonNullable[number]; + +const entryFor = ( + route: RouteManifestRoute, + contracts: ReadonlyMap, + command?: RouteManifestCliCommand, +): RouteCatalogEntry => { + const contract = route.contract === undefined ? undefined : contracts.get(route.contract); + return Object.freeze({ + ...(command === undefined ? {} : { command }), + config: route.config, + ...(contract === undefined ? {} : { + contract: Object.freeze({ + id: contract.id, + origin: Object.freeze({ ...contract.origin }), + sharedWith: Object.freeze(contract.routes.filter((routeId) => routeId !== route.id)), + }), + }), + ...(route.description === undefined ? {} : { description: route.description }), + ...(route.event === undefined ? {} : { event: route.event }), + id: route.id, + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), + kind: route.kind, + provenance: route.provenance.kind, + source: route.source, + }); +}; const groupFor = ( kind: RouteManifestKind, @@ -155,30 +179,46 @@ const groupFor = ( ...(server === undefined ? {} : { mode: server.mode, server: server.name, serverId: server.id }), }); -const serverGroups = (manifest: RouteManifest): readonly RouteCatalogGroup[] => +const serverGroups = ( + manifest: RouteManifest, + contracts: ReadonlyMap, +): readonly RouteCatalogGroup[] => [...manifest.servers] .sort((left, right) => left.name.localeCompare(right.name)) .flatMap((server) => routeCatalogKinds - .map((kind) => Object.freeze({ entries: server.routes.filter((route) => route.kind === kind).map((route) => entryFor(route)), kind })) + .map((kind) => Object.freeze({ + entries: server.routes.filter((route) => route.kind === kind).map((route) => entryFor(route, contracts)), + kind, + })) .filter((group) => group.entries.length > 0) .map((group) => groupFor(group.kind, group.entries, { id: server.id, mode: server.mode, name: server.name }))); -const cliGroups = (manifest: RouteManifest): readonly RouteCatalogGroup[] => { +const cliGroups = ( + manifest: RouteManifest, + contracts: ReadonlyMap, +): readonly RouteCatalogGroup[] => { const cli = manifest.cli; if (cli === undefined || cli.routes.length === 0) return []; const commands = new Map((cli.commands ?? []).map((command) => [command.routeId, command])); return [Object.freeze({ - entries: Object.freeze(cli.routes.map((route) => entryFor(route, commands.get(route.id))).sort(byId)), + entries: Object.freeze(cli.routes.map((route) => entryFor(route, contracts, commands.get(route.id))).sort(byId)), kind: 'cli' as const, label: kindLabels.cli, mode: cli.mode, })]; }; -const projectGroups = (manifest: RouteManifest): readonly RouteCatalogGroup[] => [ - ...(manifest.events.length === 0 ? [] : [groupFor('event-route', manifest.events.map((route) => entryFor(route)))]), - ...cliGroups(manifest), - ...(manifest.scripts.length === 0 ? [] : [groupFor('script', manifest.scripts.map((route) => entryFor(route)))]), +const projectGroups = ( + manifest: RouteManifest, + contracts: ReadonlyMap, +): readonly RouteCatalogGroup[] => [ + ...(manifest.events.length === 0 + ? [] + : [groupFor('event-route', manifest.events.map((route) => entryFor(route, contracts)))]), + ...cliGroups(manifest, contracts), + ...(manifest.scripts.length === 0 + ? [] + : [groupFor('script', manifest.scripts.map((route) => entryFor(route, contracts)))]), ]; /** @@ -190,7 +230,11 @@ export const routeCatalogFor = ( manifest: RouteManifest, epochSourceRevision?: string, ): RouteCatalog => { - const groups = Object.freeze([...serverGroups(manifest), ...projectGroups(manifest)]); + const contracts = new Map((manifest.contracts ?? []).map((contract) => [contract.id, contract])); + const groups = Object.freeze([ + ...serverGroups(manifest, contracts), + ...projectGroups(manifest, contracts), + ]); return Object.freeze({ diagnostics: manifest.diagnostics, digest: manifest.digest, diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index 70e16a795..b342274c5 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -128,6 +128,21 @@ const StatePanel = ({ state }: { readonly state?: RouteManifestState }) => `route-input-${routeId}-${key}`.replace(/[^a-zA-Z0-9_-]/gu, '-'); +const contractSummary = (entry: RouteCatalogEntry): string | undefined => { + const contract = entry.contract; + if (contract === undefined) return undefined; + // Route-local contracts use a stable declaration label instead of repeating + // the route source already shown in the adjacent table cell. + const origin = contract.origin.module === entry.source + ? 'declared in this module' + : contract.origin.module; + return [ + `Contract ${contract.origin.binding}`, + origin, + ...(contract.sharedWith.length === 0 ? [] : [`shared with ${contract.sharedWith.join(', ')}`]), + ].join(' · '); +}; + const scalarControl = ( routeId: string, key: string, @@ -207,8 +222,10 @@ const RouteInputEditor = ({ digest, entry, group, onOpenMcp }: { if (prefill !== undefined) onOpenMcp(prefill); }; + const contract = contractSummary(entry); return

{schema === undefined ? 'Raw JSON input' : 'Generated input editor'}

+ {contract === undefined ? undefined :

{contract}

} {schema === undefined ?