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
23 changes: 23 additions & 0 deletions .changeset/manifest-driven-workbench-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"agent-bundle": minor
---

Derive Workbench navigation and its route catalog from the compiled route graph
instead of artifact counts alone (#105 stage 1).

The dev server exposes one new read-only route, `GET /api/routes/manifest`,
which projects the prepared project's existing `CompiledRouteGraph` into a
browser-safe DTO: route id, kind, project-relative source, provenance, a
flattened static `config` summary, MCP server surfaces with their packaging
mode, the generated CLI command surface with its argv projection, conventional
scripts, context providers, the graph digest, and the graph's own diagnostics.
There is no second discovery pass — the manifest is a projection of the compiler
pass the build, inspect, and test harness already share.

The Workbench gains a Routes page under **Build** that renders that catalog
grouped by server and by project surface, and reports whether the manifest
matches the published build or is ahead of it. Hooks, MCP playground, and
Playground now open when either the artifact catalog or the compiled graph
declares the surface, so a routed project no longer needs configuration to reach
its own pages. Every existing page is preserved: an absent or refused manifest
degrades only the Routes page.
22 changes: 22 additions & 0 deletions docs/superpowers/plans/2026-08-25-capability-aware-workbench.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@

**Spec:** `docs/superpowers/specs/2026-08-25-capability-aware-workbench-design.md`

> Supersession note (#105 stage 1, compiler-manifest-driven navigation): this
> plan is landed and its structure invariants still hold with one amendment.
> The catalog is no longer composed from artifact, Skill, and Eval clients
> alone — it also reads the compiled route graph from one dedicated dev-server
> route (`GET /api/routes/manifest`) behind its own strict decoder, and
> `WorkbenchCapabilities` carries a `routes: RouteCatalog`. Task 1's page rules
> below are now the *union* of those counts and the compiled graph, and the
> page set gained `routes` (a compiled-catalog page under **Build**, beside
> Overview), so the Task 1 assertions and the `WorkbenchCapabilities` /
> `WorkbenchCapabilityClients` shapes quoted here are superseded by
> `packages/workbench/src/workbench-capabilities.ts` and
> `packages/workbench/tests/workbench-capabilities.test.ts`. Everything else is
> unchanged and intentionally so: there is still exactly one Workbench shell,
> one navigation rail, and one hash router in `main.tsx` /
> `workbench-screen.tsx`. The Routes catalog is a page inside them; it does not
> add a shell, a navigation component, or a router. Schema-driven input editors
> and the Agent Document stage remain stage 2 and are deliberately not
> scaffolded.

## Global Constraints

- The Workbench is desktop-only; acceptance viewport is exactly 1440×900.
Expand Down Expand Up @@ -66,6 +85,9 @@ export const pageForHash = (
```

- Page rules: Overview/Artifacts/Logs always; Skills for `skills > 0`; Hooks for `hooks > 0`; MCP for `mcpServers > 0`; Playground for `hooks + scripts > 0`; Evals and Comparisons for `evalSuites > 0`.
Amended by #105 stage 1: Routes is also always available once the catalog is
ready, and Hooks, MCP, and Playground additionally open when the compiled
route graph declares an event route, an MCP server surface, or a script route.

- [ ] **Step 1: Write failing capability derivation tests**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ Status: proposed
> work is tracked in
> [#105](https://github.com/ScriptedAlchemy/agent-bundle/issues/105). This
> spec is not the live execution plan.
>
> Supersession note (#105 stage 1): navigation is no longer derived from
> artifact counts alone. The compiled route graph is now a first-class
> navigation input, read once from the dev server's own compiler pass through
> `GET /api/routes/manifest`, and the Workbench is a ten-page shell: a `routes`
> catalog page joins Overview under **Build**. The count-derived capabilities
> below still hold for everything configuration can declare without a route
> module; per-kind availability is now the union of the artifact catalog and the
> compiled graph, so neither source can hide the other. Schema-driven input
> editors and the Agent Document stage remain stage 2. The single Workbench
> shell, navigation, and hash router in `main.tsx`/`workbench-screen.tsx` stay
> the only ones — the manifest catalog is a page inside them, not a second
> shell.

## Context

Expand Down Expand Up @@ -82,11 +95,20 @@ reuses the strict decoders and services already required by the pages, avoids
duplicating catalog schemas, and can be replaced later by a server summary
without changing product semantics.

Since #105 stage 1 the catalog also composes the compiled route graph from one
dedicated route (`GET /api/routes/manifest`) behind its own strict decoder.
That route is a projection of the prepared project's existing compiler pass, not
a second discovery: `hooks`, `mcp`, and `playground` are satisfied by either an
emitted artifact entry or a compiled route of the matching kind, and the graph's
own diagnostics render beside the catalog. A refused or absent manifest degrades
only the Routes page; every artifact-derived page keeps its evidence.

### Navigation and direct routes

Navigation receives the catalog and renders three concise groups:

- **Build:** Overview
- **Build:** Overview (and, since #105 stage 1, Routes — the compiled route
catalog, always available once the catalog is ready)
- **Capabilities:** only Skills, Hooks, Playground, and MCP capabilities that
exist in the catalog
- **Quality:** Evals and Comparisons only when Eval suites exist
Expand Down
20 changes: 20 additions & 0 deletions packages/agent-bundle/src/contracts/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Browser-consumable contract surface for the compiled route manifest the
* Workbench derives its navigation and route catalog from. Type-only: the
* compiler pass that produces the graph runs on the server.
*/
export type {
RouteManifest,
RouteManifestCliCommand,
RouteManifestCliMode,
RouteManifestCliOption,
RouteManifestCliSurface,
RouteManifestConfigEntry,
RouteManifestKind,
RouteManifestProvenance,
RouteManifestProvider,
RouteManifestResponse,
RouteManifestRoute,
RouteManifestServer,
RouteManifestServerMode,
} from '../dev/routes/route-manifest.ts';
13 changes: 13 additions & 0 deletions packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { RuntimeMcpRoutes } from './runtime-mcp-routes.ts';
import { RuntimeRoutes } from './runtime-routes.ts';
import type { DevRuntimeSession } from './runtime-provider.ts';
import { PlaygroundRoutes, type PlaygroundRouteService } from './playground/playground-routes.ts';
import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/route-manifest-routes.ts';
import { SkillDocumentError, type SkillDocumentService } from './skill-document-service.ts';
import type { Invalidation, ProjectEventMessage, ProjectStatus } from './types.ts';

Expand Down Expand Up @@ -136,6 +137,11 @@ export interface ForegroundServerOptions {
/** Durable playground trace store; the browser never selects its storage root or project identity. */
readonly playground?: PlaygroundRouteService;
readonly port?: number;
/**
* Read-only projection of the compiled route graph. The Workbench derives
* navigation from this one compiler pass; it never re-discovers routes.
*/
readonly routeManifest?: RouteManifestRouteService;
/** Optional runtime session; its lifecycle remains Workbench-owned. */
readonly runtime?: DevRuntimeSession;
/** Read-only Skill document/resource service for the workbench. */
Expand Down Expand Up @@ -434,6 +440,7 @@ export class ForegroundServer {
readonly #now: () => Date;
readonly #playgroundRoutes: PlaygroundRoutes;
readonly #port: number;
readonly #routeManifestRoutes: RouteManifestRoutes;
readonly #server: Server;
readonly #skillDocuments: SkillDocumentService | undefined;
readonly #sockets = new Set<Socket>();
Expand Down Expand Up @@ -513,6 +520,10 @@ export class ForegroundServer {
authorize: (request) => this.#assertMutationSession(request),
...(options.artifacts === undefined ? {} : { service: options.artifacts }),
});
this.#routeManifestRoutes = new RouteManifestRoutes({
authorize: (request) => this.#assertMutationSession(request),
...(options.routeManifest === undefined ? {} : { service: options.routeManifest }),
});
this.#evalRoutes = new EvalRoutes({
authorize: (request) => this.#assertMutationSession(request),
...(options.evals === undefined ? {} : { service: options.evals }),
Expand Down Expand Up @@ -657,6 +668,7 @@ export class ForegroundServer {
this.#playgroundRoutes.close();
this.#inspectorRoutes.close();
this.#artifactRoutes.close();
this.#routeManifestRoutes.close();
const releaseEvals = this.#evalRoutes.close();
void releaseEvals.catch(() => undefined);
// Fence both public Eval authorities in this turn. Agent API handlers can
Expand Down Expand Up @@ -742,6 +754,7 @@ export class ForegroundServer {
if (await this.#playgroundRoutes.handle(request, response)) return;
if (await this.#inspectorRoutes.handle(request, response)) return;
if (await this.#artifactRoutes.handle(request, response)) return;
if (this.#routeManifestRoutes.handle(request, response)) return;
if (await this.#evalRoutes.handle(request, response)) return;
if (await this.#devLogRoutes.handle(request, response)) return;
const route = skillRoute(request.url);
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-bundle/src/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ export {
runtimeClientSurfaceReloadChannelPath,
type RuntimeClientSurfaceConnectionEvent,
} from './runtime-client-surface-proxy.ts';
export { routeManifestFor } from './routes/route-manifest.ts';
export type {
RouteManifest,
RouteManifestCliCommand,
RouteManifestCliOption,
RouteManifestCliSurface,
RouteManifestConfigEntry,
RouteManifestProvider,
RouteManifestResponse,
RouteManifestRoute,
RouteManifestServer,
} from './routes/route-manifest.ts';
export {
RouteManifestRoutes,
type RouteManifestRouteService,
type RouteManifestRoutesOptions,
} from './routes/route-manifest-routes.ts';
export { RuntimeRoutes, type RuntimeRoutesOptions } from './runtime-routes.ts';
export { RuntimeMcpRoutes, type RuntimeMcpRoutesOptions } from './runtime-mcp-routes.ts';
export {
Expand Down
99 changes: 99 additions & 0 deletions packages/agent-bundle/src/dev/routes/route-manifest-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { IncomingMessage, ServerResponse } from 'node:http';

import {
diagnostic,
isRequestDiagnostic,
rawPathname,
requestError,
responseDiagnostic,
responseJson as writeJsonResponse,
} from '../http.ts';
import type { RouteManifest } from './route-manifest.ts';

export interface RouteManifestRouteService {
/**
* The manifest of the latest valid compiler pass. Throws when no valid
* project has been prepared yet, which the boundary reports as unavailable
* instead of inventing an empty catalog.
*/
manifest(): RouteManifest;
}

export interface RouteManifestRoutesOptions {
/** The foreground server injects its existing same-origin, same-session guard. */
readonly authorize: (request: IncomingMessage) => void;
/** Omitted until the workbench composes a prepared-project manifest source. */
readonly service?: RouteManifestRouteService;
}

const responseJson = (response: ServerResponse, body: unknown): void =>
writeJsonResponse(response, body, { destroyIfEnded: true });

const pathError = (): never => {
throw requestError(diagnostic('AB8120', 'Route manifest path is not valid.', 400));
};

const invalidShape = (): never => {
throw requestError(diagnostic('AB8122', 'Route manifest request has an invalid shape.', 400));
};

const isManifestRoute = (requestTarget: string | undefined): boolean => {
const pathname = rawPathname(requestTarget);
if (pathname !== '/api/routes' && !pathname.startsWith('/api/routes/')) return false;
const parts = pathname.split('/');
if (parts.length !== 4 || parts[0] !== '' || parts[1] !== 'api' || parts[2] !== 'routes' || parts[3] !== 'manifest') {
return pathError();
}
return true;
};

const noQuery = (requestTarget: string | undefined): void => {
if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape();
};

/**
* Read-only HTTP boundary over the compiled route graph. The browser names no
* path, mode, or revision: the manifest of the latest valid compiler pass is
* the whole request, so the Workbench cannot ask for a second discovery.
*/
export class RouteManifestRoutes {
readonly #authorize: (request: IncomingMessage) => void;
readonly #service: RouteManifestRouteService | undefined;
#closed = false;

constructor(options: RouteManifestRoutesOptions) {
this.#authorize = options.authorize;
this.#service = options.service;
}

close(): void {
this.#closed = true;
}

/** Synchronous by construction: the manifest is already in memory, so this boundary performs no I/O. */
handle(request: IncomingMessage, response: ServerResponse): boolean {
if (!isManifestRoute(request.url)) return false;
this.#authorize(request);
if (this.#closed) throw this.#unavailable(503);
const service = this.#service;
if (service === undefined) throw this.#unavailable(404);
if ((request.method ?? 'GET') !== 'GET') {
responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405));
return true;
}
noQuery(request.url);
let manifest: RouteManifest;
try {
manifest = service.manifest();
} catch (error) {
if (isRequestDiagnostic(error)) throw error;
throw this.#unavailable(409);
}
responseJson(response, { manifest });
return true;
}

#unavailable(status: number): Error {
return requestError(diagnostic('AB8121', 'Route manifest is not available.', status));
}
}
Loading
Loading