From 4788a65eb51d86eb43153f878e398f4837bb836f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:30:10 +0000 Subject: [PATCH 1/2] feat(workbench): expose state lifetime in routes (#269) --- .changeset/state-lifetime-catalog.md | 5 + packages/agent-bundle/src/api.ts | 65 ++---------- packages/agent-bundle/src/contracts/routes.ts | 1 + .../agent-bundle/src/core/state-inspection.ts | 77 +++++++++++++++ .../src/dev/routes/route-manifest.ts | 12 +++ .../agent-bundle/src/dev/workbench-server.ts | 12 ++- .../tests/route-manifest-routes.test.ts | 99 +++++++++++++++++++ .../src/routes/route-manifest-client.ts | 27 +++++ packages/workbench/src/routes/routes-model.ts | 3 + packages/workbench/src/routes/routes-page.css | 10 ++ packages/workbench/src/routes/routes-page.tsx | 42 +++++++- .../workbench/tests/examples-real.e2e.test.ts | 7 ++ .../tests/route-manifest-client.test.ts | 29 ++++++ packages/workbench/tests/routes-model.test.ts | 30 ++++++ packages/workbench/tests/routes-page.test.ts | 41 ++++++++ 15 files changed, 401 insertions(+), 59 deletions(-) create mode 100644 .changeset/state-lifetime-catalog.md create mode 100644 packages/agent-bundle/src/core/state-inspection.ts diff --git a/.changeset/state-lifetime-catalog.md b/.changeset/state-lifetime-catalog.md new file mode 100644 index 000000000..419545bb0 --- /dev/null +++ b/.changeset/state-lifetime-catalog.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Expose normalized state lifetime, driver, budgets, and durability details in the compiled route manifest for a read-only Workbench catalog. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 806c9fd4c..59cbf6d89 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -15,6 +15,11 @@ import { } from './build/pack-inventory.ts'; import type { CapabilityState } from './core/capabilities.ts'; import { isInsideOrEqual } from './core/paths.ts'; +import { + stateDefinitionProjection, + type StateProjectionBudgets, + type StateProjectionDriver, +} from './core/state-inspection.ts'; import { emptyCompiledRouteGraph } from './routes/graph.ts'; import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts'; import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts'; @@ -264,14 +269,9 @@ export interface InspectOptions extends ProjectOptions { readonly target?: string; } -export type StateInspectionDriver = 'memory' | 'sqlite'; +export type StateInspectionDriver = StateProjectionDriver; -export interface StateInspectionBudgets { - readonly maxCommitMs: number; - readonly maxEventBytes: number; - readonly maxRevisions: number; - readonly maxStateBytes: number; -} +export type StateInspectionBudgets = StateProjectionBudgets; export type StateInspection = | { @@ -539,61 +539,14 @@ const skippedComponentsFor = ( : 'unsupported-capability') satisfies InspectionSkipReason, }))); -const durableStateLocation = - '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)'; - -const noticeLedgerInspection = - 'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.'; - -// Keep static inspection independent of the optional runtime peer. The -// cross-package inspection test compares these policy defaults with the -// runtime export so the two package boundaries cannot drift silently. -const agentStateDefaultBudgets: StateInspectionBudgets = Object.freeze({ - maxCommitMs: 5_000, - maxEventBytes: 262_144, - maxRevisions: 100_000, - maxStateBytes: 1_048_576, -}); - -const stateDriver = ( - lifetime: NonNullable['lifetime'], -): StateInspectionDriver => { - switch (lifetime) { - case 'request': - case 'process': - return 'memory'; - case 'workspace-durable': - return 'sqlite'; - default: { - const unreachable: never = lifetime; - throw new TypeError(`Unknown normalized state lifetime ${String(unreachable)}.`); - } - } -}; - const inspectState = (model: NormalizedPlugin): StateInspection => { const definition = model.state; if (definition === undefined) return Object.freeze({ declared: false }); - const budgets: Extract['budgets'] = - definition.budgets === 'dynamic' - ? Object.freeze({ source: 'dynamic' }) - : Object.freeze({ - resolved: Object.freeze({ - ...agentStateDefaultBudgets, - ...(definition.budgets?.declared ?? {}), - }), - source: definition.budgets === undefined ? 'defaults' : 'declared', - }); + const projection = stateDefinitionProjection(definition); return deepFreeze({ - budgets, declared: true, - driver: stateDriver(definition.lifetime), - ...(definition.lifetime === 'workspace-durable' ? { durableLocation: durableStateLocation } : {}), - id: definition.id, - lifetime: definition.lifetime, - notices: [noticeLedgerInspection], + ...projection, provenance: definition.provenance, - source: definition.source, }); }; diff --git a/packages/agent-bundle/src/contracts/routes.ts b/packages/agent-bundle/src/contracts/routes.ts index 780fe5436..f1139a301 100644 --- a/packages/agent-bundle/src/contracts/routes.ts +++ b/packages/agent-bundle/src/contracts/routes.ts @@ -17,6 +17,7 @@ export type { RouteManifestRoute, RouteManifestServer, RouteManifestServerMode, + RouteManifestState, } from '../dev/routes/route-manifest.ts'; export type { RouteInputArrayItemSchema, diff --git a/packages/agent-bundle/src/core/state-inspection.ts b/packages/agent-bundle/src/core/state-inspection.ts new file mode 100644 index 000000000..9c77957de --- /dev/null +++ b/packages/agent-bundle/src/core/state-inspection.ts @@ -0,0 +1,77 @@ +import { AGENT_STATE_DEFAULT_BUDGETS } from '@agent-bundle/runtime/state'; + +import { deepFreeze } from './freeze.ts'; +import type { NormalizedStateDefinition } from './types.ts'; + +export type StateProjectionDriver = 'memory' | 'sqlite'; + +export interface StateProjectionBudgets { + readonly maxCommitMs: number; + readonly maxEventBytes: number; + readonly maxRevisions: number; + readonly maxStateBytes: number; +} + +export interface StateDefinitionProjection { + readonly budgets: + | { + readonly resolved: StateProjectionBudgets; + readonly source: 'declared' | 'defaults'; + } + | { + readonly source: 'dynamic'; + }; + readonly driver: StateProjectionDriver; + readonly durableLocation?: string; + readonly id: string; + readonly lifetime: NormalizedStateDefinition['lifetime']; + readonly notices: readonly string[]; + readonly source: string; +} + +const durableStateLocation = + '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)'; + +const noticeLedgerInspection = + 'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.'; + +const stateDriver = ( + lifetime: NormalizedStateDefinition['lifetime'], +): StateProjectionDriver => { + switch (lifetime) { + case 'request': + case 'process': + return 'memory'; + case 'workspace-durable': + return 'sqlite'; + default: { + const unreachable: never = lifetime; + throw new TypeError(`Unknown normalized state lifetime ${String(unreachable)}.`); + } + } +}; + +/** Projects the static state declaration into the facts shared by inspection and the Workbench. */ +export const stateDefinitionProjection = ( + definition: NormalizedStateDefinition, + source = definition.source, +): StateDefinitionProjection => { + const budgets: StateDefinitionProjection['budgets'] = definition.budgets === 'dynamic' + ? Object.freeze({ source: 'dynamic' }) + : Object.freeze({ + resolved: Object.freeze({ + ...AGENT_STATE_DEFAULT_BUDGETS, + ...(definition.budgets?.declared ?? {}), + }), + source: definition.budgets === undefined ? 'defaults' : 'declared', + }); + return deepFreeze({ + budgets, + driver: stateDriver(definition.lifetime), + ...(definition.lifetime === 'workspace-durable' ? { durableLocation: durableStateLocation } : {}), + id: definition.id, + lifetime: definition.lifetime, + notices: [noticeLedgerInspection], + source, + }); +}; diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index 786a18912..f7728aaec 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -1,5 +1,10 @@ import type { Diagnostic } from '../../core/diagnostics.ts'; import { deepFreeze } from '../../core/freeze.ts'; +import { + stateDefinitionProjection, + type StateDefinitionProjection, +} from '../../core/state-inspection.ts'; +import type { NormalizedStateDefinition } from '../../core/types.ts'; import type { CompiledAgentRoute, CompiledCliCommand, @@ -113,6 +118,9 @@ export interface RouteManifestProvider { readonly source: string; } +/** The effective static state declaration exposed to the browser catalog. */ +export type RouteManifestState = StateDefinitionProjection; + /** * The browser projection of one compiled route graph. It is the same compiler * pass the build, `inspect`, and the test harness read — the Workbench derives @@ -127,6 +135,8 @@ export interface RouteManifest { readonly providers: readonly RouteManifestProvider[]; readonly scripts: readonly RouteManifestRoute[]; readonly servers: readonly RouteManifestServer[]; + /** Absent when the project declares no conventional state module. */ + readonly state?: RouteManifestState; /** * The source revision of the compiler pass that produced the graph. The * browser compares it against the published build's project revision so a @@ -231,6 +241,7 @@ const manifestProvider = (provider: CompiledProvider): RouteManifestProvider => export const routeManifestFor = ( graph: CompiledRouteGraph, sourceRevision: string, + state?: NormalizedStateDefinition, ): RouteManifest => deepFreeze({ ...(graph.cli === undefined ? {} : { cli: manifestCli(graph.cli) }), diagnostics: graph.diagnostics.map((diagnostic) => ({ ...diagnostic })), @@ -239,5 +250,6 @@ export const routeManifestFor = ( providers: graph.providers.map(manifestProvider), scripts: graph.scripts.map(manifestRoute), servers: graph.servers.map(manifestServer), + ...(state === undefined ? {} : { state: stateDefinitionProjection(state, 'src/state.ts') }), sourceRevision, }); diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index fe49d8130..cb9016d22 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -773,10 +773,18 @@ export const startDevServer = async (options: StartDevServerOptions): Promise { const prepared = latestValidPreparedProject; - if (prepared === undefined || prepared.source.revision === undefined) { + if ( + prepared === undefined || + prepared.model === undefined || + prepared.source.revision === undefined + ) { throw new Error('No valid prepared project is available for the route manifest.'); } - return routeManifestFor(prepared.routeGraph ?? emptyCompiledRouteGraph, prepared.source.revision); + return routeManifestFor( + prepared.routeGraph ?? emptyCompiledRouteGraph, + prepared.source.revision, + prepared.model.state, + ); }, }; const agentApi = agentApiEnabled diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index d7ff858e3..15a8c2077 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -5,6 +5,7 @@ import { expect, it } from '@rstest/core'; import { compileRouteGraph, emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { routeManifestFor, type RouteManifest } from '../src/dev/routes/route-manifest.ts'; import { RouteManifestRoutes, type RouteManifestRouteService } from '../src/dev/routes/route-manifest-routes.ts'; +import type { NormalizedStateDefinition } from '../src/core/types.ts'; import type { CompiledRouteGraph } from '../src/routes/types.ts'; import { authorize, @@ -16,6 +17,17 @@ import { const revision = 'r'.repeat(64); +const stateDefinition = ( + budgets?: NormalizedStateDefinition['budgets'], + lifetime: NormalizedStateDefinition['lifetime'] = 'workspace-durable', +): NormalizedStateDefinition => ({ + ...(budgets === undefined ? {} : { budgets }), + id: 'fixture/catalog-state', + lifetime, + provenance: { kind: 'conventional', sourcePath: '/project/src/state.ts' }, + source: '/project/src/state.ts', +}); + class RecordingService implements RouteManifestRouteService { readonly calls: string[] = []; failure: Error | undefined; @@ -58,6 +70,44 @@ it('serves the compiled manifest without recompiling the graph', async () => { } }); +it('serves the normalized state catalog on the manifest wire', async () => { + const service = new RecordingService(); + service.value = routeManifestFor( + emptyCompiledRouteGraph, + revision, + stateDefinition({ declared: { maxStateBytes: 2_048 } }), + ); + const started = await startRoutes(service); + + try { + const read = await fetch(`${started.url}/api/routes/manifest`, { headers: headers() }); + expect(read.status).toBe(200); + await expect(read.json()).resolves.toMatchObject({ + manifest: { + state: { + budgets: { + resolved: { + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 2_048, + }, + source: 'declared', + }, + driver: 'sqlite', + durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + id: 'fixture/catalog-state', + lifetime: 'workspace-durable', + notices: [expect.stringContaining('@agent-bundle/runtime/agent-notice-ledger/v1')], + source: 'src/state.ts', + }, + }, + }); + } finally { + await started.close(); + } +}); + it('rejects invalid manifest paths, queries, and methods', async () => { const service = new RecordingService(); const started = await startRoutes(service); @@ -179,6 +229,55 @@ it('projects a compiled graph into the browser manifest with project-relative so expect(Object.isFrozen(manifest)).toBe(true); }); +it('projects declared, default, and dynamic state budgets without fabricating absent state', () => { + const declared = routeManifestFor( + emptyCompiledRouteGraph, + revision, + stateDefinition({ declared: { maxCommitMs: 25 } }, 'process'), + ); + expect(declared.state).toMatchObject({ + budgets: { + resolved: { + maxCommitMs: 25, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, + }, + source: 'declared', + }, + driver: 'memory', + id: 'fixture/catalog-state', + lifetime: 'process', + source: 'src/state.ts', + }); + expect(declared.state).not.toHaveProperty('durableLocation'); + + const defaults = routeManifestFor( + emptyCompiledRouteGraph, + revision, + stateDefinition(undefined, 'request'), + ); + expect(defaults.state?.budgets).toEqual({ + resolved: { + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, + }, + source: 'defaults', + }); + + const dynamic = routeManifestFor( + emptyCompiledRouteGraph, + revision, + stateDefinition('dynamic'), + ); + expect(dynamic.state?.budgets).toEqual({ source: 'dynamic' }); + + const absent = routeManifestFor(emptyCompiledRouteGraph, revision); + expect(absent).not.toHaveProperty('state'); +}); + it('passes the bounded input schema through as the optional manifest wire field', () => { const inputSchema = Object.freeze({ additionalProperties: false as const, diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index a256a45ef..742569df8 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -10,6 +10,7 @@ import type { RouteManifestProvider, RouteManifestRoute, RouteManifestServer, + RouteManifestState, RouteInputArrayItemSchema, RouteInputPropertySchema, RouteInputSchema, @@ -150,6 +151,31 @@ const providerSchema: z.ZodType = z.strictObject({ source: z.string(), }); +const stateBudgetsSchema = z.strictObject({ + maxCommitMs: z.number().finite(), + maxEventBytes: z.number().finite(), + maxRevisions: z.number().finite(), + maxStateBytes: z.number().finite(), +}); + +const stateSchema: z.ZodType = z.strictObject({ + budgets: z.union([ + z.strictObject({ + resolved: stateBudgetsSchema, + source: z.enum(['declared', 'defaults']), + }), + z.strictObject({ + source: z.literal('dynamic'), + }), + ]), + driver: z.enum(['memory', 'sqlite']), + durableLocation: z.string().optional(), + id: z.string(), + lifetime: z.enum(['process', 'request', 'workspace-durable']), + notices: z.array(z.string()), + source: z.string(), +}); + const manifestSchema: z.ZodType = z.strictObject({ cli: cliSchema.optional(), diagnostics: z.array(diagnosticSchema), @@ -158,6 +184,7 @@ const manifestSchema: z.ZodType = z.strictObject({ providers: z.array(providerSchema), scripts: z.array(routeSchema), servers: z.array(serverSchema), + state: stateSchema.optional(), sourceRevision: z.string(), }); diff --git a/packages/workbench/src/routes/routes-model.ts b/packages/workbench/src/routes/routes-model.ts index 21d2bf4c3..e3b62b403 100644 --- a/packages/workbench/src/routes/routes-model.ts +++ b/packages/workbench/src/routes/routes-model.ts @@ -7,6 +7,7 @@ import type { RouteManifestKind, RouteManifestRoute, RouteManifestServerMode, + RouteManifestState, RouteInputArrayItemSchema, RouteInputPropertySchema, RouteInputSchema, @@ -80,6 +81,7 @@ export interface RouteCatalog { readonly servers: readonly RouteCatalogServer[]; readonly sourceRevision?: string; readonly state: RouteCatalogState; + readonly stateDefinition?: RouteManifestState; } export type RouteInputDraftValue = boolean | string | readonly (boolean | string)[]; @@ -202,6 +204,7 @@ export const routeCatalogFor = ( .sort((left, right) => left.name.localeCompare(right.name))), sourceRevision: manifest.sourceRevision, state: epochSourceRevision === undefined || epochSourceRevision === manifest.sourceRevision ? 'current' : 'stale', + ...(manifest.state === undefined ? {} : { stateDefinition: manifest.state }), }); }; diff --git a/packages/workbench/src/routes/routes-page.css b/packages/workbench/src/routes/routes-page.css index cf4aeb1d6..e5ca89a3f 100644 --- a/packages/workbench/src/routes/routes-page.css +++ b/packages/workbench/src/routes/routes-page.css @@ -19,6 +19,16 @@ .route-group-heading { align-items: baseline; display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between; } .route-group-heading h2 { font-size: 17px; margin: 0; } .route-group-heading p { color: #596372; font-size: 13px; margin: 0; text-transform: lowercase; } +.route-state-facts, .route-state-budgets { display: grid; gap: 0; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 14px 0 0; } +.route-state-facts div, .route-state-budgets div { border-right: 1px solid #e1e5eb; min-width: 0; padding: 8px 14px 8px 0; } +.route-state-facts div:last-child, .route-state-budgets div:last-child { border-right: 0; } +.route-state-facts dt, .route-state-budgets dt { color: #596372; font-size: 11px; font-weight: 750; letter-spacing: .03em; text-transform: uppercase; } +.route-state-facts dd, .route-state-budgets dd { font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin: 5px 0 0; overflow-wrap: anywhere; } +.route-state-detail { margin-top: 16px; } +.route-state-detail h3 { color: #35445a; font-size: 12px; margin: 0; text-transform: uppercase; } +.route-state-detail p, .route-state-notice { color: #596372; font-size: 12px; line-height: 1.5; margin: 7px 0 0; } +.route-state-source { font-weight: 700; text-transform: capitalize; } +.route-state-notice { border-left: 2px solid #ccd4df; padding-left: 10px; } .route-server-summary { color: #596372; font-size: 13px; margin: 10px 0 0; max-width: 760px; } .route-table { border-collapse: collapse; margin-top: 14px; table-layout: fixed; width: 100%; } .route-table th, .route-table td { border-bottom: 1px solid #e4e8ef; padding: 11px 12px 11px 0; text-align: left; vertical-align: top; } diff --git a/packages/workbench/src/routes/routes-page.tsx b/packages/workbench/src/routes/routes-page.tsx index a2b5aaf2e..b76eb52dd 100644 --- a/packages/workbench/src/routes/routes-page.tsx +++ b/packages/workbench/src/routes/routes-page.tsx @@ -1,7 +1,10 @@ import { useAtom } from '@effect/atom-react'; import React from 'react'; -import type { RouteInputPropertySchema } from '../../../agent-bundle/src/contracts/routes.ts'; +import type { + RouteInputPropertySchema, + RouteManifestState, +} from '../../../agent-bundle/src/contracts/routes.ts'; import { routeEditorKey, routeEditorStateAtom } from './route-editor-atoms.ts'; import { cliCommandUsage, @@ -70,6 +73,42 @@ const EmptyServerSurface = ({ server }: { readonly server: RouteCatalogServer })

{emptyServerSummary(server)}

; +const StatePanel = ({ state }: { readonly state?: RouteManifestState }) =>
+

State

read-only catalog

+ {state === undefined + ?

This project declares no state module.

+ : <> +
+
ID
{state.id}
+
Effective lifetime
{state.lifetime}
+
Driver
{state.driver}
+
Source
{state.source}
+
+
+

Budgets

+ {state.budgets.source === 'dynamic' + ?

dynamic — statically unreadable

+ : <> +

{state.budgets.source}

+
+
maxCommitMs
{state.budgets.resolved.maxCommitMs}
+
maxEventBytes
{state.budgets.resolved.maxEventBytes}
+
maxRevisions
{state.budgets.resolved.maxRevisions}
+
maxStateBytes
{state.budgets.resolved.maxStateBytes}
+
+ } +
+ {state.durableLocation === undefined ? undefined :
+

Durable location

+

{state.durableLocation}

+
} + {state.notices.map((notice) =>

{notice}

)} + } +
; + const editorId = (routeId: string, key: string): string => `route-input-${routeId}-${key}`.replace(/[^a-zA-Z0-9_-]/gu, '-'); @@ -273,6 +312,7 @@ export const RoutesPage = ({ catalog, onOpenMcp }: RoutesPageProps) =>
State
{catalog.state}
+ {catalog.diagnostics.length === 0 ? undefined :

Route diagnostics ({catalog.diagnostics.length})

{catalog.diagnostics.map((diagnostic, index) =>

diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index a142aac34..c87d17343 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -368,6 +368,7 @@ e2e('drives every populated MCP App workflow surface in real Chrome', { timeout: await expect(page.getByRole('heading', { name: 'Routes', exact: true })).toBeVisible({ timeout: browserTimeout }); // Every capability here is configured rather than routed: the compiled // catalog reports an empty graph while all nine pages stay navigable. + await expect(page.getByText('This project declares no state module.', { exact: true })).toBeVisible({ timeout: browserTimeout }); await expect(page.getByText('This project declares no conventional route modules.', { exact: true })).toBeVisible({ timeout: browserTimeout }); await expect(page.locator('.route-table')).toHaveCount(0); for (const preserved of ['Overview', 'Skills', 'Hooks', 'MCP playground', 'Artifacts', 'Playground', 'Logs', 'Evals', 'Comparisons']) { @@ -587,6 +588,12 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro await waitForSettledWorkbench(page); await expect(page.getByRole('heading', { name: 'Routes', exact: true })).toBeVisible({ timeout: browserTimeout }); await expect(page.locator('.route-state')).toHaveText('current', { timeout: browserTimeout }); + const state = page.getByRole('region', { name: 'State' }); + await expect(state).toContainText('audiobook-curator/shelf', { timeout: browserTimeout }); + await expect(state).toContainText('workspace-durable', { timeout: browserTimeout }); + await expect(state).toContainText('sqlite', { timeout: browserTimeout }); + await expect(state).toContainText('src/state.ts', { timeout: browserTimeout }); + await expect(state.locator('button, input, select, textarea')).toHaveCount(0); // One generated server owns every MCP kind the curator declares, so each // kind must appear as its own server-scoped group rather than a flat list. diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index cbd7fcf8b..dd96dd647 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -84,6 +84,23 @@ const manifest = { source: 'src/mcp/library/tools/echo.ts', }], }], + state: { + budgets: { + resolved: { + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, + }, + source: 'defaults', + }, + driver: 'sqlite', + durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + id: 'library/catalog', + lifetime: 'workspace-durable', + notices: ['Generated runtimes co-mount the notice ledger store at the same lifetime.'], + source: 'src/state.ts', + }, sourceRevision: 'r'.repeat(64), }; @@ -122,6 +139,7 @@ it('reads the compiled manifest over the shared foreground session', async () => type: 'string', }); expect(decoded.cli?.commands?.[0]?.path).toEqual(['library', 'audit']); + expect(decoded.state).toEqual(manifest.state); expect(calls).toEqual([{ method: 'GET', token: 'foreground-token', url: '/api/routes/manifest' }]); }); @@ -169,6 +187,17 @@ it('rejects an unknown field on a compiled route', async () => { await expect(client.manifest()).rejects.toMatchObject({ code: 'AB8123' }); }); +it('rejects an unknown field inside the state catalog', async () => { + const client = clientFor(() => response({ + manifest: { ...manifest, state: { ...manifest.state, mutable: true } }, + })); + + await expect(client.manifest()).rejects.toMatchObject({ + code: 'AB8123', + message: 'Route manifest route returned an invalid response.', + }); +}); + it('rejects unknown fields at every input-schema level', async () => { const route = manifest.servers[0]!.routes[0]!; const inputSchema = route.inputSchema!; diff --git a/packages/workbench/tests/routes-model.test.ts b/packages/workbench/tests/routes-model.test.ts index 2885aa368..921d8973b 100644 --- a/packages/workbench/tests/routes-model.test.ts +++ b/packages/workbench/tests/routes-model.test.ts @@ -116,6 +116,23 @@ const manifest: RouteManifest = { ], }, ], + state: { + budgets: { + resolved: { + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, + }, + source: 'defaults', + }, + driver: 'sqlite', + durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state', + id: 'library/catalog', + lifetime: 'workspace-durable', + notices: ['The notice ledger is co-mounted at the same lifetime.'], + source: 'src/state.ts', + }, sourceRevision: 'r'.repeat(64), }; @@ -135,6 +152,19 @@ it('groups the compiled graph by server then by project surface', () => { expect(routeCatalogServerCount(catalog)).toBe(2); }); +it('carries the declared state catalog without deriving durability from MCP servers', () => { + const catalog = routeCatalogFor(manifest); + + expect(catalog.stateDefinition).toEqual(manifest.state); +}); + +it('keeps state honestly absent when the project declares no state module', () => { + const { state: _state, ...statelessManifest } = manifest; + const catalog = routeCatalogFor(statelessManifest); + + expect(catalog.stateDefinition).toBeUndefined(); +}); + it('orders routes within a group by compiled id', () => { const catalog = routeCatalogFor(manifest); diff --git a/packages/workbench/tests/routes-page.test.ts b/packages/workbench/tests/routes-page.test.ts index a74a77b9e..a8e4ceed1 100644 --- a/packages/workbench/tests/routes-page.test.ts +++ b/packages/workbench/tests/routes-page.test.ts @@ -66,6 +66,23 @@ const manifest: RouteManifest = { source: 'src/mcp/library/tools/echo.ts', }], }], + state: { + budgets: { + resolved: { + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, + }, + source: 'declared', + }, + driver: 'sqlite', + durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state', + id: 'library/catalog', + lifetime: 'workspace-durable', + notices: ['The notice ledger is co-mounted at the same lifetime.'], + source: 'src/state.ts', + }, sourceRevision: 'r'.repeat(64), }; @@ -86,6 +103,30 @@ it('renders the compiled catalog grouped by server and project surface', () => { expect(markup).toContain('provider:library'); }); +it('renders the declared state catalog as read-only facts', () => { + const markup = render(routeCatalogFor(manifest)); + const statePanel = markup.match(/]*aria-label="State"[^>]*>(.*?)<\/section>/u)?.[1] ?? ''; + + expect(statePanel).toContain('library/catalog'); + expect(statePanel).toContain('workspace-durable'); + expect(statePanel).toContain('sqlite'); + expect(statePanel).toContain('declared'); + expect(statePanel).toContain('maxCommitMs'); + expect(statePanel).toContain('5000'); + expect(statePanel).toContain('$AGENT_BUNDLE_PLUGIN_ROOT/state'); + expect(statePanel).toContain('notice ledger is co-mounted'); + expect(statePanel).toContain('src/state.ts'); + expect(statePanel).not.toMatch(/<(?:button|input|select|textarea)\b/u); +}); + +it('renders honest state absence without an alert', () => { + const { state: _state, ...statelessManifest } = manifest; + const markup = render(routeCatalogFor(statelessManifest)); + + expect(markup).toContain('This project declares no state module.'); + expect(markup.match(/This project declares no state module\.<\/p>/u)?.[0]).not.toContain('role="alert"'); +}); + it('shows the argv projection of a compiled CLI command', () => { const markup = render(routeCatalogFor(manifest)); From d97903114111da9e502a43bf5888b7a39256a215 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 22:48:01 +0000 Subject: [PATCH 2/2] fix(agent-bundle): keep state inspection runtime-independent Restore local policy defaults and a cross-package drift guard so static compiler consumers do not require the optional runtime peer. --- .../agent-bundle/src/core/state-inspection.ts | 14 +++++++++++--- .../agent-bundle/tests/inspect-state.test.ts | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/core/state-inspection.ts b/packages/agent-bundle/src/core/state-inspection.ts index 9c77957de..99d9387f4 100644 --- a/packages/agent-bundle/src/core/state-inspection.ts +++ b/packages/agent-bundle/src/core/state-inspection.ts @@ -1,5 +1,3 @@ -import { AGENT_STATE_DEFAULT_BUDGETS } from '@agent-bundle/runtime/state'; - import { deepFreeze } from './freeze.ts'; import type { NormalizedStateDefinition } from './types.ts'; @@ -12,6 +10,16 @@ export interface StateProjectionBudgets { readonly maxStateBytes: number; } +// Keep static inspection independent of the optional runtime peer. The +// cross-package inspection test compares these policy defaults with the +// runtime export so the two package boundaries cannot drift silently. +export const agentStateDefaultBudgets: StateProjectionBudgets = Object.freeze({ + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, +}); + export interface StateDefinitionProjection { readonly budgets: | { @@ -60,7 +68,7 @@ export const stateDefinitionProjection = ( ? Object.freeze({ source: 'dynamic' }) : Object.freeze({ resolved: Object.freeze({ - ...AGENT_STATE_DEFAULT_BUDGETS, + ...agentStateDefaultBudgets, ...(definition.budgets?.declared ?? {}), }), source: definition.budgets === undefined ? 'defaults' : 'declared', diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index 83024e1ed..a07f8cd47 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -6,6 +6,18 @@ import { AGENT_STATE_DEFAULT_BUDGETS } from '@agent-bundle/runtime/state'; import { expect, it } from '@rstest/core'; import { runCli } from '../src/cli.ts'; +import { agentStateDefaultBudgets } from '../src/core/state-inspection.ts'; + +it('keeps static inspection defaults aligned with the runtime package', () => { + expect(agentStateDefaultBudgets).toEqual({ + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, + }); + expect(agentStateDefaultBudgets).toEqual(AGENT_STATE_DEFAULT_BUDGETS); + expect(Object.isFrozen(agentStateDefaultBudgets)).toBe(true); +}); const createProject = async (): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-state-')); @@ -58,7 +70,12 @@ it('inspects volatile and workspace-durable state without inventing runtime path selected: { state: { budgets: { - resolved: AGENT_STATE_DEFAULT_BUDGETS, + resolved: { + maxCommitMs: 5_000, + maxEventBytes: 262_144, + maxRevisions: 100_000, + maxStateBytes: 1_048_576, + }, source: 'defaults', }, declared: true,