Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/state-lifetime-catalog.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 9 additions & 56 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 =
| {
Expand Down Expand Up @@ -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<NormalizedPlugin['state']>['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<StateInspection, { readonly declared: true }>['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,
});
};

Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/contracts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type {
RouteManifestRoute,
RouteManifestServer,
RouteManifestServerMode,
RouteManifestState,
} from '../dev/routes/route-manifest.ts';
export type {
RouteInputArrayItemSchema,
Expand Down
85 changes: 85 additions & 0 deletions packages/agent-bundle/src/core/state-inspection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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;
}

// 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:
| {
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({
...agentStateDefaultBudgets,
...(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,
});
};
12 changes: 12 additions & 0 deletions packages/agent-bundle/src/dev/routes/route-manifest.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 })),
Expand All @@ -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,
});
12 changes: 10 additions & 2 deletions packages/agent-bundle/src/dev/workbench-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,10 +773,18 @@ export const startDevServer = async (options: StartDevServerOptions): Promise<De
const routeManifest: RouteManifestRouteService = {
manifest: () => {
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
Expand Down
19 changes: 18 additions & 1 deletion packages/agent-bundle/tests/inspect-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-state-'));
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading