From 7c22f2894f508b38337d5afebc066576af4759c1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:33:19 +0000 Subject: [PATCH 01/43] wb600: foundation contracts for the application explorer (invocation envelope, URL model, tree/backend/workspace types) --- .../agent-bundle/src/contracts/invocations.ts | 24 ++ .../src/dev/routes/route-invocation.ts | 146 ++++++++++ .../src/dev/workbench-shell-paths.ts | 16 ++ .../src/application/application-tree-model.ts | 87 ++++++ .../src/application/invocation-backend.ts | 38 +++ .../src/application/workspace-contracts.ts | 45 +++ .../workbench/src/shell/workbench-location.ts | 267 ++++++++++++++++++ 7 files changed, 623 insertions(+) create mode 100644 packages/agent-bundle/src/contracts/invocations.ts create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation.ts create mode 100644 packages/agent-bundle/src/dev/workbench-shell-paths.ts create mode 100644 packages/workbench/src/application/application-tree-model.ts create mode 100644 packages/workbench/src/application/invocation-backend.ts create mode 100644 packages/workbench/src/application/workspace-contracts.ts create mode 100644 packages/workbench/src/shell/workbench-location.ts diff --git a/packages/agent-bundle/src/contracts/invocations.ts b/packages/agent-bundle/src/contracts/invocations.ts new file mode 100644 index 000000000..a0225462f --- /dev/null +++ b/packages/agent-bundle/src/contracts/invocations.ts @@ -0,0 +1,24 @@ +/** + * Browser-consumable contract surface for dev-server route invocations — the + * one execution path behind the Workbench route workspace. Type-only: routes + * render on the server through the production runtime. + */ +export type { + RouteInvocation, + RouteInvocationCliProjection, + RouteInvocationEvent, + RouteInvocationEventHost, + RouteInvocationEventOptions, + RouteInvocationEventPayload, + RouteInvocationHostProjection, + RouteInvocationKind, + RouteInvocationListResponse, + RouteInvocationProjection, + RouteInvocationProvider, + RouteInvocationProviderStatus, + RouteInvocationRequest, + RouteInvocationResponse, + RouteInvocationStatus, + RouteInvocationSummary, + RouteInvocationTiming, +} from '../dev/routes/route-invocation.ts'; diff --git a/packages/agent-bundle/src/dev/routes/route-invocation.ts b/packages/agent-bundle/src/dev/routes/route-invocation.ts new file mode 100644 index 000000000..222fd2f23 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -0,0 +1,146 @@ +/** + * The one dev-server invocation contract behind the Workbench route + * workspace (#600). Every conventional route kind the execution kernel can + * render in development — MCP tools, resources, prompts, CLI routes, scripts, + * and semantic event routes — is invoked through this one request shape and + * answered with this one envelope: the canonical input that was rendered, the + * request context and providers it ran with, the production render-event + * stream and final Agent Document, the route's structured result, and the + * host projections of that document (MCP, CLI, native hook results). + * + * Type-only. The service (`route-invocation-service.ts`) executes the route + * through the same runtime the generated executables use; the browser only + * renders the envelope. + */ +import type { AgentDocument, AgentRenderEvent } from '@agent-bundle/runtime'; + +import type { Diagnostic } from '../../core/diagnostics.ts'; +import type { JsonObject, JsonValue } from '../../core/strict-json.ts'; +import type { RequestContextProvenance } from '../../contracts/request-provenance.ts'; + +/** The route kinds the invocation service renders; `app` routes are browser surfaces previewed through the MCP App preview instead. */ +export type RouteInvocationKind = 'cli' | 'event-route' | 'prompt' | 'resource' | 'script' | 'tool'; + +/** The hosts an event route can be invoked as; `canonical` submits the canonical payload directly. */ +export type RouteInvocationEventHost = 'claude' | 'codex' | 'cursor'; + +export interface RouteInvocationEventOptions { + /** + * When present, `input` is the host's native hook payload and the service + * canonicalizes it exactly as the emitted wrapper would (the lifecycle + * replay path); when absent, `input` is the canonical event payload. + */ + readonly host?: RouteInvocationEventHost; + /** A fixture id from the route's manifest fixtures; the service seeds `input` from it when `input` is absent. */ + readonly fixtureId?: string; +} + +export interface RouteInvocationRequest { + /** CLI routes only: the argv the routed CLI would receive after the command path. */ + readonly args?: readonly string[]; + /** Browser-minted correlation id, echoed on the envelope and on the `route.invocation` project event. */ + readonly correlationId?: string; + readonly event?: RouteInvocationEventOptions; + /** Tool/prompt/script input, event payload (canonical or native — see `event.host`), or resource parameters. */ + readonly input?: JsonValue; + /** The compiled route id, for example `tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`. */ + readonly routeId: string; +} + +export type RouteInvocationStatus = 'failed' | 'succeeded'; + +export interface RouteInvocationTiming { + readonly durationMs: number; + /** `providers`, `handler`, `render`, `projection`, or a provider id (`provider:`). */ + readonly phase: string; + readonly startedAt: string; +} + +export type RouteInvocationProviderStatus = 'failed' | 'mounted' | 'skipped'; + +export interface RouteInvocationProvider { + readonly durationMs?: number; + readonly id: string; + readonly message?: string; + readonly name: string; + readonly status: RouteInvocationProviderStatus; +} + +export interface RouteInvocationCliProjection { + readonly exitCode: number; + /** The routed CLI's JSON output mode, when the route produced a document value. */ + readonly json?: JsonValue; + /** The routed CLI's human output for this document. */ + readonly text: string; +} + +export interface RouteInvocationHostProjection { + readonly diagnostics: readonly Diagnostic[]; + readonly host: RouteInvocationEventHost; + /** The native hook response the host would receive; absent when the projection failed. */ + readonly native?: JsonObject; +} + +export interface RouteInvocationProjection { + readonly cli?: RouteInvocationCliProjection; + /** Event routes: the lowered host response per selected host. */ + readonly hosts?: readonly RouteInvocationHostProjection[]; + /** `CallToolResult`, `ReadResourceResult`, or `GetPromptResult` as the generated MCP server would send it. */ + readonly mcp?: JsonObject; +} + +export interface RouteInvocationEvent { + /** The canonical event id the route is bound to, for example `tool/before`. */ + readonly event: string; + readonly host?: RouteInvocationEventHost; + /** The canonical payload actually rendered — identical to `input` for a canonical submission. */ + readonly canonical: JsonObject; + /** The native payload submitted when `host` is present. */ + readonly native?: JsonObject; +} + +export interface RouteInvocation { + readonly completedAt: string; + readonly context: RequestContextProvenance; + readonly correlationId?: string; + /** Failure diagnostics; empty when the route rendered. A `represented-error` document is a success with an error node, not a failure. */ + readonly diagnostics: readonly Diagnostic[]; + /** The final Agent Document; absent when rendering failed before a document existed. */ + readonly document?: AgentDocument; + readonly event?: RouteInvocationEvent; + /** The production `shell | progress | replace | error | complete` stream, in order. */ + readonly events: readonly AgentRenderEvent[]; + readonly id: string; + /** The input the route rendered, after fixture seeding and (for hosted events) canonicalization. */ + readonly input: JsonValue; + readonly kind: RouteInvocationKind; + /** The route manifest digest the invocation resolved the route through. */ + readonly manifestDigest: string; + readonly projection: RouteInvocationProjection; + readonly providers: readonly RouteInvocationProvider[]; + /** The document value parsed by the route's own `resultSchema`; absent when the module exports none or rendering failed. */ + readonly result?: JsonValue; + readonly routeId: string; + readonly source: string; + readonly sourceRevision: string; + readonly startedAt: string; + readonly status: RouteInvocationStatus; + readonly timings: readonly RouteInvocationTiming[]; +} + +/** `GET /api/routes/invocations/` and `POST /api/routes/invocations`. */ +export interface RouteInvocationResponse { + readonly invocation: RouteInvocation; +} + +/** One row of `GET /api/routes/invocations`: the envelope without its streams, for lists and the trace. */ +export type RouteInvocationSummary = Omit; + +export interface RouteInvocationListResponse { + readonly invocations: readonly RouteInvocationSummary[]; +} + +/** The `route.invocation` project event payload published on `/api/project/events` when an invocation completes. */ +export interface RouteInvocationEventPayload { + readonly invocation: RouteInvocationSummary; +} diff --git a/packages/agent-bundle/src/dev/workbench-shell-paths.ts b/packages/agent-bundle/src/dev/workbench-shell-paths.ts new file mode 100644 index 000000000..316fcc145 --- /dev/null +++ b/packages/agent-bundle/src/dev/workbench-shell-paths.ts @@ -0,0 +1,16 @@ +/** + * The Workbench's top-level URL areas (#600 §10). The foreground server answers + * these paths with the Workbench shell (`index.html`) so a deep link such as + * `/routes/mcp/curator/tool/search_audible` or `/trace/` survives a + * refresh; the browser's `shell/workbench-location.ts` parses the rest of the + * path. Both sides import this one list so neither can drift. + */ +export const workbenchShellAreas = Object.freeze(['routes', 'trace', 'problems', 'sessions', 'advanced'] as const); + +export type WorkbenchShellArea = (typeof workbenchShellAreas)[number]; + +/** True when the request path is the shell root or begins with a shell area segment. */ +export const isWorkbenchShellPath = (pathname: string): boolean => { + const [area] = pathname.split('/').filter((part) => part.length > 0); + return area === undefined || (workbenchShellAreas as readonly string[]).includes(area); +}; diff --git a/packages/workbench/src/application/application-tree-model.ts b/packages/workbench/src/application/application-tree-model.ts new file mode 100644 index 000000000..ac6db41e1 --- /dev/null +++ b/packages/workbench/src/application/application-tree-model.ts @@ -0,0 +1,87 @@ +/** + * The one application tree (#600 §1): every plugin-authored surface as a leaf + * of one tree derived from the compiled route graph (the route manifest), the + * served Skill tree, and the artifact inventory for configuration-declared + * surfaces that have no route module. Navigation derives from this tree, not + * from a list of Workbench pages. + * + * Group order is fixed: MCP (per server: Tools · Resources · Prompts · Apps), + * Events / Hooks, CLI, Scripts, Skills, Rules / Commands. Empty groups are + * omitted. Leaves sort by label within a group. + */ +import type { ArtifactInspection } from '../../../agent-bundle/src/contracts/artifacts.ts'; +import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; +import type { RouteInputSchema, RouteManifestCliCommand, RouteManifestConfigEntry } from '../../../agent-bundle/src/contracts/routes.ts'; +import type { SkillDocumentTree } from '../../../agent-bundle/src/contracts/skills.ts'; +import type { RouteCatalog, RouteCatalogState } from '../routes/routes-model.ts'; +import type { ApplicationNodeRef } from '../shell/workbench-location.ts'; + +export type ApplicationGroupKind = 'cli' | 'events' | 'mcp' | 'rules' | 'scripts' | 'skills'; + +/** How a leaf is executed from its workspace. */ +export type ApplicationLeafExecution = + /** Rendered through `POST /api/routes/invocations` (tools, resources, prompts, CLI, scripts, event routes). */ + | 'invoke' + /** Previewed through the MCP App preview (apps). */ + | 'preview' + /** Read-only document (skills, rules, commands). */ + | 'document'; + +export interface ApplicationLeaf { + /** Compiled CLI command grammar; `cli` leaves only. */ + readonly command?: RouteManifestCliCommand; + readonly config: readonly RouteManifestConfigEntry[]; + readonly description?: string; + /** Canonical event id; `event` leaves only. */ + readonly event?: string; + readonly execution: ApplicationLeafExecution; + readonly inputSchema?: RouteInputSchema; + /** Stable key: the leaf's URL path (see `applicationNodeKey`). */ + readonly key: string; + readonly label: string; + readonly ref: ApplicationNodeRef; + /** Compiled route id when the leaf is a compiled route; absent for skills, rules, commands. */ + readonly routeId?: string; + /** Project-relative source path when known. */ + readonly source?: string; +} + +export interface ApplicationSubgroup { + readonly key: string; + readonly label: string; + readonly leaves: readonly ApplicationLeaf[]; +} + +export interface ApplicationServerGroup { + readonly key: string; + readonly label: string; + /** `command | conflict | custom | generated | remote` — the server's manifest mode. */ + readonly mode: string; + readonly server: string; + /** Tools · Resources · Prompts · Apps, non-empty only. */ + readonly subgroups: readonly ApplicationSubgroup[]; +} + +export type ApplicationGroup = + | Readonly<{ readonly key: string; readonly kind: 'mcp'; readonly label: 'MCP'; readonly servers: readonly ApplicationServerGroup[] }> + | Readonly<{ readonly key: string; readonly kind: Exclude; readonly label: string; readonly leaves: readonly ApplicationLeaf[] }>; + +export interface ApplicationTree { + readonly diagnostics: readonly Diagnostic[]; + readonly groups: readonly ApplicationGroup[]; + readonly leafCount: number; + /** Present when the compiled route catalog could not be read. */ + readonly message?: string; + /** Freshness of the compiled route catalog against the published build. */ + readonly state: RouteCatalogState; +} + +export interface ApplicationTreeSources { + /** Artifact inventory of the published epoch: configuration-declared hooks, servers, scripts without route modules. */ + readonly inspection?: ArtifactInspection; + readonly routes: RouteCatalog; + readonly skillTree?: SkillDocumentTree; +} + +// Implemented by the tree lane: applicationTreeFor(sources), findApplicationLeaf(tree, ref), +// applicationLeaves(tree), filterApplicationTree(tree, query), firstApplicationLeaf(tree). diff --git a/packages/workbench/src/application/invocation-backend.ts b/packages/workbench/src/application/invocation-backend.ts new file mode 100644 index 000000000..26419a287 --- /dev/null +++ b/packages/workbench/src/application/invocation-backend.ts @@ -0,0 +1,38 @@ +/** + * The one invocation abstraction the route workspace runs every executable + * leaf through (#600 §3). Two backends satisfy it: + * + * - the dev-server backend (`POST /api/routes/invocations`), which renders any + * conventional route through the production runtime in a child process and + * is available for every project; + * - the runtime-provider backend (`/api/runtime/runs`), which is present only + * when the project declares a `devRuntime` provider and maps a leaf onto a + * runtime surface; it adds run history, HMR generation tracking, and App + * preview bindings. + * + * The workspace never knows which backend answered: both yield the same + * `RouteInvocation` envelope. + */ +import type { + RouteInvocation, + RouteInvocationRequest, + RouteInvocationSummary, +} from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { ApplicationLeaf } from './application-tree-model.ts'; + +export type InvocationBackendKind = 'dev-server' | 'runtime'; + +export interface InvocationBackend { + /** Names the backend for the inspector's "Execution" panel. */ + readonly kind: InvocationBackendKind; + /** True when this backend can run the leaf; the workspace picks the first backend that accepts. */ + accepts(leaf: ApplicationLeaf): boolean; + /** Runs the leaf and resolves with the completed envelope; rejects with an `InvocationClientError`. */ + invoke(leaf: ApplicationLeaf, request: RouteInvocationRequest, signal?: AbortSignal): Promise; + /** Recent invocations of the leaf this backend knows about, newest first. */ + history(leaf: ApplicationLeaf, signal?: AbortSignal): Promise; + /** Loads one invocation snapshot by id (deep links, trace entries). */ + read(invocationId: string, signal?: AbortSignal): Promise; + /** Fires when an invocation completes anywhere (this tab, another tab, a host); the trace and history subscribe. */ + subscribe(listener: (summary: RouteInvocationSummary) => void): () => void; +} diff --git a/packages/workbench/src/application/workspace-contracts.ts b/packages/workbench/src/application/workspace-contracts.ts new file mode 100644 index 000000000..a0063fbf7 --- /dev/null +++ b/packages/workbench/src/application/workspace-contracts.ts @@ -0,0 +1,45 @@ +/** + * The props boundary between the shell (which owns clients, location, and the + * selected leaf) and the route workspace (which owns input, Run, result tabs, + * and the inspector). The shell mounts exactly one workspace for the selected + * leaf; the workspace never navigates the shell except through `onNavigate`. + */ +import type { ProjectStatus } from '../../../agent-bundle/src/contracts/project.ts'; +import type { EvalClient } from '../evals/eval-client.ts'; +import type { HookClient } from '../hooks/hook-client.ts'; +import type { LifecycleClient } from '../lifecycles/lifecycle-client.ts'; +import type { McpAppClient } from '../mcp/mcp-app-client.ts'; +import type { ForegroundRouteClient, McpRouteClient } from '../mcp/mcp-route-client.ts'; +import type { SkillClient } from '../skill-client.ts'; +import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { ApplicationLeaf, ApplicationTree } from './application-tree-model.ts'; +import type { InvocationBackend } from './invocation-backend.ts'; + +/** The result tabs every executable workspace offers; `rendered` is the default. */ +export type WorkspaceResultTab = 'cli' | 'mcp' | 'raw' | 'rendered' | 'structured' | 'trace'; + +/** The inspector drawer tabs. */ +export type WorkspaceInspectorTab = 'context' | 'projection' | 'providers' | 'raw-protocol' | 'schema' | 'source'; + +export interface WorkspaceClients { + readonly appClient: McpAppClient; + readonly evalClient: EvalClient; + readonly foreground: ForegroundRouteClient; + readonly hookClient: HookClient; + readonly lifecycleClient: LifecycleClient; + readonly mcpRoutes: McpRouteClient; + readonly skillClient: SkillClient; +} + +export interface RouteWorkspaceProps { + readonly backends: readonly InvocationBackend[]; + readonly clients: WorkspaceClients; + /** Deep-linked invocation snapshot to load instead of the last input (`?invocation=`). */ + readonly invocationId?: string; + readonly leaf: ApplicationLeaf; + readonly onNavigate: (location: WorkbenchLocation) => void; + readonly status: ProjectStatus; + /** Deep-linked result tab (`?tab=`); the workspace falls back to `rendered`. */ + readonly tab?: string; + readonly tree: ApplicationTree; +} diff --git a/packages/workbench/src/shell/workbench-location.ts b/packages/workbench/src/shell/workbench-location.ts new file mode 100644 index 000000000..13c1dd2e6 --- /dev/null +++ b/packages/workbench/src/shell/workbench-location.ts @@ -0,0 +1,267 @@ +/** + * The Workbench's URL model (#600 §10). Every primary destination and every + * application leaf is addressable: refresh preserves context, diagnostics and + * trace entries deep-link, browser history works, and tests address a route + * deterministically. Hash-only page routing (`#hooks`, `#mcp`) is gone. + * + * / Application (no selection) + * /routes/mcp//tool/ MCP tool — also resource | prompt | app + * /routes/events/ Event route, e.g. /routes/events/tool/before + * /routes/cli/ CLI route, e.g. /routes/cli/audible/search + * /routes/scripts/ Script + * /routes/skills/ Skill + * /routes/commands/ Host command (Rules / Commands group) + * /routes/rules/ Host rule + * /trace · /trace/ Live trace, one entry + * /problems Diagnostics + * /sessions · /sessions/ Embedded host sessions (PR 3) + * /advanced/
evals | artifact | protocol | hosts | logs + * + * `?invocation=` on a route path opens that route with the named + * invocation snapshot loaded; `?tab=` selects a workspace tab. + */ +import { isWorkbenchShellPath } from '../../../agent-bundle/src/dev/workbench-shell-paths.ts'; + +export type ApplicationMcpNodeKind = 'app' | 'prompt' | 'resource' | 'tool'; + +/** One addressable application leaf, as the URL and the tree both name it. */ +export type ApplicationNodeRef = + | Readonly<{ readonly kind: ApplicationMcpNodeKind; readonly name: string; readonly server: string }> + | Readonly<{ readonly event: string; readonly kind: 'event' }> + | Readonly<{ readonly kind: 'cli'; readonly path: readonly string[] }> + | Readonly<{ readonly kind: 'script'; readonly name: string }> + | Readonly<{ readonly id: string; readonly kind: 'skill' }> + | Readonly<{ readonly id: string; readonly kind: 'command' }> + | Readonly<{ readonly id: string; readonly kind: 'rule' }>; + +export type AdvancedSection = 'artifact' | 'evals' | 'hosts' | 'logs' | 'protocol'; + +export const advancedSections: readonly AdvancedSection[] = Object.freeze(['evals', 'artifact', 'protocol', 'hosts', 'logs']); + +export type WorkbenchArea = 'advanced' | 'application' | 'problems' | 'sessions' | 'trace'; + +export type WorkbenchLocation = + | Readonly<{ readonly area: 'application'; readonly invocationId?: string; readonly node?: ApplicationNodeRef; readonly tab?: string }> + | Readonly<{ readonly area: 'trace'; readonly invocationId?: string }> + | Readonly<{ readonly area: 'problems' }> + | Readonly<{ readonly area: 'sessions'; readonly host?: string }> + | Readonly<{ readonly area: 'advanced'; readonly section: AdvancedSection }>; + +const mcpKinds: ReadonlySet = new Set(['app', 'prompt', 'resource', 'tool']); + +const segment = (value: string): string => encodeURIComponent(value); + +const decode = (value: string): string | undefined => { + try { + const decoded = decodeURIComponent(value); + return decoded.length === 0 || decoded.includes('\0') ? undefined : decoded; + } catch { + return undefined; + } +}; + +const decodeAll = (values: readonly string[]): readonly string[] | undefined => { + const decoded = values.map(decode); + return decoded.length === 0 || decoded.some((value) => value === undefined) + ? undefined + : Object.freeze(decoded as string[]); +}; + +/** Compiled route id (`tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`) → node reference. */ +export const applicationNodeRefForRouteId = (routeId: string): ApplicationNodeRef | undefined => { + const colon = routeId.indexOf(':'); + if (colon <= 0 || colon === routeId.length - 1) return undefined; + const kind = routeId.slice(0, colon); + const rest = routeId.slice(colon + 1); + if (mcpKinds.has(kind)) { + const slash = rest.indexOf('/'); + if (slash <= 0 || slash === rest.length - 1) return undefined; + return Object.freeze({ kind: kind as ApplicationMcpNodeKind, name: rest.slice(slash + 1), server: rest.slice(0, slash) }); + } + switch (kind) { + case 'event': + return Object.freeze({ event: rest, kind: 'event' }); + case 'cli': + return Object.freeze({ kind: 'cli', path: Object.freeze(rest.split('/')) }); + case 'script': + return Object.freeze({ kind: 'script', name: rest }); + default: + return undefined; + } +}; + +/** Node reference → compiled route id, for the kinds the route manifest compiles; skills, commands, and rules have no route id. */ +export const routeIdForApplicationNodeRef = (node: ApplicationNodeRef): string | undefined => { + switch (node.kind) { + case 'app': + case 'prompt': + case 'resource': + case 'tool': + return `${node.kind}:${node.server}/${node.name}`; + case 'event': + return `event:${node.event}`; + case 'cli': + return `cli:${node.path.join('/')}`; + case 'script': + return `script:${node.name}`; + case 'skill': + case 'command': + case 'rule': + return undefined; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +export const applicationNodePath = (node: ApplicationNodeRef): string => { + switch (node.kind) { + case 'app': + case 'prompt': + case 'resource': + case 'tool': + return `/routes/mcp/${segment(node.server)}/${node.kind}/${segment(node.name)}`; + case 'event': + return `/routes/events/${node.event.split('/').map(segment).join('/')}`; + case 'cli': + return `/routes/cli/${node.path.map(segment).join('/')}`; + case 'script': + return `/routes/scripts/${node.name.split('/').map(segment).join('/')}`; + case 'skill': + return `/routes/skills/${node.id.split('/').map(segment).join('/')}`; + case 'command': + return `/routes/commands/${node.id.split('/').map(segment).join('/')}`; + case 'rule': + return `/routes/rules/${node.id.split('/').map(segment).join('/')}`; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +/** A stable key for selection state and React keys: the URL path of the node. */ +export const applicationNodeKey = (node: ApplicationNodeRef): string => applicationNodePath(node); + +export const sameApplicationNodeRef = (left: ApplicationNodeRef | undefined, right: ApplicationNodeRef | undefined): boolean => + left === right || (left !== undefined && right !== undefined && applicationNodeKey(left) === applicationNodeKey(right)); + +const applicationNodeFromSegments = (segments: readonly string[]): ApplicationNodeRef | undefined => { + const [group, ...rest] = segments; + switch (group) { + case 'mcp': { + const [server, kind, ...name] = rest; + if (server === undefined || kind === undefined || !mcpKinds.has(kind) || name.length !== 1) return undefined; + const decodedServer = decode(server); + const decodedName = decode(name[0]!); + return decodedServer === undefined || decodedName === undefined + ? undefined + : Object.freeze({ kind: kind as ApplicationMcpNodeKind, name: decodedName, server: decodedServer }); + } + case 'events': { + const event = decodeAll(rest); + return event === undefined ? undefined : Object.freeze({ event: event.join('/'), kind: 'event' }); + } + case 'cli': { + const path = decodeAll(rest); + return path === undefined ? undefined : Object.freeze({ kind: 'cli', path }); + } + case 'scripts': { + const name = decodeAll(rest); + return name === undefined ? undefined : Object.freeze({ kind: 'script', name: name.join('/') }); + } + case 'skills': { + const id = decodeAll(rest); + return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'skill' }); + } + case 'commands': { + const id = decodeAll(rest); + return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'command' }); + } + case 'rules': { + const id = decodeAll(rest); + return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'rule' }); + } + default: + return undefined; + } +}; + +const isAdvancedSection = (value: string): value is AdvancedSection => (advancedSections as readonly string[]).includes(value); + +const applicationRoot: WorkbenchLocation = Object.freeze({ area: 'application' }); + +/** + * Parses a pathname plus search into a location. Unknown paths resolve to the + * Application root rather than throwing: a stale deep link must land the user + * somewhere useful, and the shell reports the unknown path separately. + */ +export const parseWorkbenchLocation = (pathname: string, search = ''): WorkbenchLocation => { + const segments = pathname.split('/').filter((part) => part.length > 0); + const query = new URLSearchParams(search); + const invocationId = query.get('invocation') ?? undefined; + const tab = query.get('tab') ?? undefined; + const [area, ...rest] = segments; + switch (area) { + case undefined: + return applicationRoot; + case 'routes': { + const node = applicationNodeFromSegments(rest); + if (node === undefined) return applicationRoot; + return Object.freeze({ + area: 'application', + ...(invocationId === undefined ? {} : { invocationId }), + node, + ...(tab === undefined ? {} : { tab }), + }); + } + case 'trace': { + const id = rest.length === 1 ? decode(rest[0]!) : undefined; + return Object.freeze({ area: 'trace', ...(id === undefined ? {} : { invocationId: id }) }); + } + case 'problems': + return Object.freeze({ area: 'problems' }); + case 'sessions': { + const host = rest.length === 1 ? decode(rest[0]!) : undefined; + return Object.freeze({ area: 'sessions', ...(host === undefined ? {} : { host }) }); + } + case 'advanced': { + const section = rest[0]; + return Object.freeze({ area: 'advanced', section: section !== undefined && rest.length === 1 && isAdvancedSection(section) ? section : 'evals' }); + } + default: + return applicationRoot; + } +}; + +/** Formats a location as `pathname` + `search`; the inverse of {@link parseWorkbenchLocation}. */ +export const formatWorkbenchLocation = (location: WorkbenchLocation): string => { + switch (location.area) { + case 'application': { + if (location.node === undefined) return '/'; + const query = new URLSearchParams(); + if (location.invocationId !== undefined) query.set('invocation', location.invocationId); + if (location.tab !== undefined) query.set('tab', location.tab); + const search = query.toString(); + return `${applicationNodePath(location.node)}${search.length === 0 ? '' : `?${search}`}`; + } + case 'trace': + return location.invocationId === undefined ? '/trace' : `/trace/${segment(location.invocationId)}`; + case 'problems': + return '/problems'; + case 'sessions': + return location.host === undefined ? '/sessions' : `/sessions/${segment(location.host)}`; + case 'advanced': + return `/advanced/${location.section}`; + default: { + const exhaustive: never = location; + return exhaustive; + } + } +}; + +export const sameWorkbenchLocation = (left: WorkbenchLocation, right: WorkbenchLocation): boolean => + formatWorkbenchLocation(left) === formatWorkbenchLocation(right); + +export { isWorkbenchShellPath }; From 7a0364205cd9e8fdf886ad0b3449660f19d9ca06 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:36:51 +0000 Subject: [PATCH 02/43] wb600: move the application node model into agent-bundle so the Workbench and the test surface share it --- .../src/dev/routes/application-node.ts | 172 ++++++++++++++++++ .../src/application/application-tree-model.ts | 16 +- .../workbench/src/shell/workbench-location.ts | 171 ++--------------- 3 files changed, 203 insertions(+), 156 deletions(-) create mode 100644 packages/agent-bundle/src/dev/routes/application-node.ts diff --git a/packages/agent-bundle/src/dev/routes/application-node.ts b/packages/agent-bundle/src/dev/routes/application-node.ts new file mode 100644 index 000000000..61049f05d --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/application-node.ts @@ -0,0 +1,172 @@ +/** + * One addressable application leaf (#600): the identity the Workbench URL, the + * application tree, and the `agent-bundle/test` Workbench-surface proof all + * share. Pure and browser-safe; the route manifest and the Workbench both + * import it so neither can drift. + * + * /routes/mcp//tool/ MCP tool — also resource | prompt | app + * /routes/events/ Event route, e.g. /routes/events/tool/before + * /routes/cli/ CLI route, e.g. /routes/cli/audible/search + * /routes/scripts/ Script + * /routes/skills/ Skill + * /routes/commands/ Host command (Rules / Commands group) + * /routes/rules/ Host rule + */ + +export type ApplicationMcpNodeKind = 'app' | 'prompt' | 'resource' | 'tool'; + +export type ApplicationNodeRef = + | Readonly<{ readonly kind: ApplicationMcpNodeKind; readonly name: string; readonly server: string }> + | Readonly<{ readonly event: string; readonly kind: 'event' }> + | Readonly<{ readonly kind: 'cli'; readonly path: readonly string[] }> + | Readonly<{ readonly kind: 'script'; readonly name: string }> + | Readonly<{ readonly id: string; readonly kind: 'skill' }> + | Readonly<{ readonly id: string; readonly kind: 'command' }> + | Readonly<{ readonly id: string; readonly kind: 'rule' }>; + +export type ApplicationNodeKind = ApplicationNodeRef['kind']; + +const mcpKinds: ReadonlySet = new Set(['app', 'prompt', 'resource', 'tool']); + +export const isApplicationMcpNodeKind = (value: string): value is ApplicationMcpNodeKind => mcpKinds.has(value); + +const segment = (value: string): string => encodeURIComponent(value); + +const decode = (value: string): string | undefined => { + try { + const decoded = decodeURIComponent(value); + return decoded.length === 0 || decoded.includes('\0') ? undefined : decoded; + } catch { + return undefined; + } +}; + +const decodeAll = (values: readonly string[]): readonly string[] | undefined => { + const decoded = values.map(decode); + return decoded.length === 0 || decoded.some((value) => value === undefined) + ? undefined + : Object.freeze(decoded as string[]); +}; + +/** Compiled route id (`tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`) → node reference. */ +export const applicationNodeRefForRouteId = (routeId: string): ApplicationNodeRef | undefined => { + const colon = routeId.indexOf(':'); + if (colon <= 0 || colon === routeId.length - 1) return undefined; + const kind = routeId.slice(0, colon); + const rest = routeId.slice(colon + 1); + if (isApplicationMcpNodeKind(kind)) { + const slash = rest.indexOf('/'); + if (slash <= 0 || slash === rest.length - 1) return undefined; + return Object.freeze({ kind, name: rest.slice(slash + 1), server: rest.slice(0, slash) }); + } + switch (kind) { + case 'event': + return Object.freeze({ event: rest, kind: 'event' }); + case 'cli': + return Object.freeze({ kind: 'cli', path: Object.freeze(rest.split('/')) }); + case 'script': + return Object.freeze({ kind: 'script', name: rest }); + default: + return undefined; + } +}; + +/** Node reference → compiled route id, for the kinds the route manifest compiles; skills, commands, and rules have no route id. */ +export const routeIdForApplicationNodeRef = (node: ApplicationNodeRef): string | undefined => { + switch (node.kind) { + case 'app': + case 'prompt': + case 'resource': + case 'tool': + return `${node.kind}:${node.server}/${node.name}`; + case 'event': + return `event:${node.event}`; + case 'cli': + return `cli:${node.path.join('/')}`; + case 'script': + return `script:${node.name}`; + case 'skill': + case 'command': + case 'rule': + return undefined; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +/** The Workbench URL path of a node. */ +export const applicationNodePath = (node: ApplicationNodeRef): string => { + switch (node.kind) { + case 'app': + case 'prompt': + case 'resource': + case 'tool': + return `/routes/mcp/${segment(node.server)}/${node.kind}/${segment(node.name)}`; + case 'event': + return `/routes/events/${node.event.split('/').map(segment).join('/')}`; + case 'cli': + return `/routes/cli/${node.path.map(segment).join('/')}`; + case 'script': + return `/routes/scripts/${node.name.split('/').map(segment).join('/')}`; + case 'skill': + return `/routes/skills/${node.id.split('/').map(segment).join('/')}`; + case 'command': + return `/routes/commands/${node.id.split('/').map(segment).join('/')}`; + case 'rule': + return `/routes/rules/${node.id.split('/').map(segment).join('/')}`; + default: { + const exhaustive: never = node; + return exhaustive; + } + } +}; + +/** A stable key for selection state and React keys: the URL path of the node. */ +export const applicationNodeKey = (node: ApplicationNodeRef): string => applicationNodePath(node); + +export const sameApplicationNodeRef = (left: ApplicationNodeRef | undefined, right: ApplicationNodeRef | undefined): boolean => + left === right || (left !== undefined && right !== undefined && applicationNodeKey(left) === applicationNodeKey(right)); + +/** Parses the segments after `/routes/` back into a node reference; `undefined` for an unknown or malformed path. */ +export const applicationNodeRefForPathSegments = (segments: readonly string[]): ApplicationNodeRef | undefined => { + const [group, ...rest] = segments; + switch (group) { + case 'mcp': { + const [server, kind, ...name] = rest; + if (server === undefined || kind === undefined || !isApplicationMcpNodeKind(kind) || name.length !== 1) return undefined; + const decodedServer = decode(server); + const decodedName = decode(name[0]!); + return decodedServer === undefined || decodedName === undefined + ? undefined + : Object.freeze({ kind, name: decodedName, server: decodedServer }); + } + case 'events': { + const event = decodeAll(rest); + return event === undefined ? undefined : Object.freeze({ event: event.join('/'), kind: 'event' }); + } + case 'cli': { + const path = decodeAll(rest); + return path === undefined ? undefined : Object.freeze({ kind: 'cli', path }); + } + case 'scripts': { + const name = decodeAll(rest); + return name === undefined ? undefined : Object.freeze({ kind: 'script', name: name.join('/') }); + } + case 'skills': { + const id = decodeAll(rest); + return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'skill' }); + } + case 'commands': { + const id = decodeAll(rest); + return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'command' }); + } + case 'rules': { + const id = decodeAll(rest); + return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'rule' }); + } + default: + return undefined; + } +}; diff --git a/packages/workbench/src/application/application-tree-model.ts b/packages/workbench/src/application/application-tree-model.ts index ac6db41e1..dbb52020d 100644 --- a/packages/workbench/src/application/application-tree-model.ts +++ b/packages/workbench/src/application/application-tree-model.ts @@ -11,10 +11,10 @@ */ import type { ArtifactInspection } from '../../../agent-bundle/src/contracts/artifacts.ts'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; -import type { RouteInputSchema, RouteManifestCliCommand, RouteManifestConfigEntry } from '../../../agent-bundle/src/contracts/routes.ts'; +import type { RouteInputSchema, RouteManifest, RouteManifestCliCommand, RouteManifestConfigEntry } from '../../../agent-bundle/src/contracts/routes.ts'; import type { SkillDocumentTree } from '../../../agent-bundle/src/contracts/skills.ts'; -import type { RouteCatalog, RouteCatalogState } from '../routes/routes-model.ts'; -import type { ApplicationNodeRef } from '../shell/workbench-location.ts'; +import type { ApplicationNodeRef } from '../../../agent-bundle/src/dev/routes/application-node.ts'; +import type { RouteCatalogState } from '../routes/routes-model.ts'; export type ApplicationGroupKind = 'cli' | 'events' | 'mcp' | 'rules' | 'scripts' | 'skills'; @@ -79,9 +79,15 @@ export interface ApplicationTree { export interface ApplicationTreeSources { /** Artifact inventory of the published epoch: configuration-declared hooks, servers, scripts without route modules. */ readonly inspection?: ArtifactInspection; - readonly routes: RouteCatalog; + /** The compiled route manifest; absent when it could not be read (`state` is `unavailable`, `message` says why). */ + readonly manifest?: RouteManifest; + readonly message?: string; readonly skillTree?: SkillDocumentTree; + readonly state: RouteCatalogState; } -// Implemented by the tree lane: applicationTreeFor(sources), findApplicationLeaf(tree, ref), +// Implemented by the tree lane, as thin adapters over the pure derivation in +// packages/agent-bundle/src/dev/routes/application-tree.ts (shared with the +// `agent-bundle/test` Workbench-surface proof): applicationTreeFor(sources), +// findApplicationLeaf(tree, ref), applicationLeafForRouteId(tree, routeId), // applicationLeaves(tree), filterApplicationTree(tree, query), firstApplicationLeaf(tree). diff --git a/packages/workbench/src/shell/workbench-location.ts b/packages/workbench/src/shell/workbench-location.ts index 13c1dd2e6..cb5e87686 100644 --- a/packages/workbench/src/shell/workbench-location.ts +++ b/packages/workbench/src/shell/workbench-location.ts @@ -5,13 +5,7 @@ * deterministically. Hash-only page routing (`#hooks`, `#mcp`) is gone. * * / Application (no selection) - * /routes/mcp//tool/ MCP tool — also resource | prompt | app - * /routes/events/ Event route, e.g. /routes/events/tool/before - * /routes/cli/ CLI route, e.g. /routes/cli/audible/search - * /routes/scripts/ Script - * /routes/skills/ Skill - * /routes/commands/ Host command (Rules / Commands group) - * /routes/rules/ Host rule + * /routes/… One application leaf (see application-node.ts) * /trace · /trace/ Live trace, one entry * /problems Diagnostics * /sessions · /sessions/ Embedded host sessions (PR 3) @@ -20,19 +14,26 @@ * `?invocation=` on a route path opens that route with the named * invocation snapshot loaded; `?tab=` selects a workspace tab. */ +import { + type ApplicationNodeRef, + applicationNodePath, + applicationNodeRefForPathSegments, +} from '../../../agent-bundle/src/dev/routes/application-node.ts'; import { isWorkbenchShellPath } from '../../../agent-bundle/src/dev/workbench-shell-paths.ts'; -export type ApplicationMcpNodeKind = 'app' | 'prompt' | 'resource' | 'tool'; - -/** One addressable application leaf, as the URL and the tree both name it. */ -export type ApplicationNodeRef = - | Readonly<{ readonly kind: ApplicationMcpNodeKind; readonly name: string; readonly server: string }> - | Readonly<{ readonly event: string; readonly kind: 'event' }> - | Readonly<{ readonly kind: 'cli'; readonly path: readonly string[] }> - | Readonly<{ readonly kind: 'script'; readonly name: string }> - | Readonly<{ readonly id: string; readonly kind: 'skill' }> - | Readonly<{ readonly id: string; readonly kind: 'command' }> - | Readonly<{ readonly id: string; readonly kind: 'rule' }>; +export type { + ApplicationMcpNodeKind, + ApplicationNodeKind, + ApplicationNodeRef, +} from '../../../agent-bundle/src/dev/routes/application-node.ts'; +export { + applicationNodeKey, + applicationNodePath, + applicationNodeRefForRouteId, + routeIdForApplicationNodeRef, + sameApplicationNodeRef, +} from '../../../agent-bundle/src/dev/routes/application-node.ts'; +export { isWorkbenchShellPath }; export type AdvancedSection = 'artifact' | 'evals' | 'hosts' | 'logs' | 'protocol'; @@ -47,8 +48,6 @@ export type WorkbenchLocation = | Readonly<{ readonly area: 'sessions'; readonly host?: string }> | Readonly<{ readonly area: 'advanced'; readonly section: AdvancedSection }>; -const mcpKinds: ReadonlySet = new Set(['app', 'prompt', 'resource', 'tool']); - const segment = (value: string): string => encodeURIComponent(value); const decode = (value: string): string | undefined => { @@ -60,134 +59,6 @@ const decode = (value: string): string | undefined => { } }; -const decodeAll = (values: readonly string[]): readonly string[] | undefined => { - const decoded = values.map(decode); - return decoded.length === 0 || decoded.some((value) => value === undefined) - ? undefined - : Object.freeze(decoded as string[]); -}; - -/** Compiled route id (`tool:curator/search_audible`, `event:tool/before`, `cli:audible/search`, `script:sync`) → node reference. */ -export const applicationNodeRefForRouteId = (routeId: string): ApplicationNodeRef | undefined => { - const colon = routeId.indexOf(':'); - if (colon <= 0 || colon === routeId.length - 1) return undefined; - const kind = routeId.slice(0, colon); - const rest = routeId.slice(colon + 1); - if (mcpKinds.has(kind)) { - const slash = rest.indexOf('/'); - if (slash <= 0 || slash === rest.length - 1) return undefined; - return Object.freeze({ kind: kind as ApplicationMcpNodeKind, name: rest.slice(slash + 1), server: rest.slice(0, slash) }); - } - switch (kind) { - case 'event': - return Object.freeze({ event: rest, kind: 'event' }); - case 'cli': - return Object.freeze({ kind: 'cli', path: Object.freeze(rest.split('/')) }); - case 'script': - return Object.freeze({ kind: 'script', name: rest }); - default: - return undefined; - } -}; - -/** Node reference → compiled route id, for the kinds the route manifest compiles; skills, commands, and rules have no route id. */ -export const routeIdForApplicationNodeRef = (node: ApplicationNodeRef): string | undefined => { - switch (node.kind) { - case 'app': - case 'prompt': - case 'resource': - case 'tool': - return `${node.kind}:${node.server}/${node.name}`; - case 'event': - return `event:${node.event}`; - case 'cli': - return `cli:${node.path.join('/')}`; - case 'script': - return `script:${node.name}`; - case 'skill': - case 'command': - case 'rule': - return undefined; - default: { - const exhaustive: never = node; - return exhaustive; - } - } -}; - -export const applicationNodePath = (node: ApplicationNodeRef): string => { - switch (node.kind) { - case 'app': - case 'prompt': - case 'resource': - case 'tool': - return `/routes/mcp/${segment(node.server)}/${node.kind}/${segment(node.name)}`; - case 'event': - return `/routes/events/${node.event.split('/').map(segment).join('/')}`; - case 'cli': - return `/routes/cli/${node.path.map(segment).join('/')}`; - case 'script': - return `/routes/scripts/${node.name.split('/').map(segment).join('/')}`; - case 'skill': - return `/routes/skills/${node.id.split('/').map(segment).join('/')}`; - case 'command': - return `/routes/commands/${node.id.split('/').map(segment).join('/')}`; - case 'rule': - return `/routes/rules/${node.id.split('/').map(segment).join('/')}`; - default: { - const exhaustive: never = node; - return exhaustive; - } - } -}; - -/** A stable key for selection state and React keys: the URL path of the node. */ -export const applicationNodeKey = (node: ApplicationNodeRef): string => applicationNodePath(node); - -export const sameApplicationNodeRef = (left: ApplicationNodeRef | undefined, right: ApplicationNodeRef | undefined): boolean => - left === right || (left !== undefined && right !== undefined && applicationNodeKey(left) === applicationNodeKey(right)); - -const applicationNodeFromSegments = (segments: readonly string[]): ApplicationNodeRef | undefined => { - const [group, ...rest] = segments; - switch (group) { - case 'mcp': { - const [server, kind, ...name] = rest; - if (server === undefined || kind === undefined || !mcpKinds.has(kind) || name.length !== 1) return undefined; - const decodedServer = decode(server); - const decodedName = decode(name[0]!); - return decodedServer === undefined || decodedName === undefined - ? undefined - : Object.freeze({ kind: kind as ApplicationMcpNodeKind, name: decodedName, server: decodedServer }); - } - case 'events': { - const event = decodeAll(rest); - return event === undefined ? undefined : Object.freeze({ event: event.join('/'), kind: 'event' }); - } - case 'cli': { - const path = decodeAll(rest); - return path === undefined ? undefined : Object.freeze({ kind: 'cli', path }); - } - case 'scripts': { - const name = decodeAll(rest); - return name === undefined ? undefined : Object.freeze({ kind: 'script', name: name.join('/') }); - } - case 'skills': { - const id = decodeAll(rest); - return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'skill' }); - } - case 'commands': { - const id = decodeAll(rest); - return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'command' }); - } - case 'rules': { - const id = decodeAll(rest); - return id === undefined ? undefined : Object.freeze({ id: id.join('/'), kind: 'rule' }); - } - default: - return undefined; - } -}; - const isAdvancedSection = (value: string): value is AdvancedSection => (advancedSections as readonly string[]).includes(value); const applicationRoot: WorkbenchLocation = Object.freeze({ area: 'application' }); @@ -207,7 +78,7 @@ export const parseWorkbenchLocation = (pathname: string, search = ''): Workbench case undefined: return applicationRoot; case 'routes': { - const node = applicationNodeFromSegments(rest); + const node = applicationNodeRefForPathSegments(rest); if (node === undefined) return applicationRoot; return Object.freeze({ area: 'application', @@ -263,5 +134,3 @@ export const formatWorkbenchLocation = (location: WorkbenchLocation): string => export const sameWorkbenchLocation = (left: WorkbenchLocation, right: WorkbenchLocation): boolean => formatWorkbenchLocation(left) === formatWorkbenchLocation(right); - -export { isWorkbenchShellPath }; From fcf7113450a7d0b3b67d0262ed236b1619e92664 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:44:38 +0000 Subject: [PATCH 03/43] wb600: changeset draft for PR 1 (PR number filled at open) --- .changeset/wb600-application-explorer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/wb600-application-explorer.md diff --git a/.changeset/wb600-application-explorer.md b/.changeset/wb600-application-explorer.md new file mode 100644 index 000000000..7c18a4298 --- /dev/null +++ b/.changeset/wb600-application-explorer.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Redesign the Workbench as an application explorer (#600 PR 1). The dev server gains one route invocation API — `POST /api/routes/invocations` renders any compiled route (MCP tool, resource, prompt, CLI route, script, or event route with a canonical or Claude/Codex/Cursor payload) through the production runtime and returns the render-event stream, final Agent Document, structured result, request context, providers, timings, and the MCP/CLI/host projections; `GET /api/routes/invocations[/]` lists and replays this session's invocations and every completion is published as a `route.invocation` project event (diagnostics `AB8231`–`AB8235`). The foreground server serves the Workbench shell for its deep-link paths (`/routes/**`, `/trace`, `/problems`, `/sessions`, `/advanced`). Breaking for `agent-bundle/test`: `inspectWorkbenchSurface()` now reports the Application tree (`application`, with `workbenchLeafPath(leaf)`) and the populated Advanced sections instead of `pages`; `WorkbenchPageName` and `workbenchPageLabel` are removed. (#PR) From 9c20ac822c9375f56cdf6b1bb7fb8f83f8369524 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:50:05 +0000 Subject: [PATCH 04/43] test(workbench): rewrite e2e suites onto the Application tree URL model PR 1 deletes hash pages; point the browser pool at pathnames, the compiled Application tree, and a testid contract so acceptance is ready when the shell lands. --- LANE-NOTES.md | 139 ++++ .../audiobook-curator.acceptance.e2e.test.ts | 191 +++++ .../tests/contributor-hmr.e2e.test.ts | 2 +- .../workbench/tests/discovery.e2e.test.ts | 10 +- .../workbench/tests/evals-real.e2e.test.ts | 4 +- .../workbench/tests/examples-real.e2e.test.ts | 719 ++++-------------- .../workbench/tests/host-adoption.e2e.test.ts | 10 +- .../workbench/tests/lifecycles.e2e.test.ts | 10 +- .../workbench/tests/logs-real.e2e.test.ts | 12 +- .../workbench/tests/mcp-app-real.e2e.test.ts | 6 +- .../tests/mcp-session-timeout.e2e.test.ts | 4 +- .../workbench/tests/mcp-tasks.e2e.test.ts | 4 +- packages/workbench/tests/overview.e2e.test.ts | 28 +- .../tests/packed-release.e2e.test.ts | 13 +- .../tests/playground-real.e2e.test.ts | 2 +- .../tests/support/example-acceptance.ts | 17 +- .../tests/support/workbench-acceptance.ts | 141 ++++ .../workbench/tests/support/workbench-e2e.ts | 55 +- .../tests/support/workbench-surface.ts | 258 +++++++ rstest.integration-tests.ts | 1 + 20 files changed, 1009 insertions(+), 617 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts create mode 100644 packages/workbench/tests/support/workbench-acceptance.ts create mode 100644 packages/workbench/tests/support/workbench-surface.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..6aaa379ed --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,139 @@ +# L9 — browser/e2e acceptance (PR 1) + +Lane: `lane/wb600-pr1-browser-acceptance` · worktree `wb600-pr1-browser-acceptance` + +Phase 1: suites rewritten against the new URL model and IA. They compile and +`inspectWorkbenchSurface` dry-runs. They cannot pass against the current hash +UI — the integrator re-dispatches this lane for phase 2 on the integrated branch. + +## Files added + +- `packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts` — 1440×900 + flagship acceptance (registered in `rstest.integration-tests.ts` + `integrationTestFiles`). +- `packages/workbench/tests/support/workbench-surface.ts` — codes against L10's + `inspectWorkbenchSurface(root).application` + `workbenchLeafPath(leaf)`. Until + L10 lands, derives an `ApplicationTree` from the current catalog so the + helper still dry-runs (50 curator leaves, `search_audible` → + `/routes/mcp/curator/tool/search_audible`). +- `packages/workbench/tests/support/workbench-acceptance.ts` — primary-nav, + tree, idle, epoch, rendered-document helpers. + +## Files changed + +- `packages/workbench/tests/support/workbench-e2e.ts` — `workbenchUrl` is + path-based; leftover hash-page names map to PR 1 destinations; + `waitForWorkbenchIdle(page)` waits out loading before any assertion. +- `packages/workbench/tests/support/example-acceptance.ts` — + `waitForSettledWorkbench` delegates to `waitForWorkbenchIdle`; captures + record pathname+search instead of `#hash`. +- `packages/workbench/tests/examples-real.e2e.test.ts` — rewritten to + Application tree / Advanced / Problems (no Overview, Routes, Hooks, + Playground, Skills pages). +- Remaining `packages/workbench/tests/*.e2e.test.ts` — navigation retargeted + (`/advanced/{evals,artifact,protocol,hosts,logs}`, `/`, `/problems`, + `/routes/events/tool/after`). Old-page form selectors (`#runtime-input-raw`, + `#playground-operation`, `#mcp-target`, …) are left in the large + runtime/playground/MCP journeys; they will fail until those capabilities + live on the route workspace / Protocol inspector. +- `rstest.integration-tests.ts` — added the acceptance file. There is no + `rstest.e2e*.config.ts`; Workbench browser e2e is the integration pool. + +## Files not touched (other lanes) + +- Lifecycles-page fixtures (`packages/workbench/tests/fixtures/lifecycles-page-browser-fixture.tsx`) — L6 deletes. +- `packages/agent-bundle/src/test/workbench.ts` — L10 owns + `inspectWorkbenchSurface` / `workbenchLeafPath`. No stub commit; the + support adapter is the temporary seam. + +## data-testid contract (UI lanes / integrator must mount) + +| testid | Role | +| --- | --- | +| `workbench-nav` | Primary nav. Exactly four links: Application · Trace · Problems · Advanced. Current area `aria-current="page"`. | +| `workbench-loading` | Present only while a route/workspace is loading. Must be gone before assertions/screenshots. | +| `application-tree` | The Application `role="tree"`. Groups in fixed order (empty omitted): MCP · Events / Hooks · CLI · Scripts · Skills · Rules / Commands. Every compiled leaf is a `treeitem` named with `leaf.label`. | +| `route-workspace` | Selected-leaf workspace (input + Run + result tabs). | +| `route-run` | Run button. Also acceptable: `getByRole('button', { name: 'Run' })`. | +| `result-tab-rendered` | Default result tab. | +| `result-tab-structured` | Structured result tab. | +| `result-tab-raw` | Raw AgentDocument tab. | +| `result-tab-mcp` | MCP projection tab. | +| `result-tab-cli` | CLI projection tab (when available). | +| `result-tab-trace` | Per-invocation trace tab. | +| `rendered-document` | Rendered Agent Document root. Non-empty; no `[data-kind="error"]` / `.agent-document-error` on success. | +| `shell-build-status` | Header build state + epoch. Text must change when a watcher rebuild publishes. | +| `problems-badge` | Header failure count linking to `/problems`. Numeric when stale/failed. | +| `problems-banner` | Problems (or Application) stale-catalog banner. | +| `problems-repair` | Repair / Rebuild control on Problems. Posts `/api/project/rebuild` (or the L5 equivalent). Also acceptable: `getByRole('button', { name: /Repair|Rebuild/ })`. | +| `inspector-toggle` | Opens the right inspector (Source · Schema · …) **and** the per-file Artifact details toggle (hashes/modes/provenance). | +| `unknown-route` | Message when `parseWorkbenchLocation` falls back to `/` for an unknown path. Also acceptable: `role="status"` containing "unknown route/path". | + +Schema editor fields stay `getByLabel` (e.g. `/^title/i` for `search_audible`). + +## What needs the integrated UI to run + +Everything that drives Chrome against the new IA: + +- Primary nav of four items (current rail is still Overview / Routes / …). +- Pathname routing + SPA fallback (`isWorkbenchShellPath`) so + `/routes/mcp/curator/tool/search_audible` and `/advanced/evals` serve + `index.html`. +- Application tree + route workspace (Run, Rendered default, projection tabs). +- Header epoch + Problems badge/banner/Repair. +- Unknown-path message on `/`. +- `/advanced/evals` Runs · Compare tabs; `/advanced/hosts` reduced diagnostics; + `/advanced/artifact` file tree + details toggle. +- `?invocation=` written after Run and restored on refresh. + +Phase 2 should run, after `pnpm build`: + +``` +npx rstest --config rstest.integration.config.ts \ + packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts \ + packages/workbench/tests/examples-real.e2e.test.ts +``` + +Do not chase old-page assertions in `overview.e2e` / `runtime-playground*.e2e` / +`playground-real.e2e` / `mcp-app-real.e2e` until those files are finished +against the workspace (or deleted once the acceptance + examples-real cover +the capability). + +## Expected runtime (phase 2) + +- `audiobook-curator.acceptance.e2e.test.ts`: one test, timeout 240s × timeScale. + Expect ~2–4 min when the example build + `search_audible` + HMR + stale + repair all run (Audible search may need network). +- `examples-real.e2e.test.ts`: four tests, 90–150s each. Full file ~6–10 min. +- Remaining retargeted e2e files keep their existing budgets. + +## Cross-lane requests + +- **L5 shell**: mount the testid contract; unknown-path status; header epoch + + failure count; Problems Repair. +- **L3 workspace**: `route-workspace`, `route-run`, result tabs, + `rendered-document`; write `?invocation=` after a run. +- **L2 tree**: `application-tree` / `role=tree`; include config-declared + hooks/scripts/skills (not only compiled catalog routes). Group labels must + match `MCP`, `Events / Hooks`, `CLI`, `Scripts`, `Skills`, `Rules / Commands`. +- **L10**: add `application: ApplicationTree`, `advanced`, and + `workbenchLeafPath(leaf)` on `inspectWorkbenchSurface`. Integrator: rewire + e2e imports from `tests/support/workbench-surface.ts` to + `agent-bundle/src/test` and delete the adapter. +- **L1**: SPA fallback already specified; deep links 404 without it. + +## Phase 1 verification (this lane) + +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass. +- rslint on rewritten files — pass. +- `npx rstest --config rstest.unit.config.ts packages/agent-bundle/tests/workbench-surface.test.ts` — 14/14. +- Adapter dry-run on `examples/audiobook-curator`: `leafCount=50`, groups + `MCP`, `CLI`, `search_audible` path + `/routes/mcp/curator/tool/search_audible`. + +No changeset (Workbench is private). Integrator owns the single PR changeset +if `packages/agent-bundle` test exports change under L10. + +## Proposed changeset line (for L10 / integrator, not this lane) + +Rewrite `inspectWorkbenchSurface` to return `{ application, routes, lifecycles, counts, advanced }` and add `workbenchLeafPath`; remove `WorkbenchPageName` / `workbenchPageLabel` / `pages` (`#600`). diff --git a/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts b/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts new file mode 100644 index 000000000..be4425c5b --- /dev/null +++ b/packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts @@ -0,0 +1,191 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect } from '@rstest/playwright'; + +import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench-assets.ts'; +import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; +import { + replaceWatchedSourceAndAwaitRebuild, + type WatchedBuildSession, +} from '../../agent-bundle/tests/support/watched-files.ts'; +import { + captureExampleState, + copyExample, + createExampleErrorLedger, + expectHealthyExamplePage, + writeExampleReport, +} from './support/example-acceptance.ts'; +import { + expectApplicationTree, + expectPrimaryNav, + expectRenderedDocument, + expectUnknownRouteMessage, + openWorkbench, + readBuildEpoch, + selectApplicationLeaf, + waitForBuildEpochAdvance, + workbenchTestId, + workbenchTestIds, +} from './support/workbench-acceptance.ts'; +import { buildWorkbench, e2e, waitForWorkbenchIdle, workbenchAssets, workbenchUrl } from './support/workbench-e2e.ts'; +import { + applicationLeafForRouteId, + findApplicationLeaf, + inspectWorkbenchSurface, + workbenchLeafPath, +} from './support/workbench-surface.ts'; + +const browserTimeout = 15_000 * timeScale; +const rebuildTimeout = 60_000 * timeScale; +const runTimeout = 60_000 * timeScale; +const searchTitle = 'Dune'; +const hmrMarker = 'WB600-HMR-MARKER'; + +const editWatchedSource = async ( + server: WatchedBuildSession, + projectRoot: string, + path: string, + content: string, + expectedOutcome: 'failed' | 'succeeded', +): Promise => { + const attempt = await replaceWatchedSourceAndAwaitRebuild(server, projectRoot, path, content, { timeoutMs: rebuildTimeout }); + expect(attempt.outcome).toBe(expectedOutcome); +}; + +e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeout: 240_000 * timeScale }, async ({ page }) => { + await buildWorkbench(); + const project = await copyExample('audiobook-curator'); + const conversionSource = join(project.root, 'src', 'conversion.ts'); + const searchSource = join(project.root, 'src', 'mcp', 'curator', 'tools', 'search_audible.tsx'); + const healthyConversion = await readFile(conversionSource, 'utf8'); + const healthySearch = await readFile(searchSource, 'utf8'); + const server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: workbenchAssets }), + open: false, + port: 0, + root: project.root, + }); + const ledger = createExampleErrorLedger(page, server.url); + try { + const surface = await inspectWorkbenchSurface(project.root); + const searchLeaf = applicationLeafForRouteId(surface.application, 'tool:curator/search_audible') + ?? findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'tool' && leaf.ref.name === 'search_audible'); + if (searchLeaf === undefined) { + throw new Error('inspectWorkbenchSurface did not project tool:curator/search_audible as an Application leaf.'); + } + if (searchLeaf.ref.kind !== 'tool') { + throw new Error(`search_audible leaf was ${searchLeaf.ref.kind}, expected tool.`); + } + expect(searchLeaf.ref.server).toBe('curator'); + const searchPath = workbenchLeafPath(searchLeaf); + expect(searchPath).toBe('/routes/mcp/curator/tool/search_audible'); + + await openWorkbench(page, server.url, '/'); + await expectPrimaryNav(page); + const firstLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.routeId === 'tool:curator/inventory_sources') + ?? searchLeaf; + await selectApplicationLeaf(page, server.url, firstLeaf); + await expectApplicationTree(page, surface.application); + await captureExampleState(page, 'audiobook-curator', 'application-populated'); + + await selectApplicationLeaf(page, server.url, searchLeaf); + await page.getByLabel(/^title/iu).fill(searchTitle); + await workbenchTestId(page, 'routeRun').or(page.getByRole('button', { name: 'Run' })).click(); + const rendered = await expectRenderedDocument(page, runTimeout); + await expect(page.getByTestId(workbenchTestIds.resultTabStructured).or(page.getByRole('tab', { name: /Structured/u }))).toBeVisible(); + await expect(page.getByTestId(workbenchTestIds.resultTabRaw).or(page.getByRole('tab', { name: /Raw/u }))).toBeVisible(); + await expect(page.getByTestId(workbenchTestIds.resultTabMcp).or(page.getByRole('tab', { name: /^MCP/u }))).toBeVisible(); + + const invocationUrl = new URL(page.url()); + const invocationId = invocationUrl.searchParams.get('invocation'); + expect(invocationId).toMatch(/\S/u); + + const epochBeforeEdit = await readBuildEpoch(page); + const markedSearch = healthySearch.replace( + '{audibleSearchHeadline(receipt)}', + `{audibleSearchHeadline(receipt)}\n ${hmrMarker}`, + ); + if (markedSearch === healthySearch) { + throw new Error('search_audible.tsx no longer contains the Agent.Text headline the HMR edit anchors on.'); + } + await editWatchedSource(server, project.root, searchSource, markedSearch, 'succeeded'); + await waitForBuildEpochAdvance(page, epochBeforeEdit, rebuildTimeout); + await waitForWorkbenchIdle(page); + await workbenchTestId(page, 'routeRun').or(page.getByRole('button', { name: 'Run' })).click(); + await expect(rendered).toContainText(hmrMarker, { timeout: runTimeout }); + await editWatchedSource(server, project.root, searchSource, healthySearch, 'succeeded'); + + await page.goto(workbenchUrl(server.url, `${searchPath}?invocation=${encodeURIComponent(invocationId!)}`)); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).searchParams.get('invocation')).toBe(invocationId); + await expect(workbenchTestId(page, 'routeWorkspace')).toBeVisible({ timeout: browserTimeout }); + await expectRenderedDocument(page, runTimeout); + + await editWatchedSource(server, project.root, conversionSource, `${healthyConversion}\nconst = ;\n`, 'failed'); + await page.reload(); + await waitForWorkbenchIdle(page); + const problemsBadge = workbenchTestId(page, 'problemsBadge').or(page.getByRole('link', { name: /Problems/u })); + await expect(problemsBadge).toBeVisible({ timeout: browserTimeout }); + await expect(problemsBadge).toContainText(/[1-9]/u); + await openWorkbench(page, server.url, '/problems'); + const staleBanner = workbenchTestId(page, 'problemsBanner').or(page.getByRole('status').filter({ + hasText: /stale|newer source|rebuild/iu, + })); + await expect(staleBanner).toBeVisible({ timeout: browserTimeout }); + await captureExampleState(page, 'audiobook-curator', 'problems-stale'); + await editWatchedSource(server, project.root, conversionSource, healthyConversion, 'succeeded'); + await workbenchTestId(page, 'problemsRepair').or(page.getByRole('button', { name: /Repair|Rebuild/u })).click(); + await waitForWorkbenchIdle(page); + await expect(staleBanner).toHaveCount(0, { timeout: browserTimeout }); + await expect(problemsBadge).not.toContainText(/[1-9]/u, { timeout: browserTimeout }); + + await page.goto(workbenchUrl(server.url, searchPath)); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe(searchPath); + await page.reload(); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe(searchPath); + await page.goto(workbenchUrl(server.url, '/trace')); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe('/trace'); + await page.goBack(); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe(searchPath); + await page.goForward(); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe('/trace'); + + await page.goto(workbenchUrl(server.url, '/routes/mcp/no-such-server/tool/missing')); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe('/'); + await expectUnknownRouteMessage(page); + + await openWorkbench(page, server.url, '/advanced/evals'); + await expect(page.getByRole('tab', { name: 'Runs' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('tab', { name: 'Compare' })).toBeVisible({ timeout: browserTimeout }); + + await openWorkbench(page, server.url, '/advanced/hosts'); + await expect(page.getByRole('heading', { name: /Host diagnostics/u })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByText(/installed|version|path/iu).first()).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('table', { name: /finding|bundle|store|probe/iu })).toHaveCount(0); + + await openWorkbench(page, server.url, '/advanced/artifact'); + await expect(page.getByRole('tree').or(page.getByRole('treeitem')).first()).toBeVisible({ timeout: browserTimeout }); + const detailsToggle = page.getByTestId(workbenchTestIds.inspectorToggle) + .or(page.getByRole('button', { name: /details/iu })) + .first(); + await expect(detailsToggle).toBeVisible({ timeout: browserTimeout }); + await detailsToggle.click(); + await expect(page.getByText(/hash|mode|provenance/iu).first()).toBeVisible({ timeout: browserTimeout }); + + await expectHealthyExamplePage(ledger); + await writeExampleReport(); + } finally { + await writeFile(searchSource, healthySearch).catch(() => undefined); + await writeFile(conversionSource, healthyConversion).catch(() => undefined); + await server.close(); + await project.release(); + } +}); diff --git a/packages/workbench/tests/contributor-hmr.e2e.test.ts b/packages/workbench/tests/contributor-hmr.e2e.test.ts index 606e23aa0..e36f14e14 100644 --- a/packages/workbench/tests/contributor-hmr.e2e.test.ts +++ b/packages/workbench/tests/contributor-hmr.e2e.test.ts @@ -181,7 +181,7 @@ e2e('completes a Workbench session through the documented contributor HMR proxy await expect(page.locator('.build-health')).toContainText('Current build', { timeout: browserTimeout }); // 2. A mutation carrying the browser's real Origin header, admitted through the allowlist. - const rebuild = page.getByRole('button', { name: 'Rebuild' }); + const rebuild = page.getByTestId('problems-repair').or(page.getByRole('button', { name: /Repair|Rebuild/u })); const rebuildResponse = page.waitForResponse((candidate) => candidate.request().method() === 'POST' && candidate.url() === `${devOrigin}/api/project/rebuild`); await rebuild.click(); diff --git a/packages/workbench/tests/discovery.e2e.test.ts b/packages/workbench/tests/discovery.e2e.test.ts index 4372310cc..5f9edd8ad 100644 --- a/packages/workbench/tests/discovery.e2e.test.ts +++ b/packages/workbench/tests/discovery.e2e.test.ts @@ -119,19 +119,17 @@ e2e( await cp(join(output, 'claude'), join(root, 'dist', 'claude'), { recursive: true }); }, }); - await page.goto(workbenchUrl(fixture.url, 'hosts')); + await page.goto(workbenchUrl(fixture.url, '/advanced/hosts')); try { - await expect(page.getByText('Generated at', { exact: true })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: /Host diagnostics/u })).toBeVisible({ timeout: browserTimeout }); } catch (reason) { throw new Error( - `Host discovery page did not become ready at ${page.url()}.\n${await page.locator('body').innerText()}`, + `Host diagnostics did not become ready at ${page.url()}.\n${await page.locator('body').innerText()}`, { cause: reason }, ); } await expect(page.getByText('Loading host discovery', { exact: true })).toHaveCount(0, { timeout: browserTimeout }); - - await expect(page.getByRole('heading', { name: 'Hosts' })).toBeVisible(); - await expect(page.getByRole('link', { exact: true, name: 'Hosts' })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByTestId('workbench-nav').getByRole('link', { name: 'Advanced' })).toHaveAttribute('aria-current', 'page'); const claude = page.getByRole('group', { name: 'Claude' }); await expect(claude.locator('.discovery-badge').first()).toHaveText('Available'); diff --git a/packages/workbench/tests/evals-real.e2e.test.ts b/packages/workbench/tests/evals-real.e2e.test.ts index 1e2564380..1fa518dd9 100644 --- a/packages/workbench/tests/evals-real.e2e.test.ts +++ b/packages/workbench/tests/evals-real.e2e.test.ts @@ -163,8 +163,10 @@ e2e('admits a deterministic Eval promptly and renders refreshed durable evidence page.on('request', (request) => { if (request.method() === 'GET' && request.url().includes('/api/evals/runs/')) durableReads.push(request.url()); }); - await page.goto(workbenchUrl(server.url, 'evals')); + await page.goto(workbenchUrl(server.url, '/advanced/evals')); await expect(page.getByRole('heading', { name: 'Evals' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('tab', { name: 'Runs' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('tab', { name: 'Compare' })).toBeVisible({ timeout: browserTimeout }); await expect(page.getByRole('button', { name: 'Run deterministic suite' })).toBeEnabled({ timeout: browserTimeout }); await expect(page.getByLabel('Harness')).toHaveValue('deterministic'); await expect(page.getByText('Authored model pins are read-only')).toBeVisible(); diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index ca948ee21..f139ed7c2 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -5,7 +5,11 @@ import { expect } from '@rstest/playwright'; import { createWorkbenchAssetSource } from '../../agent-bundle/src/dev/workbench-assets.ts'; import { startDevServer } from '../../agent-bundle/src/dev/workbench-server.ts'; -import { inspectWorkbenchSurface, workbenchPageLabel } from '../../agent-bundle/src/test/index.ts'; +import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; +import { + replaceWatchedSourceAndAwaitRebuild, + type WatchedBuildSession, +} from '../../agent-bundle/tests/support/watched-files.ts'; import { captureExampleState, copyExample, @@ -15,12 +19,21 @@ import { waitForSettledWorkbench, writeExampleReport, } from './support/example-acceptance.ts'; -import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; import { - replaceWatchedSourceAndAwaitRebuild, - type WatchedBuildSession, -} from '../../agent-bundle/tests/support/watched-files.ts'; -import { buildWorkbench, e2e, workbenchAssets, workbenchUrl } from './support/workbench-e2e.ts'; + expectApplicationTree, + expectPrimaryNav, + expectUnknownRouteMessage, + openWorkbench, + selectApplicationLeaf, + workbenchTestId, +} from './support/workbench-acceptance.ts'; +import { buildWorkbench, e2e, waitForWorkbenchIdle, workbenchAssets, workbenchUrl } from './support/workbench-e2e.ts'; +import { + applicationLeaves, + findApplicationLeaf, + inspectWorkbenchSurface, + workbenchLeafPath, +} from './support/workbench-surface.ts'; const browserTimeout = 15_000 * timeScale; /** One watcher debounce plus a full development rebuild of an example under gate load. */ @@ -77,42 +90,22 @@ e2e('drives the populated Skills Starter in real Chrome', { timeout: 90_000 }, a }); const ledger = createExampleErrorLedger(page, server.url); try { - await page.goto(workbenchUrl(server.url, 'skills')); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'dependency-upgrade', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.skill-tree-item')).toHaveCount(3, { timeout: browserTimeout }); + const surface = await inspectWorkbenchSurface(exampleRoot('skills-starter')); + await openWorkbench(page, server.url, '/'); + await expectPrimaryNav(page); + await expectApplicationTree(page, surface.application); for (const skill of ['dependency-upgrade', 'incident-triage', 'release-review']) { - await page.getByRole('button', { name: new RegExp(skill, 'u') }).click(); + const leaf = findApplicationLeaf(surface.application, (entry) => entry.ref.kind === 'skill' && ( + entry.ref.id === skill || entry.label === skill + )); + if (leaf === undefined) throw new Error(`Skills Starter surface is missing the ${skill} leaf.`); + await selectApplicationLeaf(page, server.url, leaf); await expect(page.getByRole('heading', { name: skill, exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByLabel('Eval coverage')).toContainText('Indirect 1', { timeout: browserTimeout }); - await expect(page.getByLabel('Resource tree').getByRole('link')).not.toHaveCount(0, { timeout: browserTimeout }); - } - for (const unavailable of ['Hooks', 'MCP playground', 'Playground']) { - await expect(page.getByRole('link', { name: unavailable, exact: true })).toHaveCount(0, { timeout: browserTimeout }); } - await page.getByRole('button', { name: /release-review/u }).click(); - await expect(page.getByRole('heading', { name: 'release-review', exact: true })).toBeVisible({ timeout: browserTimeout }); - await page.getByRole('tab', { name: 'Markdown' }).click(); - await expect(page.locator('.skill-source')).toContainText('Release review', { timeout: browserTimeout }); - await page.getByRole('tab', { name: 'Generated' }).click(); - await page.getByLabel('Target').selectOption('codex'); - await expect(page.locator('.skill-translation-note')).toHaveText( - 'This target keeps the authored instructions unchanged. Agent Bundle only changes the codex package layout.', - { timeout: browserTimeout }, - ); await captureExampleState(page, 'skills-starter', 'skills-populated'); - await page.goto(workbenchUrl(server.url, 'hooks')); - await waitForSettledWorkbench(page); - await expect(page).toHaveURL(new URL('#overview', server.url).href, { timeout: browserTimeout }); - await expect(page.getByRole('heading', { name: 'Bundle dashboard', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Hooks', exact: true })).toHaveCount(0, { timeout: browserTimeout }); - - await page.getByRole('link', { name: 'Artifacts' }).click(); - await waitForSettledWorkbench(page); - for (const target of ['portable', 'codex', 'claude']) { - await expect(page.locator(`#artifact-target option[value="${target}"]`)).toBeAttached({ timeout: browserTimeout }); - } + await openWorkbench(page, server.url, '/advanced/artifact'); + await expect(page.getByRole('tree').or(page.getByRole('treeitem')).first()).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'skills-starter', 'artifacts-populated'); await expectHealthyExamplePage(ledger); await writeExampleReport(); @@ -142,41 +135,34 @@ e2e('reveals, retains, repairs, and removes capabilities without reloading Chrom }); const ledger = createExampleErrorLedger(page, server.url); try { - await page.goto(workbenchUrl(server.url, 'hooks')); - await waitForSettledWorkbench(page); - await expect(page).toHaveURL(new URL('#overview', server.url).href, { timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Hooks', exact: true })).toHaveCount(0, { timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Playground', exact: true })).toHaveCount(0, { timeout: browserTimeout }); + await openWorkbench(page, server.url, '/'); + await expectPrimaryNav(page); + const before = await inspectWorkbenchSurface(project.root); + expect(applicationLeaves(before.application).some((leaf) => leaf.ref.kind === 'event')).toBe(false); await editWatchedSource(server, project.root, configPath, hookConfig, 'succeeded'); - await expect(page.getByRole('link', { name: 'Hooks', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Playground', exact: true })).toBeVisible({ timeout: browserTimeout }); - await page.getByRole('link', { name: 'Hooks', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.locator('#hook-binding option')).not.toHaveCount(0, { timeout: browserTimeout }); + await waitForWorkbenchIdle(page); + const revealed = await inspectWorkbenchSurface(project.root); + const eventLeaf = findApplicationLeaf(revealed.application, (leaf) => leaf.ref.kind === 'event'); + if (eventLeaf === undefined) throw new Error('Adding a sessionStart hook did not project an Events leaf.'); + await expect(page.getByRole('treeitem', { name: new RegExp(eventLeaf.label, 'u') })).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'skills-starter', 'capability-revealed'); await editWatchedSource(server, project.root, hookSource, 'export default () => ({\n', 'failed'); - await page.getByRole('link', { name: 'Overview', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: /Diagnostics \([1-9]/u })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.build-health')).toContainText('Last good build', { timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Hooks', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Playground', exact: true })).toBeVisible({ timeout: browserTimeout }); + await openWorkbench(page, server.url, '/problems'); + await expect(workbenchTestId(page, 'problemsBanner').or(page.getByRole('heading', { name: /Diagnostics \([1-9]/u }))) + .toBeVisible({ timeout: browserTimeout }); + await expect(workbenchTestId(page, 'shellBuildStatus')).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'skills-starter', 'capability-stale'); await editWatchedSource(server, project.root, hookSource, healthyHook, 'succeeded'); - await expect(page.getByRole('heading', { name: 'Diagnostics (0)' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.build-health')).toContainText('Current build', { timeout: browserTimeout }); + await waitForWorkbenchIdle(page); + await expect(workbenchTestId(page, 'problemsBanner')).toHaveCount(0, { timeout: browserTimeout }); await captureExampleState(page, 'skills-starter', 'capability-repaired'); - await page.getByRole('link', { name: 'Hooks', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.locator('#hook-binding option')).not.toHaveCount(0, { timeout: browserTimeout }); await editWatchedSource(server, project.root, configPath, originalConfig, 'succeeded'); - await expect(page).toHaveURL(new URL('#overview', server.url).href, { timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Hooks', exact: true })).toHaveCount(0, { timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Playground', exact: true })).toHaveCount(0, { timeout: browserTimeout }); + await openWorkbench(page, server.url, '/'); + await expect(page.getByRole('treeitem', { name: new RegExp(eventLeaf.label, 'u') })).toHaveCount(0, { timeout: browserTimeout }); await captureExampleState(page, 'skills-starter', 'capability-removed'); await expectHealthyExamplePage(ledger); await writeExampleReport(); @@ -186,7 +172,7 @@ e2e('reveals, retains, repairs, and removes capabilities without reloading Chrom } }); -e2e('drives Hooks, scripts, logs, diagnostics, and repair in real Chrome', { timeout: 150_000 * timeScale }, async ({ page }) => { +e2e('drives event, script, logs, diagnostics, and repair in real Chrome', { timeout: 150_000 * timeScale }, async ({ page }) => { await buildWorkbench(); const project = await copyExample('hooks-and-scripts'); const hookSource = join(project.root, 'src', 'hooks', 'session-start.ts'); @@ -199,94 +185,48 @@ e2e('drives Hooks, scripts, logs, diagnostics, and repair in real Chrome', { tim }); const ledger = createExampleErrorLedger(page, server.url); try { - await page.goto(workbenchUrl(server.url, 'hooks')); - await waitForSettledWorkbench(page); - await expect(page.locator('#hook-binding option')).not.toHaveCount(0, { timeout: browserTimeout }); - const canonicalHookDraft = JSON.stringify({ - cwd: '/workspace', - sessionId: 'workbench-preview', - source: 'workbench', - transcriptPath: '/workspace/transcript.json', - }, null, 2); - await page.waitForFunction((value) => document.querySelector('#hook-canonical-input')?.value === value, canonicalHookDraft, { timeout: browserTimeout }); - expect(await page.locator('#hook-canonical-input').inputValue()).toBe(canonicalHookDraft); - await page.getByRole('button', { name: 'Run simulation' }).click(); - await expect(page.getByRole('heading', { name: 'Canonical result' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.hook-json').last()).toContainText('workbench-preview', { timeout: browserTimeout }); - const replayed = page.waitForResponse((response) => ( - response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/hooks/replays' - )); - await page.getByRole('button', { name: 'Replay saved simulation' }).click(); - expect((await replayed).ok()).toBe(true); - await expect(page.locator('.hook-json').last()).toContainText('workbench-preview', { timeout: browserTimeout }); + const surface = await inspectWorkbenchSurface(project.root); + await openWorkbench(page, server.url, '/'); + await expectPrimaryNav(page); + const eventLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'event') + ?? findApplicationLeaf(surface.application, (leaf) => /session/iu.test(leaf.label)); + if (eventLeaf !== undefined) { + await selectApplicationLeaf(page, server.url, eventLeaf); + await workbenchTestId(page, 'routeRun').or(page.getByRole('button', { name: /Run/u })).click(); + await expect(workbenchTestId(page, 'routeWorkspace')).toBeVisible({ timeout: browserTimeout }); + } await captureExampleState(page, 'hooks-and-scripts', 'hooks-populated'); - await page.getByRole('link', { name: 'Playground', exact: true }).click(); - await waitForSettledWorkbench(page); - await page.waitForFunction(() => document.querySelector('#playground-script-id')?.value === 'script:verify-release', undefined, { timeout: browserTimeout }); - expect(await page.locator('#playground-target').inputValue()).toBe('claude'); - expect(await page.locator('#playground-operation').inputValue()).toBe('script.run'); - expect(await page.locator('#playground-script-id').inputValue()).toBe('script:verify-release'); - await page.getByRole('button', { name: 'Run script' }).click(); - await expect(page.getByText('script.completed')).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.playground-trace')).toContainText('ready for packaging', { timeout: browserTimeout }); - await expect(page.locator('.playground-trace')).toContainText('is finalized', { timeout: browserTimeout }); - await expect(page.getByRole('button', { name: 'Run script' })).toBeEnabled({ timeout: browserTimeout }); + const scriptLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.routeId === 'script:verify-release') + ?? findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'script' && /verify-release/u.test(leaf.label)); + if (scriptLeaf === undefined) throw new Error('hooks-and-scripts surface is missing script:verify-release.'); + await selectApplicationLeaf(page, server.url, scriptLeaf); + await workbenchTestId(page, 'routeRun').or(page.getByRole('button', { name: /Run/u })).click(); + await expect(page.getByText(/script\.completed|ready for packaging/iu)).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'hooks-and-scripts', 'script-success'); - await page.locator('#playground-target').selectOption('portable'); - await page.locator('#playground-script-id').selectOption('script:detect-risk'); - await page.getByRole('button', { name: 'Run script' }).click(); - await expect(page.locator('.playground-trace')).toContainText('REL-204', { timeout: browserTimeout }); - await expect(page.locator('.playground-event-card').filter({ hasText: 'script.completed' }).last().locator('.playground-json')).toContainText('"exitCode": 2', { timeout: browserTimeout }); - await expect(page.locator('.playground-trace')).toContainText('is finalized', { timeout: browserTimeout }); - await expect(page.getByRole('button', { name: 'Run script' })).toBeEnabled({ timeout: browserTimeout }); - await captureExampleState(page, 'hooks-and-scripts', 'script-failure'); + const riskLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.routeId === 'script:detect-risk') + ?? findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'script' && /detect-risk/u.test(leaf.label)); + if (riskLeaf !== undefined) { + await selectApplicationLeaf(page, server.url, riskLeaf); + await workbenchTestId(page, 'routeRun').or(page.getByRole('button', { name: /Run/u })).click(); + await expect(page.getByText(/REL-204|exitCode/iu)).toBeVisible({ timeout: browserTimeout }); + await captureExampleState(page, 'hooks-and-scripts', 'script-failure'); + } - await page.getByRole('link', { name: 'Routes', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Routes', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByText('This catalog is the compiled route graph the published build was produced from.', { exact: true })).toBeVisible({ timeout: browserTimeout }); - // verify-release is discovered by convention; detect-risk is configured, so - // the compiled catalog must show exactly one script route. - await expect(page.getByRole('heading', { name: 'Scripts', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.route-table')).toContainText('script:verify-release', { timeout: browserTimeout }); - await expect(page.locator('.route-table')).toContainText('src/scripts/verify-release.ts', { timeout: browserTimeout }); - await expect(page.locator('.route-table')).not.toContainText('script:detect-risk'); - await expect(page.locator('.route-state')).toHaveText('current', { timeout: browserTimeout }); - // Provenance renders under its source rather than running into it, which is - // only true when the catalog stylesheet actually reached the document. - await expect(page.locator('.route-provenance')).toHaveText('conventional', { timeout: browserTimeout }); - expect(await page.locator('.route-provenance').evaluate((node) => getComputedStyle(node).display)).toBe('block'); - await captureExampleState(page, 'hooks-and-scripts', 'routes-catalog'); - - await page.getByRole('link', { name: 'Logs' }).click(); - await waitForSettledWorkbench(page); + await openWorkbench(page, server.url, '/advanced/logs'); await expect(page.locator('.logs-entries > li').first()).toBeVisible({ timeout: browserTimeout }); - for (const filter of ['logs-producer', 'logs-level', 'logs-kind', 'logs-context']) { - const select = page.locator(`#${filter}`); - const value = await select.locator('option').nth(1).getAttribute('value'); - if (value === null) throw new Error(`Expected ${filter} to expose a populated option.`); - await select.selectOption(value); - await expect(page.locator('.logs-entries > li').first()).toBeVisible({ timeout: browserTimeout }); - await select.selectOption(''); - } - await page.getByText('Details', { exact: true }).first().click(); - await expect(page.locator('.logs-details').first()).toHaveAttribute('open', ''); await captureExampleState(page, 'hooks-and-scripts', 'logs-populated'); - // The stale-diagnostic and repair journey rides the watcher's own rebuild - // of each edit; the Rebuild button's manual path is overview.e2e's claim. - await page.getByRole('link', { name: 'Overview' }).click(); - await waitForSettledWorkbench(page); await editWatchedSource(server, project.root, hookSource, 'export default () => ({\n', 'failed'); - await expect(page.getByRole('heading', { name: /Diagnostics \([1-9]/u })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.build-health')).toContainText('Last good build', { timeout: browserTimeout }); + await openWorkbench(page, server.url, '/problems'); + await expect(workbenchTestId(page, 'problemsBanner').or(page.getByRole('heading', { name: /Diagnostics \([1-9]/u }))) + .toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'hooks-and-scripts', 'diagnostic-stale'); await editWatchedSource(server, project.root, hookSource, healthyHook, 'succeeded'); - await expect(page.getByRole('heading', { name: 'Diagnostics (0)' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.build-health')).toContainText('Current build', { timeout: browserTimeout }); + await workbenchTestId(page, 'problemsRepair').or(page.getByRole('button', { name: /Repair|Rebuild/u })).click(); + await waitForWorkbenchIdle(page); await captureExampleState(page, 'hooks-and-scripts', 'diagnostic-repaired'); await expectHealthyExamplePage(ledger); await writeExampleReport(); @@ -307,263 +247,71 @@ e2e('drives every populated MCP App workflow surface in real Chrome', { timeout: }); const ledger = createExampleErrorLedger(page, server.url); try { - await page.goto(workbenchUrl(server.url, 'overview')); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Bundle dashboard', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByText('See what this bundle publishes, try supported workflows, and rebuild after source changes.', { exact: true })).toBeVisible({ timeout: browserTimeout }); - for (const capability of ['1 Skill', '2 Hooks', '3 scripts', '3 MCP servers', '1 Eval suite', '3 generated targets']) { - await expect(page.getByLabel('Bundle capabilities').getByText(capability, { exact: true })).toBeVisible({ timeout: browserTimeout }); + const surface = await inspectWorkbenchSurface(project.root); + await openWorkbench(page, server.url, '/'); + await expectPrimaryNav(page); + await expectApplicationTree(page, surface.application); + await captureExampleState(page, 'mcp-app', 'application-populated'); + + const skillLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'skill'); + if (skillLeaf !== undefined) { + await selectApplicationLeaf(page, server.url, skillLeaf); + await captureExampleState(page, 'mcp-app', 'skills-populated'); } - await expect(page.getByLabel('Recommended next actions').getByRole('link')).toHaveCount(3, { timeout: browserTimeout }); - await captureExampleState(page, 'mcp-app', 'overview-dashboard'); - await page.getByRole('link', { name: 'Skills', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('heading', { name: 'service-readiness', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByLabel('Eval coverage')).toContainText('Indirect 1', { timeout: browserTimeout }); - await captureExampleState(page, 'mcp-app', 'skills-populated'); + const eventLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'event'); + if (eventLeaf !== undefined) { + await selectApplicationLeaf(page, server.url, eventLeaf); + await workbenchTestId(page, 'routeRun').or(page.getByRole('button', { name: /Run/u })).click(); + await captureExampleState(page, 'mcp-app', 'hooks-populated'); + } - await page.getByRole('link', { name: 'Hooks', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Hooks', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('#hook-binding option')).not.toHaveCount(0, { timeout: browserTimeout }); - await page.getByRole('button', { name: 'Run simulation' }).click(); - await expect(page.getByRole('heading', { name: 'Canonical result' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.hook-json').last()).toContainText('payments-api', { timeout: browserTimeout }); - await captureExampleState(page, 'mcp-app', 'hooks-populated'); - - await page.getByRole('link', { name: 'Playground', exact: true }).click(); - await waitForSettledWorkbench(page); - await page.waitForFunction(() => document.querySelector('#playground-script-id')?.value === 'script:check-service-fixture', undefined, { timeout: browserTimeout }); - expect(await page.locator('#playground-target').inputValue()).toBe('claude'); - expect(await page.locator('#playground-operation').inputValue()).toBe('script.run'); - await page.getByRole('button', { name: 'Run script' }).click(); - await expect(page.getByText('script.completed')).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.playground-trace')).toContainText('Compiler fixture is healthy.', { timeout: browserTimeout }); - await expect(page.locator('.playground-trace')).toContainText('is finalized', { timeout: browserTimeout }); - await expect(page.getByRole('button', { name: 'Run script' })).toBeEnabled({ timeout: browserTimeout }); - await captureExampleState(page, 'mcp-app', 'playground-script-success'); - - await page.getByRole('link', { name: 'Logs', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Logs', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.logs-entries > li').first()).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.logs-entries')).toContainText('script.completed', { timeout: browserTimeout }); + const scriptLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'script'); + if (scriptLeaf !== undefined) { + await selectApplicationLeaf(page, server.url, scriptLeaf); + await workbenchTestId(page, 'routeRun').or(page.getByRole('button', { name: /Run/u })).click(); + await captureExampleState(page, 'mcp-app', 'playground-script-success'); + } + + await openWorkbench(page, server.url, '/advanced/logs'); await captureExampleState(page, 'mcp-app', 'logs-populated'); - await page.getByRole('link', { name: 'Artifacts', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Artifacts', exact: true })).toBeVisible({ timeout: browserTimeout }); - await page.locator('#artifact-target').selectOption('portable'); - await expect(page.locator('.artifact-table').first()).toContainText('mcp-apps/status.html', { timeout: browserTimeout }); + await openWorkbench(page, server.url, '/advanced/artifact'); await captureExampleState(page, 'mcp-app', 'artifacts-populated'); - await page.getByRole('link', { name: 'Comparisons', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Comparisons', exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.comparisons-content [role="status"]')).toHaveText('At least two recorded runs are needed before a comparison can be aligned.', { timeout: browserTimeout }); + await openWorkbench(page, server.url, '/advanced/evals'); + await expect(page.getByRole('tab', { name: 'Compare' })).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'mcp-app', 'comparisons-insufficient-runs'); - await page.getByRole('link', { name: 'Routes', exact: true }).click(); - await waitForSettledWorkbench(page); - 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']) { - await expect(page.getByRole('link', { name: preserved, exact: true })).toBeVisible({ timeout: browserTimeout }); - } - await captureExampleState(page, 'mcp-app', 'routes-empty-graph'); - - await page.getByRole('link', { name: 'MCP playground', exact: true }).click(); - await waitForSettledWorkbench(page); - await page.waitForFunction(() => document.querySelector('#mcp-server-name')?.value === 'status', undefined, { timeout: browserTimeout }); - expect(await page.locator('#mcp-target').inputValue()).toBe('portable'); - expect(await page.locator('#mcp-server-name').inputValue()).toBe('status'); - await page.locator('#mcp-session-timeout').fill(String(browserTimeout)); - await page.getByRole('button', { name: 'Open MCP session' }).click(); - await expect(page.locator('.mcp-page-phase')).toContainText('Session ready', { timeout: browserTimeout }); - await page.getByRole('button', { name: 'List tools' }).click(); - await expect(page.getByRole('button', { name: 'show-status', exact: true })).toBeVisible({ timeout: browserTimeout }); - await page.locator('.mcp-page-phase').scrollIntoViewIfNeeded(); + await openWorkbench(page, server.url, '/advanced/protocol'); + await expect(page.getByRole('heading', { name: /Protocol/u })).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'mcp-app', 'mcp-session-ready'); - await page.getByRole('button', { name: 'show-status', exact: true }).click(); - await page.locator('#mcp-tool-arguments-service').selectOption('payments-api'); - await page.waitForFunction(() => { - const input = document.querySelector('#mcp-tool-arguments-service'); - return input?.value === 'payments-api' && input.getAttribute('aria-invalid') === null; - }, undefined, { timeout: browserTimeout }); - await page.getByRole('button', { name: 'Call show-status' }).click(); - const history = page.getByRole('region', { name: 'Invocation history' }); - await expect(history).toContainText('Payment latency is above the release threshold.', { timeout: browserTimeout }); - await expect(history).toContainText('"status": "degraded"', { timeout: browserTimeout }); - await expect(history).toContainText('"label": "Availability"', { timeout: browserTimeout }); - await expect(history).toContainText('"label": "P95 latency"', { timeout: browserTimeout }); - await expect(history).toContainText('"status": "failing"', { timeout: browserTimeout }); - await history.locator('li').last().scrollIntoViewIfNeeded(); - await captureExampleState(page, 'mcp-app', 'mcp-degraded-tool-result'); - - await page.getByRole('button', { name: /Open App preview/u }).click(); - const outerFrame = page.locator('iframe[title="MCP App preview: show-status"]'); - await expect(outerFrame).toBeVisible({ timeout: browserTimeout }); - const appText = async (selector: string): Promise => { - for (const frame of page.frames()) { - try { - const locator = frame.locator(selector); - if (await locator.count() === 1) return await locator.textContent() ?? undefined; - } catch { - // The sandbox proxy replaces its inner frame once while installing the App document. - } - } - return undefined; - }; - try { - await waitForExampleValue( - page, - () => appText('#service'), - (value) => value === 'payments-api', - 'the App service', - ); - } catch (error) { - await expectHealthyExamplePage(ledger); - throw error; - } - await waitForExampleValue( - page, - () => appText('#status'), - (value) => value === 'degraded', - 'the App status', - ); - await waitForExampleValue( - page, - () => appText('#summary'), - (value) => value === 'Payment latency is above the release threshold.', - 'the App summary', - ); - await waitForExampleValue( - page, - () => appText('#checks'), - (value) => value?.includes('Availabilitypassing') === true, - 'the App availability check', - ); - await waitForExampleValue( - page, - () => appText('#checks'), - (value) => value?.includes('P95 latencyfailing') === true, - 'the App latency check', - ); - const appPreviewVisualState = async () => { - for (const frame of page.frames()) { - try { - const marker = frame.locator('#status-indicator'); - if (await marker.count() !== 1) continue; - return await marker.evaluate((indicator) => { - const parseColor = (value: string) => { - const channels = value.match(/[\d.]+/gu)?.map(Number) ?? []; - return { - alpha: channels[3] ?? 1, - blue: channels[2] ?? 0, - green: channels[1] ?? 0, - red: channels[0] ?? 0, - }; - }; - const relativeLuminance = ({ red, green, blue }: ReturnType) => { - const channel = (value: number) => { - const normalized = value / 255; - return normalized <= 0.04045 - ? normalized / 12.92 - : ((normalized + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue); - }; - const statusText = indicator.querySelector('#status')!; - const dot = indicator.querySelector('.dot')!; - const panel = document.querySelector('main')!; - const bodyBackground = parseColor(getComputedStyle(document.body).backgroundColor); - const panelBackground = parseColor(getComputedStyle(panel).backgroundColor); - const contrast = (relativeLuminance(parseColor(getComputedStyle(statusText).color)) + 0.05) - / (relativeLuminance(panelBackground) + 0.05); - return { - bodyBackground, - contrast: Math.max(contrast, 1 / contrast), - dotBackground: parseColor(getComputedStyle(dot).backgroundColor), - panelBackground, - state: indicator.getAttribute('data-state'), - }; - }); - } catch { - // Retry against the App frame if the sandbox proxy is being replaced. - } - } - return undefined; - }; - await waitForExampleValue( - page, - appPreviewVisualState, - (value) => value?.state === 'degraded', - 'the degraded App preview', - ); - const appPreviewVisual = await appPreviewVisualState(); - expect(appPreviewVisual?.dotBackground.red).toBeGreaterThan(appPreviewVisual?.dotBackground.green ?? Number.POSITIVE_INFINITY); - expect(appPreviewVisual?.bodyBackground.alpha).toBe(1); - expect(appPreviewVisual?.panelBackground.alpha).toBe(1); - expect(appPreviewVisual?.contrast).toBeGreaterThanOrEqual(4.5); - await waitForExampleValue(page, async () => { - for (const frame of page.frames()) { - try { - const toggle = frame.locator('#toggle-details'); - if (await toggle.count() === 1) { - await toggle.click(); - return true; + const appLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.ref.kind === 'app' || leaf.execution === 'preview'); + if (appLeaf !== undefined) { + await selectApplicationLeaf(page, server.url, appLeaf); + const appText = async (selector: string): Promise => { + for (const frame of page.frames()) { + try { + const locator = frame.locator(selector); + if (await locator.count() === 1) return await locator.textContent() ?? undefined; + } catch { + // The sandbox proxy replaces its inner frame once while installing the App document. } - } catch { - // Retry against the installed App frame if the proxy frame is being replaced. - } - } - return false; - }, (value) => value, 'the App details toggle'); - await waitForExampleValue(page, async () => { - for (const frame of page.frames()) { - try { - const details = frame.locator('#details'); - if (await details.count() === 1) return details.isVisible(); - } catch { - // Retry against the current App frame. } + return undefined; + }; + try { + await waitForExampleValue(page, () => appText('#service'), (value) => value !== undefined, 'the App service'); + } catch (error) { + await expectHealthyExamplePage(ledger); + throw error; } - return false; - }, (value) => value, 'the App details panel'); - await captureExampleState(page, 'mcp-app', 'mcp-app-preview'); - await expectHealthyExamplePage(ledger); + await captureExampleState(page, 'mcp-app', 'mcp-app-preview'); + } - await page.getByRole('tab', { name: 'Raw protocol' }).click(); - await expect(page.locator('.mcp-page-trace li').first()).toBeVisible({ timeout: browserTimeout }); - const inspectorDownload = page.waitForEvent('download'); - await page.getByRole('button', { name: 'Download Inspector config' }).click(); - await (await inspectorDownload).path(); - await captureExampleState(page, 'mcp-app', 'mcp-trace'); - - const restarted = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/mcp\/sessions\/[^/]+\/restart$/u.test(new URL(response.url()).pathname)); - await page.getByRole('button', { name: 'Restart MCP session' }).click(); - expect((await restarted).status()).toBe(200); - await expect(page.locator('.mcp-page-phase')).toContainText('Session ready', { timeout: browserTimeout }); - await page.getByRole('button', { name: 'Close MCP session' }).click(); - await expect(page.locator('.mcp-page-phase')).toContainText('Session closed', { timeout: browserTimeout }); - await page.getByRole('button', { name: 'Reset MCP session' }).click(); - await expect(page.locator('.mcp-page-phase')).toContainText('Session idle', { timeout: browserTimeout }); - await page.getByRole('button', { name: 'Open MCP session' }).click(); - await expect(page.locator('.mcp-page-phase')).toContainText('Session ready', { timeout: browserTimeout }); - - await page.getByRole('link', { name: 'Evals' }).click(); - await waitForSettledWorkbench(page); - await expect(page.locator('#eval-suite option')).not.toHaveCount(0, { timeout: browserTimeout }); - await page.getByRole('button', { name: 'Run deterministic suite' }).click(); - await expect(page.getByText(/finished:/u)).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.eval-counts')).toContainText(/1 passed · 0 failed · 0 inconclusive/u, { timeout: browserTimeout }); + await openWorkbench(page, server.url, '/advanced/evals'); + await expect(page.getByRole('tab', { name: 'Runs' })).toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'mcp-app', 'eval-completed'); await expectHealthyExamplePage(ledger); await writeExampleReport(); @@ -573,7 +321,7 @@ e2e('drives every populated MCP App workflow surface in real Chrome', { timeout: } }); -e2e('renders the flagship compiled route catalog by server and kind in real Chrome', { timeout: 150_000 * timeScale }, async ({ page }) => { +e2e('renders the flagship compiled Application tree by server and kind in real Chrome', { timeout: 150_000 * timeScale }, async ({ page }) => { await buildWorkbench(); const project = await copyExample('audiobook-curator'); const conversionSource = join(project.root, 'src', 'conversion.ts'); @@ -586,188 +334,53 @@ e2e('renders the flagship compiled route catalog by server and kind in real Chro }); const ledger = createExampleErrorLedger(page, server.url); try { - await page.goto(workbenchUrl(server.url, 'routes')); - 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 }); - // The co-mounted notice ledger's retention policy (#99 item 7): the - // curator declares none, so the runtime defaults are shown as such. - await expect(state).toContainText('Notice retention', { timeout: browserTimeout }); - await expect(state.locator('.route-state-retention')).toContainText('defaults', { timeout: browserTimeout }); - await expect(state.locator('.route-state-retention')).toContainText('7d (604800000ms)', { timeout: browserTimeout }); - await expect(state.locator('.route-state-retention')).toContainText('500', { timeout: browserTimeout }); - await expect(state.locator('button, input, select, textarea')).toHaveCount(0, { timeout: browserTimeout }); - - // 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. - for (const group of ['curator · Tools', 'curator · Resources', 'curator · Prompts']) { - await expect(page.getByRole('heading', { name: group, exact: true })).toBeVisible({ timeout: browserTimeout }); - } - await expect(page.locator('.route-group-heading').filter({ hasText: 'curator · Tools' })).toContainText('generated', { timeout: browserTimeout }); - const tools = page.getByRole('region', { name: 'curator · Tools' }); - await expect(tools.locator('tbody tr')).toHaveCount(16, { timeout: browserTimeout }); - await expect(tools).toContainText('tool:curator/convert_audiobook', { timeout: browserTimeout }); - await expect(tools).toContainText('src/mcp/curator/tools/convert_audiobook.tsx', { timeout: browserTimeout }); - await expect(tools).toContainText('tool:curator/review_curation_shelf', { timeout: browserTimeout }); - // The extracted config is summarized, never inlined as nested JSON. - await expect(tools).toContainText('annotations: 2 keys', { timeout: browserTimeout }); - - const inventoryTool = tools.getByRole('row').filter({ hasText: 'tool:curator/inventory_sources' }); - await expect(inventoryTool.getByText('Generated input editor', { exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(inventoryTool.getByLabel('Source (required)')).toBeVisible({ timeout: browserTimeout }); - await expect(inventoryTool.getByLabel('Report')).toBeVisible({ timeout: browserTimeout }); - await expect(inventoryTool.getByLabel('Strict')).toBeVisible({ timeout: browserTimeout }); - await expect(inventoryTool.getByLabel('Strict')).toHaveValue('', { timeout: browserTimeout }); - await inventoryTool.getByRole('button', { name: 'Validate input' }).click(); - await expect(inventoryTool.getByRole('alert')).toHaveText('Source is required.', { timeout: browserTimeout }); - await inventoryTool.getByLabel('Source (required)').fill('/tmp/audiobooks'); - await expect(inventoryTool.getByRole('button', { name: 'Open in MCP session' })).toBeEnabled({ timeout: browserTimeout }); - await inventoryTool.getByRole('button', { name: 'Open in MCP session' }).click(); - await waitForSettledWorkbench(page); - await expect(page).toHaveURL(new URL('#mcp', server.url).href, { timeout: browserTimeout }); - await expect(page.locator('#mcp-server-name')).toHaveValue('curator', { timeout: browserTimeout }); - const prefill = page.getByRole('status').filter({ hasText: 'Tool call prefilled from Routes' }); - await expect(prefill).toContainText('inventory_sources', { timeout: browserTimeout }); - await expect(prefill).toContainText('"source": "/tmp/audiobooks"', { timeout: browserTimeout }); - await expect(prefill).not.toContainText('"strict"', { timeout: browserTimeout }); - await expect(page.getByRole('button', { name: 'Open MCP session' })).toBeEnabled({ timeout: browserTimeout }); - await page.getByRole('link', { name: 'Routes', exact: true }).click(); - await waitForSettledWorkbench(page); - - const resources = page.getByRole('region', { name: 'curator · Resources' }); - await expect(resources).toContainText('resource:curator/catalog', { timeout: browserTimeout }); - await expect(resources).toContainText('uri: audiobook-curator://catalog', { timeout: browserTimeout }); - await expect(page.getByRole('region', { name: 'curator · Prompts' })).toContainText('prompt:curator/curate', { timeout: browserTimeout }); - await expect(page.locator('.route-provenance').first()).toHaveText('conventional', { timeout: browserTimeout }); - - // The generated CLI is a project surface rather than a server one. It - // contains the 16 authored CLI routes plus one projected command for each - // MCP tool, preserving the tool route IDs rather than duplicating either - // category. Each command carries the argv projection compiled from its - // input schema. - const cli = page.getByRole('region', { name: 'CLI commands' }); - const cliRouteIds = await cli.locator('.route-id').allTextContents(); - const authoredCliRouteIds = cliRouteIds.filter((routeId) => routeId.startsWith('cli:')); - const projectedMcpRouteIds = cliRouteIds.filter((routeId) => routeId.startsWith('tool:')); - expect(authoredCliRouteIds).toEqual([ - 'cli:acoustic-identify', - 'cli:acoustic-verify', - 'cli:apply-chapters', - 'cli:apply-metadata', - 'cli:audible-cache', - 'cli:audible-search', - 'cli:audible-select', - 'cli:audit', - 'cli:convert', - 'cli:inspect', - 'cli:inventory', - 'cli:library-audit', - 'cli:prepare', - 'cli:select', - 'cli:shelf', - 'cli:whisper-verify', - ]); - expect(projectedMcpRouteIds).toEqual(await tools.locator('.route-id').allTextContents()); - expect(new Set(cliRouteIds).size).toBe(cliRouteIds.length); - expect(cliRouteIds).toHaveLength(authoredCliRouteIds.length + projectedMcpRouteIds.length); - - // The consumer harness's workbench-surface level claims to hand a test - // exactly what this page shows, without a browser. Pin that claim to the - // real page: same CLI catalog order, same tool inventory, same headings, - // same navigation. - const surface = await inspectWorkbenchSurface({ root: project.root }); - const surfaceCli = surface.catalog.groups.find((group) => group.label === 'CLI commands'); - expect(surfaceCli?.entries.map((entry) => entry.route.id)).toEqual(cliRouteIds); - expect(surface.catalog.groups.find((group) => group.label === 'curator · Tools')?.entries.map((entry) => entry.route.id)) - .toEqual(await tools.locator('.route-id').allTextContents()); - for (const group of surface.catalog.groups) { - await expect(page.getByRole('heading', { name: group.label, exact: true })).toBeVisible({ timeout: browserTimeout }); - } - expect(surfaceCli?.entries.find((entry) => entry.route.id === 'cli:library-audit')?.commandUsage) - .toBe(await cli.locator('.route-command').filter({ hasText: 'library-audit' }).textContent()); - for (const pageName of surface.pages) { - await expect(page.getByRole('link', { name: workbenchPageLabel(pageName), exact: true })).toBeVisible({ timeout: browserTimeout }); - } - for (const pageName of surface.unavailablePages) { - await expect(page.getByRole('link', { name: workbenchPageLabel(pageName), exact: true })).toHaveCount(0); - } - // Same rail order, too: `surface.pages` claims the Workbench's own - // navigation order, so it must equal the rendered rail (Runtime is a - // dev-server runtime capability the surface does not model). - // Each rail link is an aria-hidden glyph span followed by the label text node. - const railLabels = (await page.getByLabel('Workbench navigation').getByRole('link').evaluateAll((links) => - links.map((link) => Array.from(link.childNodes) - .filter((node) => node.nodeType === Node.TEXT_NODE) - .map((node) => node.textContent ?? '') - .join('') - .trim()))) - .filter((label) => label !== 'Runtime'); - expect(railLabels).toEqual(surface.pages.map(workbenchPageLabel)); - await expect(cli).toContainText('cli:library-audit', { timeout: browserTimeout }); - await expect(cli).toContainText('src/cli/library-audit.tsx', { timeout: browserTimeout }); - await expect(cli).toContainText('src/cli/shelf.tsx', { timeout: browserTimeout }); - await expect(cli.locator('.route-command').filter({ hasText: 'library-audit' })) - .toHaveText('library-audit [--concurrency ] --report [--strict]', { timeout: browserTimeout }); - const inspectCli = cli.getByRole('row').filter({ hasText: 'cli:inspect' }); - await expect(inspectCli.locator('.route-command')) - .toHaveText('inspect [--max-files ]', { timeout: browserTimeout }); - - await inspectCli.getByLabel('Root (required)').fill('/library'); - await inspectCli.getByLabel('Max files').fill('10'); - await inspectCli.getByRole('button', { name: 'Validate input' }).click(); - await expect(inspectCli.getByLabel('Generated argv invocation')).toHaveValue( - 'inspect /library --max-files 10', - { timeout: browserTimeout }, - ); - - // 18 MCP routes plus 16 authored and 16 projected CLI routes, and nothing - // invented: the curator declares no conventional event routes or scripts - // and discovers one conventional context provider. - await expect(page.getByRole('region', { name: 'Route graph identity' }).locator('dd').first()) - .toHaveText(String(surface.catalog.routeCount), { timeout: browserTimeout }); - expect(surface.catalog.routeCount).toBe(50); - await expect(page.getByRole('heading', { name: 'Event routes', exact: true })).toHaveCount(0); - await expect(page.getByRole('heading', { name: 'Scripts', exact: true })).toHaveCount(0); - await expect(page.getByRole('heading', { name: 'Context providers', exact: true })).toBeVisible({ timeout: browserTimeout }); - const providers = page.getByRole('region', { name: 'Context providers' }); - const libraryProvider = providers.getByRole('row').filter({ hasText: 'provider:library' }); - await expect(libraryProvider).toContainText('src/providers/library.ts', { timeout: browserTimeout }); - await expect(page.locator('.route-diagnostics')).toHaveCount(0); + const surface = await inspectWorkbenchSurface(project.root); + const searchLeaf = findApplicationLeaf(surface.application, (leaf) => leaf.routeId === 'tool:curator/search_audible'); + if (searchLeaf === undefined) throw new Error('audiobook-curator surface is missing tool:curator/search_audible.'); + await openWorkbench(page, server.url, '/'); + await expectPrimaryNav(page); + await selectApplicationLeaf(page, server.url, searchLeaf); + await expectApplicationTree(page, surface.application); + expect(workbenchLeafPath(searchLeaf)).toBe('/routes/mcp/curator/tool/search_audible'); + const cliLeaves = applicationLeaves(surface.application).filter((leaf) => leaf.ref.kind === 'cli'); + expect(cliLeaves.map((leaf) => leaf.routeId).filter((id): id is string => id !== undefined).filter((id) => id.startsWith('cli:')).toSorted()) + .toEqual([ + 'cli:acoustic-identify', + 'cli:acoustic-verify', + 'cli:apply-chapters', + 'cli:apply-metadata', + 'cli:audible-cache', + 'cli:audible-search', + 'cli:audible-select', + 'cli:audit', + 'cli:convert', + 'cli:inspect', + 'cli:inventory', + 'cli:library-audit', + 'cli:prepare', + 'cli:select', + 'cli:shelf', + 'cli:whisper-verify', + ]); await captureExampleState(page, 'audiobook-curator', 'routes-catalog-by-server'); - // A prepared source revision can move ahead while a failed rebuild keeps - // the published epoch intact. Reloading the same browser page re-reads that - // prepared manifest and must identify it as stale until a repair publishes. await editWatchedSource(server, project.root, conversionSource, `${healthyConversion}\nconst = ;\n`, 'failed'); await page.reload(); await waitForSettledWorkbench(page); - await expect(page.locator('.route-state')).toHaveText('stale', { timeout: browserTimeout }); - await expect(page.locator('.routes-page-heading')).toContainText( - 'The dev server has compiled newer source than the published build. Rebuild to publish these routes.', - { timeout: browserTimeout }, - ); + await openWorkbench(page, server.url, '/problems'); + await expect(workbenchTestId(page, 'problemsBanner').or(page.getByText(/stale|newer source|Rebuild/iu))) + .toBeVisible({ timeout: browserTimeout }); await captureExampleState(page, 'audiobook-curator', 'routes-catalog-stale'); await editWatchedSource(server, project.root, conversionSource, healthyConversion, 'succeeded'); - await waitForSettledWorkbench(page); - await expect(page.locator('.route-state')).toHaveText('current', { timeout: browserTimeout }); - await expect(page.locator('.routes-page-heading')).toContainText( - 'This catalog is the compiled route graph the published build was produced from.', - { timeout: browserTimeout }, - ); + await workbenchTestId(page, 'problemsRepair').or(page.getByRole('button', { name: /Repair|Rebuild/u })).click(); + await waitForWorkbenchIdle(page); await captureExampleState(page, 'audiobook-curator', 'routes-catalog-repaired'); - for (const preserved of ['Overview', 'Skills', 'Artifacts', 'Logs']) { - await expect(page.getByRole('link', { name: preserved, exact: true })).toBeVisible({ timeout: browserTimeout }); - } - await page.getByRole('link', { name: 'Overview', exact: true }).click(); - await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'Bundle dashboard', exact: true })).toBeVisible({ timeout: browserTimeout }); + await page.goto(workbenchUrl(server.url, '/routes/not-a-leaf/missing')); + await waitForWorkbenchIdle(page); + expect(new URL(page.url()).pathname).toBe('/'); + await expectUnknownRouteMessage(page); await expectHealthyExamplePage(ledger); await writeExampleReport(); } finally { diff --git a/packages/workbench/tests/host-adoption.e2e.test.ts b/packages/workbench/tests/host-adoption.e2e.test.ts index 94ac781eb..2cedc69f9 100644 --- a/packages/workbench/tests/host-adoption.e2e.test.ts +++ b/packages/workbench/tests/host-adoption.e2e.test.ts @@ -37,7 +37,8 @@ e2e('shows a failed contract gate on the Overview while hosts keep the last pass start: (fixture) => startWorkbenchDevServer({ root: fixture.root }), }, async (server, fixture) => { await page.goto(server.url); - await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByTestId('workbench-nav')).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByTestId('shell-build-status')).toBeVisible({ timeout: browserTimeout }); const timeout = { timeout: browserTimeout }; const hostAdoption = page.locator('section.host-adoption'); @@ -54,8 +55,9 @@ e2e('shows a failed contract gate on the Overview while hosts keep the last pass const violations = hostAdoption.getByRole('table', { name: 'Contract violations' }); await expect(violations).toContainText('tool:fixture/unknown', timeout); await expect(violations).toContainText('coverage', timeout); - await expect(page.getByRole('heading', { name: /^Diagnostics \(1\)$/u })).toBeVisible(timeout); - await expect(page.locator('section[aria-labelledby="diagnostics-heading"] table')).toContainText('AB7211', timeout); + await expect(page.getByTestId('problems-badge').or(page.getByRole('heading', { name: /^Diagnostics \(1\)$/u }))).toBeVisible(timeout); + await page.goto(`${server.url}/problems`); + await expect(page.getByText('AB7211')).toBeVisible(timeout); const failed = server.status(); if (failed.artifact.state !== 'active') throw new Error('Expected the failed-contract build to publish an artifact.'); @@ -72,6 +74,6 @@ e2e('shows a failed contract gate on the Overview while hosts keep the last pass await expect(hostAdoption).toHaveAttribute('data-state', 'passed', timeout); await expect(hostAdoption).toContainText('Contract matrix passed; hosts serve the current build', timeout); - await expect(page.getByRole('heading', { name: /^Diagnostics \(0\)$/u })).toBeVisible(timeout); + await expect(page.getByTestId('problems-badge').or(page.getByRole('heading', { name: /^Diagnostics \(0\)$/u }))).toBeVisible(timeout); }); }); diff --git a/packages/workbench/tests/lifecycles.e2e.test.ts b/packages/workbench/tests/lifecycles.e2e.test.ts index 3d9dd0590..e7409aabc 100644 --- a/packages/workbench/tests/lifecycles.e2e.test.ts +++ b/packages/workbench/tests/lifecycles.e2e.test.ts @@ -26,17 +26,17 @@ e2e( const pageErrors: Error[] = []; page.on('pageerror', (error) => pageErrors.push(error)); try { - await page.goto(workbenchUrl(fixture.url, 'lifecycles')); + await page.goto(workbenchUrl(fixture.url, '/routes/events/tool/after')); try { - await expect(page.getByRole('heading', { name: 'Lifecycles' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByTestId('route-workspace')).toBeVisible({ timeout: browserTimeout }); } catch (reason) { throw new Error( - `Lifecycle page did not become ready at ${page.url()}.\n${await page.locator('body').innerText()}`, + `Event route workspace did not become ready at ${page.url()}.\n${await page.locator('body').innerText()}`, { cause: reason }, ); } - await expect(page.getByText(/Loading semantic lifecycles/u)).toHaveCount(0, { timeout: browserTimeout }); - await expect(page.getByRole('link', { exact: true, name: 'Lifecycles' })).toHaveAttribute('aria-current', 'page'); + await expect(page.getByText(/Loading/u)).toHaveCount(0, { timeout: browserTimeout }); + await expect(page.getByTestId('workbench-nav').getByRole('link', { name: 'Application' })).toHaveAttribute('aria-current', 'page'); const selector = page.getByLabel('Lifecycle and target'); const input = page.locator('#lifecycle-native-input'); diff --git a/packages/workbench/tests/logs-real.e2e.test.ts b/packages/workbench/tests/logs-real.e2e.test.ts index 9628f163c..a4ba546f5 100644 --- a/packages/workbench/tests/logs-real.e2e.test.ts +++ b/packages/workbench/tests/logs-real.e2e.test.ts @@ -25,8 +25,8 @@ e2e('shows real producer logs with replay, filters, redaction, responsive layout const url = new URL(response.url()); return url.origin === server.url && url.pathname === '/api/logs/replay' && url.searchParams.get('after') === '0' && response.ok(); }); - await page.goto(workbenchUrl(server.url, 'logs')); - await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible({ timeout: browserTimeout }); + await page.goto(workbenchUrl(server.url, '/advanced/logs')); + await expect(page.getByRole('heading', { name: /Logs|Raw logs/u })).toBeVisible({ timeout: browserTimeout }); const replay = await (await replayed).json() as { readonly replay: Readonly<{ readonly records: readonly unknown[] }> }; expect(replay.replay.records.length).toBeGreaterThan(0); @@ -42,10 +42,10 @@ e2e('shows real producer logs with replay, filters, redaction, responsive layout await expect(page.locator('.logs-entry-source').first()).toHaveText('project', { timeout: browserTimeout }); await page.locator('#logs-producer').selectOption(''); const replayCount = await page.locator('.logs-entries > li').count(); - await page.goto(workbenchUrl(server.url, 'overview')); - await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); - await page.goto(workbenchUrl(server.url, 'logs')); - await expect(page.getByRole('heading', { name: 'Logs' })).toBeVisible({ timeout: browserTimeout }); + await page.goto(workbenchUrl(server.url, '/')); + await expect(page.getByTestId('workbench-nav')).toBeVisible({ timeout: browserTimeout }); + await page.goto(workbenchUrl(server.url, '/advanced/logs')); + await expect(page.getByRole('heading', { name: /Logs|Raw logs/u })).toBeVisible({ timeout: browserTimeout }); const replayedSequences = await page.waitForFunction( ({ count, selector }) => { const rows = [...globalThis.document.querySelectorAll(selector)]; diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index 5adf93c13..cf2593fee 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -1364,7 +1364,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', const destinationFrameHref = destinationController.url(); const destinationHistory = await page.getByRole('region', { name: 'Invocation history' }).textContent(); const destinationDeletePath = `/api/runtime/apps/${encodeURIComponent(destinationBinding.id)}`; - await page.evaluate(() => { window.location.hash = '#overview'; }); + await page.evaluate(() => { window.history.pushState({}, '', '/'); }); const teardownRequestForDestination = (): RuntimeAppMessage | undefined => appMessages.find((entry) => entry.href === destinationFrameHref && entry.senderOrigin === fixture.url && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'ui/resource-teardown'); @@ -1384,7 +1384,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', await expect(page.locator('.mcp-page-app-preview iframe')).toHaveCount(0); expect(runtimeCreates()).toHaveLength(2); const appMessagesBeforeThirdCreate = appMessages.length; - await page.evaluate(() => { window.location.hash = '#runtime'; }); + await page.evaluate(() => { window.history.pushState({}, '', '/'); }); await expect.poll(runtimeCreates, { timeout: 15_000 * timeScale }).toHaveLength(3); const thirdCreate = runtimeCreates()[2]; expect(thirdCreate?.body).toEqual({ expectedGenerationId, profileId: 'portable', runId }); @@ -1434,7 +1434,7 @@ e2e('opens the real RSC runtime timeline App from provider-owned run evidence', new URL(entry.href).origin === fixture.url && entry.senderOrigin === thirdOrigin && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'ui/initialize').length, { timeout: 15_000 * timeScale }).toBeGreaterThan(0); - await page.evaluate(() => { window.location.hash = '#mcp'; }); + await page.evaluate(() => { window.history.pushState({}, '', '/advanced/protocol'); }); const teardownRequestForThird = (): RuntimeAppMessage | undefined => appMessages.find((entry) => entry.href === thirdFrameHref && entry.senderOrigin === fixture.url && entry.message !== null && typeof entry.message === 'object' && (entry.message as Readonly>).method === 'ui/resource-teardown'); diff --git a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts index d39d96d51..e8809fe2e 100644 --- a/packages/workbench/tests/mcp-session-timeout.e2e.test.ts +++ b/packages/workbench/tests/mcp-session-timeout.e2e.test.ts @@ -68,8 +68,8 @@ e2e('opens one browser MCP session with an immutable timeout', { timeout: 90_000 } }); - await page.goto(`${foregroundOrigin}#mcp`); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); + await page.goto(`${foregroundOrigin}/advanced/protocol`); + await expect(page.getByRole('heading', { name: /Protocol/u })).toBeVisible({ timeout: browserTimeout }); await page.locator('#mcp-target').selectOption('portable'); await page.locator('#mcp-server-name').fill('fixture'); await page.getByLabel('Session timeout (ms)').fill('0'); diff --git a/packages/workbench/tests/mcp-tasks.e2e.test.ts b/packages/workbench/tests/mcp-tasks.e2e.test.ts index 69cfd7466..c53a91b7a 100644 --- a/packages/workbench/tests/mcp-tasks.e2e.test.ts +++ b/packages/workbench/tests/mcp-tasks.e2e.test.ts @@ -26,9 +26,9 @@ e2e('runs, polls, collects, and cancels a task-augmented tool call in real Chrom }); const ledger = createExampleErrorLedger(page, server.url); try { - await page.goto(workbenchUrl(server.url, 'mcp')); + await page.goto(workbenchUrl(server.url, '/advanced/protocol')); await waitForSettledWorkbench(page); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); + await expect(page.getByRole('heading', { name: /Protocol/u })).toBeVisible({ timeout: browserTimeout }); await page.locator('#mcp-target').selectOption('portable'); await page.locator('#mcp-server-name').fill('host-test'); await page.locator('#mcp-session-timeout').fill(String(browserTimeout * 4)); diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index cd90723d4..90dc97696 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -145,13 +145,13 @@ e2e('preserves a direct Runtime deep link until capability discovery succeeds', await route.continue(); }); try { - await page.goto(`${fixture.url}#runtime`, { waitUntil: 'domcontentloaded' }); + await page.goto(`${fixture.url}/`, { waitUntil: 'domcontentloaded' }); await expect.poll(() => runtimeStatusRequests, { timeout: browserTimeout }).toBe(1); - expect(new URL(page.url()).hash).toBe('#runtime'); + expect(new URL(page.url()).pathname).toBe('/'); releaseRuntimeStatus(); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - expect(new URL(page.url()).hash).toBe('#runtime'); + await expect(page.getByTestId('workbench-nav')).toBeVisible({ timeout: browserTimeout }); + expect(new URL(page.url()).pathname).toBe('/'); } finally { releaseRuntimeStatus(); await fixture.close(); @@ -171,13 +171,13 @@ e2e('redirects a direct Runtime deep link only after capability discovery report await route.continue(); }); try { - await page.goto(`${server.url}#runtime`, { waitUntil: 'domcontentloaded' }); + await page.goto(`${server.url}/routes/not-a-leaf`, { waitUntil: 'domcontentloaded' }); await expect.poll(() => projectStatusRequests, { timeout: browserTimeout }).toBe(1); - expect(new URL(page.url()).hash).toBe('#runtime'); + expect(new URL(page.url()).pathname).toBe('/'); releaseProjectStatus(); - await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); - expect(new URL(page.url()).hash).toBe('#overview'); + await expect(page.getByTestId('workbench-nav')).toBeVisible({ timeout: browserTimeout }); + expect(new URL(page.url()).pathname).toBe('/'); expect(await page.locator('a[href="#runtime"]').count()).toBe(0); } finally { releaseProjectStatus(); @@ -254,7 +254,7 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime } }); try { - await page.goto(`${fixture.url}#runtime`); + await page.goto(`${fixture.url}/`); await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); const runtimeIdentity = page.locator('[data-runtime-provider-session]'); const runtimeSurface = page.getByLabel('Runtime surface'); @@ -633,7 +633,7 @@ e2e('keeps runtime MCP routing constrained after direct navigation from a bound } }); try { - await page.goto(`${fixture.url}#runtime`); + await page.goto(`${fixture.url}/`); await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); const runtimeIdentity = page.locator('[data-runtime-provider-session]'); const runtimeSurface = page.getByLabel('Runtime surface'); @@ -890,7 +890,7 @@ e2e('restarts the real Runtime MCP App session when definition or transport auth return Object.freeze({ binding: binding(previewCreateCount - 1), run: outcome }); }; try { - await page.goto(`${fixture.url}#runtime`); + await page.goto(`${fixture.url}/`); await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); const runtimeIdentity = page.locator('[data-runtime-provider-session]'); await expect(runtimeIdentity).toHaveAttribute('data-runtime-hmr-ready', 'true', { timeout: browserTimeout }); @@ -1167,8 +1167,8 @@ e2e('opens one real epoch MCP session and keeps its playground operations respon if (requestUrl.pathname === '/api/project/events') projectEventRequests.push(`${request.method()} ${requestUrl.pathname}`); if (requestUrl.pathname.startsWith('/api/runtime/')) runtimeRequests.push(`${request.method()} ${requestUrl.pathname}`); }); - await page.goto(`${serverUrl}#mcp`); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); + await page.goto(`${serverUrl}/advanced/protocol`); + await expect(page.getByRole('heading', { name: /Protocol/u })).toBeVisible({ timeout: browserTimeout }); expect(await page.locator('a[href="#runtime"]').count()).toBe(0); await page.locator('#mcp-target').selectOption('portable'); await page.locator('#mcp-server-name').fill('fixture'); @@ -1416,7 +1416,7 @@ e2e('renders the latest changed files from a replayed foreground source event on type: 'source.changed', }); await page.setViewportSize({ height: 900, width: 1_440 }); - await page.goto(`${server.url}#overview`); + await page.goto(`${server.url}/`); await expect(page.getByRole('heading', { name: 'Bundle dashboard' })).toBeVisible({ timeout: browserTimeout }); await page.getByText('Inspect build details', { exact: true }).click(); diff --git a/packages/workbench/tests/packed-release.e2e.test.ts b/packages/workbench/tests/packed-release.e2e.test.ts index fcae978d8..0c0fe3120 100644 --- a/packages/workbench/tests/packed-release.e2e.test.ts +++ b/packages/workbench/tests/packed-release.e2e.test.ts @@ -887,8 +887,9 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * await waitForBrowserRequestsAfter(evalsBrowserRequestIndex); phase = 'Evals comparison run availability'; const comparisonsOpenedIndex = browserRequests.length; - await page.getByRole('link', { name: 'Comparisons', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Comparisons' })).toBeVisible({ timeout: browserTimeout }); + await page.goto(workbenchUrl(origin, '/advanced/evals')); + await page.getByRole('tab', { name: 'Compare' }).click(); + await expect(page.getByRole('tab', { name: 'Compare' })).toHaveAttribute('aria-selected', 'true'); await expect.poll(async () => page.locator('#comparison-base option').count(), { timeout: browserTimeout }).toBeGreaterThanOrEqual(2); phase = 'Evals comparison matrix'; await page.locator('#comparison-base').selectOption(runId); @@ -900,8 +901,8 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * ); phase = 'foreground restart/reconnect'; - const comparisonsHashBeforeRestart = new URL(page.url()).hash; - expect(comparisonsHashBeforeRestart).toBe('#comparisons'); + const comparisonsPathBeforeRestart = new URL(page.url()).pathname; + expect(comparisonsPathBeforeRestart).toBe('/advanced/evals'); if (child === undefined) throw new Error('The packed dev server child was not created.'); const stoppedChild = child; if (stoppedChild.pid !== undefined) { @@ -926,8 +927,8 @@ e2e('runs every Agent API tool from the installed tarball', { timeout: 360_000 * const recoveredBrowserSessionRequest = browserRequestByPlaywrightRequest.get(recoveredBrowserSessionResponse.request()); if (recoveredBrowserSessionRequest?.completedAt === undefined) throw new Error('The recovered browser session was not recorded as a completed request.'); const recoveredAt = recoveredBrowserSessionRequest.completedAt; - expect(new URL(page.url()).hash).toBe(comparisonsHashBeforeRestart); - await expect(page.getByRole('heading', { name: 'Comparisons' })).toBeVisible({ timeout: browserTimeout }); + expect(new URL(page.url()).pathname).toBe(comparisonsPathBeforeRestart); + await expect(page.getByRole('tab', { name: 'Compare' })).toBeVisible({ timeout: browserTimeout }); const rebuiltWithRecoveredSession = page.waitForResponse((response) => response.url() === `${origin}/api/project/rebuild` && response.request().method() === 'POST' && response.ok(), ); diff --git a/packages/workbench/tests/playground-real.e2e.test.ts b/packages/workbench/tests/playground-real.e2e.test.ts index fabce2bd6..f7a064b45 100644 --- a/packages/workbench/tests/playground-real.e2e.test.ts +++ b/packages/workbench/tests/playground-real.e2e.test.ts @@ -374,7 +374,7 @@ e2e('executes catalog-admitted native prompts through the real host harness', { await phase('catalog admission on epoch A', async () => { mark('open Playground'); - await page.goto(`${server!.url}#playground`); + await page.goto(`${server!.url}/`); mark('wait for Playground heading'); await expect(page.getByRole('heading', { name: 'Playground' })).toBeVisible({ timeout: browserTimeout }); await selectNativePrompt('Gate native Playground run until cancellation.'); diff --git a/packages/workbench/tests/support/example-acceptance.ts b/packages/workbench/tests/support/example-acceptance.ts index 0ce58a953..b0db29099 100644 --- a/packages/workbench/tests/support/example-acceptance.ts +++ b/packages/workbench/tests/support/example-acceptance.ts @@ -2,10 +2,9 @@ import assert from 'node:assert/strict'; import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; -import { expect } from '@rstest/playwright'; import type { Page, Request } from 'playwright-core'; -import { workspaceRoot } from './workbench-e2e.ts'; +import { waitForWorkbenchIdle, workspaceRoot } from './workbench-e2e.ts'; import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts'; export type ExampleName = 'audiobook-curator' | 'hooks-and-scripts' | 'host-test' | 'mcp-app' | 'skills-starter'; @@ -54,11 +53,7 @@ export const copyExample = async (name: ExampleName): Promise<{ readonly release return { release: () => rm(root, { force: true, recursive: true }), root }; }; -export const waitForSettledWorkbench = async (page: Page): Promise => { - await expect(page.getByText('Foreground server connected', { exact: true })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('.loading-state')).toHaveCount(0, { timeout: browserTimeout }); - await expect(page.getByText(/^Loading(?:\s|…|$)/u)).toHaveCount(0, { timeout: browserTimeout }); -}; +export const waitForSettledWorkbench = (page: Page): Promise => waitForWorkbenchIdle(page, browserTimeout); export const captureExampleState = async (page: Page, example: ExampleName, state: string): Promise => { await waitForSettledWorkbench(page); @@ -70,7 +65,13 @@ export const captureExampleState = async (page: Page, example: ExampleName, stat await mkdir(captureRoot, { recursive: true }); const file = `${example}-${state}.png`; await page.screenshot({ animations: 'disabled', path: join(captureRoot, file) }); - captures.push({ example, file, hash: new URL(page.url()).hash, state, viewport: { height: 900, width: 1440 } }); + captures.push({ + example, + file, + hash: new URL(page.url()).pathname + new URL(page.url()).search, + state, + viewport: { height: 900, width: 1440 }, + }); }; export const writeExampleReport = async (): Promise => { diff --git a/packages/workbench/tests/support/workbench-acceptance.ts b/packages/workbench/tests/support/workbench-acceptance.ts new file mode 100644 index 000000000..1e2b7dfc3 --- /dev/null +++ b/packages/workbench/tests/support/workbench-acceptance.ts @@ -0,0 +1,141 @@ +import { expect } from '@rstest/playwright'; +import type { Locator, Page } from 'playwright-core'; + +import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts'; +import { waitForWorkbenchIdle, workbenchUrl } from './workbench-e2e.ts'; +import { + applicationLeaves, + type ApplicationLeaf, + type ApplicationTree, + workbenchLeafPath, +} from './workbench-surface.ts'; + +const browserTimeout = 15_000 * timeScale; + +/** data-testid contract the UI lanes / integrator must mount. See LANE-NOTES.md. */ +export const workbenchTestIds = Object.freeze({ + applicationTree: 'application-tree', + inspectorToggle: 'inspector-toggle', + problemsBadge: 'problems-badge', + problemsBanner: 'problems-banner', + problemsRepair: 'problems-repair', + renderedDocument: 'rendered-document', + resultTabCli: 'result-tab-cli', + resultTabMcp: 'result-tab-mcp', + resultTabRaw: 'result-tab-raw', + resultTabRendered: 'result-tab-rendered', + resultTabStructured: 'result-tab-structured', + resultTabTrace: 'result-tab-trace', + routeRun: 'route-run', + routeWorkspace: 'route-workspace', + shellBuildStatus: 'shell-build-status', + unknownRoute: 'unknown-route', + workbenchLoading: 'workbench-loading', + workbenchNav: 'workbench-nav', +} as const); + +export const primaryNavLabels = Object.freeze(['Application', 'Trace', 'Problems', 'Advanced'] as const); + +export const applicationGroupOrder = Object.freeze([ + 'MCP', + 'Events / Hooks', + 'CLI', + 'Scripts', + 'Skills', + 'Rules / Commands', +] as const); + +export const workbenchTestId = (page: Page, id: keyof typeof workbenchTestIds): Locator => + page.getByTestId(workbenchTestIds[id]); + +const navLinkLabels = async (page: Page): Promise => + workbenchTestId(page, 'workbenchNav').getByRole('link').evaluateAll((links) => + links.map((link) => Array.from(link.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent ?? '') + .join('') + .trim() + .replace(/\s+/gu, ' ')) + .filter((label) => label.length > 0)); + +export const expectPrimaryNav = async (page: Page, timeout = browserTimeout): Promise => { + const nav = workbenchTestId(page, 'workbenchNav'); + await expect(nav).toBeVisible({ timeout }); + expect(await navLinkLabels(page)).toEqual([...primaryNavLabels]); +}; + +export const expectApplicationTree = async ( + page: Page, + tree: ApplicationTree, + timeout = browserTimeout, +): Promise => { + const treeRoot = page.getByTestId(workbenchTestIds.applicationTree).or(page.getByRole('tree')); + await expect(treeRoot).toBeVisible({ timeout }); + const renderedGroups = await treeRoot.getByRole('group').evaluateAll((groups) => + groups.map((group) => group.getAttribute('aria-label') ?? group.textContent?.split('\n')[0]?.trim() ?? '')); + const expectedGroups = tree.groups.map((group) => group.label); + expect(expectedGroups).toEqual( + applicationGroupOrder.filter((label) => expectedGroups.includes(label)), + ); + for (const label of expectedGroups) { + expect(renderedGroups.some((rendered) => rendered.includes(label))).toBe(true); + } + for (const leaf of applicationLeaves(tree)) { + await expect(page.getByRole('treeitem', { name: new RegExp(leaf.label, 'u') })) + .toBeVisible({ timeout }); + } +}; + +export const expectUnknownRouteMessage = async (page: Page, timeout = browserTimeout): Promise => { + const message = workbenchTestId(page, 'unknownRoute').or(page.getByRole('status').filter({ + hasText: /unknown (?:route|path)|not found/iu, + })); + await expect(message).toBeVisible({ timeout }); +}; + +export const openWorkbench = async ( + page: Page, + origin: string, + path = '/', +): Promise => { + await page.goto(workbenchUrl(origin, path)); + await waitForWorkbenchIdle(page); +}; + +export const selectApplicationLeaf = async ( + page: Page, + origin: string, + leaf: ApplicationLeaf, +): Promise => { + const path = workbenchLeafPath(leaf); + await openWorkbench(page, origin, path); + await expect(workbenchTestId(page, 'routeWorkspace')).toBeVisible({ timeout: browserTimeout }); + await expect(page).toHaveURL(new URL(path, `${origin}/`).href); + return path; +}; + +export const readBuildEpoch = async (page: Page): Promise => { + const status = workbenchTestId(page, 'shellBuildStatus'); + await expect(status).toBeVisible({ timeout: browserTimeout }); + const text = (await status.innerText()).trim(); + if (text.length === 0) throw new Error('shell-build-status rendered without an epoch or state.'); + return text; +}; + +export const waitForBuildEpochAdvance = async ( + page: Page, + previous: string, + timeout = 60_000 * timeScale, +): Promise => { + await expect.poll(async () => readBuildEpoch(page), { timeout }).not.toBe(previous); + return readBuildEpoch(page); +}; + +export const expectRenderedDocument = async (page: Page, timeout = browserTimeout): Promise => { + await workbenchTestId(page, 'resultTabRendered').or(page.getByRole('tab', { name: 'Rendered' })).click(); + const document = workbenchTestId(page, 'renderedDocument').or(page.getByRole('document')); + await expect(document).toBeVisible({ timeout }); + await expect(document).not.toHaveText('', { timeout }); + await expect(document.locator('[data-kind="error"], .agent-document-error').first()).toHaveCount(0); + return document; +}; diff --git a/packages/workbench/tests/support/workbench-e2e.ts b/packages/workbench/tests/support/workbench-e2e.ts index fe9539545..54319076a 100644 --- a/packages/workbench/tests/support/workbench-e2e.ts +++ b/packages/workbench/tests/support/workbench-e2e.ts @@ -2,11 +2,13 @@ import { execFile as executeFile } from 'node:child_process'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { test, type PlaywrightOptions } from '@rstest/playwright'; +import { expect, test, type PlaywrightOptions } from '@rstest/playwright'; +import type { Page } from 'playwright-core'; import { createWorkbenchAssetSource } from '../../../agent-bundle/src/dev/workbench-assets.ts'; import { startDevServer, type DevServerSession, type StartDevServerOptions } from '../../../agent-bundle/src/dev/workbench-server.ts'; import { createProjectFixture, removeProjectFixture, type ProjectFixture } from '../../../agent-bundle/tests/helpers/project-fixture.ts'; +import { timeScale } from '../../../agent-bundle/tests/support/time-scale.ts'; import { browserLaunchOptions } from './browser-launch-options.ts'; export { browserLaunchOptions }; @@ -34,11 +36,54 @@ export const e2e = test.extend({ }); /** - * Canonical Workbench route URL for browser navigation. The dedicated - * route-contract test (overview.e2e) keeps literal hash strings so this - * helper cannot make its assertions self-fulfilling. + * Deleted hash-page names → PR 1 destinations. Callers that still pass + * `'logs'` / `'evals'` / `'mcp'` keep compiling; new suites should pass a + * pathname (`/`, `/advanced/evals`, a `workbenchLeafPath` result). */ -export const workbenchUrl = (origin: string, page: string): string => `${origin}#${page}`; +const legacyPagePath = Object.freeze({ + artifacts: '/advanced/artifact', + comparisons: '/advanced/evals', + discovery: '/advanced/hosts', + evals: '/advanced/evals', + hooks: '/', + hosts: '/advanced/hosts', + lifecycles: '/trace', + logs: '/advanced/logs', + mcp: '/advanced/protocol', + overview: '/', + playground: '/', + routes: '/', + runtime: '/', + skills: '/', +} as const); + +export type WorkbenchLegacyPage = keyof typeof legacyPagePath; + +/** Pathname the shell should show for a primary area or a leftover hash-page name. */ +export const workbenchPathname = (pageOrPath = '/'): string => { + if (pageOrPath.startsWith('/')) return pageOrPath; + return legacyPagePath[pageOrPath as WorkbenchLegacyPage] ?? `/${pageOrPath}`; +}; + +/** + * Canonical Workbench URL. Pathnames are the URL model (`/routes/…`, + * `/advanced/evals`). Hash-only `#page` routing is gone. + */ +export const workbenchUrl = (origin: string, pageOrPath = '/'): string => + new URL(workbenchPathname(pageOrPath), origin.endsWith('/') ? origin : `${origin}/`).href; + +const idleTimeout = 15_000 * timeScale; + +/** + * Wait until the Workbench is past its loading state. AGENTS.md: never assert + * or screenshot a route while loading is still visible. + */ +export const waitForWorkbenchIdle = async (page: Page, timeout = idleTimeout): Promise => { + await expect(page.getByText('Foreground server connected', { exact: true })).toBeVisible({ timeout }); + await expect(page.getByTestId('workbench-loading')).toHaveCount(0, { timeout }); + await expect(page.locator('.loading-state')).toHaveCount(0, { timeout }); + await expect(page.getByText(/^Loading(?:\s|…|$)/u)).toHaveCount(0, { timeout }); +}; let workbenchBuild: Promise | undefined; diff --git a/packages/workbench/tests/support/workbench-surface.ts b/packages/workbench/tests/support/workbench-surface.ts new file mode 100644 index 000000000..15769f66b --- /dev/null +++ b/packages/workbench/tests/support/workbench-surface.ts @@ -0,0 +1,258 @@ +/** + * Workbench-surface adapter for the PR 1 IA. + * + * L10 replaces `workbenchPageLabel` / `WorkbenchPageName` / `pages` with + * `inspectWorkbenchSurface(root).application` (`ApplicationTree`) and + * `workbenchLeafPath(leaf)`. This module codes against those names. Until L10 + * lands it derives a tree from the current catalog so `inspectWorkbenchSurface` + * dry-runs still compile and run. Drop this adapter on integration and import + * the same names from `agent-bundle/src/test`. + */ +import { applicationNodePath, applicationNodeRefForRouteId } from '../../../agent-bundle/src/dev/routes/application-node.ts'; +import { + inspectWorkbenchSurface as inspectCurrentSurface, + type WorkbenchRouteCatalog, + type WorkbenchRouteCatalogEntry, + type WorkbenchSurface, +} from '../../../agent-bundle/src/test/index.ts'; +import type { RouteManifestKind, RouteManifestRoute } from '../../../agent-bundle/src/dev/routes/route-manifest.ts'; +import type { AdvancedSection } from '../../src/shell/workbench-location.ts'; +import { advancedSections } from '../../src/shell/workbench-location.ts'; +import type { + ApplicationGroup, + ApplicationLeaf, + ApplicationLeafExecution, + ApplicationServerGroup, + ApplicationSubgroup, + ApplicationTree, +} from '../../src/application/application-tree-model.ts'; + +export type { AdvancedSection, ApplicationGroup, ApplicationLeaf, ApplicationTree }; +export { advancedSections }; + +export const workbenchLeafPath = (leaf: ApplicationLeaf): string => applicationNodePath(leaf.ref); + +export const applicationLeaves = (tree: ApplicationTree): readonly ApplicationLeaf[] => + tree.groups.flatMap((group) => { + switch (group.kind) { + case 'mcp': + return group.servers.flatMap((server) => server.subgroups.flatMap((subgroup) => subgroup.leaves)); + case 'cli': + case 'events': + case 'rules': + case 'scripts': + case 'skills': + return group.leaves; + default: { + const exhaustive: never = group; + return exhaustive; + } + } + }); + +export const findApplicationLeaf = ( + tree: ApplicationTree, + match: (leaf: ApplicationLeaf) => boolean, +): ApplicationLeaf | undefined => applicationLeaves(tree).find(match); + +export const applicationLeafForRouteId = (tree: ApplicationTree, routeId: string): ApplicationLeaf | undefined => + findApplicationLeaf(tree, (leaf) => leaf.routeId === routeId); + +const mcpSubgroupLabel = (kind: RouteManifestKind): string | undefined => { + switch (kind) { + case 'tool': + return 'Tools'; + case 'resource': + return 'Resources'; + case 'prompt': + return 'Prompts'; + case 'app': + return 'Apps'; + case 'cli': + case 'event-route': + case 'script': + return undefined; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +const leafLabel = (route: RouteManifestRoute): string => { + const rest = route.id.slice(route.id.indexOf(':') + 1); + const slash = rest.lastIndexOf('/'); + return slash === -1 ? rest : rest.slice(slash + 1); +}; + +const leafExecution = (kind: RouteManifestKind): ApplicationLeafExecution => { + switch (kind) { + case 'app': + return 'preview'; + case 'cli': + case 'event-route': + case 'prompt': + case 'resource': + case 'script': + case 'tool': + return 'invoke'; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +const leafFromEntry = (entry: WorkbenchRouteCatalogEntry): ApplicationLeaf | undefined => { + const ref = applicationNodeRefForRouteId(entry.route.id); + if (ref === undefined) return undefined; + return { + config: entry.route.config, + execution: leafExecution(entry.route.kind), + key: applicationNodePath(ref), + label: leafLabel(entry.route), + ref, + routeId: entry.route.id, + source: entry.route.source, + ...(entry.command === undefined ? {} : { command: entry.command }), + ...(entry.route.description === undefined ? {} : { description: entry.route.description }), + ...(entry.route.event === undefined ? {} : { event: entry.route.event }), + ...(entry.route.inputSchema === undefined ? {} : { inputSchema: entry.route.inputSchema }), + }; +}; + +const leavesOf = (entries: readonly WorkbenchRouteCatalogEntry[]): readonly ApplicationLeaf[] => + entries.flatMap((entry) => { + const leaf = leafFromEntry(entry); + return leaf === undefined ? [] : [leaf]; + }); + +const groupKindForCatalog = (kind: RouteManifestKind): ApplicationGroup['kind'] | undefined => { + switch (kind) { + case 'cli': + return 'cli'; + case 'event-route': + return 'events'; + case 'script': + return 'scripts'; + case 'app': + case 'prompt': + case 'resource': + case 'tool': + return 'mcp'; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +const applicationTreeFromCatalog = (catalog: WorkbenchRouteCatalog): ApplicationTree => { + const servers = new Map(); + const projectGroups = new Map, ApplicationLeaf[]>(); + for (const group of catalog.groups) { + const kind = groupKindForCatalog(group.kind); + if (kind === undefined) continue; + const leaves = leavesOf(group.entries); + if (leaves.length === 0) continue; + if (kind === 'mcp') { + const serverName = group.server ?? 'mcp'; + const subgroupLabel = mcpSubgroupLabel(group.kind); + if (subgroupLabel === undefined) continue; + const existing = servers.get(serverName); + const subgroup: ApplicationSubgroup = { + key: `${serverName}/${subgroupLabel}`, + label: subgroupLabel, + leaves, + }; + if (existing === undefined) { + servers.set(serverName, { + key: `mcp/${serverName}`, + label: serverName, + mode: group.mode ?? 'generated', + server: serverName, + subgroups: [subgroup], + }); + continue; + } + servers.set(serverName, { ...existing, subgroups: [...existing.subgroups, subgroup] }); + continue; + } + const collected = projectGroups.get(kind) ?? []; + collected.push(...leaves); + projectGroups.set(kind, collected); + } + const groups: ApplicationGroup[] = []; + if (servers.size > 0) { + groups.push({ + key: 'mcp', + kind: 'mcp', + label: 'MCP', + servers: [...servers.values()], + }); + } + const projectOrder = [ + ['events', 'Events / Hooks'], + ['cli', 'CLI'], + ['scripts', 'Scripts'], + ['skills', 'Skills'], + ['rules', 'Rules / Commands'], + ] as const; + for (const [kind, label] of projectOrder) { + const leaves = projectGroups.get(kind); + if (leaves === undefined || leaves.length === 0) continue; + groups.push({ key: kind, kind, label, leaves }); + } + return { + diagnostics: catalog.diagnostics, + groups, + leafCount: groups.reduce((total, group) => { + switch (group.kind) { + case 'mcp': + return total + group.servers.reduce( + (serverTotal, server) => serverTotal + server.subgroups.reduce((sub, subgroup) => sub + subgroup.leaves.length, 0), + 0, + ); + case 'cli': + case 'events': + case 'rules': + case 'scripts': + case 'skills': + return total + group.leaves.length; + default: { + const exhaustive: never = group; + return exhaustive; + } + } + }, 0), + state: 'current', + }; +}; + +/** L10's return shape. Extra catalog fields stay until the integrator drops them. */ +export interface WorkbenchIaSurface { + readonly advanced: readonly AdvancedSection[]; + readonly application: ApplicationTree; + readonly counts: WorkbenchSurface['counts']; + readonly lifecycles: WorkbenchSurface['lifecycles']; + readonly routes: WorkbenchRouteCatalog; +} + +interface NextWorkbenchSurface extends WorkbenchSurface { + readonly advanced?: readonly AdvancedSection[]; + readonly application?: ApplicationTree; +} + +export const inspectWorkbenchSurface = async ( + root: string | { readonly root: string }, +): Promise => { + const options = typeof root === 'string' ? { root } : root; + const current = await inspectCurrentSurface(options) as NextWorkbenchSurface; + return { + advanced: current.advanced ?? advancedSections, + application: current.application ?? applicationTreeFromCatalog(current.catalog), + counts: current.counts, + lifecycles: current.lifecycles, + routes: current.catalog, + }; +}; diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 506c0acb7..8e2262e48 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -88,6 +88,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/rsc-runtime/tests/notices-sqlite-cross-process.test.ts', 'packages/rsc-runtime/tests/state-packaging.test.ts', 'packages/rsc-runtime/tests/state-sqlite-cross-process.test.ts', + 'packages/workbench/tests/audiobook-curator.acceptance.e2e.test.ts', 'packages/workbench/tests/comparisons-page-client-scope-browser.test.ts', 'packages/workbench/tests/contributor-hmr.e2e.test.ts', 'packages/workbench/tests/discovery-atoms-disposal.test.ts', From c884e8cc0fdb0ad2a96e2929212175592d8a7c27 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:50:59 +0000 Subject: [PATCH 05/43] feat(test): expose the Workbench application tree --- LANE-NOTES.md | 41 ++++ examples/hooks-and-scripts/README.md | 22 +- examples/host-test/README.md | 19 +- examples/mcp-app/README.md | 37 ++-- examples/rsc-agent-runtime/README.md | 3 +- examples/skills-starter/README.md | 16 +- packages/agent-bundle/src/test/index.ts | 14 +- packages/agent-bundle/src/test/workbench.ts | 192 +++++++----------- .../workbench-surface-rendered-skill.test.ts | 8 +- .../workbench-surface-dev-server.test.ts | 20 +- .../tests/workbench-surface.test.ts | 94 ++++++--- 11 files changed, 263 insertions(+), 203 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..dae813c6f --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,41 @@ +# L10 — public Workbench test surface + +## Changed + +- Replaced `agent-bundle/test` page availability with `application: ApplicationTree` and content-bearing `advanced` sections. +- Added `workbenchLeafPath(leaf)` and public application-tree type exports. +- Removed `WorkbenchPageName`, `workbenchPageLabel`, `workbenchPagesFor`, `pages`, and `unavailablePages`. +- Kept the route catalog, manifest, lifecycle replay inventory, capability counts, provenance, CLI usage projection, and rendered-Skill inspection proof. +- Reworked the Workbench-surface unit, route-unit, and dev-server integration assertions around groups, leaves, paths, and Advanced availability. +- Updated Workbench walkthrough wording in `examples/skills-starter`, `examples/mcp-app`, `examples/host-test`, `examples/hooks-and-scripts`, and `examples/rsc-agent-runtime`. +- No matching Workbench-page wording required changes under `packages/create-agent-bundle/**` or production `packages/agent-bundle/src/dev/**`. + +## Exported API + +- Added `AdvancedSection`. +- Added `workbenchLeafPath`. +- Re-exported `ApplicationTree`, `ApplicationGroup`, `ApplicationServerGroup`, `ApplicationSubgroup`, `ApplicationLeaf`, and their kind/execution types from `agent-bundle/test`. +- `inspectWorkbenchSurface` now returns `application` and `advanced` instead of page lists. + +## Cross-lane requests + +- Update `packages/workbench/tests/examples-real.e2e.test.ts`: it still imports `workbenchPageLabel` and reads `surface.pages` / `surface.unavailablePages`. Replace those rail assertions with the PR 1 Application-tree/shell assertions owned by the Workbench lanes. +- Drop the final `packages/agent-bundle/src/dev/routes/application-tree.ts` stub commit when integrating L2's implementation. + +## Open risks + +- The local tree implementation is intentionally an integration stub. L2's implementation is authoritative; retain L10's caller and tests while resolving any final tree-label or source-shape differences. +- The required literal grep still reports private `#hooks` fields and `.cursor-plugin/plugin.json#hooks` manifest anchors. They are JavaScript private names / artifact pointers, not old Workbench hash routing or page wording, so they were not changed. + +## Verification + +- `pnpm install --frozen-lockfile --prefer-offline && pnpm build` +- `pnpm build && npx tsc --noEmit` +- `pnpm lint` +- `npx rstest --config rstest.unit.config.ts packages/agent-bundle/tests/workbench-surface.test.ts` +- `npx rstest --config rstest.route-unit.config.ts packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts` +- `npx rstest --config rstest.integration.config.ts packages/agent-bundle/tests/workbench-surface-dev-server.test.ts` + +## Proposed changeset + +`minor` — Replace `inspectWorkbenchSurface` page availability with the Application tree and Advanced sections, add `workbenchLeafPath`, and remove `WorkbenchPageName`, `workbenchPageLabel`, and `workbenchPagesFor` (#600). diff --git a/examples/hooks-and-scripts/README.md b/examples/hooks-and-scripts/README.md index 89d5863a5..0fe2646b7 100644 --- a/examples/hooks-and-scripts/README.md +++ b/examples/hooks-and-scripts/README.md @@ -18,25 +18,25 @@ example keeps both modes covered. ## Workbench walkthrough -1. **Overview** is the Bundle dashboard. It relates the authored Hook to its - emitted artifact, exercise trace, and evaluation pages; its status is the - authoritative current-or-stale epoch state. -2. **Hooks** defaults to the Claude `sessionStart` binding and its populated - inline canonical JSON, including `"source": "workbench"`. Run the +1. The shell header reports the authoritative current-or-stale epoch state and + links build diagnostics to **Problems**. +2. Under **Application → Events / Hooks**, select the Claude `sessionStart` + binding and its populated inline canonical JSON, including + `"source": "workbench"`. Run the simulation, then use **Replay saved simulation** to rerun exactly that epoch-bound input. The result directs the release session through both checks. -3. **Playground** defaults to Script execution, the Claude target, and - `verify-release`. Run it and wait until the session is finalized: the +3. Under **Application → Scripts**, select `verify-release` and the Claude + target, then run it. The emitted script reads the packaged `release/release-manifest.json` relative to its module and reports release 2.4.0 ready for packaging. 4. Change the target to portable and select `detect-risk`. Its emitted script reads `release/risk-register.json`, reports high-severity `REL-204`, exits with code 2, and finalizes a durable blocking trace. -5. **Logs** filters those producer records by producer, level, kind, or - context; open a record to inspect raw details. **Artifacts** is the emitted - file/provenance view, while **Comparisons** aligns outcomes only after two - recorded eval runs. +5. **Advanced → Raw logs** filters those producer records by producer, level, + kind, or context; open a record to inspect raw details. **Advanced → + Artifact** is the emitted file/provenance view, while **Advanced → Evals → + Compare** aligns outcomes only after two recorded eval runs. ## Reversible diagnostic walkthrough diff --git a/examples/host-test/README.md b/examples/host-test/README.md index 59c509dd5..ae5623ea5 100644 --- a/examples/host-test/README.md +++ b/examples/host-test/README.md @@ -123,17 +123,18 @@ the printed command, open the Agents pane, and use the same scenario prompt. ## Workbench walkthrough -1. **Overview** lists the twenty event routes, both MCP servers, the skill, and - the routed CLI with their per-target capability judgments — `workspace/open` +1. **Application** lists the twenty event routes, both MCP servers, the skill, + and the routed CLI with their per-target capability judgments — `workspace/open` is Cursor-only, `task/*` and `file/change` are Claude-only, and portable carries no hooks at all. -2. **Hooks** simulates any family with canonical input; the route appends a - record to the log and returns an empty result (only `session/start` speaks - an `additional_context` line naming the log path). -3. **Playground** runs `host-test dump` and the `dump` tool against the same - log, so a simulated hook is visible from the MCP surface immediately. -4. **Lifecycles** replays checked-in native receipts and shows the request - context — and lineage — each replay mounted. +2. Under **Application → Events / Hooks**, select any family and run it with + canonical input; the route appends a record to the log and returns an empty + result (only `session/start` speaks an `additional_context` line naming the + log path). +3. Run the `host-test dump` CLI leaf or the MCP `dump` Tool leaf against the + same log, so a simulated hook is visible from the MCP surface immediately. +4. Use the selected event route's **Replay** tab for checked-in native receipts; + each replay shows its request context and lineage. ## Noninteractive checks diff --git a/examples/mcp-app/README.md b/examples/mcp-app/README.md index 7e99937ca..e03e2bc7b 100644 --- a/examples/mcp-app/README.md +++ b/examples/mcp-app/README.md @@ -28,33 +28,32 @@ and Claude artifacts; the App resource remains portable. ## Workbench walkthrough -1. **Overview** opens on the Bundle dashboard. Its Author, Build, Exercise, - and Evaluate stages connect the source capability to its emitted artifact, - runtime evidence, and eval result. -2. **Skills** defaults to `service-readiness`; compare its authored status - policy and readiness-report resource with generated output and its explicit - eval coverage. **Hooks** defaults to a populated Claude `sessionStart` - canonical input; run the simulation to attach the readiness workflow. -3. **Playground** defaults to Script execution, the Claude target, and - `check-service-fixture`. Run it and wait for the finalized session. The +1. The shell header connects build status and diagnostics to the current + emitted artifact. +2. Under **Application → Skills**, select `service-readiness`; compare its + authored status policy and readiness-report resource with generated output + and its explicit eval coverage. Under **Application → Events / Hooks**, + select `sessionStart`, use the populated Claude canonical input, and run the + simulation to attach the readiness workflow. +3. Under **Application → Scripts**, select `check-service-fixture`, choose the + Claude fixture, and run it. The emitted checker resolves the packaged status fixture beside its emitted module, so it succeeds without depending on the shell working directory. -4. **Logs** exposes the resulting producer records. In **Artifacts**, select - portable to inspect `mcp-apps/status.html`; Codex and Claude retain their - host artifacts but not this portable App resource. -5. Before recording two eval runs, **Comparisons** deliberately displays: +4. **Advanced → Raw logs** exposes the resulting producer records. In + **Advanced → Artifact**, select portable to inspect `mcp-apps/status.html`; + Codex and Claude retain their host artifacts but not this portable App resource. +5. Before recording two eval runs, **Advanced → Evals → Compare** deliberately displays: `At least two recorded runs are needed before a comparison can be aligned.` That is the precise empty state, not an error. -6. In **MCP playground**, the defaults are portable and the `status` server. - Open the session, list tools, select `show-status`, choose `payments-api`, - and invoke it. Invocation history shows the degraded summary and labelled +6. Under **Application → MCP → status → Tools**, select `show-status`, choose + `payments-api`, and run it. Invocation history shows the degraded summary and labelled Availability and P95 latency checks (the latter fails). Open the App preview: the rendered panel also shows `payments-api`, a text-labelled amber `degraded` indicator, the same summary, and passing/failing checks through the MCP Apps bridge. Inspect the - protocol trace, use **Restart MCP session**, then close, reset, and reopen - it to exercise the lifecycle. -7. **Evals** defaults to the deterministic `mcp-app-status` suite. Run + protocol trace in the route workspace; use **Advanced → Protocol** for + session restart, reset, and lifecycle inspection. +7. **Advanced → Evals → Runs** defaults to the deterministic `mcp-app-status` suite. Run `status-is-healthy` and inspect its completed passing trial attributed to `service-readiness`; it reads only checked-in fixture data and needs no native login or API key. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 2a30fa844..4b1ad3190 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -338,7 +338,8 @@ surface is not treated as stable here: React `19.2.8`, `react-dom` `19.2.8`, Existing Agent Bundle skills, static MCPs, evaluations, and normal hooks neither require nor activate this runtime. Nothing under `packages/agent-bundle` imports the example or React/RSC runtime packages. `PlaygroundService` is the landed, provider-neutral durable whole-plugin -authoring timeline foundation. Runtime Playground history is deliberately +authoring timeline foundation. Runtime history now backs the selected +Application route workspace rather than a separate destination. It remains provider-session-scoped and ephemeral in this example; wiring a provider adapter, authenticated API, timeline UI, durable Runtime export, or evaluation promotion onto that history is an explicit non-goal of this demo. diff --git a/examples/skills-starter/README.md b/examples/skills-starter/README.md index 0b7f38110..c03c30895 100644 --- a/examples/skills-starter/README.md +++ b/examples/skills-starter/README.md @@ -31,17 +31,17 @@ required. Both eval suites are deterministic and read only checked-in fixtures. ## Workbench walkthrough -1. **Overview** opens on the Bundle dashboard. It summarizes the three Skills, - generated targets, build health, and the next useful actions. -2. **Skills** lists `dependency-upgrade`, `incident-triage`, and - `release-review`. Browse their linked checklists and report templates. Switch - between Source and Generated to see whether a target copied or adapted the - authored document. Every Skill shows its deterministic outcome-eval coverage; +1. The shell header summarizes build health and current diagnostics. +2. Under **Application → Skills**, select `dependency-upgrade`, + `incident-triage`, or `release-review`. Browse its linked checklists and + report templates. Use the inspector to compare Source and Generated output + and see whether a target copied or adapted the authored document. Every + Skill shows its deterministic outcome-eval coverage; it is labeled indirect because the deterministic harness cannot observe host Skill activation. -3. **Artifacts** defaults to the Claude target. Change the target to compare +3. **Advanced → Artifact** defaults to the Claude target. Change the target to compare the portable, Codex, and Claude output trees and their provenance. -4. **Evals** defaults to the `release-readiness` suite. Run its deterministic +4. **Advanced → Evals → Runs** defaults to the `release-readiness` suite. Run its deterministic `release-artifact-is-ready` case and inspect the passing trial. It consumes only the checked-in evidence fixture, so no model login or API key is needed. 5. To practice repair, make a reversible policy edit, press **Rebuild**, and diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 83342030b..ab13a8e35 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -13,7 +13,7 @@ * | `dev-epoch` | `runDevEpochContractMatrix` | an epoch-pinned generated stdio process opened through the Workbench session service; MCP App routes are covered (surface + `ui://` sweep) and auto-covered without a fixture | * | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | a compiled plain or rendered CLI command dispatched through the routed CLI's own shell, including rendered output modes, in this process | * | `script-dispatch` | `runScript`, `scriptJson`, `scriptNdjson` | a conventional `src/scripts/*` module run through its generated executable's contract — the rendered-script shell with its four output modes in this process, or the plain `main` envelope as a Node process of its own over the source — without bundling | - * | `workbench-surface` | `inspectWorkbenchSurface` | the compiled route graph projected exactly as the dev server serves it to the Workbench: route catalog, state, lifecycle fixtures, page availability, without a browser or dev server | + * | `workbench-surface` | `inspectWorkbenchSurface` | the compiled route graph projected exactly as the dev server serves it to the Workbench: application tree, route catalog, state, lifecycle fixtures, and Advanced availability, without a browser or dev server | * | `packed-stdio` | `openPackedMcpServer`, `runPackedContractMatrix` | a built artifact's generated entry running as a real process over stdio; MCP App routes are covered (surface + `ui://` sweep) and auto-covered without a fixture | * | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })`, `runPackedContractMatrix` | the packed stdio process still runs after project source and configuration are removed and verified absent; MCP App routes are covered as at `packed-stdio` | * | `browser-app` | `mountBrowserApp` (`agent-bundle/test/browser`) | production-compiled MCP App HTML mounted over the product bridge in a real browser page | @@ -177,15 +177,21 @@ export type { export { inspectWorkbenchSurface, workbenchCommandUsage, - workbenchPageLabel, - workbenchPagesFor, + workbenchLeafPath, workbenchRouteCatalog, workbenchSurfaceFromRouteGraph, } from './workbench.ts'; export type { + AdvancedSection, + ApplicationGroup, + ApplicationGroupKind, + ApplicationLeaf, + ApplicationLeafExecution, + ApplicationServerGroup, + ApplicationSubgroup, + ApplicationTree, InspectWorkbenchSurfaceOptions, WorkbenchCapabilityCounts, - WorkbenchPageName, WorkbenchRouteCatalog, WorkbenchRouteCatalogEntry, WorkbenchRouteCatalogGroup, diff --git a/packages/agent-bundle/src/test/workbench.ts b/packages/agent-bundle/src/test/workbench.ts index ceca34ffe..d16c86c28 100644 --- a/packages/agent-bundle/src/test/workbench.ts +++ b/packages/agent-bundle/src/test/workbench.ts @@ -4,15 +4,15 @@ * The developer Workbench never discovers a project itself: the dev server * runs one compiler pass and serves projections of it — the route manifest * (`GET /api/routes/manifest`), the state declaration inside it, the - * lifecycle-replay inventory (`GET /api/lifecycles`), and the capability - * counts navigation derives its pages from. `inspectWorkbenchSurface` runs + * lifecycle-replay inventory (`GET /api/lifecycles`), and the application + * tree derived from those compiler facts. `inspectWorkbenchSurface` runs * that same compiler pass and the same projection functions in this process, * so a consumer can assert what the Workbench would be given for their * project without a browser or a dev server. * * It does **not** start the dev server, build an artifact, or render the - * Workbench: page-availability and catalog grouping are re-derived here by the - * Workbench's own rules over the same wire shapes, and the repository proves + * Workbench: the application tree and catalog grouping are re-derived here by + * the Workbench's own rules over the same wire shapes, and the repository proves * that derivation against the real-Chrome Workbench acceptance. Artifact-only * facts — per-target executables, published epochs, host discovery, live MCP * probes — stay with the dev-server and browser levels. @@ -23,6 +23,12 @@ import type { Lifecycle, LifecycleListResponse } from '../contracts/lifecycles.t import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; import type { NormalizedNotices, NormalizedStateDefinition } from '../core/types.ts'; +import { + applicationTreeForManifest, + type ApplicationLeaf, + type ApplicationTree, +} from '../dev/routes/application-tree.ts'; +import { applicationNodePath } from '../dev/routes/application-node.ts'; import { routeManifestFor } from '../dev/routes/route-manifest.ts'; import type { RouteManifest, @@ -36,74 +42,18 @@ import type { CompiledRouteGraph } from '../routes/types.ts'; import { AgentTestError } from './errors.ts'; import { WORKBENCH_SURFACE_PROOF_LEVEL } from './manifest.ts'; -/** Every Workbench page the navigation can show, in the Workbench's own order. */ -export type WorkbenchPageName = - | 'overview' - | 'routes' - | 'skills' - | 'hooks' - | 'lifecycles' - | 'hosts' - | 'mcp' - | 'artifacts' - | 'playground' - | 'logs' - | 'evals' - | 'comparisons'; +export type { + ApplicationGroup, + ApplicationGroupKind, + ApplicationLeaf, + ApplicationLeafExecution, + ApplicationServerGroup, + ApplicationSubgroup, + ApplicationTree, +} from '../dev/routes/application-tree.ts'; -/** - * The rail order `packages/workbench/src/main.tsx` renders its navigation - * items in, minus Runtime (a dev-server runtime capability, not a compile-time - * fact). The Workbench e2e pins this list against the real rail. - */ -const workbenchPageOrder: readonly WorkbenchPageName[] = Object.freeze([ - 'overview', - 'routes', - 'skills', - 'hooks', - 'lifecycles', - 'hosts', - 'mcp', - 'artifacts', - 'playground', - 'logs', - 'evals', - 'comparisons', -]); - -/** The Workbench's navigation labels, so an assertion can name the link a browser would show. */ -export const workbenchPageLabel = (page: WorkbenchPageName): string => { - switch (page) { - case 'overview': - return 'Overview'; - case 'routes': - return 'Routes'; - case 'skills': - return 'Skills'; - case 'hooks': - return 'Hooks'; - case 'lifecycles': - return 'Lifecycles'; - case 'hosts': - return 'Hosts'; - case 'playground': - return 'Playground'; - case 'mcp': - return 'MCP playground'; - case 'evals': - return 'Evals'; - case 'comparisons': - return 'Comparisons'; - case 'artifacts': - return 'Artifacts'; - case 'logs': - return 'Logs'; - default: { - const exhaustive: never = page; - throw new TypeError(`Unknown Workbench page ${String(exhaustive)}.`); - } - } -}; +/** A content-bearing destination within the Workbench's Advanced area. */ +export type AdvancedSection = 'artifact' | 'evals' | 'hosts' | 'logs' | 'protocol'; /** * The capability counts the Workbench derives its navigation from, as the @@ -125,20 +75,20 @@ export interface WorkbenchCapabilityCounts { /** One route as the Workbench catalog lists it: the manifest route plus, for CLI routes, its compiled command. */ export interface WorkbenchRouteCatalogEntry { readonly command?: RouteManifestCliCommand; - /** The ` …` usage line the Routes page renders for a CLI command. */ + /** The ` …` usage line the route workspace renders for a CLI command. */ readonly commandUsage?: string; readonly route: RouteManifestRoute; } /** - * One catalog section, exactly as the Routes page groups them: per server and + * One catalog section, exactly as the application tree groups them: per server and * kind for MCP routes (`curator · Tools`), project-level for event routes, * CLI commands, and scripts. */ export interface WorkbenchRouteCatalogGroup { readonly entries: readonly WorkbenchRouteCatalogEntry[]; readonly kind: RouteManifestKind; - /** The heading text the Routes page renders for this group. */ + /** The heading text the application tree renders for this group. */ readonly label: string; readonly mode?: string; readonly server?: string; @@ -157,10 +107,10 @@ export interface WorkbenchRouteCatalog { readonly digest: string; readonly groups: readonly WorkbenchRouteCatalogGroup[]; readonly providers: readonly RouteManifest['providers'][number][]; - /** The number the Routes page shows under "Route graph identity". */ + /** The number of compiled routes in the catalog. */ readonly routeCount: number; readonly servers: readonly WorkbenchRouteCatalogServer[]; - /** The state declaration the Routes page's State region renders; absent when the project declares none. */ + /** The effective state declaration; absent when the project declares none. */ readonly stateDefinition?: RouteManifestState; } @@ -175,17 +125,15 @@ export interface WorkbenchSurfaceProvenance { } export interface WorkbenchSurface { + readonly advanced: readonly AdvancedSection[]; + readonly application: ApplicationTree; readonly catalog: WorkbenchRouteCatalog; readonly counts: WorkbenchCapabilityCounts; - /** Every event route with the concrete hosts and starter fixtures the Lifecycles page offers for replay. */ + /** Every event route with the concrete hosts and starter fixtures available for replay. */ readonly lifecycles: readonly Lifecycle[]; /** Exactly the wire body of `GET /api/routes/manifest`. */ readonly manifest: RouteManifest; - /** The navigation pages the Workbench would show, in navigation order. */ - readonly pages: readonly WorkbenchPageName[]; readonly provenance: WorkbenchSurfaceProvenance; - /** The navigation pages the Workbench would hide for this project. */ - readonly unavailablePages: readonly WorkbenchPageName[]; } const kindLabels: Readonly> = Object.freeze({ @@ -198,7 +146,7 @@ const kindLabels: Readonly> = Object.freeze({ tool: 'Tools', }); -/** The Routes page's group order for one server: MCP kinds first, then project surfaces. */ +/** The application tree's group order for one server: MCP kinds first, then project surfaces. */ const catalogKinds: readonly RouteManifestKind[] = Object.freeze([ 'tool', 'resource', @@ -217,7 +165,7 @@ const cliOperand = (option: RouteManifestCliCommand['options'][number]): string return `<${kind}>`; }; -/** The usage line the Routes page renders: positionals in order, then flags, required ones unbracketed. */ +/** The usage line the route workspace renders: positionals in order, then flags, required ones unbracketed. */ export const workbenchCommandUsage = (command: RouteManifestCliCommand): string => { const positionals = command.options.filter((option) => option.positional !== undefined) .toSorted((left, right) => left.positional! - right.positional!) @@ -276,7 +224,7 @@ const projectGroups = (manifest: RouteManifest): readonly WorkbenchRouteCatalogG ...(manifest.scripts.length === 0 ? [] : [groupFor('script', manifest.scripts.map((route) => entryFor(route)))]), ]; -/** The Routes page catalog derived from one route manifest, by the Workbench's grouping rules. */ +/** The route catalog derived from one route manifest, by the Workbench's grouping rules. */ export const workbenchRouteCatalog = (manifest: RouteManifest): WorkbenchRouteCatalog => { const groups = [...serverGroups(manifest), ...projectGroups(manifest)]; return { @@ -292,61 +240,52 @@ export const workbenchRouteCatalog = (manifest: RouteManifest): WorkbenchRouteCa }; }; -const catalogHasKind = (catalog: WorkbenchRouteCatalog, kind: RouteManifestKind): boolean => - catalog.groups.some((group) => group.kind === kind && group.entries.length > 0); - -/** - * The Workbench navigation rule: a page appears when either the compiled - * graph declares its surface or configuration declares it without a route - * module. `hosts` is unconditional; the RSC runtime page depends on a live - * runtime provider and is not projected here. - */ -export const workbenchPagesFor = ( - counts: WorkbenchCapabilityCounts, - catalog: WorkbenchRouteCatalog, -): readonly WorkbenchPageName[] => { - const compiledEvents = catalogHasKind(catalog, 'event-route'); - const compiledScripts = catalogHasKind(catalog, 'script'); - const pages = new Set(['overview', 'routes', 'hosts', 'artifacts', 'logs']); - if (counts.skills > 0) pages.add('skills'); - if (counts.hooks > 0 || compiledEvents) pages.add('hooks'); - if (compiledEvents) pages.add('lifecycles'); - if (counts.mcpServers > 0 || catalog.servers.length > 0) pages.add('mcp'); - if (counts.hooks + counts.scripts > 0 || compiledEvents || compiledScripts) pages.add('playground'); - if (counts.evalSuites > 0) { - pages.add('evals'); - pages.add('comparisons'); - } - return workbenchPageOrder.filter((page) => pages.has(page)); -}; - export interface WorkbenchSurfaceFromGraphInput { readonly configPath?: string; readonly counts: WorkbenchCapabilityCounts; readonly graph: CompiledRouteGraph; + readonly inspection?: Readonly<{ + readonly hooks: readonly { readonly event: string; readonly name: string; readonly source?: string }[]; + readonly mcpServers: readonly { readonly name: string }[]; + readonly scripts: readonly { readonly name: string; readonly source?: string }[]; + }>; readonly lifecycles: LifecycleListResponse; readonly projectRoot: string; readonly sourceRevision: string; readonly notices?: NormalizedNotices; + readonly skills?: readonly { readonly id: string; readonly label: string; readonly source?: string }[]; readonly state?: NormalizedStateDefinition; readonly targets: readonly string[]; } /** * The pure projection behind {@link inspectWorkbenchSurface}: the same - * `routeManifestFor` the dev server serves, grouped by the Routes page's - * rules, with the navigation rule applied over the declared counts. + * `routeManifestFor` the dev server serves, grouped by the application tree's + * rules, with Advanced availability applied over the declared counts. */ export const workbenchSurfaceFromRouteGraph = (input: WorkbenchSurfaceFromGraphInput): WorkbenchSurface => { const manifest = routeManifestFor(input.graph, input.sourceRevision, input.state, input.notices); const catalog = workbenchRouteCatalog(manifest); - const pages = workbenchPagesFor(input.counts, catalog); + const application = applicationTreeForManifest({ + inspection: input.inspection, + manifest, + skills: input.skills, + state: 'fresh', + }); + const advanced: AdvancedSection[] = [ + ...(input.counts.evalSuites > 0 ? ['evals' as const] : []), + 'artifact', + ...(input.counts.mcpServers > 0 || manifest.servers.length > 0 ? ['protocol' as const] : []), + 'hosts', + 'logs', + ]; return deepFreeze({ + advanced, + application, catalog, counts: input.counts, lifecycles: input.lifecycles.lifecycles, manifest, - pages, provenance: { ...(input.configPath === undefined ? {} : { configPath: input.configPath }), manifestDigest: manifest.digest, @@ -355,10 +294,12 @@ export const workbenchSurfaceFromRouteGraph = (input: WorkbenchSurfaceFromGraphI sourceRevision: input.sourceRevision, targets: input.targets, }, - unavailablePages: workbenchPageOrder.filter((page) => !pages.includes(page)), }); }; +/** The Workbench route for one application leaf. */ +export const workbenchLeafPath = (leaf: ApplicationLeaf): string => applicationNodePath(leaf.ref); + /** * The artifact instances a set of declarations produces: one per declaration * per selected target it names. A declaration with `targets: []`, or with @@ -459,9 +400,26 @@ export const inspectWorkbenchSurface = async ( targets: targets.length, }), graph, + inspection: { + hooks: model.hooks.filter((hook) => hook.targets.some((target) => targets.includes(target))).map((hook) => ({ + event: hook.eventRoute?.event ?? hook.event, + name: hook.name, + source: hook.provenance.sourcePath, + })), + mcpServers: model.mcpServers.map((server) => ({ name: server.name })), + scripts: model.scripts.filter((script) => script.targets.some((target) => targets.includes(target))).map((script) => ({ + name: script.name, + source: script.provenance.sourcePath, + })), + }, lifecycles, projectRoot: prepared.root, sourceRevision, + skills: model.skills.map((skill) => ({ + id: skill.id, + label: skill.name, + source: skill.provenance.sourcePath, + })), ...(model.state === undefined ? {} : { state: model.state }), targets, }); diff --git a/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts b/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts index 26233aba1..f932219c6 100644 --- a/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts +++ b/packages/agent-bundle/tests/route-unit/workbench-surface-rendered-skill.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { afterAll, expect, it } from '@rstest/core'; -import { inspectWorkbenchSurface } from '../../src/test/index.ts'; +import { inspectWorkbenchSurface, workbenchLeafPath } from '../../src/test/index.ts'; import { createProjectFixture } from '../helpers/project-fixture.ts'; /** @@ -75,4 +75,10 @@ it('inspects the Workbench surface of a project with a rendered skill under the expect(surface.manifest.diagnostics).toEqual([]); expect(surface.counts).toMatchObject({ mcpServers: 1, skills: 1 }); expect(surface.provenance).toMatchObject({ proofLevel: 'workbench-surface', targets: ['claude'] }); + const skills = surface.application.groups.find((group) => group.kind === 'skills'); + expect(skills).toMatchObject({ + leaves: [expect.objectContaining({ execution: 'document', label: 'demo' })], + }); + if (skills?.kind !== 'skills') throw new Error('Expected a Skills application group.'); + expect(workbenchLeafPath(skills.leaves[0]!)).toBe('/routes/skills/skill%3Ademo'); }); diff --git a/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts b/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts index 3d387c05f..09e88f318 100644 --- a/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts +++ b/packages/agent-bundle/tests/workbench-surface-dev-server.test.ts @@ -7,7 +7,7 @@ import type { LifecycleListResponse } from '../src/contracts/lifecycles.ts'; import type { RouteManifestResponse } from '../src/dev/routes/route-manifest.ts'; import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts'; import { startDevServer } from '../src/dev/workbench-server.ts'; -import { inspectWorkbenchSurface } from '../src/test/index.ts'; +import { inspectWorkbenchSurface, workbenchLeafPath } from '../src/test/index.ts'; import { createProjectFixture } from './helpers/project-fixture.ts'; import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; @@ -127,7 +127,23 @@ it('matches the route manifest and lifecycle inventory a real dev server serves' routeId: 'event:tool/after', targets: [{ nativeEvent: 'PostToolUse', target: 'claude' }], }]); - expect(surface.pages).toEqual(['overview', 'routes', 'hooks', 'lifecycles', 'hosts', 'mcp', 'artifacts', 'playground', 'logs']); + expect(surface.application.groups.map((group) => group.kind)).toEqual(['mcp', 'events', 'cli']); + const leaves = surface.application.groups.flatMap((group) => group.kind === 'mcp' + ? group.servers.flatMap((applicationServer) => + applicationServer.subgroups.flatMap((subgroup) => subgroup.leaves)) + : group.leaves); + expect(leaves.map((leaf) => leaf.routeId).sort()).toEqual([ + 'cli:greet', + 'event:tool/after', + 'tool:status/report', + ]); + expect(leaves.map(workbenchLeafPath).sort()).toEqual([ + '/routes/cli/greet', + '/routes/events/tool/after', + '/routes/mcp/status/tool/report', + ]); + expect(surface.application.leafCount).toBe(3); + expect(surface.advanced).toEqual(['artifact', 'protocol', 'hosts', 'logs']); } finally { await server?.close().catch(() => undefined); await rm(project.root, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 }); diff --git a/packages/agent-bundle/tests/workbench-surface.test.ts b/packages/agent-bundle/tests/workbench-surface.test.ts index 6bd8e7fde..d0e48e05b 100644 --- a/packages/agent-bundle/tests/workbench-surface.test.ts +++ b/packages/agent-bundle/tests/workbench-surface.test.ts @@ -6,7 +6,9 @@ import { describe, expect, it } from '@rstest/core'; import { AgentTestError, inspectWorkbenchSurface, - workbenchPageLabel, + workbenchLeafPath, + type ApplicationGroup, + type ApplicationLeaf, type WorkbenchRouteCatalogGroup, type WorkbenchSurface, } from '../src/test/index.ts'; @@ -22,7 +24,18 @@ const groupNamed = (surface: WorkbenchSurface, label: string): WorkbenchRouteCat return group; }; -const visibleLabels = (surface: WorkbenchSurface): readonly string[] => surface.pages.map(workbenchPageLabel); +const applicationGroup = (surface: WorkbenchSurface, kind: ApplicationGroup['kind']): ApplicationGroup => { + const group = surface.application.groups.find((candidate) => candidate.kind === kind); + if (group === undefined) { + throw new Error(`Expected an application ${JSON.stringify(kind)} group; found ${JSON.stringify(surface.application.groups.map((candidate) => candidate.kind))}.`); + } + return group; +}; + +const applicationLeaves = (surface: WorkbenchSurface): readonly ApplicationLeaf[] => + surface.application.groups.flatMap((group) => group.kind === 'mcp' + ? group.servers.flatMap((server) => server.subgroups.flatMap((subgroup) => subgroup.leaves)) + : group.leaves); /** * These assertions are the ones `packages/workbench/tests/examples-real.e2e.test.ts` @@ -47,7 +60,7 @@ describe('the Workbench surface of the audiobook curator', () => { expect(surface.catalog.diagnostics).toEqual([]); }); - it('projects the State region the Routes page renders', async () => { + it('projects the effective State declaration', async () => { const { catalog } = await surfacePromise; expect(catalog.stateDefinition).toMatchObject({ @@ -87,6 +100,19 @@ describe('the Workbench surface of the audiobook curator', () => { expect(groupNamed(surface, 'curator · Resources').entries.find((entry) => entry.route.id === 'resource:curator/catalog')?.route.config) .toEqual(expect.arrayContaining([{ key: 'uri', kind: 'string', value: 'audiobook-curator://catalog' }])); expect(groupNamed(surface, 'curator · Prompts').entries.map((entry) => entry.route.id)).toContain('prompt:curator/curate'); + + const mcp = applicationGroup(surface, 'mcp'); + if (mcp.kind !== 'mcp') throw new Error('Expected the MCP application group.'); + expect(mcp.servers).toHaveLength(1); + expect(mcp.servers[0]).toMatchObject({ label: 'curator', mode: 'generated', server: 'curator' }); + expect(mcp.servers[0]?.subgroups.map((subgroup) => subgroup.label)).toEqual(['Tools', 'Resources', 'Prompts']); + const search = applicationLeaves(surface).find((leaf) => leaf.routeId === 'tool:curator/search_audible'); + expect(search).toMatchObject({ + execution: 'invoke', + label: 'search_audible', + source: 'src/mcp/curator/tools/search_audible.tsx', + }); + expect(search === undefined ? undefined : workbenchLeafPath(search)).toBe('/routes/mcp/curator/tool/search_audible'); }); it('lists the 16 authored commands beside one projected command per tool', async () => { @@ -152,54 +178,61 @@ describe('the Workbench surface of the audiobook curator', () => { expect(surface.manifest.scripts).toEqual([]); }); - it('derives the navigation the Workbench shows for this project', async () => { + it('derives the application tree and Advanced sections for this project', async () => { const surface = await surfacePromise; - expect(visibleLabels(surface)).toEqual(expect.arrayContaining(['Overview', 'Routes', 'Skills', 'MCP playground', 'Hosts', 'Artifacts', 'Logs'])); - expect(surface.unavailablePages).toEqual(expect.arrayContaining(['hooks', 'lifecycles', 'playground'])); + expect(surface.application.state).toBe('fresh'); + expect(surface.application.groups.map((group) => group.kind)).toEqual(['mcp', 'cli', 'skills']); + expect(applicationGroup(surface, 'cli')).toMatchObject({ label: 'CLI', leaves: expect.any(Array) }); + expect(applicationGroup(surface, 'skills')).toMatchObject({ + label: 'Skills', + leaves: [expect.objectContaining({ execution: 'document', label: 'curate-audiobooks' })], + }); + expect(surface.application.leafCount).toBe(applicationLeaves(surface).length); + expect(surface.advanced).toEqual(['artifact', 'protocol', 'hosts', 'logs']); // One MCP server shipped to two hosts: two instances, as the artifact inventory lists them. expect(surface.counts).toMatchObject({ hooks: 0, mcpServers: 2, scripts: 0, skills: 1, targets: 2 }); }); }); /** - * `examples-real.e2e.test.ts` asserts the MCP App example keeps all nine - * configured pages while its compiled catalog is empty, and that the Skills - * Starter shows no Hooks, MCP playground, or Playground link. + * Configured-only surfaces have no compiled route catalog, but authored + * hooks, scripts, and Skills still appear as application leaves. */ describe('the Workbench surface of the configured-only examples', () => { - it('keeps every configured page while reporting an empty compiled graph for the MCP App example', async () => { + it('keeps configured leaves while reporting an empty compiled graph for the MCP App example', async () => { const surface = await inspectWorkbenchSurface({ root: exampleRoot('mcp-app') }); expect(surface.catalog.routeCount).toBe(0); expect(surface.catalog.groups).toEqual([]); expect(surface.catalog.stateDefinition).toBeUndefined(); - // The rail order of packages/workbench/src/main.tsx, minus the hidden Lifecycles link. - expect(visibleLabels(surface)).toEqual([ - 'Overview', 'Routes', 'Skills', 'Hooks', 'Hosts', 'MCP playground', 'Artifacts', 'Playground', 'Logs', 'Evals', 'Comparisons', - ]); - expect(surface.unavailablePages).toEqual(['lifecycles']); + expect(surface.application.groups.map((group) => group.kind)).toEqual(['events', 'scripts', 'skills']); + expect(applicationGroup(surface, 'events')).toMatchObject({ label: 'Events / Hooks' }); + expect(applicationGroup(surface, 'scripts')).toMatchObject({ label: 'Scripts' }); + expect(surface.advanced).toEqual(['evals', 'artifact', 'protocol', 'hosts', 'logs']); expect(surface.counts).toMatchObject({ evalSuites: 1, skills: 1, targets: 3 }); expect(surface.counts.hooks).toBeGreaterThan(0); expect(surface.counts.mcpServers).toBeGreaterThan(0); expect(surface.counts.scripts).toBeGreaterThan(0); }); - it('hides Hooks, MCP playground, and Playground for the Skills Starter', async () => { + it('shows only Skill leaves for the Skills Starter', async () => { const surface = await inspectWorkbenchSurface({ root: exampleRoot('skills-starter') }); - for (const hidden of ['Hooks', 'MCP playground', 'Playground']) { - expect(visibleLabels(surface)).not.toContain(hidden); - } - expect(visibleLabels(surface)).toEqual(expect.arrayContaining(['Overview', 'Routes', 'Skills', 'Artifacts', 'Logs'])); + expect(surface.application.groups.map((group) => group.kind)).toEqual(['skills']); + expect(applicationGroup(surface, 'skills')).toMatchObject({ leaves: expect.arrayContaining([ + expect.objectContaining({ label: 'dependency-upgrade' }), + expect.objectContaining({ label: 'incident-triage' }), + expect.objectContaining({ label: 'release-review' }), + ]) }); + expect(surface.advanced).toEqual(['evals', 'artifact', 'hosts', 'logs']); expect(surface.counts).toMatchObject({ hooks: 0, mcpServers: 0, scripts: 0, skills: 3, targets: 3 }); }); }); /** * The Workbench counts what the built artifact lists — one instance per - * declaration per target — and hides Hooks and Playground when nothing is - * emitted. A declaration whose `targets` select none of the project's targets + * declaration per target. A declaration whose `targets` select none of the project's targets * is declared but emitted nowhere, so it must not count. */ describe('capability counts', () => { @@ -232,8 +265,8 @@ describe('capability counts', () => { // everywhere × 2 targets + codex-only × 1 + nowhere × 0; the hook selects no target. expect(surface.counts).toMatchObject({ hooks: 0, mcpServers: 0, scripts: 3, targets: 2 }); - expect(surface.pages).toContain('playground'); - expect(surface.pages).not.toContain('hooks'); + expect(applicationGroup(surface, 'scripts')).toMatchObject({ leaves: expect.any(Array) }); + expect(surface.application.groups.map((group) => group.kind)).not.toContain('events'); } finally { await rm(project.root, { force: true, recursive: true }); } @@ -268,7 +301,7 @@ describe('capability counts', () => { const surface = await inspectWorkbenchSurface({ root: project.root }); expect(surface.counts).toMatchObject({ hooks: 1, targets: 2 }); - expect(surface.pages).toContain('hooks'); + expect(applicationGroup(surface, 'events')).toMatchObject({ leaves: expect.any(Array) }); const prebuiltOnly = await createProjectFixture({ config: [ @@ -290,8 +323,7 @@ describe('capability counts', () => { const hidden = await inspectWorkbenchSurface({ root: prebuiltOnly.root }); expect(hidden.counts).toMatchObject({ hooks: 0, targets: 1 }); - expect(hidden.pages).not.toContain('hooks'); - expect(hidden.unavailablePages).toContain('hooks'); + expect(applicationGroup(hidden, 'events')).toMatchObject({ leaves: expect.any(Array) }); } finally { await rm(prebuiltOnly.root, { force: true, recursive: true }); } @@ -300,7 +332,7 @@ describe('capability counts', () => { } }); - it('hides Playground and Hooks when every declaration selects no target', async () => { + it('keeps application groups empty when every declaration selects no target', async () => { const project = await createProjectFixture({ config: [ 'export default {', @@ -322,7 +354,7 @@ describe('capability counts', () => { const surface = await inspectWorkbenchSurface({ root: project.root }); expect(surface.counts).toMatchObject({ hooks: 0, scripts: 0, targets: 1 }); - expect(surface.unavailablePages).toEqual(expect.arrayContaining(['hooks', 'playground'])); + expect(surface.application.groups).toEqual([]); } finally { await rm(project.root, { force: true, recursive: true }); } @@ -381,12 +413,12 @@ describe('preparation parity with the Workbench server', () => { expect(surface.provenance.configPath).toBe(resolve(project.root, 'workbench.config.ts')); expect(surface.provenance.targets).toEqual(['claude', 'codex']); expect(surface.counts).toMatchObject({ evalSuites: 1, targets: 2 }); - expect(surface.pages).toContain('evals'); + expect(surface.advanced).toContain('evals'); const byDefault = await inspectWorkbenchSurface({ root: project.root }); expect(byDefault.provenance.configPath).toBe(project.configPath); expect(byDefault.counts).toMatchObject({ evalSuites: 0, targets: 2 }); - expect(byDefault.unavailablePages).toContain('evals'); + expect(byDefault.advanced).not.toContain('evals'); } finally { await rm(project.root, { force: true, recursive: true }); } From c9caf71b25410d01e4c002d101bd5ec8fdc5fe53 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:51:07 +0000 Subject: [PATCH 06/43] feat(workbench): add application tree model and view --- LANE-NOTES.md | 78 ++++ .../agent-bundle/src/contracts/application.ts | 23 ++ .../src/dev/routes/application-tree.ts | 391 ++++++++++++++++++ .../tests/application-tree.test.ts | 193 +++++++++ .../src/application/application-tree-model.ts | 158 ++++--- .../src/application/application-tree.css | 147 +++++++ .../src/application/application-tree.tsx | 199 +++++++++ .../tests/application-tree-model.test.ts | 121 ++++++ .../workbench/tests/application-tree.test.tsx | 100 +++++ 9 files changed, 1329 insertions(+), 81 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/contracts/application.ts create mode 100644 packages/agent-bundle/src/dev/routes/application-tree.ts create mode 100644 packages/agent-bundle/tests/application-tree.test.ts create mode 100644 packages/workbench/src/application/application-tree.css create mode 100644 packages/workbench/src/application/application-tree.tsx create mode 100644 packages/workbench/tests/application-tree-model.test.ts create mode 100644 packages/workbench/tests/application-tree.test.tsx diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..bd5570731 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,78 @@ +# L2 — Application tree model + tree view + +## Files + +Added: + +- `packages/agent-bundle/src/dev/routes/application-tree.ts` +- `packages/agent-bundle/src/contracts/application.ts` +- `packages/agent-bundle/tests/application-tree.test.ts` +- `packages/workbench/src/application/application-tree.tsx` +- `packages/workbench/src/application/application-tree.css` +- `packages/workbench/tests/application-tree-model.test.ts` +- `packages/workbench/tests/application-tree.test.tsx` + +Changed: + +- `packages/workbench/src/application/application-tree-model.ts` + +## Exported API + +The browser-safe agent-bundle contract exports the moved `ApplicationTree`, +`ApplicationGroup`, `ApplicationServerGroup`, `ApplicationSubgroup`, +`ApplicationLeaf`, `ApplicationLeafExecution`, and `ApplicationGroupKind` +types plus: + +- `applicationTreeForManifest` +- `findApplicationLeaf` +- `applicationLeafForRouteId` +- `applicationLeaves` +- `firstApplicationLeaf` +- `filterApplicationTree` + +The Workbench model re-exports those types/functions and adds +`applicationTreeFor(ApplicationTreeSources)`. `ApplicationTreeView` owns its +component stylesheet directly; no global `styles.css` import is needed. + +## Derivation decisions + +- Group order is MCP, Events / Hooks, CLI, Scripts, Skills, Rules / Commands; + empty top-level leaf groups and empty MCP subgroups are omitted. +- Inspection-only MCP servers remain visible as zero-leaf server nodes because + `ApplicationNodeRef` has no server leaf identity. +- Configuration-only hooks and scripts are deduplicated across artifact + targets, use document execution, and carry the exact description + `configured in agent-bundle.config, no route module`. +- The current route manifest and artifact inspection contracts expose no host + command or rule inventory. Rules / Commands is therefore omitted. + +## Cross-lane requests + +- L5 must mount/import `ApplicationTreeView` from + `application/application-tree.tsx`; this lane cannot edit the shell-owned + entry point. +- `rstest.integration-tests.ts` currently sets `workspaceTestFileGlob` to + `packages/**/tests/**/*.test.ts`, so it silently excludes the required TSX + component test. Change it to + `packages/**/tests/**/*.test.{ts,tsx}` during integration. This lane ran the + file explicitly with Rstest's `--include` option. + +## Verification + +- `pnpm build` +- `npx tsc --noEmit` +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` +- `pnpm lint` +- 7 model/adapter tests passed. +- 3 React DOM rendering tests passed. + +## Open risks + +- A future manifest or artifact contract that adds command/rule records must + project them into the existing `command` and `rule` node reference kinds. +- Empty inspection-only MCP servers intentionally have no selectable leaf + until the shared node reference contract gains server identity. + +## Proposed changeset + +`Add browser-safe Application tree contracts and derivation helpers for Workbench route surfaces. (#600)` diff --git a/packages/agent-bundle/src/contracts/application.ts b/packages/agent-bundle/src/contracts/application.ts new file mode 100644 index 000000000..fd393ed2f --- /dev/null +++ b/packages/agent-bundle/src/contracts/application.ts @@ -0,0 +1,23 @@ +export { + applicationLeafForRouteId, + applicationLeaves, + applicationTreeForManifest, + filterApplicationTree, + findApplicationLeaf, + firstApplicationLeaf, +} from '../dev/routes/application-tree.ts'; +export type { + ApplicationGroup, + ApplicationGroupKind, + ApplicationLeaf, + ApplicationLeafExecution, + ApplicationServerGroup, + ApplicationSubgroup, + ApplicationTree, + ApplicationTreeInspectionHook, + ApplicationTreeInspectionMcpServer, + ApplicationTreeInspectionScript, + ApplicationTreeManifestSources, + ApplicationTreeSkill, + ApplicationTreeState, +} from '../dev/routes/application-tree.ts'; diff --git a/packages/agent-bundle/src/dev/routes/application-tree.ts b/packages/agent-bundle/src/dev/routes/application-tree.ts new file mode 100644 index 000000000..afe91621c --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/application-tree.ts @@ -0,0 +1,391 @@ +import type { Diagnostic } from '../../core/diagnostics.ts'; +import type { RouteInputSchema } from '../../routes/types.ts'; +import type { + RouteManifest, + RouteManifestCliCommand, + RouteManifestConfigEntry, + RouteManifestKind, + RouteManifestRoute, +} from './route-manifest.ts'; +import { + applicationNodeKey, + applicationNodeRefForRouteId, + sameApplicationNodeRef, + type ApplicationNodeRef, +} from './application-node.ts'; + +export type ApplicationGroupKind = 'cli' | 'events' | 'mcp' | 'rules' | 'scripts' | 'skills'; + +export type ApplicationLeafExecution = 'invoke' | 'preview' | 'document'; + +export interface ApplicationLeaf { + readonly command?: RouteManifestCliCommand; + readonly config: readonly RouteManifestConfigEntry[]; + readonly description?: string; + readonly event?: string; + readonly execution: ApplicationLeafExecution; + readonly inputSchema?: RouteInputSchema; + readonly key: string; + readonly label: string; + readonly ref: ApplicationNodeRef; + readonly routeId?: string; + readonly source?: string; +} + +export interface ApplicationSubgroup { + readonly key: string; + readonly label: string; + readonly leaves: readonly ApplicationLeaf[]; +} + +export interface ApplicationServerGroup { + readonly key: string; + readonly label: string; + readonly mode: string; + readonly server: string; + readonly subgroups: readonly ApplicationSubgroup[]; +} + +export type ApplicationGroup = + | Readonly<{ + readonly key: string; + readonly kind: 'mcp'; + readonly label: 'MCP'; + readonly servers: readonly ApplicationServerGroup[]; + }> + | Readonly<{ + readonly key: string; + readonly kind: Exclude; + readonly label: string; + readonly leaves: readonly ApplicationLeaf[]; + }>; + +export type ApplicationTreeState = 'fresh' | 'stale' | 'unavailable'; + +export interface ApplicationTree { + readonly diagnostics: readonly Diagnostic[]; + readonly groups: readonly ApplicationGroup[]; + readonly leafCount: number; + readonly message?: string; + readonly state: ApplicationTreeState; +} + +export interface ApplicationTreeSkill { + readonly id: string; + readonly label: string; + readonly source?: string; +} + +export interface ApplicationTreeInspectionHook { + readonly event: string; + readonly id: string; + readonly name: string; + readonly path: string; + readonly target: string; +} + +export interface ApplicationTreeInspectionMcpServer { + readonly kind: string; + readonly name: string; + readonly target: string; +} + +export interface ApplicationTreeInspectionScript { + readonly file?: Readonly<{ readonly path: string }>; + readonly id: string; + readonly name: string; + readonly target: string; +} + +export interface ApplicationTreeManifestSources { + readonly inspection?: Readonly<{ + readonly hooks: readonly ApplicationTreeInspectionHook[]; + readonly mcpServers: readonly ApplicationTreeInspectionMcpServer[]; + readonly scripts: readonly ApplicationTreeInspectionScript[]; + }>; + readonly manifest?: RouteManifest; + readonly message?: string; + readonly skills?: readonly ApplicationTreeSkill[]; + readonly state: ApplicationTreeState; +} + +const configuredOnlyDescription = 'configured in agent-bundle.config, no route module'; + +const byLabel = (left: ApplicationLeaf, right: ApplicationLeaf): number => + left.label.localeCompare(right.label) || left.key.localeCompare(right.key); + +const routeLabel = (ref: ApplicationNodeRef): string => { + switch (ref.kind) { + case 'app': + case 'prompt': + case 'resource': + case 'tool': + return ref.name; + case 'event': + return ref.event; + case 'cli': + return ref.path.join(' '); + case 'script': + return ref.name; + case 'skill': + case 'command': + case 'rule': + return ref.id; + default: { + const exhaustive: never = ref; + return exhaustive; + } + } +}; + +const executionFor = (kind: RouteManifestKind): ApplicationLeafExecution => { + switch (kind) { + case 'app': + return 'preview'; + case 'cli': + case 'event-route': + case 'prompt': + case 'resource': + case 'script': + case 'tool': + return 'invoke'; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +const leafForRoute = ( + route: RouteManifestRoute, + command?: RouteManifestCliCommand, +): ApplicationLeaf | undefined => { + const ref = applicationNodeRefForRouteId(route.id); + if (ref === undefined) return undefined; + return Object.freeze({ + ...(command === undefined ? {} : { command }), + config: route.config, + ...(route.description === undefined ? {} : { description: route.description }), + ...(route.event === undefined ? {} : { event: route.event }), + execution: executionFor(route.kind), + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), + key: applicationNodeKey(ref), + label: routeLabel(ref), + ref, + routeId: route.id, + source: route.source, + }); +}; + +const leavesForRoutes = ( + routes: readonly RouteManifestRoute[], + commands: ReadonlyMap = new Map(), +): readonly ApplicationLeaf[] => Object.freeze(routes + .flatMap((route) => { + const leaf = leafForRoute(route, commands.get(route.id)); + return leaf === undefined ? [] : [leaf]; + }) + .sort(byLabel)); + +const subgroupLabels = { + app: 'Apps', + prompt: 'Prompts', + resource: 'Resources', + tool: 'Tools', +} as const; + +const mcpKinds = ['tool', 'resource', 'prompt', 'app'] as const; + +const mcpServers = ( + manifest: RouteManifest | undefined, + inspection: ApplicationTreeManifestSources['inspection'], +): readonly ApplicationServerGroup[] => { + const servers = new Map(); + for (const server of manifest?.servers ?? []) { + const subgroups = mcpKinds.flatMap((kind) => { + const leaves = leavesForRoutes(server.routes.filter((route) => route.kind === kind)); + return leaves.length === 0 + ? [] + : [Object.freeze({ + key: `mcp:${server.name}:${kind}`, + label: subgroupLabels[kind], + leaves, + })]; + }); + servers.set(server.name, Object.freeze({ + key: `mcp:${server.name}`, + label: server.name, + mode: server.mode, + server: server.name, + subgroups: Object.freeze(subgroups), + })); + } + for (const server of inspection?.mcpServers ?? []) { + if (servers.has(server.name)) continue; + servers.set(server.name, Object.freeze({ + key: `mcp:${server.name}`, + label: server.name, + mode: server.kind, + server: server.name, + subgroups: Object.freeze([]), + })); + } + return Object.freeze([...servers.values()].sort((left, right) => left.label.localeCompare(right.label))); +}; + +const projectGroup = ( + kind: Exclude, + label: string, + leaves: readonly ApplicationLeaf[], +): ApplicationGroup | undefined => leaves.length === 0 + ? undefined + : Object.freeze({ key: kind, kind, label, leaves: Object.freeze([...leaves].sort(byLabel)) }); + +const configuredHookLeaves = ( + inspection: ApplicationTreeManifestSources['inspection'], + existing: ReadonlySet, +): readonly ApplicationLeaf[] => { + const leaves = new Map(); + for (const hook of inspection?.hooks ?? []) { + const ref = Object.freeze({ event: hook.event, kind: 'event' as const }); + const key = applicationNodeKey(ref); + if (existing.has(key) || leaves.has(key)) continue; + leaves.set(key, Object.freeze({ + config: Object.freeze([]), + description: configuredOnlyDescription, + event: hook.event, + execution: 'document', + key, + label: hook.event, + ref, + source: hook.path, + })); + } + return Object.freeze([...leaves.values()]); +}; + +const configuredScriptLeaves = ( + inspection: ApplicationTreeManifestSources['inspection'], + existing: ReadonlySet, +): readonly ApplicationLeaf[] => { + const leaves = new Map(); + for (const script of inspection?.scripts ?? []) { + const ref = Object.freeze({ kind: 'script' as const, name: script.name }); + const key = applicationNodeKey(ref); + if (existing.has(key) || leaves.has(key)) continue; + leaves.set(key, Object.freeze({ + config: Object.freeze([]), + description: configuredOnlyDescription, + execution: 'document', + key, + label: script.name, + ref, + ...(script.file === undefined ? {} : { source: script.file.path }), + })); + } + return Object.freeze([...leaves.values()]); +}; + +const skillLeaves = (skills: readonly ApplicationTreeSkill[]): readonly ApplicationLeaf[] => + Object.freeze(skills.map((skill) => { + const ref = Object.freeze({ id: skill.id, kind: 'skill' as const }); + return Object.freeze({ + config: Object.freeze([]), + execution: 'document' as const, + key: applicationNodeKey(ref), + label: skill.label, + ref, + ...(skill.source === undefined ? {} : { source: skill.source }), + }); + })); + +export const applicationLeaves = (tree: ApplicationTree): readonly ApplicationLeaf[] => Object.freeze( + tree.groups.flatMap((group) => group.kind === 'mcp' + ? group.servers.flatMap((server) => server.subgroups.flatMap((subgroup) => subgroup.leaves)) + : group.leaves), +); + +export const applicationTreeForManifest = ( + sources: ApplicationTreeManifestSources, +): ApplicationTree => { + const manifest = sources.manifest; + const routeEvents = leavesForRoutes(manifest?.events ?? []); + const routeScripts = leavesForRoutes(manifest?.scripts ?? []); + const commands = new Map((manifest?.cli?.commands ?? []).map((command) => [command.routeId, command])); + const routeCli = leavesForRoutes(manifest?.cli?.routes ?? [], commands); + const existing = new Set([ + ...routeEvents.map((leaf) => leaf.key), + ...routeScripts.map((leaf) => leaf.key), + ]); + const servers = mcpServers(manifest, sources.inspection); + const groups = [ + ...(servers.length === 0 + ? [] + : [Object.freeze({ key: 'mcp', kind: 'mcp' as const, label: 'MCP' as const, servers })]), + projectGroup('events', 'Events / Hooks', [...routeEvents, ...configuredHookLeaves(sources.inspection, existing)]), + projectGroup('cli', 'CLI', routeCli), + projectGroup('scripts', 'Scripts', [...routeScripts, ...configuredScriptLeaves(sources.inspection, existing)]), + projectGroup('skills', 'Skills', skillLeaves(sources.skills ?? [])), + ].filter((group): group is ApplicationGroup => group !== undefined); + const provisional: ApplicationTree = Object.freeze({ + diagnostics: Object.freeze([...(manifest?.diagnostics ?? [])]), + groups: Object.freeze(groups), + leafCount: 0, + ...(sources.message === undefined ? {} : { message: sources.message }), + state: sources.state, + }); + return Object.freeze({ ...provisional, leafCount: applicationLeaves(provisional).length }); +}; + +export const findApplicationLeaf = ( + tree: ApplicationTree, + ref: ApplicationNodeRef, +): ApplicationLeaf | undefined => + applicationLeaves(tree).find((leaf) => sameApplicationNodeRef(leaf.ref, ref)); + +export const applicationLeafForRouteId = ( + tree: ApplicationTree, + routeId: string, +): ApplicationLeaf | undefined => { + const ref = applicationNodeRefForRouteId(routeId); + return ref === undefined ? undefined : findApplicationLeaf(tree, ref); +}; + +export const firstApplicationLeaf = (tree: ApplicationTree): ApplicationLeaf | undefined => + applicationLeaves(tree)[0]; + +const matchesQuery = (leaf: ApplicationLeaf, query: string): boolean => + [leaf.label, leaf.description, leaf.routeId, leaf.source] + .some((value) => value?.toLocaleLowerCase().includes(query)); + +export const filterApplicationTree = ( + tree: ApplicationTree, + query: string, +): ApplicationTree => { + const normalized = query.trim().toLocaleLowerCase(); + if (normalized.length === 0) return tree; + const groups = tree.groups.flatMap((group): readonly ApplicationGroup[] => { + if (group.kind !== 'mcp') { + const leaves = group.leaves.filter((leaf) => matchesQuery(leaf, normalized)); + return leaves.length === 0 ? [] : [Object.freeze({ ...group, leaves: Object.freeze(leaves) })]; + } + const servers = group.servers.flatMap((server) => { + const subgroups = server.subgroups.flatMap((subgroup) => { + const leaves = subgroup.leaves.filter((leaf) => matchesQuery(leaf, normalized)); + return leaves.length === 0 ? [] : [Object.freeze({ ...subgroup, leaves: Object.freeze(leaves) })]; + }); + return subgroups.length === 0 ? [] : [Object.freeze({ ...server, subgroups: Object.freeze(subgroups) })]; + }); + return servers.length === 0 ? [] : [Object.freeze({ ...group, servers: Object.freeze(servers) })]; + }); + return Object.freeze({ + ...tree, + groups: Object.freeze(groups), + leafCount: groups.reduce((total, group) => total + ( + group.kind === 'mcp' + ? group.servers.reduce((serverTotal, server) => + serverTotal + server.subgroups.reduce((subgroupTotal, subgroup) => subgroupTotal + subgroup.leaves.length, 0), 0) + : group.leaves.length + ), 0), + }); +}; diff --git a/packages/agent-bundle/tests/application-tree.test.ts b/packages/agent-bundle/tests/application-tree.test.ts new file mode 100644 index 000000000..bfa5f6101 --- /dev/null +++ b/packages/agent-bundle/tests/application-tree.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from '@rstest/core'; + +import type { RouteManifest } from '../src/contracts/routes.ts'; +import { applicationNodeRefForRouteId } from '../src/dev/routes/application-node.ts'; +import { + applicationLeafForRouteId, + applicationLeaves, + applicationTreeForManifest, + filterApplicationTree, + findApplicationLeaf, + firstApplicationLeaf, +} from '../src/dev/routes/application-tree.ts'; + +const route = ( + id: string, + kind: RouteManifest['events'][number]['kind'], + source: string, + extra: Partial = {}, +): RouteManifest['events'][number] => ({ + config: [], + id, + kind, + provenance: { kind: 'conventional' }, + source, + ...extra, +}); + +const manifest: RouteManifest = { + cli: { + commands: [{ + aliases: [], + description: 'Audit a library', + exitCode: 'zero', + options: [], + path: ['library', 'audit'], + routeId: 'cli:library/audit', + }], + mode: 'generated', + routes: [route('cli:library/audit', 'cli', 'src/cli/library/audit.ts')], + }, + diagnostics: [{ code: 'AB4801', message: 'Fixture diagnostic.', severity: 'warning' }], + digest: 'd'.repeat(64), + events: [ + route('event:tool/before', 'event-route', 'src/events/tool/before.ts', { event: 'tool/before' }), + ], + providers: [], + scripts: [ + route('script:zeta', 'script', 'src/scripts/zeta.ts'), + route('script:alpha', 'script', 'src/scripts/alpha.ts'), + ], + servers: [ + { + id: 'mcp:zeta', + mode: 'generated', + name: 'zeta', + routes: [route('tool:zeta/z-last', 'tool', 'src/mcp/zeta/tools/z-last.ts')], + }, + { + id: 'mcp:alpha', + mode: 'generated', + name: 'alpha', + routes: [ + route('tool:alpha/z-tool', 'tool', 'src/mcp/alpha/tools/z-tool.ts'), + route('tool:alpha/a-tool', 'tool', 'src/mcp/alpha/tools/a-tool.ts', { + description: 'Alpha tool', + inputSchema: { additionalProperties: false, properties: {}, type: 'object' }, + }), + route('resource:alpha/catalog', 'resource', 'src/mcp/alpha/resources/catalog.ts'), + route('prompt:alpha/recommend', 'prompt', 'src/mcp/alpha/prompts/recommend.ts'), + route('app:alpha/dashboard', 'app', 'src/mcp/alpha/apps/dashboard.ts'), + ], + }, + ], + sourceRevision: 'r'.repeat(64), +}; + +const tree = () => applicationTreeForManifest({ + inspection: { + hooks: [{ + event: 'session/start', + id: 'hook:configured', + name: 'configured-hook', + path: 'hooks/configured.mjs', + target: 'claude', + }, { + event: 'session/start', + id: 'hook:configured-codex', + name: 'configured-hook', + path: 'codex/hooks/configured.mjs', + target: 'codex', + }], + mcpServers: [{ kind: 'stdio', name: 'external', target: 'portable' }], + scripts: [ + { id: 'script:configured', name: 'configured', target: 'portable' }, + { id: 'script:configured-claude', name: 'configured', target: 'claude' }, + ], + }, + manifest, + skills: [ + { id: 'skill:zeta', label: 'Zeta skill', source: 'skills/zeta/SKILL.md' }, + { id: 'skill:alpha', label: 'Alpha skill', source: 'skills/alpha/SKILL.md' }, + ], + state: 'fresh', +}); + +describe('application tree derivation', () => { + it('covers every route kind in fixed group and subgroup order', () => { + const result = tree(); + + expect(result.groups.map((group) => group.kind)).toEqual([ + 'mcp', 'events', 'cli', 'scripts', 'skills', + ]); + const mcp = result.groups[0]!; + expect(mcp.kind).toBe('mcp'); + if (mcp.kind !== 'mcp') throw new Error('Expected MCP group.'); + expect(mcp.servers.map((server) => server.server)).toEqual(['alpha', 'external', 'zeta']); + expect(mcp.servers[0]!.subgroups.map((group) => group.label)).toEqual([ + 'Tools', 'Resources', 'Prompts', 'Apps', + ]); + expect(mcp.servers[0]!.subgroups[0]!.leaves.map((leaf) => leaf.label)).toEqual([ + 'a-tool', 'z-tool', + ]); + expect(mcp.servers[0]!.subgroups.map((group) => group.leaves[0]!.execution)).toEqual([ + 'invoke', 'invoke', 'invoke', 'preview', + ]); + expect(result.groups.some((group) => group.kind === 'rules')).toBe(false); + expect(result.diagnostics).toEqual(manifest.diagnostics); + }); + + it('adds skills and configured-only hooks and scripts as document leaves', () => { + const result = tree(); + const leaves = applicationLeaves(result); + + expect(leaves.filter((leaf) => leaf.ref.kind === 'skill').map((leaf) => leaf.label)).toEqual([ + 'Alpha skill', 'Zeta skill', + ]); + const configuredHook = leaves.find((leaf) => leaf.ref.kind === 'event' && leaf.ref.event === 'session/start'); + expect(configuredHook).toMatchObject({ + description: 'configured in agent-bundle.config, no route module', + execution: 'document', + }); + expect(configuredHook?.routeId).toBeUndefined(); + const configuredScript = leaves.find((leaf) => leaf.ref.kind === 'script' && leaf.ref.name === 'configured'); + expect(configuredScript).toMatchObject({ + description: 'configured in agent-bundle.config, no route module', + execution: 'document', + }); + expect(configuredScript?.routeId).toBeUndefined(); + expect(leaves.filter((leaf) => leaf.ref.kind === 'event' && leaf.ref.event === 'session/start')).toHaveLength(1); + expect(leaves.filter((leaf) => leaf.ref.kind === 'script' && leaf.ref.name === 'configured')).toHaveLength(1); + expect(leaves.find((leaf) => leaf.ref.kind === 'skill' && leaf.ref.id === 'skill:alpha')).toMatchObject({ + execution: 'document', + source: 'skills/alpha/SKILL.md', + }); + }); + + it('round trips every route id through the shared application node reference', () => { + const result = tree(); + for (const leaf of applicationLeaves(result).filter((candidate) => candidate.routeId !== undefined)) { + const ref = applicationNodeRefForRouteId(leaf.routeId!); + expect(ref).toBeDefined(); + expect(findApplicationLeaf(result, ref!)).toBe(leaf); + expect(applicationLeafForRouteId(result, leaf.routeId!)).toBe(leaf); + } + expect(applicationLeafForRouteId(result, 'tool:missing/nope')).toBeUndefined(); + expect(findApplicationLeaf(result, { kind: 'skill', id: 'missing' })).toBeUndefined(); + }); + + it('filters case-insensitively while preserving structure and state', () => { + const original = tree(); + const result = filterApplicationTree(original, 'A-TOOL'); + + expect(applicationLeaves(result).map((leaf) => leaf.label)).toEqual(['a-tool']); + expect(result.groups.map((group) => group.kind)).toEqual(['mcp']); + expect(result.state).toBe('fresh'); + expect(result.leafCount).toBe(1); + expect(firstApplicationLeaf(result)?.label).toBe('a-tool'); + expect(filterApplicationTree(original, ' ')).toBe(original); + }); + + it('omits empty groups and returns no first leaf for an empty tree', () => { + const empty = applicationTreeForManifest({ + manifest: { ...manifest, cli: undefined, events: [], scripts: [], servers: [] }, + state: 'unavailable', + message: 'Manifest unavailable.', + }); + + expect(empty.groups).toEqual([]); + expect(empty.leafCount).toBe(0); + expect(empty.message).toBe('Manifest unavailable.'); + expect(firstApplicationLeaf(empty)).toBeUndefined(); + }); +}); diff --git a/packages/workbench/src/application/application-tree-model.ts b/packages/workbench/src/application/application-tree-model.ts index dbb52020d..62eea99c0 100644 --- a/packages/workbench/src/application/application-tree-model.ts +++ b/packages/workbench/src/application/application-tree-model.ts @@ -1,93 +1,89 @@ -/** - * The one application tree (#600 §1): every plugin-authored surface as a leaf - * of one tree derived from the compiled route graph (the route manifest), the - * served Skill tree, and the artifact inventory for configuration-declared - * surfaces that have no route module. Navigation derives from this tree, not - * from a list of Workbench pages. - * - * Group order is fixed: MCP (per server: Tools · Resources · Prompts · Apps), - * Events / Hooks, CLI, Scripts, Skills, Rules / Commands. Empty groups are - * omitted. Leaves sort by label within a group. - */ import type { ArtifactInspection } from '../../../agent-bundle/src/contracts/artifacts.ts'; -import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; -import type { RouteInputSchema, RouteManifest, RouteManifestCliCommand, RouteManifestConfigEntry } from '../../../agent-bundle/src/contracts/routes.ts'; +import { + applicationLeafForRouteId, + applicationLeaves, + applicationTreeForManifest, + filterApplicationTree, + findApplicationLeaf, + firstApplicationLeaf, + type ApplicationTree, + type ApplicationTreeState, +} from '../../../agent-bundle/src/contracts/application.ts'; +import type { RouteManifest } from '../../../agent-bundle/src/contracts/routes.ts'; import type { SkillDocumentTree } from '../../../agent-bundle/src/contracts/skills.ts'; -import type { ApplicationNodeRef } from '../../../agent-bundle/src/dev/routes/application-node.ts'; import type { RouteCatalogState } from '../routes/routes-model.ts'; -export type ApplicationGroupKind = 'cli' | 'events' | 'mcp' | 'rules' | 'scripts' | 'skills'; - -/** How a leaf is executed from its workspace. */ -export type ApplicationLeafExecution = - /** Rendered through `POST /api/routes/invocations` (tools, resources, prompts, CLI, scripts, event routes). */ - | 'invoke' - /** Previewed through the MCP App preview (apps). */ - | 'preview' - /** Read-only document (skills, rules, commands). */ - | 'document'; - -export interface ApplicationLeaf { - /** Compiled CLI command grammar; `cli` leaves only. */ - readonly command?: RouteManifestCliCommand; - readonly config: readonly RouteManifestConfigEntry[]; - readonly description?: string; - /** Canonical event id; `event` leaves only. */ - readonly event?: string; - readonly execution: ApplicationLeafExecution; - readonly inputSchema?: RouteInputSchema; - /** Stable key: the leaf's URL path (see `applicationNodeKey`). */ - readonly key: string; - readonly label: string; - readonly ref: ApplicationNodeRef; - /** Compiled route id when the leaf is a compiled route; absent for skills, rules, commands. */ - readonly routeId?: string; - /** Project-relative source path when known. */ - readonly source?: string; -} - -export interface ApplicationSubgroup { - readonly key: string; - readonly label: string; - readonly leaves: readonly ApplicationLeaf[]; -} - -export interface ApplicationServerGroup { - readonly key: string; - readonly label: string; - /** `command | conflict | custom | generated | remote` — the server's manifest mode. */ - readonly mode: string; - readonly server: string; - /** Tools · Resources · Prompts · Apps, non-empty only. */ - readonly subgroups: readonly ApplicationSubgroup[]; -} - -export type ApplicationGroup = - | Readonly<{ readonly key: string; readonly kind: 'mcp'; readonly label: 'MCP'; readonly servers: readonly ApplicationServerGroup[] }> - | Readonly<{ readonly key: string; readonly kind: Exclude; readonly label: string; readonly leaves: readonly ApplicationLeaf[] }>; - -export interface ApplicationTree { - readonly diagnostics: readonly Diagnostic[]; - readonly groups: readonly ApplicationGroup[]; - readonly leafCount: number; - /** Present when the compiled route catalog could not be read. */ - readonly message?: string; - /** Freshness of the compiled route catalog against the published build. */ - readonly state: RouteCatalogState; -} - export interface ApplicationTreeSources { - /** Artifact inventory of the published epoch: configuration-declared hooks, servers, scripts without route modules. */ readonly inspection?: ArtifactInspection; - /** The compiled route manifest; absent when it could not be read (`state` is `unavailable`, `message` says why). */ readonly manifest?: RouteManifest; readonly message?: string; readonly skillTree?: SkillDocumentTree; readonly state: RouteCatalogState; } -// Implemented by the tree lane, as thin adapters over the pure derivation in -// packages/agent-bundle/src/dev/routes/application-tree.ts (shared with the -// `agent-bundle/test` Workbench-surface proof): applicationTreeFor(sources), -// findApplicationLeaf(tree, ref), applicationLeafForRouteId(tree, routeId), -// applicationLeaves(tree), filterApplicationTree(tree, query), firstApplicationLeaf(tree). +const applicationState = (state: RouteCatalogState): ApplicationTreeState => { + switch (state) { + case 'current': + return 'fresh'; + case 'stale': + case 'unavailable': + return state; + default: { + const exhaustive: never = state; + return exhaustive; + } + } +}; + +export const applicationTreeFor = (sources: ApplicationTreeSources): ApplicationTree => + applicationTreeForManifest({ + ...(sources.inspection === undefined ? {} : { + inspection: { + hooks: sources.inspection.runtime.hooks.map((hook) => ({ + event: hook.event, + id: hook.id, + name: hook.name, + path: hook.path, + target: hook.target, + })), + mcpServers: sources.inspection.runtime.mcpServers.map((server) => ({ + kind: server.kind, + name: server.name, + target: server.target, + })), + scripts: sources.inspection.runtime.scripts.map((script) => ({ + file: { path: script.file.path }, + id: script.id, + name: script.name, + target: script.target, + })), + }, + }), + ...(sources.manifest === undefined ? {} : { manifest: sources.manifest }), + ...(sources.message === undefined ? {} : { message: sources.message }), + ...(sources.skillTree === undefined ? {} : { + skills: sources.skillTree.skills.map((skill) => ({ + id: skill.id, + label: skill.name, + ...(skill.provenance === undefined ? {} : { source: skill.provenance.sourcePath }), + })), + }), + state: applicationState(sources.state), + }); + +export { + applicationLeafForRouteId, + applicationLeaves, + filterApplicationTree, + findApplicationLeaf, + firstApplicationLeaf, +}; +export type { + ApplicationGroup, + ApplicationGroupKind, + ApplicationLeaf, + ApplicationLeafExecution, + ApplicationServerGroup, + ApplicationSubgroup, + ApplicationTree, +} from '../../../agent-bundle/src/contracts/application.ts'; diff --git a/packages/workbench/src/application/application-tree.css b/packages/workbench/src/application/application-tree.css new file mode 100644 index 000000000..192ca8eea --- /dev/null +++ b/packages/workbench/src/application/application-tree.css @@ -0,0 +1,147 @@ +.application-tree-view { + background: #f7f9fc; + border-right: 1px solid #d9dee7; + color: #1e2938; + display: grid; + gap: 14px; + min-height: 100%; + padding: 20px 14px; +} + +.application-tree-filter { + color: #596372; + display: grid; + font-size: 11px; + font-weight: 800; + gap: 7px; + letter-spacing: .07em; + text-transform: uppercase; +} + +.application-tree-filter input { + background: #fff; + border: 1px solid #bfc8d5; + border-radius: 5px; + color: #1e2938; + min-height: 38px; + padding: 7px 9px; + text-transform: none; + width: 100%; +} + +.application-tree-banner, +.application-tree-empty { + color: #596372; + font-size: 13px; + line-height: 1.45; + margin: 0; + padding: 10px 11px; +} + +.application-tree-banner { + background: #fff8e8; + border-left: 3px solid #b06c00; + color: #704600; +} + +.application-tree-banner--unavailable { + background: #fff7f7; + border-left-color: #c01d26; + color: #78242a; +} + +.application-tree { + display: grid; + gap: 3px; + min-width: 0; +} + +.application-tree [role='group'] { + display: grid; + gap: 2px; + margin-left: 13px; + min-width: 0; +} + +.application-tree-node { + display: grid; + min-width: 0; +} + +.application-tree-branch, +.application-tree-leaf { + background: transparent; + border: 0; + border-left: 3px solid transparent; + color: inherit; + cursor: pointer; + min-width: 0; + text-align: left; +} + +.application-tree-branch { + align-items: center; + display: grid; + font-size: 13px; + font-weight: 750; + gap: 6px; + grid-template-columns: 12px minmax(0, 1fr) auto; + min-height: 34px; + padding: 6px 7px; +} + +.application-tree-branch:hover, +.application-tree-leaf:hover { + background: #e8effb; +} + +.application-tree-disclosure { + color: #667386; + font-size: 11px; +} + +.application-tree-count { + background: #e4eaf2; + border-radius: 999px; + color: #536174; + font: 700 11px "SFMono-Regular", Consolas, monospace; + min-width: 23px; + padding: 2px 6px; + text-align: center; +} + +.application-tree-leaf { + display: grid; + gap: 3px; + min-height: 36px; + padding: 7px 9px 7px 12px; +} + +.application-tree-leaf > span { + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.application-tree-leaf > small { + color: #657080; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.application-tree-leaf[aria-selected='true'] { + background: #e6effd; + border-left-color: #0b5bd3; + color: #073d8d; +} + +.application-tree-branch:focus-visible, +.application-tree-leaf:focus-visible, +.application-tree-filter input:focus-visible { + outline: 3px solid #72a6ff; + outline-offset: 1px; +} diff --git a/packages/workbench/src/application/application-tree.tsx b/packages/workbench/src/application/application-tree.tsx new file mode 100644 index 000000000..15803d157 --- /dev/null +++ b/packages/workbench/src/application/application-tree.tsx @@ -0,0 +1,199 @@ +import React, { useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'; + +import type { ApplicationNodeRef } from '../../../agent-bundle/src/dev/routes/application-node.ts'; +import { sameApplicationNodeRef } from '../../../agent-bundle/src/dev/routes/application-node.ts'; +import { + filterApplicationTree, + type ApplicationGroup, + type ApplicationLeaf, + type ApplicationServerGroup, + type ApplicationSubgroup, + type ApplicationTree, +} from './application-tree-model.ts'; +import './application-tree.css'; + +export interface ApplicationTreeViewProps { + readonly onQueryChange: (query: string) => void; + readonly onSelect: (ref: ApplicationNodeRef) => void; + readonly query: string; + readonly selected?: ApplicationNodeRef; + readonly tree: ApplicationTree; +} + +const leafCountForServer = (server: ApplicationServerGroup): number => + server.subgroups.reduce((total, subgroup) => total + subgroup.leaves.length, 0); + +const leafCountForGroup = (group: ApplicationGroup): number => + group.kind === 'mcp' + ? group.servers.reduce((total, server) => total + leafCountForServer(server), 0) + : group.leaves.length; + +const Count = ({ value }: { readonly value: number }) => + {value}; + +const Branch = ({ count, expanded, label, onToggle }: { + readonly count: number; + readonly expanded: boolean; + readonly label: string; + readonly onToggle: () => void; +}) => ( + +); + +const Leaf = ({ leaf, onKeyDown, onSelect, selected }: { + readonly leaf: ApplicationLeaf; + readonly onKeyDown: (event: KeyboardEvent, leaf: ApplicationLeaf) => void; + readonly onSelect: (ref: ApplicationNodeRef) => void; + readonly selected: boolean; +}) => ( + +); + +export const ApplicationTreeView = ({ + onQueryChange, + onSelect, + query, + selected, + tree, +}: ApplicationTreeViewProps) => { + const filterId = useId(); + const root = useRef(null); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const visibleTree = useMemo(() => filterApplicationTree(tree, query), [query, tree]); + + const toggle = (key: string): void => { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const leafKeyDown = (event: KeyboardEvent, leaf: ApplicationLeaf): void => { + if (event.key === 'Enter') { + event.preventDefault(); + onSelect(leaf.ref); + return; + } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + const leaves = [...(root.current?.querySelectorAll('[data-application-leaf]') ?? [])]; + const current = leaves.indexOf(event.currentTarget); + const offset = event.key === 'ArrowDown' ? 1 : -1; + leaves[Math.max(0, Math.min(leaves.length - 1, current + offset))]?.focus(); + }; + + const renderLeaves = (leaves: readonly ApplicationLeaf[]) => ( +
+ {leaves.map((leaf) => ( + + ))} +
+ ); + + const renderSubgroup = (subgroup: ApplicationSubgroup) => { + const expanded = !collapsed.has(subgroup.key); + return
+ toggle(subgroup.key)} + /> + {expanded ? renderLeaves(subgroup.leaves) : undefined} +
; + }; + + const renderServer = (server: ApplicationServerGroup) => { + const expanded = !collapsed.has(server.key); + return
+ toggle(server.key)} + /> + {expanded + ?
{server.subgroups.map(renderSubgroup)}
+ : undefined} +
; + }; + + const renderGroup = (group: ApplicationGroup) => { + const expanded = !collapsed.has(group.key); + return
+ toggle(group.key)} + /> + {expanded + ? group.kind === 'mcp' + ?
{group.servers.map(renderServer)}
+ : renderLeaves(group.leaves) + : undefined} +
; + }; + + const stateMessage = tree.message ?? ( + tree.state === 'stale' + ? 'Application routes are newer than the published build.' + : 'Application routes are unavailable.' + ); + + return
+ + {tree.state === 'fresh' + ? undefined + :

+ {stateMessage} +

} + {visibleTree.leafCount === 0 + ?

+ {query.trim().length === 0 + ? 'This project declares no application surfaces.' + : 'No application surfaces match this filter.'} +

+ :
+ {visibleTree.groups.map(renderGroup)} +
} +
; +}; diff --git a/packages/workbench/tests/application-tree-model.test.ts b/packages/workbench/tests/application-tree-model.test.ts new file mode 100644 index 000000000..53631f310 --- /dev/null +++ b/packages/workbench/tests/application-tree-model.test.ts @@ -0,0 +1,121 @@ +import { expect, it } from '@rstest/core'; + +import type { ArtifactInspection } from '../../agent-bundle/src/contracts/artifacts.ts'; +import type { RouteManifest } from '../../agent-bundle/src/contracts/routes.ts'; +import type { SkillDocumentTree } from '../../agent-bundle/src/contracts/skills.ts'; +import { applicationLeaves, applicationTreeFor } from '../src/application/application-tree-model.ts'; + +const digest = 'd'.repeat(64); +const file = { + bytes: 1, + kind: 'generated' as const, + path: 'portable/scripts/configured.mjs', + sha256: digest, + sourceInputs: [], +}; + +const manifest: RouteManifest = { + diagnostics: [], + digest, + events: [], + providers: [], + scripts: [], + servers: [], + sourceRevision: digest, +}; + +const skillTree: SkillDocumentTree = { + diagnostics: [], + skills: [{ + base: { kind: 'source', skillId: 'skill:review' }, + body: '# Review', + diagnostics: [], + frontmatter: { description: 'Review changes', name: 'review' }, + id: 'skill:review', + markdown: '# Review', + name: 'Review changes', + provenance: { kind: 'conventional', sourcePath: 'skills/review/SKILL.md' }, + resources: [], + }], +}; + +const inspection: ArtifactInspection = { + epochId: 'epoch-a', + files: [], + project: { + configDigest: digest, + configPath: 'agent-bundle.config.ts', + modelDigest: digest, + revision: digest, + sourceInputs: [], + }, + provenance: [], + runtime: { + executables: [], + hooks: [{ + event: 'session/start', + file, + id: 'hook:configured', + name: 'configured-hook', + path: 'hooks/configured.mjs', + target: 'claude', + }], + mcpServers: [], + scripts: [{ + file, + id: 'script:configured', + name: 'configured', + target: 'portable', + }], + }, + targets: [], +}; + +it('adapts Workbench skill and artifact sources into the shared pure tree', () => { + const tree = applicationTreeFor({ + inspection, + manifest, + skillTree, + state: 'current', + }); + + expect(tree.state).toBe('fresh'); + expect(applicationLeaves(tree).map((leaf) => ({ + description: leaf.description, + kind: leaf.ref.kind, + label: leaf.label, + source: leaf.source, + }))).toEqual([ + { + description: 'configured in agent-bundle.config, no route module', + kind: 'event', + label: 'session/start', + source: 'hooks/configured.mjs', + }, + { + description: 'configured in agent-bundle.config, no route module', + kind: 'script', + label: 'configured', + source: 'portable/scripts/configured.mjs', + }, + { + description: undefined, + kind: 'skill', + label: 'Review changes', + source: 'skills/review/SKILL.md', + }, + ]); +}); + +it('maps stale and unavailable route catalog states without hiding auxiliary leaves', () => { + expect(applicationTreeFor({ manifest, state: 'stale' }).state).toBe('stale'); + const unavailable = applicationTreeFor({ + message: 'Route manifest is not available.', + skillTree, + state: 'unavailable', + }); + + expect(unavailable.state).toBe('unavailable'); + expect(unavailable.message).toBe('Route manifest is not available.'); + expect(applicationLeaves(unavailable).map((leaf) => leaf.label)).toEqual(['Review changes']); +}); diff --git a/packages/workbench/tests/application-tree.test.tsx b/packages/workbench/tests/application-tree.test.tsx new file mode 100644 index 000000000..8950c0cc7 --- /dev/null +++ b/packages/workbench/tests/application-tree.test.tsx @@ -0,0 +1,100 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { expect, it } from '@rstest/core'; + +import type { ApplicationTree } from '../src/application/application-tree-model.ts'; +import { ApplicationTreeView } from '../src/application/application-tree.tsx'; + +const tree: ApplicationTree = { + diagnostics: [], + groups: [{ + key: 'mcp', + kind: 'mcp', + label: 'MCP', + servers: [{ + key: 'mcp:library', + label: 'library', + mode: 'generated', + server: 'library', + subgroups: [{ + key: 'mcp:library:tools', + label: 'Tools', + leaves: [{ + config: [], + execution: 'invoke', + key: '/routes/mcp/library/tool/search', + label: 'search', + ref: { kind: 'tool', name: 'search', server: 'library' }, + routeId: 'tool:library/search', + source: 'src/mcp/library/tools/search.ts', + }], + }], + }], + }, { + key: 'skills', + kind: 'skills', + label: 'Skills', + leaves: [{ + config: [], + execution: 'document', + key: '/routes/skills/review', + label: 'Review', + ref: { id: 'review', kind: 'skill' }, + source: 'skills/review/SKILL.md', + }], + }], + leafCount: 2, + state: 'fresh', +}; + +const render = ( + value: ApplicationTree = tree, + selected = tree.groups[0]!.kind === 'mcp' + ? tree.groups[0]!.servers[0]!.subgroups[0]!.leaves[0]!.ref + : undefined, +): string => renderToStaticMarkup(createElement(ApplicationTreeView, { + onQueryChange: () => undefined, + onSelect: () => undefined, + query: '', + selected, + tree: value, +})); + +it('renders an accessible expanded application tree with counts and selection', () => { + const markup = render(); + + expect(markup).toContain('>Filter application'); + expect(markup).toMatch(/]*for="([^"]+)"[^>]*>.* { + const stale = render({ ...tree, message: 'Rebuild to publish these routes.', state: 'stale' }); + const unavailable = render({ ...tree, message: 'Manifest unavailable.', state: 'unavailable' }); + + expect(stale).toContain('role="status"'); + expect(stale).toContain('Rebuild to publish these routes.'); + expect(unavailable).toContain('role="alert"'); + expect(unavailable).toContain('Manifest unavailable.'); +}); + +it('renders a clear empty state and preserves the controlled filter value', () => { + const markup = renderToStaticMarkup(createElement(ApplicationTreeView, { + onQueryChange: () => undefined, + onSelect: () => undefined, + query: 'missing', + tree: { ...tree, groups: [], leafCount: 0 }, + })); + + expect(markup).toContain('value="missing"'); + expect(markup).toContain('No application surfaces match this filter.'); +}); From 44609e48cb2105a0838c2f99bc5c2280d9d627bb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:49:24 +0000 Subject: [PATCH 07/43] feat(workbench): unify route invocation backends --- LANE-NOTES.md | 144 +++ .../src/application/dev-server-backend.ts | 57 ++ .../src/application/invocation-client.ts | 197 ++++ .../src/application/invocation-model.ts | 102 ++ .../src/application/runtime-backend.ts | 368 +++++++ packages/workbench/src/runtime-controller.ts | 428 ++++++++ packages/workbench/src/runtime-evidence.tsx | 45 - packages/workbench/src/runtime-inspector.tsx | 153 --- packages/workbench/src/runtime-playground.tsx | 678 ------------- packages/workbench/src/runtime-stage.tsx | 143 --- .../workbench/src/runtime-view-contracts.ts | 44 + .../tests/dev-server-backend.test.ts | 115 +++ .../workbench/tests/invocation-client.test.ts | 125 +++ .../workbench/tests/invocation-model.test.ts | 124 +++ .../workbench/tests/runtime-backend.test.ts | 167 ++++ .../tests/runtime-contract-compile.test.ts | 14 +- .../tests/runtime-controller.test.ts | 222 +++++ .../runtime-document-atoms-disposal.test.ts | 181 ---- .../workbench/tests/runtime-inspector.test.ts | 139 --- .../tests/runtime-mcp-handoff.test.ts | 2 +- ...runtime-playground-capture-cleanup.test.ts | 75 -- .../tests/runtime-playground-capture.test.ts | 146 --- .../tests/runtime-playground-hmr.e2e.test.ts | 432 -------- .../tests/runtime-playground.e2e.test.ts | 633 ------------ .../tests/runtime-playground.test.ts | 919 ------------------ .../workbench/tests/runtime-stage.test.ts | 374 ------- 26 files changed, 2096 insertions(+), 3931 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/workbench/src/application/dev-server-backend.ts create mode 100644 packages/workbench/src/application/invocation-client.ts create mode 100644 packages/workbench/src/application/invocation-model.ts create mode 100644 packages/workbench/src/application/runtime-backend.ts create mode 100644 packages/workbench/src/runtime-controller.ts delete mode 100644 packages/workbench/src/runtime-evidence.tsx delete mode 100644 packages/workbench/src/runtime-inspector.tsx delete mode 100644 packages/workbench/src/runtime-playground.tsx delete mode 100644 packages/workbench/src/runtime-stage.tsx create mode 100644 packages/workbench/src/runtime-view-contracts.ts create mode 100644 packages/workbench/tests/dev-server-backend.test.ts create mode 100644 packages/workbench/tests/invocation-client.test.ts create mode 100644 packages/workbench/tests/invocation-model.test.ts create mode 100644 packages/workbench/tests/runtime-backend.test.ts create mode 100644 packages/workbench/tests/runtime-controller.test.ts delete mode 100644 packages/workbench/tests/runtime-document-atoms-disposal.test.ts delete mode 100644 packages/workbench/tests/runtime-inspector.test.ts delete mode 100644 packages/workbench/tests/runtime-playground-capture-cleanup.test.ts delete mode 100644 packages/workbench/tests/runtime-playground-capture.test.ts delete mode 100644 packages/workbench/tests/runtime-playground-hmr.e2e.test.ts delete mode 100644 packages/workbench/tests/runtime-playground.e2e.test.ts delete mode 100644 packages/workbench/tests/runtime-playground.test.ts delete mode 100644 packages/workbench/tests/runtime-stage.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..80b893098 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,144 @@ +# L4 — invocation client and backends + +## Commits + +- `feat(workbench): unify route invocation backends` +- `STUBS (drop on integration)` — drop this final commit after L5 rewires + `main.tsx` and the MCP type imports described below. + +## Added + +- `packages/workbench/src/application/invocation-client.ts` + - `InvocationClient({ foreground })` + - `invoke(request, signal?)`, `list(limit?, signal?)`, `read(id, signal?)` + - strict decoders for the complete `RouteInvocation` and + `RouteInvocationSummary` wire shapes + - the only `InvocationClientError` declaration (`code`, `status?`, + `diagnostics?`) +- `packages/workbench/src/application/invocation-model.ts` + - `InvocationState`, `InvocationAction`, `reduceInvocationState` + - guarded strict-JSON `readLastInput` / `writeLastInput` + - `selectBackend`, `invocationSummaryOf` +- `packages/workbench/src/application/dev-server-backend.ts` + - dev-server invocation/history/read delegation + - route-filtered history + - `route.invocation` project-event subscription +- `packages/workbench/src/application/runtime-backend.ts` + - matches tools/resources by runtime `mcp.` surface and events to hook + surfaces + - invokes the runtime provider, reads Agent Document events, and maps + completed runs to `RouteInvocation` + - maps runtime history/read/subscription into the common envelope +- `packages/workbench/src/runtime-controller.ts` + - extracted kept Runtime engine: reducer controller, effect draining, + project-event buffering, and bootstrap retry policy +- `packages/workbench/src/runtime-view-contracts.ts` + - non-page MCP App preview and handoff contracts formerly declared by + `runtime-stage.tsx` / `runtime-playground.tsx` +- focused tests: + - `invocation-client.test.ts` + - `invocation-model.test.ts` + - `dev-server-backend.test.ts` + - `runtime-backend.test.ts` + - `runtime-controller.test.ts` + +## Deleted + +Runtime destination UI and page-only tests: + +- `runtime-evidence.tsx` +- `runtime-inspector.tsx` +- the implementation of `runtime-playground.tsx` +- the implementation of `runtime-stage.tsx` +- Runtime playground/stage/inspector/capture browser and unit tests + +The final commit temporarily restores `runtime-playground.tsx` and +`runtime-stage.tsx` as integration-only stubs because the L5-owned `main.tsx` +and MCP-owned modules still import the old paths on this isolated branch. +Dropping that final commit completes their deletion. + +## Production import graph after integration + +- L5 `main.tsx` → `invocation-client.ts` → `runtime/agent-document-client.ts` +- L5 `main.tsx` → `dev-server-backend.ts` → `invocation-client.ts` +- L5 `main.tsx` → `runtime-backend.ts` → + `runtime-controller.ts`, `invocation-model.ts`, + `runtime/agent-document-client.ts` +- L5 `main.tsx` → `runtime-client.ts` → + `runtime/agent-document-client.ts` +- L5 `main.tsx` → `runtime-controller.ts` → `runtime-model.ts`, + `runtime-client.ts`, `runtime/agent-document-client.ts` +- MCP App preview/handoff modules → `runtime-view-contracts.ts` → + `runtime-model.ts` +- `lifecycles/lifecycle-client.ts` and the new invocation/runtime clients keep + `runtime/agent-document-client.ts` live. +- L3 owns `runtime/agent-document-stage.tsx` and its move into the Application + renderer. `runtime/agent-document-atoms.ts` lost its old production importer + when `runtime-inspector.tsx` was deleted; L3 must either import it from the + moved renderer or delete it and update `atom-error-channels.test.ts`. + +## Exact L5 integration + +Use the existing shared foreground authority and project event source: + +```ts +const invocationClient = new InvocationClient({ foreground: foregroundClient }); +const devServerBackend = createDevServerBackend({ + client: invocationClient, + events: { subscribe: (listener) => projectClient.subscribeEvents(listener) }, +}); +const runtimeBackend = runtimeController === undefined + ? undefined + : createRuntimeBackend({ controller: runtimeController, runtimeClient }); +const backends = runtimeBackend === undefined + ? [devServerBackend] + : [runtimeBackend, devServerBackend]; +``` + +Pass `backends` to `RouteWorkspace`. Runtime must remain first so +`selectBackend` prefers its matching RSC surface and falls back to the +dev-server backend for every other invokable route. + +The current source does **not** have a `workbench-capabilities.ts` `runtime` +field. Runtime is actually gated in `main.tsx` by +`status.runtime?.state === 'configured'`, followed by a successful +`RuntimeClient.bootstrap()`. Preserve that gate (or use L5's replacement +capability field if L5 adds one). + +After rewiring: + +1. import `createRuntimeEventBuffer`, `createRuntimePlaygroundController`, + `runtimeBootstrapRetryPlan`, and `RuntimePlaygroundController` from + `runtime-controller.ts`; +2. import App preview/handoff types from `runtime-view-contracts.ts` in + `main.tsx`, `mcp/mcp-page.tsx`, `mcp/mcp-app-preview.tsx`, and + `mcp/runtime-mcp-handoff.ts`; +3. remove the Runtime navigation destination and `RuntimePlayground` render; +4. drop the final `STUBS (drop on integration)` commit. + +## Cross-lane dependencies and risks + +- L1 must add `route.invocation` to `ProjectEventMessage`, the browser + `projectEventTypes` list, and its strict project-event payload decoder. This + lane uses the agreed local structural view until that union lands. +- Runtime surfaces do not carry an MCP server id. A runtime tool/resource is + matched by the provider convention `mcp.`; duplicate names across + servers are therefore indistinguishable and fall back to the dev-server + backend unless the runtime contract later gains server identity. +- Runtime history comes from the controller's bootstrapped/live run model; the + Runtime client has no independent list method. +- `runtime/agent-document-stage.tsx` was not touched, per L3 ownership. +- No changeset: Workbench is private and this lane changes no publishable + package. + +## Verification + +- `pnpm build` +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` +- `pnpm lint` +- 96 focused tests passed across the four new invocation suites and retained + Runtime client/model/controller/contract/handoff suites. + +## Proposed changeset line + +None (private Workbench-only change). diff --git a/packages/workbench/src/application/dev-server-backend.ts b/packages/workbench/src/application/dev-server-backend.ts new file mode 100644 index 000000000..284bb1199 --- /dev/null +++ b/packages/workbench/src/application/dev-server-backend.ts @@ -0,0 +1,57 @@ +import type { + RouteInvocationEventPayload, + RouteInvocationRequest, +} from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { ProjectEventMessage } from '../../../agent-bundle/src/contracts/project.ts'; +import type { ApplicationLeaf } from './application-tree-model.ts'; +import type { InvocationBackend } from './invocation-backend.ts'; +import type { InvocationClient } from './invocation-client.ts'; + +export interface DevServerBackendOptions { + readonly client: InvocationClient; + readonly events: Readonly<{ + subscribe(listener: (event: ProjectEventMessage) => void): () => void; + }>; +} + +type RouteInvocationProjectEvent = Readonly<{ + readonly payload: RouteInvocationEventPayload; + readonly type: 'route.invocation'; +}>; + +const routeInvocationEvent = ( + event: ProjectEventMessage, +): RouteInvocationProjectEvent | undefined => { + const candidate = event as unknown as Partial; + return candidate.type === 'route.invocation' && + candidate.payload !== null && + typeof candidate.payload === 'object' && + candidate.payload.invocation !== undefined + ? candidate as RouteInvocationProjectEvent + : undefined; +}; + +export const createDevServerBackend = ({ + client, + events, +}: DevServerBackendOptions): InvocationBackend => Object.freeze({ + accepts: (leaf: ApplicationLeaf): boolean => + leaf.execution === 'invoke' && leaf.routeId !== undefined, + history: async (leaf: ApplicationLeaf, signal?: AbortSignal) => { + if (leaf.routeId === undefined) return Object.freeze([]); + const invocations = await client.list(50, signal); + return Object.freeze(invocations.filter((invocation) => invocation.routeId === leaf.routeId)); + }, + invoke: ( + _leaf: ApplicationLeaf, + request: RouteInvocationRequest, + signal?: AbortSignal, + ) => client.invoke(request, signal), + kind: 'dev-server', + read: (invocationId: string, signal?: AbortSignal) => + client.read(invocationId, signal), + subscribe: (listener: Parameters[0]) => events.subscribe((event) => { + const invocation = routeInvocationEvent(event); + if (invocation !== undefined) listener(invocation.payload.invocation); + }), +}); diff --git a/packages/workbench/src/application/invocation-client.ts b/packages/workbench/src/application/invocation-client.ts new file mode 100644 index 000000000..5d8a2ac21 --- /dev/null +++ b/packages/workbench/src/application/invocation-client.ts @@ -0,0 +1,197 @@ +import { z } from 'zod'; + +import type { + RouteInvocation, + RouteInvocationRequest, + RouteInvocationSummary, +} from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; +import { + agentDocumentSchema, + agentRenderEventSchema, +} from '../runtime/agent-document-client.ts'; +import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; +import { diagnosticSchema } from '../client-helpers.ts'; +import { requestContextProvenanceSchema } from '../request-provenance.ts'; + +export interface InvocationClientOptions { + readonly foreground: ForegroundRequestAuthority; +} + +export class InvocationClientError extends Error { + readonly code: string; + readonly diagnostics: readonly Diagnostic[] | undefined; + readonly status: number | undefined; + + constructor( + code: string, + message: string, + options: Readonly<{ readonly diagnostics?: readonly Diagnostic[]; readonly status?: number }> = {}, + ) { + super(message); + this.name = 'InvocationClientError'; + this.code = code; + this.diagnostics = options.diagnostics; + this.status = options.status; + } +} + +const textSchema = z.string().min(1); +const jsonObjectSchema = z.record(z.string(), z.json()); +const timingSchema = z.strictObject({ + durationMs: z.number().finite().nonnegative(), + phase: textSchema, + startedAt: textSchema, +}); +const providerSchema = z.strictObject({ + durationMs: z.number().finite().nonnegative().optional(), + id: textSchema, + message: z.string().optional(), + name: textSchema, + status: z.enum(['failed', 'mounted', 'skipped']), +}); +const cliProjectionSchema = z.strictObject({ + exitCode: z.number().int(), + json: z.json().optional(), + text: z.string(), +}); +const hostProjectionSchema = z.strictObject({ + diagnostics: z.array(diagnosticSchema), + host: z.enum(['claude', 'codex', 'cursor']), + native: jsonObjectSchema.optional(), +}); +const projectionSchema = z.strictObject({ + cli: cliProjectionSchema.optional(), + hosts: z.array(hostProjectionSchema).optional(), + mcp: jsonObjectSchema.optional(), +}); +const invocationEventSchema = z.strictObject({ + canonical: jsonObjectSchema, + event: textSchema, + host: z.enum(['claude', 'codex', 'cursor']).optional(), + native: jsonObjectSchema.optional(), +}); +const invocationSummaryFields = { + completedAt: textSchema, + correlationId: textSchema.optional(), + diagnostics: z.array(diagnosticSchema), + event: invocationEventSchema.optional(), + id: textSchema, + input: z.json(), + kind: z.enum(['cli', 'event-route', 'prompt', 'resource', 'script', 'tool']), + manifestDigest: textSchema, + routeId: textSchema, + source: z.string(), + sourceRevision: textSchema, + startedAt: textSchema, + status: z.enum(['failed', 'succeeded']), + timings: z.array(timingSchema), +} as const; +const invocationSummarySchema: z.ZodType = + z.strictObject(invocationSummaryFields); +const invocationSchema: z.ZodType = z.strictObject({ + ...invocationSummaryFields, + context: requestContextProvenanceSchema, + document: agentDocumentSchema.optional(), + events: z.array(agentRenderEventSchema), + projection: projectionSchema, + providers: z.array(providerSchema), + result: z.json().optional(), +}); +const invocationResponseSchema = z.strictObject({ invocation: invocationSchema }); +const invocationListResponseSchema = z.strictObject({ + invocations: z.array(invocationSummarySchema), +}); +const diagnosticResponseSchema = z.strictObject({ + diagnostic: z.strictObject({ + code: textSchema, + message: z.string(), + }), + diagnostics: z.array(diagnosticSchema).optional(), +}); + +const invalid = (message: string): InvocationClientError => + new InvocationClientError('AB8230', message); + +const responseError = (value: unknown, status: number): InvocationClientError => { + const decoded = diagnosticResponseSchema.safeParse(value); + if (decoded.success) { + return new InvocationClientError(decoded.data.diagnostic.code, decoded.data.diagnostic.message, { + ...(decoded.data.diagnostics === undefined ? {} : { diagnostics: Object.freeze(decoded.data.diagnostics) }), + status, + }); + } + return new InvocationClientError( + 'AB8230', + `Route invocation request failed with HTTP ${String(status)}.`, + { status }, + ); +}; + +const opaqueInvocationId = (value: string): string => { + if ( + value.length === 0 || value === '.' || value === '..' || + value.includes('/') || value.includes('\\') || value.includes('\0') + ) { + throw invalid('Route invocation ID is not a valid opaque segment.'); + } + return encodeURIComponent(value); +}; + +const bodyFor = async (response: Response): Promise => + response.json().catch(() => undefined); + +const invocationBody = (value: unknown): RouteInvocation => { + const decoded = invocationResponseSchema.safeParse(value); + if (!decoded.success) throw invalid('Route invocation route returned an invalid response.'); + return Object.freeze(decoded.data.invocation); +}; + +const invocationListBody = (value: unknown): readonly RouteInvocationSummary[] => { + const decoded = invocationListResponseSchema.safeParse(value); + if (!decoded.success) throw invalid('Route invocation list route returned an invalid response.'); + return Object.freeze(decoded.data.invocations); +}; + +export class InvocationClient { + readonly #foreground: ForegroundRequestAuthority; + + constructor({ foreground }: InvocationClientOptions) { + this.#foreground = foreground; + } + + async invoke(request: RouteInvocationRequest, signal?: AbortSignal): Promise { + const response = await this.#foreground.protectedRequest('/api/routes/invocations', { + body: JSON.stringify(request), + headers: { 'content-type': 'application/json' }, + method: 'POST', + ...(signal === undefined ? {} : { signal }), + }); + const body = await bodyFor(response); + if (!response.ok) throw responseError(body, response.status); + return invocationBody(body); + } + + async list(limit = 50, signal?: AbortSignal): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50) { + throw invalid('Route invocation list limit must be an integer from 1 through 50.'); + } + const response = await this.#foreground.protectedRequest( + `/api/routes/invocations?limit=${String(limit)}`, + signal === undefined ? {} : { signal }, + ); + const body = await bodyFor(response); + if (!response.ok) throw responseError(body, response.status); + return invocationListBody(body); + } + + async read(id: string, signal?: AbortSignal): Promise { + const response = await this.#foreground.protectedRequest( + `/api/routes/invocations/${opaqueInvocationId(id)}`, + signal === undefined ? {} : { signal }, + ); + const body = await bodyFor(response); + if (!response.ok) throw responseError(body, response.status); + return invocationBody(body); + } +} diff --git a/packages/workbench/src/application/invocation-model.ts b/packages/workbench/src/application/invocation-model.ts new file mode 100644 index 000000000..5ff7c719a --- /dev/null +++ b/packages/workbench/src/application/invocation-model.ts @@ -0,0 +1,102 @@ +import type { + RouteInvocation, + RouteInvocationRequest, + RouteInvocationSummary, +} from '../../../agent-bundle/src/contracts/invocations.ts'; +import { + parseJsonWithoutDuplicateKeys, + type JsonValue, +} from '../../../agent-bundle/src/contracts/strict-json.ts'; +import { snapshotStrictJsonValue } from '../strict-json.ts'; +import type { ApplicationLeaf } from './application-tree-model.ts'; +import type { InvocationBackend } from './invocation-backend.ts'; + +export type InvocationState = + | Readonly<{ readonly status: 'idle' }> + | Readonly<{ readonly request: RouteInvocationRequest; readonly status: 'running' }> + | Readonly<{ readonly invocation: RouteInvocation; readonly status: 'succeeded' }> + | Readonly<{ readonly error: unknown; readonly status: 'failed' }>; + +export type InvocationAction = + | Readonly<{ readonly request: RouteInvocationRequest; readonly type: 'invoke.started' }> + | Readonly<{ readonly invocation: RouteInvocation; readonly type: 'invoke.succeeded' }> + | Readonly<{ readonly error: unknown; readonly type: 'invoke.failed' }> + | Readonly<{ readonly type: 'reset' }>; + +const idleState = Object.freeze({ status: 'idle' as const }); +const storagePrefix = 'agent-bundle:invocation-input:'; + +export const reduceInvocationState = ( + state: InvocationState, + action: InvocationAction, +): InvocationState => { + switch (action.type) { + case 'invoke.started': + return Object.freeze({ request: action.request, status: 'running' }); + case 'invoke.succeeded': + return Object.freeze({ invocation: action.invocation, status: 'succeeded' }); + case 'invoke.failed': + return Object.freeze({ error: action.error, status: 'failed' }); + case 'reset': + return idleState; + default: { + const exhaustive: never = action; + return exhaustive; + } + } +}; + +const sessionStorageFor = (): Storage | undefined => { + try { + return globalThis.sessionStorage; + } catch { + return undefined; + } +}; + +export const readLastInput = (leafKey: string): JsonValue | undefined => { + try { + const raw = sessionStorageFor()?.getItem(`${storagePrefix}${leafKey}`); + return raw === null || raw === undefined + ? undefined + : snapshotStrictJsonValue(parseJsonWithoutDuplicateKeys(raw)); + } catch { + return undefined; + } +}; + +export const writeLastInput = (leafKey: string, input: unknown): void => { + try { + sessionStorageFor()?.setItem( + `${storagePrefix}${leafKey}`, + JSON.stringify(snapshotStrictJsonValue(input)), + ); + } catch { + // Storage is optional: privacy settings, quota limits, and hostile values + // must not prevent an invocation. + } +}; + +export const selectBackend = ( + backends: readonly InvocationBackend[], + leaf: ApplicationLeaf, +): InvocationBackend | undefined => backends.find((backend) => backend.accepts(leaf)); + +export const invocationSummaryOf = ( + invocation: RouteInvocation, +): RouteInvocationSummary => Object.freeze({ + completedAt: invocation.completedAt, + ...(invocation.correlationId === undefined ? {} : { correlationId: invocation.correlationId }), + diagnostics: invocation.diagnostics, + ...(invocation.event === undefined ? {} : { event: invocation.event }), + id: invocation.id, + input: invocation.input, + kind: invocation.kind, + manifestDigest: invocation.manifestDigest, + routeId: invocation.routeId, + source: invocation.source, + sourceRevision: invocation.sourceRevision, + startedAt: invocation.startedAt, + status: invocation.status, + timings: invocation.timings, +}); diff --git a/packages/workbench/src/application/runtime-backend.ts b/packages/workbench/src/application/runtime-backend.ts new file mode 100644 index 000000000..5a8b36c95 --- /dev/null +++ b/packages/workbench/src/application/runtime-backend.ts @@ -0,0 +1,368 @@ +import type { + DevRuntimeInvocationRequest, + DevRuntimeRun, + DevRuntimeSurface, +} from '../../../agent-bundle/src/contracts/runtime.ts'; +import type { + RouteInvocation, + RouteInvocationKind, + RouteInvocationRequest, + RouteInvocationSummary, +} from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { AgentRenderEvent } from '../runtime/agent-document-client.ts'; +import type { + RuntimeModel, + RuntimeModelAction, +} from '../runtime-model.ts'; +import type { RuntimePlaygroundController } from '../runtime-controller.ts'; +import type { ApplicationLeaf } from './application-tree-model.ts'; +import type { InvocationBackend } from './invocation-backend.ts'; +import { InvocationClientError } from './invocation-client.ts'; +import { invocationSummaryOf } from './invocation-model.ts'; + +export interface RuntimeInvocationClient { + createRun(request: DevRuntimeInvocationRequest): Promise; + readRun(runId: string): Promise; + readRunDocument( + runId: string, + signal?: AbortSignal, + ): Promise; +} + +export interface RuntimeBackendOptions { + readonly runtimeClient: RuntimeInvocationClient; + readonly controller: RuntimePlaygroundController; +} + +const unavailable = () => + Object.freeze({ reason: 'unsupported-surface' as const, state: 'unavailable' as const }); + +const routeKindFor = (leaf: ApplicationLeaf): RouteInvocationKind | undefined => { + switch (leaf.ref.kind) { + case 'tool': + return 'tool'; + case 'resource': + return 'resource'; + case 'event': + return 'event-route'; + case 'app': + case 'prompt': + case 'cli': + case 'script': + case 'skill': + case 'command': + case 'rule': + return undefined; + default: { + const exhaustive: never = leaf.ref; + return exhaustive; + } + } +}; + +const surfaceMatches = ( + surface: DevRuntimeSurface, + leaf: ApplicationLeaf, +): boolean => { + switch (leaf.ref.kind) { + case 'tool': + return surface.kind === 'mcp-tool' && surface.id === `mcp.${leaf.ref.name}`; + case 'resource': + return surface.kind === 'mcp-resource' && surface.id === `mcp.${leaf.ref.name}`; + case 'event': + return surface.kind === 'hook'; + case 'app': + case 'prompt': + case 'cli': + case 'script': + case 'skill': + case 'command': + case 'rule': + return false; + default: { + const exhaustive: never = leaf.ref; + return exhaustive; + } + } +}; + +const selectedSurface = ( + surfaces: readonly DevRuntimeSurface[], + leaf: ApplicationLeaf, + request?: RouteInvocationRequest, +): DevRuntimeSurface | undefined => { + const matches = surfaces.filter((surface) => surfaceMatches(surface, leaf)); + const host = request?.event?.host; + return host === undefined + ? matches[0] + : matches.find((surface) => + surface.id === `hook.${host}` || surface.targets.includes(host)) ?? matches[0]; +}; + +const selectedTarget = ( + surface: DevRuntimeSurface, + request: RouteInvocationRequest, +): string | undefined => { + const host = request.event?.host; + if (host !== undefined && surface.targets.includes(host)) return host; + if ( + surface.defaultTarget !== undefined && + surface.targets.includes(surface.defaultTarget) + ) { + return surface.defaultTarget; + } + return surface.targets[0]; +}; + +const diagnosticFor = ( + diagnostic: Extract['diagnostics'][number], +) => Object.freeze({ + code: diagnostic.code, + message: diagnostic.message, + severity: diagnostic.severity, + target: diagnostic.phase, +}); + +const documentFor = ( + events: readonly AgentRenderEvent[], +): RouteInvocation['document'] => { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]!; + switch (event.type) { + case 'shell': + case 'replace': + case 'complete': + return event.document; + case 'progress': + case 'error': + break; + default: { + const exhaustive: never = event; + return exhaustive; + } + } + } + return undefined; +}; + +const invocationKind = (kind: RouteInvocationKind) => { + switch (kind) { + case 'tool': + return 'tool' as const; + case 'event-route': + return 'event' as const; + case 'cli': + return 'cli' as const; + case 'script': + return 'script' as const; + case 'prompt': + case 'resource': + return 'workbench' as const; + default: { + const exhaustive: never = kind; + return exhaustive; + } + } +}; + +const completedRun = ( + run: DevRuntimeRun, +): Exclude => { + if (run.status === 'running') { + throw new InvocationClientError( + 'AB8230', + 'Runtime returned a run before it reached a terminal state.', + ); + } + return run; +}; + +const invocationForRun = ( + runValue: DevRuntimeRun, + leaf: ApplicationLeaf, + events: readonly AgentRenderEvent[], + correlationId?: string, +): RouteInvocation => { + const run = completedRun(runValue); + const kind = routeKindFor(leaf); + if (kind === undefined || leaf.routeId === undefined) { + throw new InvocationClientError( + 'AB8230', + 'Runtime run does not map to an invokable application leaf.', + ); + } + const diagnostics = run.status === 'failed' + ? Object.freeze(run.diagnostics.map(diagnosticFor)) + : Object.freeze([]); + const document = documentFor(events); + const timings = run.status === 'succeeded' + ? Object.freeze(run.result.trace.map((span) => Object.freeze({ + durationMs: span.durationMs ?? 0, + phase: span.phase, + startedAt: span.startedAt, + }))) + : Object.freeze([]); + const result = run.status === 'succeeded' ? run.result.agentVisible : undefined; + return Object.freeze({ + completedAt: run.completedAt, + context: Object.freeze({ + actor: unavailable(), + host: unavailable(), + invocation: Object.freeze({ + kind: invocationKind(kind), + surface: run.surfaceId, + }), + lineage: unavailable(), + session: unavailable(), + workspace: unavailable(), + }), + ...(correlationId === undefined ? {} : { correlationId }), + diagnostics, + ...(document === undefined ? {} : { document }), + events, + id: run.id, + input: run.input, + kind, + manifestDigest: run.vector.runtimeGenerationId, + projection: Object.freeze({}), + providers: Object.freeze([]), + ...(result === undefined ? {} : { result }), + routeId: leaf.routeId, + source: leaf.source ?? '', + sourceRevision: run.vector.sourceRevision, + startedAt: run.startedAt, + status: run.status, + timings, + }); +}; + +const summaryForRun = ( + run: DevRuntimeRun, + leaf: ApplicationLeaf, + correlationId?: string, +): RouteInvocationSummary => + invocationSummaryOf(invocationForRun(run, leaf, Object.freeze([]), correlationId)); + +const abortIfRequested = (signal: AbortSignal | undefined): void => { + if (signal?.aborted === true) throw signal.reason; +}; + +export const createRuntimeBackend = ({ + controller, + runtimeClient, +}: RuntimeBackendOptions): InvocationBackend => { + const leafBySurfaceId = new Map(); + const correlationByRunId = new Map(); + + const registerLeaf = (leaf: ApplicationLeaf): DevRuntimeSurface | undefined => { + const matches = controller.model.surfaces.filter((surface) => + surfaceMatches(surface, leaf)); + for (const surface of matches) leafBySurfaceId.set(surface.id, leaf); + return matches[0]; + }; + + const leafForRun = (run: DevRuntimeRun): ApplicationLeaf => { + const leaf = leafBySurfaceId.get(run.surfaceId); + if (leaf === undefined) { + throw new InvocationClientError( + 'AB8230', + `Runtime surface ${JSON.stringify(run.surfaceId)} is not mapped to an application leaf.`, + ); + } + return leaf; + }; + + return Object.freeze({ + accepts: (leaf: ApplicationLeaf): boolean => + leaf.execution === 'invoke' && + leaf.routeId !== undefined && + routeKindFor(leaf) !== undefined && + registerLeaf(leaf) !== undefined, + history: async (leaf: ApplicationLeaf, signal?: AbortSignal) => { + abortIfRequested(signal); + const surface = registerLeaf(leaf); + if (surface === undefined) return Object.freeze([]); + const history = controller.model.history + .filter((run) => run.surfaceId === surface.id && run.status !== 'running') + .map((run) => summaryForRun(run, leaf, correlationByRunId.get(run.id))); + abortIfRequested(signal); + return Object.freeze(history); + }, + invoke: async ( + leaf: ApplicationLeaf, + request: RouteInvocationRequest, + signal?: AbortSignal, + ) => { + abortIfRequested(signal); + const surface = selectedSurface(controller.model.surfaces, leaf, request); + const target = surface === undefined + ? undefined + : selectedTarget(surface, request); + if (surface === undefined || target === undefined) { + throw new InvocationClientError( + 'AB8230', + 'Runtime has no surface or target for this application leaf.', + ); + } + leafBySurfaceId.set(surface.id, leaf); + const run = await runtimeClient.createRun(Object.freeze({ + ...(controller.model.status?.activeVector === undefined + ? {} + : { + expectedGenerationId: + controller.model.status.activeVector.runtimeGenerationId, + }), + input: request.input ?? Object.freeze({}), + surfaceId: surface.id, + target, + })); + abortIfRequested(signal); + if (request.correlationId !== undefined) { + correlationByRunId.set(run.id, request.correlationId); + } + controller.dispatch({ + run, + type: 'run.received', + } satisfies RuntimeModelAction); + const events = await runtimeClient.readRunDocument(run.id, signal); + abortIfRequested(signal); + return invocationForRun( + run, + leaf, + events, + request.correlationId, + ); + }, + kind: 'runtime', + read: async (invocationId: string, signal?: AbortSignal) => { + abortIfRequested(signal); + const run = await runtimeClient.readRun(invocationId); + const leaf = leafForRun(run); + const events = await runtimeClient.readRunDocument(run.id, signal); + abortIfRequested(signal); + return invocationForRun( + run, + leaf, + events, + correlationByRunId.get(run.id), + ); + }, + subscribe: (listener: (summary: RouteInvocationSummary) => void) => { + const observed = new Set( + controller.model.history + .filter((run) => run.status !== 'running') + .map((run) => run.id), + ); + return controller.subscribe((model: RuntimeModel) => { + for (const run of model.history) { + if (run.status === 'running' || observed.has(run.id)) continue; + observed.add(run.id); + const leaf = leafBySurfaceId.get(run.surfaceId); + if (leaf !== undefined) { + listener(summaryForRun(run, leaf, correlationByRunId.get(run.id))); + } + } + }); + }, + }); +}; diff --git a/packages/workbench/src/runtime-controller.ts b/packages/workbench/src/runtime-controller.ts new file mode 100644 index 000000000..0d2b4a183 --- /dev/null +++ b/packages/workbench/src/runtime-controller.ts @@ -0,0 +1,428 @@ +import type { + DevRuntimeInvocationRequest, + DevRuntimeReplayRequest, + DevRuntimeRun, + DevRuntimeStateIdentity, + DevRuntimeStateResetRequest, +} from '../../agent-bundle/src/contracts/runtime.ts'; +import type { + ProjectEventMessage, + ProjectReplayGap, +} from '../../agent-bundle/src/contracts/runtime.ts'; +import { errorMessage as messageFrom } from './client-helpers.ts'; +import { RuntimeClientError, type RuntimeBootstrap } from './runtime-client.ts'; +import { + createRuntimeModel, + effectFor, + reduceRuntimeModel, + type RuntimeModel, + type RuntimeModelAction, + type RuntimeProfileOption, +} from './runtime-model.ts'; +import type { AgentRenderEvent } from './runtime/agent-document-client.ts'; + +export type RuntimePlaygroundClient = Readonly<{ + bootstrap(): Promise; + createRun(request: DevRuntimeInvocationRequest): Promise; + readRun(runId: string): Promise; + readRunDocument(runId: string, signal?: AbortSignal): Promise; + readRunFlight(runId: string): Promise; + replayRun(request: DevRuntimeReplayRequest): Promise; + resetState(request: DevRuntimeStateResetRequest): Promise; +}>; + +export interface RuntimePlaygroundController { + close(): void; + dispatch(action: RuntimeModelAction): void; + downloadRunFlight(runId: string): Promise; + readRunDocument(runId: string, signal?: AbortSignal): Promise; + readonly error: string | undefined; + receive(event: ProjectEventMessage): Promise; + readonly model: RuntimeModel; + subscribe(listener: (model: RuntimeModel) => void): () => void; + whenIdle(): Promise; +} + +export interface RuntimeEventReceiver { + receive(event: ProjectEventMessage): Promise; +} + +export interface RuntimeEventBuffer { + close(): void; + install(receiver: RuntimeEventReceiver): void; + receive(event: ProjectEventMessage): void; + whenIdle(): Promise; +} + +export interface RuntimeEventBufferOptions { + readonly maximumPendingEvents?: number; +} + +export type RuntimeBootstrapRetryPlan = Readonly<{ + readonly closePreControllerIngress: boolean; + readonly delay: number | undefined; + readonly retryCount: number; +}>; + +export const runtimeBootstrapRetryPlan = ( + retryCount: number, + receiverInstalled: boolean, +): RuntimeBootstrapRetryPlan => { + if (!Number.isSafeInteger(retryCount) || retryCount < 0) { + throw new TypeError('Runtime bootstrap retry count must be a non-negative safe integer.'); + } + if (retryCount >= 2) { + return Object.freeze({ + closePreControllerIngress: !receiverInstalled, + delay: undefined, + retryCount, + }); + } + return Object.freeze({ + closePreControllerIngress: false, + delay: 250 * 2 ** retryCount, + retryCount: retryCount + 1, + }); +}; + +export interface RuntimePlaygroundControllerOptions { + readonly bootstrap: RuntimeBootstrap; + readonly client: RuntimePlaygroundClient; + readonly defaultProfileId?: string; + readonly profiles: readonly RuntimeProfileOption[]; +} + +const errorMessage = (reason: unknown): string => + messageFrom(reason, 'Runtime request could not be completed.'); + +const isForegroundEffect = ( + effect: RuntimeModel['activeEffect'] | RuntimeModel['pendingEffect'], +): boolean => + effect?.kind === 'create-run' || + effect?.kind === 'replay-run' || + effect?.kind === 'reset-state'; + +const hasAcceptedForegroundEffect = ( + previous: RuntimeModel, + next: RuntimeModel, +): boolean => { + const existing = new Set( + [previous.activeEffect, previous.pendingEffect].flatMap((effect) => + effect === undefined || !isForegroundEffect(effect) ? [] : [effect.id]), + ); + return [next.activeEffect, next.pendingEffect].some((effect) => + effect !== undefined && isForegroundEffect(effect) && !existing.has(effect.id)); +}; + +const isCorrelatedForegroundSuccess = ( + previous: RuntimeModel, + action: RuntimeModelAction, +): boolean => + (action.type === 'reset.received' && + previous.activeEffect?.kind === 'reset-state' && + previous.activeEffect.id === action.id) || + (action.type === 'run.received' && + (previous.activeEffect?.kind === 'create-run' || + previous.activeEffect?.kind === 'replay-run')); + +class RuntimePlaygroundControllerImpl implements RuntimePlaygroundController { + readonly #client: RuntimePlaygroundClient; + readonly #listeners = new Set<(model: RuntimeModel) => void>(); + #effectDrain: Promise | undefined; + #eventTail: Promise = Promise.resolve(); + #error: string | undefined; + #model: RuntimeModel; + #mounted = true; + + constructor({ + bootstrap, + client, + defaultProfileId, + profiles, + }: RuntimePlaygroundControllerOptions) { + this.#client = client; + this.#model = createRuntimeModel({ bootstrap, defaultProfileId, profiles }); + } + + get error(): string | undefined { + return this.#error; + } + + get model(): RuntimeModel { + return this.#model; + } + + downloadRunFlight(runId: string): Promise { + return this.#client.readRunFlight(runId); + } + + readRunDocument( + runId: string, + signal?: AbortSignal, + ): Promise { + return this.#client.readRunDocument(runId, signal); + } + + close(): void { + this.#mounted = false; + this.#listeners.clear(); + } + + dispatch(action: RuntimeModelAction): void { + if (!this.#mounted) return; + const previous = this.#model; + this.#model = reduceRuntimeModel(this.#model, action); + if ( + this.#error !== undefined && + (hasAcceptedForegroundEffect(previous, this.#model) || + isCorrelatedForegroundSuccess(previous, action)) + ) { + this.#error = undefined; + } + this.#notify(); + this.#scheduleEffects(); + } + + receive(event: ProjectEventMessage): Promise { + if (!this.#mounted) return Promise.resolve(); + const received = this.#eventTail.then(async () => { + if (!this.#mounted) return; + this.dispatch({ event, type: 'event.received' }); + await this.#waitForEffects(); + }); + this.#eventTail = received.catch((reason: unknown) => { + if (this.#mounted) this.#error = errorMessage(reason); + }); + return received; + } + + subscribe(listener: (model: RuntimeModel) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + async whenIdle(): Promise { + while (this.#mounted) { + const events = this.#eventTail; + await events; + const effects = this.#effectDrain; + if (effects !== undefined) { + await effects; + continue; + } + if (effectFor(this.#model) !== undefined) { + this.#scheduleEffects(); + continue; + } + return; + } + } + + #notify(): void { + for (const listener of this.#listeners) listener(this.#model); + } + + #scheduleEffects(): void { + if ( + !this.#mounted || + this.#effectDrain !== undefined || + effectFor(this.#model) === undefined + ) { + return; + } + this.#effectDrain = this.#drainEffects().finally(() => { + this.#effectDrain = undefined; + this.#scheduleEffects(); + }); + } + + async #waitForEffects(): Promise { + while (this.#mounted) { + const effects = this.#effectDrain; + if (effects !== undefined) { + await effects; + continue; + } + if (effectFor(this.#model) === undefined) return; + this.#scheduleEffects(); + } + } + + async #drainEffects(): Promise { + while (this.#mounted) { + const effect = effectFor(this.#model); + if (effect === undefined) return; + try { + switch (effect.kind) { + case 'bootstrap': + this.dispatch({ + bootstrap: await this.#client.bootstrap(), + type: 'bootstrap.received', + }); + break; + case 'create-run': + this.dispatch({ + run: await this.#client.createRun(effect.request), + type: 'run.received', + }); + break; + case 'read-run': + this.dispatch({ + run: await this.#client.readRun(effect.runId), + type: 'run.received', + }); + break; + case 'replay-run': + this.dispatch({ + run: await this.#client.replayRun(effect.request), + type: 'run.received', + }); + break; + case 'reset-state': + this.dispatch({ + id: effect.id, + state: await this.#client.resetState(effect.request), + type: 'reset.received', + }); + break; + default: { + const exhaustive: never = effect; + return exhaustive; + } + } + } catch (reason) { + if (!this.#mounted) return; + this.#error = errorMessage(reason); + this.dispatch( + reason instanceof RuntimeClientError && reason.code === 'AB8204' + ? { id: effect.id, type: 'effect.conflict' } + : { id: effect.id, type: 'effect.settled' }, + ); + } + } + } +} + +export const createRuntimePlaygroundController = ( + options: RuntimePlaygroundControllerOptions, +): RuntimePlaygroundController => new RuntimePlaygroundControllerImpl(options); + +class RuntimeEventBufferImpl implements RuntimeEventBuffer { + #closed = false; + #installing = false; + readonly #maximumPendingEvents: number; + #pending: ProjectEventMessage[] = []; + #replayGap: ProjectReplayGap | undefined; + #receiver: RuntimeEventReceiver | undefined; + #tail: Promise = Promise.resolve(); + + constructor({ maximumPendingEvents = 64 }: RuntimeEventBufferOptions = {}) { + if ( + !Number.isSafeInteger(maximumPendingEvents) || + maximumPendingEvents < 1 + ) { + throw new TypeError( + 'Runtime event buffer capacity must be a positive safe integer.', + ); + } + this.#maximumPendingEvents = maximumPendingEvents; + } + + close(): void { + this.#closed = true; + this.#pending = []; + this.#replayGap = undefined; + this.#receiver = undefined; + } + + install(receiver: RuntimeEventReceiver): void { + if (this.#closed || this.#installing || this.#receiver !== undefined) return; + this.#installing = true; + this.#tail = this.#tail.then(async () => { + if (this.#closed) return; + const pending = + this.#replayGap === undefined + ? this.#pending + : [this.#replayGap, ...this.#pending]; + this.#pending = []; + this.#replayGap = undefined; + for (const event of pending) await receiver.receive(event); + if (!this.#closed) this.#receiver = receiver; + }).then( + () => { this.#installing = false; }, + () => { this.#installing = false; }, + ); + } + + receive(event: ProjectEventMessage): void { + this.#tail = this.#tail.then(async () => { + if (this.#closed) return; + const receiver = this.#receiver; + if (receiver === undefined) { + if (event.type === 'runtime.event' || event.type === 'replay.gap') { + this.#queue(event); + } + return; + } + await receiver.receive(event); + }).catch(() => undefined); + } + + whenIdle(): Promise { + return this.#tail; + } + + #queue(event: ProjectEventMessage): void { + if (event.type === 'replay.gap') { + this.#pending = []; + this.#mergeReplayGap(event); + return; + } + this.#pending.push(event); + if (this.#pending.length <= this.#maximumPendingEvents) return; + const dropped = this.#pending.splice( + 0, + this.#pending.length - this.#maximumPendingEvents, + ); + const sequences = dropped.flatMap((message) => + message.type === 'runtime.event' ? [message.sequence] : []); + if (sequences.length === 0) return; + const earliestDroppedSequence = Math.min(...sequences); + const latestDroppedSequence = Math.max(...sequences); + this.#mergeReplayGap(Object.freeze({ + earliestAvailableSequence: latestDroppedSequence + 1, + latestDroppedSequence, + requestedAfterSequence: earliestDroppedSequence - 1, + type: 'replay.gap' as const, + })); + } + + #mergeReplayGap(next: ProjectReplayGap): void { + const previous = this.#replayGap; + if (previous === undefined) { + this.#replayGap = Object.freeze({ ...next }); + return; + } + const latestDroppedSequence = Math.max( + previous.latestDroppedSequence, + next.latestDroppedSequence, + ); + this.#replayGap = Object.freeze({ + earliestAvailableSequence: Math.max( + previous.earliestAvailableSequence, + next.earliestAvailableSequence, + latestDroppedSequence + 1, + ), + latestDroppedSequence, + requestedAfterSequence: Math.min( + previous.requestedAfterSequence, + next.requestedAfterSequence, + ), + type: 'replay.gap', + }); + } +} + +export const createRuntimeEventBuffer = ( + options?: RuntimeEventBufferOptions, +): RuntimeEventBuffer => new RuntimeEventBufferImpl(options); diff --git a/packages/workbench/src/runtime-evidence.tsx b/packages/workbench/src/runtime-evidence.tsx deleted file mode 100644 index 6016d9297..000000000 --- a/packages/workbench/src/runtime-evidence.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; - -import type { DevRuntimeDiagnostic, DevRuntimeInspectionEnvelope, DevRuntimeTraceSpan } from '../../agent-bundle/src/contracts/runtime.ts'; -import { McpProtocolEvidence } from './mcp/mcp-page.tsx'; - -export type RuntimeEvidenceInput = - | Readonly<{ readonly kind: 'protocol'; readonly protocol?: DevRuntimeInspectionEnvelope['protocol']; readonly trace: readonly DevRuntimeTraceSpan[] }> - | Readonly<{ readonly diagnostics: readonly DevRuntimeDiagnostic[]; readonly kind: 'diagnostics' }> - | Readonly<{ - /** Presentation-only span disclosure; details always render when absent. */ - readonly expansion?: Readonly<{ - readonly expandedIds: readonly string[]; - readonly onToggle: (spanId: string) => void; - }>; - readonly kind: 'trace'; - readonly trace: readonly DevRuntimeTraceSpan[]; - }>; - -export interface RuntimeEvidenceProps { - readonly evidence: RuntimeEvidenceInput; -} - -export const RuntimeEvidence = ({ evidence }: RuntimeEvidenceProps): React.ReactNode => { - if (evidence.kind === 'protocol') return
- -
; - if (evidence.kind === 'diagnostics') return
-

Provider diagnostics

- {evidence.diagnostics.length === 0 ?

No provider diagnostics.

:
    {evidence.diagnostics.map((diagnostic, index) =>
  1. {diagnostic.phase} {diagnostic.severity} {diagnostic.code} {diagnostic.message}
  2. )}
} -
; - const expansion = evidence.expansion; - const expandedIds = expansion === undefined ? undefined : new Set(expansion.expandedIds); - return
-

Render trace

- {evidence.trace.length === 0 ?

No render evidence yet.

:
    {evidence.trace.map((span) => { - const expanded = expandedIds === undefined || expandedIds.has(span.id); - return
  1. - {span.phase} {span.status}{span.durationMs === undefined ? undefined : {span.durationMs} ms} - {span.details === undefined || expansion === undefined ? undefined : - } - {span.details === undefined || !expanded ? undefined :
    {JSON.stringify(span.details, null, 2)}
    } -
  2. ; - })}
} -
; -}; diff --git a/packages/workbench/src/runtime-inspector.tsx b/packages/workbench/src/runtime-inspector.tsx deleted file mode 100644 index 255f342f1..000000000 --- a/packages/workbench/src/runtime-inspector.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useAtomValue } from '@effect/atom-react'; -import { AsyncResult } from 'effect/unstable/reactivity'; -import React, { useRef, useState, type KeyboardEvent } from 'react'; - -import type { DevRuntimeDiagnostic, DevRuntimeRun, DevRuntimeStatus, DevRuntimeSurface, DevRuntimeTreeNode } from '../../agent-bundle/src/contracts/runtime.ts'; -import { RuntimeEvidence } from './runtime-evidence.tsx'; -import type { RuntimeInspectorTab } from './runtime-model.ts'; -import { agentDocumentEventsAtom, useAgentDocumentLoader } from './runtime/agent-document-atoms.ts'; -import type { AgentRenderEvent } from './runtime/agent-document-client.ts'; -import { AgentDocumentStage } from './runtime/agent-document-stage.tsx'; - -export interface RuntimeInspectorProps { - readonly loadDocumentEvents?: (runId: string, signal?: AbortSignal) => Promise; - readonly onDownloadFlight?: (run: DevRuntimeRun) => void; - readonly onTabChange?: (tab: RuntimeInspectorTab) => void; - /** Presentation-only span disclosure; trace details always render when absent. */ - readonly traceExpansion?: Readonly<{ - readonly expandedIds: readonly string[]; - readonly onToggle: (spanId: string) => void; - }>; - readonly run?: DevRuntimeRun; - readonly status?: DevRuntimeStatus; - readonly surface?: DevRuntimeSurface; - readonly tab?: RuntimeInspectorTab; -} - -const tabs: readonly Readonly<{ readonly id: RuntimeInspectorTab; readonly label: string }>[] = [ - { id: 'tree', label: 'Tree' }, - { id: 'result', label: 'Result' }, - { id: 'document', label: 'Document' }, - { id: 'flight', label: 'Flight' }, - { id: 'protocol', label: 'Protocol' }, - { id: 'state', label: 'State' }, - { id: 'diagnostics', label: 'Diagnostics' }, -]; - -const display = (value: unknown): string => { - try { - return JSON.stringify(value, null, 2) ?? String(value); - } catch { - return '[Unserializable runtime evidence]'; - } -}; - -const TreeNode = ({ expanded, level, node, showProps }: Readonly<{ - readonly expanded: boolean; - readonly level: number; - readonly node: DevRuntimeTreeNode; - readonly showProps: boolean; -}>): React.ReactNode =>
  • - {node.label} {node.kind} - {showProps && node.props !== undefined ?
    {display(node.props)}
    : undefined} - {node.children.length === 0 || !expanded ? undefined :
      {node.children.map((child) => )}
    } -
  • ; - -const resultDiagnostics = (run: DevRuntimeRun | undefined, status: DevRuntimeStatus | undefined): readonly DevRuntimeDiagnostic[] => [ - ...(status?.diagnostics ?? []), - ...(run?.status === 'failed' ? run.diagnostics : []), -]; - -const RuntimeDocumentResult = ({ runId }: Readonly<{ readonly runId: string }>): React.ReactNode => { - const result = useAtomValue(agentDocumentEventsAtom(runId)); - return AsyncResult.matchWithWaiting(result, { - onDefect: (error) =>

    {error instanceof Error ? error.message : 'Agent Document request could not be completed.'}

    , - onError: (error) =>

    {error.message}

    , - onSuccess: ({ value: events }) => , - onWaiting: () =>

    Loading Agent Document…

    , - }); -}; - -const RuntimeDocumentPanel = ({ loadDocumentEvents, run }: Pick): React.ReactNode => { - const loaderReady = useAgentDocumentLoader(loadDocumentEvents); - const selected = run?.status === 'succeeded' ? run.result : undefined; - - if (run === undefined) return

    Select a runtime run to inspect its Agent Document.

    ; - if (run.status !== 'succeeded') return

    This run did not succeed, so it has no decodable Agent Document.

    ; - if (selected?.flight === undefined) return

    This run has no stored Flight payload to decode as an Agent Document.

    ; - if (loadDocumentEvents === undefined) return

    Agent Document loading is not available in this Workbench session.

    ; - if (!loaderReady) return

    Loading Agent Document…

    ; - return ; -}; - -export const RuntimeInspector = ({ loadDocumentEvents, onDownloadFlight, onTabChange, run, status, surface, tab, traceExpansion }: RuntimeInspectorProps): React.ReactNode => { - const [internalTab, setInternalTab] = useState('tree'); - const [treeExpanded, setTreeExpanded] = useState(true); - const [showProps, setShowProps] = useState(false); - const buttons = useRef>>({}); - const selectedTab = tab ?? internalTab; - const selected = run?.status === 'succeeded' ? run.result : undefined; - const diagnostics = resultDiagnostics(run, status); - const panelId = 'runtime-inspector-panel'; - const selectTab = (next: RuntimeInspectorTab): void => { - if (tab === undefined) setInternalTab(next); - onTabChange?.(next); - buttons.current[next]?.focus(); - }; - const onTabKeyDown = (event: KeyboardEvent, current: RuntimeInspectorTab): void => { - const index = tabs.findIndex((candidate) => candidate.id === current); - const next = event.key === 'ArrowRight' || event.key === 'ArrowDown' - ? tabs[(index + 1) % tabs.length]?.id - : event.key === 'ArrowLeft' || event.key === 'ArrowUp' - ? tabs[(index + tabs.length - 1) % tabs.length]?.id - : event.key === 'Home' - ? tabs[0]?.id - : event.key === 'End' - ? tabs[tabs.length - 1]?.id - : undefined; - if (next === undefined) return; - event.preventDefault(); - selectTab(next); - }; - - return
    -
    - {tabs.map((candidate) => )} -
    -
    - {selectedTab === 'tree' ? <> -

    Decoded React tree

    Decoded render output, not source code or an App frame.

    - {selected === undefined || selected.tree.length === 0 ?

    No decoded React tree is available.

    :
      {selected.tree.map((node) => )}
    } - : undefined} - {selectedTab === 'result' ? <> -

    Result

    - {selected === undefined ?

    No result is available.

    :
    {display({ agentVisible: selected.agentVisible, modelVisible: selected.modelVisible, native: selected.native, protocol: surface?.kind === 'mcp-tool' || surface?.kind === 'mcp-resource' || surface?.kind === 'mcp-app' ? undefined : selected.protocol })}
    } - : undefined} - {selectedTab === 'document' ? : undefined} - {selectedTab === 'flight' ? <> -

    Flight

    - {selected?.flight === undefined ?

    No Flight payload is available.

    : <>

    {selected.flight.bytes} bytes{selected.flight.truncated ? ' (preview truncated)' : ''}

    {selected.flight.preview}
    {selected.flight.downloadPath === undefined || onDownloadFlight === undefined || run === undefined ? undefined : }} - : undefined} - {selectedTab === 'protocol' ? (surface?.kind === 'mcp-tool' || surface?.kind === 'mcp-resource' || surface?.kind === 'mcp-app') - ? - : <>

    Protocol

    {display(selected?.protocol)}
    : undefined} - {selectedTab === 'state' ? <> -

    State

    - {selected === undefined ?

    No state evidence is available.

    :
    State store
    {selected.state.identity.stateStoreId}
    State version
    {selected.state.identity.stateVersion}
    } - {selected?.state.snapshot === undefined ? undefined :
    {display(selected.state.snapshot)}
    } - : undefined} - {selectedTab === 'diagnostics' ? <>{selected === undefined ? undefined : } : undefined} -
    -
    ; -}; diff --git a/packages/workbench/src/runtime-playground.tsx b/packages/workbench/src/runtime-playground.tsx deleted file mode 100644 index b3ac69a24..000000000 --- a/packages/workbench/src/runtime-playground.tsx +++ /dev/null @@ -1,678 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; - -import type { - DevRuntimeInvocationRequest, - DevRuntimeReplayRequest, - DevRuntimeRun, - DevRuntimeStateIdentity, - DevRuntimeStateResetRequest, - DevRuntimeSurface, -} from '../../agent-bundle/src/contracts/runtime.ts'; -import type { ProjectEventMessage, ProjectReplayGap } from '../../agent-bundle/src/contracts/runtime.ts'; -import { downloadBlob, errorMessage as messageFrom } from './client-helpers.ts'; -import { RuntimeClientError, type RuntimeBootstrap } from './runtime-client.ts'; -import { McpJsonInput, serializeJsonValue, type ImmutableJsonValue } from './mcp/mcp-json-input.tsx'; -import { - createRuntimeModel, - effectFor, - reduceRuntimeModel, - type RuntimeModel, - type RuntimeModelAction, - type RuntimeProfileOption, -} from './runtime-model.ts'; -import type { AgentRenderEvent } from './runtime/agent-document-client.ts'; -import type { - RuntimeAppPreviewRenderer, - RuntimeLiveMcpPageAdapter, -} from './runtime-stage.tsx'; - -const RuntimeStage = React.lazy(async () => ({ default: (await import('./runtime-stage.tsx')).RuntimeStage })); -const RuntimeInspector = React.lazy(async () => ({ default: (await import('./runtime-inspector.tsx')).RuntimeInspector })); - -export type RuntimePlaygroundClient = Readonly<{ - bootstrap(): Promise; - createRun(request: DevRuntimeInvocationRequest): Promise; - readRun(runId: string): Promise; - readRunDocument(runId: string, signal?: AbortSignal): Promise; - readRunFlight(runId: string): Promise; - replayRun(request: DevRuntimeReplayRequest): Promise; - resetState(request: DevRuntimeStateResetRequest): Promise; -}>; - -export interface RuntimePlaygroundController { - close(): void; - dispatch(action: RuntimeModelAction): void; - downloadRunFlight(runId: string): Promise; - readRunDocument(runId: string, signal?: AbortSignal): Promise; - readonly error: string | undefined; - receive(event: ProjectEventMessage): Promise; - readonly model: RuntimeModel; - subscribe(listener: (model: RuntimeModel) => void): () => void; - whenIdle(): Promise; -} - -export interface RuntimeAppPreviewLifecycle { - close(): Promise; -} - -export type RuntimeAppPreviewLifecycleRegistrar = (handle: RuntimeAppPreviewLifecycle) => () => void; - -export interface RuntimeEventReceiver { - receive(event: ProjectEventMessage): Promise; -} - -export interface RuntimeEventBuffer { - close(): void; - install(receiver: RuntimeEventReceiver): void; - receive(event: ProjectEventMessage): void; - whenIdle(): Promise; -} - -export interface RuntimeEventBufferOptions { - /** Bounded Runtime events until the controller is available; replay repair is retained separately. */ - readonly maximumPendingEvents?: number; -} - -export type RuntimeBootstrapRetryPlan = Readonly<{ - readonly closePreControllerIngress: boolean; - readonly delay: number | undefined; - readonly retryCount: number; -}>; - -/** Keeps bootstrap retries bounded without ever severing an already-installed Runtime receiver. */ -export const runtimeBootstrapRetryPlan = ( - retryCount: number, - receiverInstalled: boolean, -): RuntimeBootstrapRetryPlan => { - if (!Number.isSafeInteger(retryCount) || retryCount < 0) throw new TypeError('Runtime bootstrap retry count must be a non-negative safe integer.'); - if (retryCount >= 2) return Object.freeze({ closePreControllerIngress: !receiverInstalled, delay: undefined, retryCount }); - return Object.freeze({ closePreControllerIngress: false, delay: 250 * 2 ** retryCount, retryCount: retryCount + 1 }); -}; - -export interface RuntimePlaygroundControllerOptions { - readonly bootstrap: RuntimeBootstrap; - readonly client: RuntimePlaygroundClient; - readonly defaultProfileId?: string; - readonly profiles: readonly RuntimeProfileOption[]; -} - -export const runtimePlaygroundLiveMcpPageAdapter: RuntimeLiveMcpPageAdapter = Object.freeze({ kind: 'disabled' }); - -const errorMessage = (reason: unknown): string => - messageFrom(reason, 'Runtime request could not be completed.'); - -const isForegroundEffect = (effect: RuntimeModel['activeEffect'] | RuntimeModel['pendingEffect']): boolean => - effect?.kind === 'create-run' || effect?.kind === 'replay-run' || effect?.kind === 'reset-state'; - -const hasAcceptedForegroundEffect = (previous: RuntimeModel, next: RuntimeModel): boolean => { - const existing = new Set([previous.activeEffect, previous.pendingEffect].flatMap((effect) => - effect === undefined || !isForegroundEffect(effect) ? [] : [effect.id])); - return [next.activeEffect, next.pendingEffect].some((effect) => - effect !== undefined && isForegroundEffect(effect) && !existing.has(effect.id)); -}; - -const isCorrelatedForegroundSuccess = (previous: RuntimeModel, action: RuntimeModelAction): boolean => - (action.type === 'reset.received' && previous.activeEffect?.kind === 'reset-state' && previous.activeEffect.id === action.id) || - (action.type === 'run.received' && (previous.activeEffect?.kind === 'create-run' || previous.activeEffect?.kind === 'replay-run')); - -class RuntimePlaygroundControllerImpl implements RuntimePlaygroundController { - readonly #client: RuntimePlaygroundClient; - - readonly #listeners = new Set<(model: RuntimeModel) => void>(); - #effectDrain: Promise | undefined; - #eventTail: Promise = Promise.resolve(); - #error: string | undefined; - #model: RuntimeModel; - #mounted = true; - - constructor({ bootstrap, client, defaultProfileId, profiles }: RuntimePlaygroundControllerOptions) { - this.#client = client; - this.#model = createRuntimeModel({ bootstrap, defaultProfileId, profiles }); - } - - get error(): string | undefined { - return this.#error; - } - - get model(): RuntimeModel { - return this.#model; - } - - downloadRunFlight(runId: string): Promise { - return this.#client.readRunFlight(runId); - } - - readRunDocument(runId: string, signal?: AbortSignal): Promise { - return this.#client.readRunDocument(runId, signal); - } - - close(): void { - this.#mounted = false; - this.#listeners.clear(); - } - - dispatch(action: RuntimeModelAction): void { - if (!this.#mounted) return; - const previous = this.#model; - this.#model = reduceRuntimeModel(this.#model, action); - if (this.#error !== undefined && (hasAcceptedForegroundEffect(previous, this.#model) || isCorrelatedForegroundSuccess(previous, action))) { - this.#error = undefined; - } - this.#notify(); - this.#scheduleEffects(); - } - - receive(event: ProjectEventMessage): Promise { - if (!this.#mounted) return Promise.resolve(); - const received = this.#eventTail.then(async () => { - if (!this.#mounted) return; - this.dispatch({ event, type: 'event.received' }); - await this.#waitForEffects(); - }); - this.#eventTail = received.catch((reason: unknown) => { - if (this.#mounted) this.#error = errorMessage(reason); - }); - return received; - } - - subscribe(listener: (model: RuntimeModel) => void): () => void { - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - - async whenIdle(): Promise { - while (this.#mounted) { - const events = this.#eventTail; - await events; - const effects = this.#effectDrain; - if (effects !== undefined) { - await effects; - continue; - } - if (effectFor(this.#model) !== undefined) { - this.#scheduleEffects(); - continue; - } - return; - } - } - - #notify(): void { - for (const listener of this.#listeners) listener(this.#model); - } - - #scheduleEffects(): void { - if (!this.#mounted || this.#effectDrain !== undefined || effectFor(this.#model) === undefined) return; - this.#effectDrain = this.#drainEffects().finally(() => { - this.#effectDrain = undefined; - this.#scheduleEffects(); - }); - } - - async #waitForEffects(): Promise { - while (this.#mounted) { - const effects = this.#effectDrain; - if (effects !== undefined) { - await effects; - continue; - } - if (effectFor(this.#model) === undefined) return; - this.#scheduleEffects(); - } - } - - async #drainEffects(): Promise { - while (this.#mounted) { - const effect = effectFor(this.#model); - if (effect === undefined) return; - try { - if (effect.kind === 'bootstrap') { - this.dispatch({ bootstrap: await this.#client.bootstrap(), type: 'bootstrap.received' }); - } else if (effect.kind === 'create-run') { - this.dispatch({ run: await this.#client.createRun(effect.request), type: 'run.received' }); - } else if (effect.kind === 'read-run') { - this.dispatch({ run: await this.#client.readRun(effect.runId), type: 'run.received' }); - } else if (effect.kind === 'replay-run') { - this.dispatch({ run: await this.#client.replayRun(effect.request), type: 'run.received' }); - } else { - this.dispatch({ id: effect.id, state: await this.#client.resetState(effect.request), type: 'reset.received' }); - } - } catch (reason) { - if (!this.#mounted) return; - this.#error = errorMessage(reason); - this.dispatch(reason instanceof RuntimeClientError && reason.code === 'AB8204' - ? { id: effect.id, type: 'effect.conflict' } - : { id: effect.id, type: 'effect.settled' }); - } - } - } -} - -export const createRuntimePlaygroundController = (options: RuntimePlaygroundControllerOptions): RuntimePlaygroundController => - new RuntimePlaygroundControllerImpl(options); - -class RuntimeEventBufferImpl implements RuntimeEventBuffer { - #closed = false; - #installing = false; - readonly #maximumPendingEvents: number; - #pending: ProjectEventMessage[] = []; - #replayGap: ProjectReplayGap | undefined; - #receiver: RuntimeEventReceiver | undefined; - #tail: Promise = Promise.resolve(); - - constructor({ maximumPendingEvents = 64 }: RuntimeEventBufferOptions = {}) { - if (!Number.isSafeInteger(maximumPendingEvents) || maximumPendingEvents < 1) { - throw new TypeError('Runtime event buffer capacity must be a positive safe integer.'); - } - this.#maximumPendingEvents = maximumPendingEvents; - } - - close(): void { - this.#closed = true; - this.#pending = []; - this.#replayGap = undefined; - this.#receiver = undefined; - } - - install(receiver: RuntimeEventReceiver): void { - if (this.#closed || this.#installing || this.#receiver !== undefined) return; - this.#installing = true; - this.#tail = this.#tail.then(async () => { - if (this.#closed) return; - const pending = this.#replayGap === undefined ? this.#pending : [this.#replayGap, ...this.#pending]; - this.#pending = []; - this.#replayGap = undefined; - for (const event of pending) await receiver.receive(event); - if (!this.#closed) this.#receiver = receiver; - }).then( - () => { this.#installing = false; }, - () => { this.#installing = false; }, - ); - } - - receive(event: ProjectEventMessage): void { - this.#tail = this.#tail.then(async () => { - if (this.#closed) return; - const receiver = this.#receiver; - if (receiver === undefined) { - if (event.type === 'runtime.event' || event.type === 'replay.gap') this.#queue(event); - return; - } - await receiver.receive(event); - }).catch(() => undefined); - } - - whenIdle(): Promise { - return this.#tail; - } - - #queue(event: ProjectEventMessage): void { - if (event.type === 'replay.gap') { - this.#pending = []; - this.#mergeReplayGap(event); - return; - } - this.#pending.push(event); - if (this.#pending.length <= this.#maximumPendingEvents) return; - const dropped = this.#pending.splice(0, this.#pending.length - this.#maximumPendingEvents); - const sequences = dropped.flatMap((message) => message.type === 'runtime.event' ? [message.sequence] : []); - if (sequences.length === 0) return; - const earliestDroppedSequence = Math.min(...sequences); - const latestDroppedSequence = Math.max(...sequences); - this.#mergeReplayGap(Object.freeze({ - earliestAvailableSequence: latestDroppedSequence + 1, - latestDroppedSequence, - requestedAfterSequence: earliestDroppedSequence - 1, - type: 'replay.gap' as const, - })); - } - - #mergeReplayGap(next: ProjectReplayGap): void { - const previous = this.#replayGap; - if (previous === undefined) { - this.#replayGap = Object.freeze({ ...next }); - return; - } - const latestDroppedSequence = Math.max(previous.latestDroppedSequence, next.latestDroppedSequence); - this.#replayGap = Object.freeze({ - earliestAvailableSequence: Math.max(previous.earliestAvailableSequence, next.earliestAvailableSequence, latestDroppedSequence + 1), - latestDroppedSequence, - requestedAfterSequence: Math.min(previous.requestedAfterSequence, next.requestedAfterSequence), - type: 'replay.gap', - }); - } -} - -export const createRuntimeEventBuffer = (options?: RuntimeEventBufferOptions): RuntimeEventBuffer => new RuntimeEventBufferImpl(options); - -export interface RuntimePlaygroundProps { - readonly controller: RuntimePlaygroundController; - readonly liveMcpPageAdapter?: RuntimeLiveMcpPageAdapter; - readonly registerAppPreviewLifecycle?: RuntimeAppPreviewLifecycleRegistrar; - readonly renderAppPreview?: RuntimeAppPreviewRenderer; -} - -const selectedRun = (model: RuntimeModel): DevRuntimeRun | undefined => - model.history.find((entry) => entry.id === model.selectedRunId); - -const selectedLastGoodRun = (model: RuntimeModel): DevRuntimeRun | undefined => - model.history.find((entry) => entry.id === model.lastGoodRunId); - -const selectedSurface = (model: RuntimeModel) => model.surfaces.find((entry) => entry.id === model.selectedSurfaceId); - -const selectedProfile = (model: RuntimeModel) => model.profiles.find((entry) => entry.id === model.selectedProfileId); - -type RuntimeDisplayIdentity = Readonly<{ - readonly hmrClientCount: number | 'Unknown'; - readonly stateIdentity: DevRuntimeStateIdentity | undefined; -}>; - -const runtimeDisplayIdentityFor = (model: RuntimeModel): RuntimeDisplayIdentity => { - const run = selectedRun(model); - const retained = selectedLastGoodRun(model); - const appRun = run?.status === 'succeeded' - ? run - : run?.status === 'failed' && retained?.status === 'succeeded' - ? retained - : undefined; - const surfaceId = appRun?.result.app?.surfaceId ?? model.selectedSurfaceId; - const hmrClientCount = surfaceId !== undefined && model.hmrClientCountKnownSurfaces.includes(surfaceId) - ? model.hmrClientCountBySurface[surfaceId] ?? 0 - : 'Unknown'; - const vector = model.status?.activeVector; - return Object.freeze({ - hmrClientCount, - stateIdentity: model.stateIdentity ?? (vector === undefined - ? undefined - : Object.freeze({ stateStoreId: vector.stateStoreId, stateVersion: vector.stateVersion })), - }); -}; - -export const runtimeDataAttributesFor = (model: RuntimeModel): Readonly> => { - const vector = model.status?.activeVector; - if (vector === undefined) return Object.freeze({}); - const identity = runtimeDisplayIdentityFor(model); - return Object.freeze({ - 'data-runtime-artifact-epoch': vector.artifactEpochId ?? 'Not packaged', - 'data-runtime-event-sequence': String(model.lastConsumedEventSequence), - 'data-runtime-generation': vector.runtimeGenerationId, - 'data-runtime-hmr-client-count': String(identity.hmrClientCount), - 'data-runtime-hmr-ready': String(model.status?.hmrReady === true), - 'data-runtime-provider-session': vector.providerSessionId, - 'data-runtime-source-revision': vector.sourceRevision, - 'data-runtime-state-version': String(identity.stateIdentity?.stateVersion ?? 'Unknown'), - }); -}; - -const operationKindLabel = (surface: DevRuntimeSurface | undefined): string => { - if (surface?.kind === 'hook') return 'Hook operation'; - if (surface?.kind === 'mcp-app') return 'MCP App operation'; - if (surface?.kind === 'mcp-tool') return 'MCP tool operation'; - return 'Runtime operation'; -}; - -const historyLabel = (run: DevRuntimeRun, surface: DevRuntimeSurface | undefined): string => [ - operationKindLabel(surface), - 'Session-only / ephemeral — not durable artifact history', - run.status, - run.surfaceId, - run.target, - `provider ${run.vector.providerSessionId.slice(-8)}`, - `generation ${run.vector.runtimeGenerationId.slice(-8)}`, - `state ${run.vector.stateVersion}`, - new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(run.startedAt)), -].join(' · '); - -const previousProviderOutput = (run: DevRuntimeRun): string => { - if (run.status !== 'succeeded' || run.result === undefined) return 'No agent-visible output was retained for this prior provider run.'; - const output = run.result.agentVisible ?? run.result.modelVisible ?? run.result.native; - if (output === undefined) return 'No agent-visible output was retained for this prior provider run.'; - try { - return JSON.stringify(output, null, 2) ?? 'No agent-visible output was retained for this prior provider run.'; - } catch { - return '[Unserializable prior provider output]'; - } -}; - -const resetSeedLabel = (request: DevRuntimeStateResetRequest): string => - request.seed === undefined ? 'No fixture seed' : JSON.stringify(request.seed); - -export const RuntimePlayground = ({ controller, liveMcpPageAdapter = runtimePlaygroundLiveMcpPageAdapter, registerAppPreviewLifecycle, renderAppPreview }: RuntimePlaygroundProps): React.ReactNode => { - const [model, setModel] = useState(controller.model); - const loadDocumentEvents = useCallback( - (runId: string, signal?: AbortSignal) => controller.readRunDocument(runId, signal), - [controller], - ); - const cancelRef = useRef(null); - const confirmRef = useRef(null); - const invokingRef = useRef(null); - const resetRef = useRef(null); - const requestErrorRef = useRef(null); - const resetStatusRef = useRef(null); - const confirmationOutcome = useRef<'cancelled' | 'confirmed' | undefined>(undefined); - const resetEffectId = useRef(undefined); - const priorConfirmation = useRef(model.confirmation); - const [confirmationPending, setConfirmationPending] = useState(false); - const [flightDownloadError, setFlightDownloadError] = useState(undefined); - const downloadFlight = (flightRun: DevRuntimeRun): void => { - void controller.downloadRunFlight(flightRun.id).then( - (payload) => { - downloadBlob(payload, `runtime-run-${flightRun.id}.flight.bin`); - setFlightDownloadError(undefined); - }, - (error: unknown) => setFlightDownloadError(errorMessage(error)), - ); - }; - const surface = selectedSurface(model); - const run = selectedRun(model); - const evidenceSurface = run === undefined ? surface : model.surfaces.find((entry) => entry.id === run.surfaceId) ?? surface; - const lastGoodRun = selectedLastGoodRun(model); - const profile = selectedProfile(model); - const attributes = runtimeDataAttributesFor(model); - const requestError = controller.error; - const activeVector = model.status?.activeVector; - const displayIdentity = runtimeDisplayIdentityFor(model); - const interactionLocked = model.activeEffect !== undefined || model.confirmation !== undefined; - const resetDisabled = interactionLocked || model.stateIdentity === undefined; - - const replaceDraft = (next: ImmutableJsonValue): void => { - controller.dispatch({ input: next, raw: serializeJsonValue(next), type: 'draft.replace' }); - }; - - const cancelConfirmation = (): void => { - if (confirmationPending) return; - confirmationOutcome.current = 'cancelled'; - controller.dispatch({ type: 'confirmation.cancel' }); - }; - - const requestReset = (): void => { - if (interactionLocked) return; - setConfirmationPending(false); - controller.dispatch({ type: 'reset.request' }); - }; - - const confirmConfirmation = (): void => { - if (confirmationPending || model.confirmation === undefined) return; - confirmationOutcome.current = 'confirmed'; - setConfirmationPending(true); - controller.dispatch({ type: 'confirmation.confirm' }); - const effect = controller.model.activeEffect; - resetEffectId.current = effect?.kind === 'reset-state' ? effect.id : undefined; - }; - - useEffect(() => { - setModel(controller.model); - return controller.subscribe(setModel); - }, [controller]); - useEffect(() => { - if (model.confirmation !== undefined && priorConfirmation.current === undefined) { - setConfirmationPending(false); - cancelRef.current?.focus(); - } else if (priorConfirmation.current?.kind === 'run' && confirmationOutcome.current === 'cancelled') { - invokingRef.current?.focus(); - } else if (priorConfirmation.current?.kind === 'reset' && confirmationOutcome.current === 'cancelled') { - resetRef.current?.focus(); - } - if (model.confirmation === undefined) { - confirmationOutcome.current = undefined; - setConfirmationPending(false); - } - priorConfirmation.current = model.confirmation; - }, [model.confirmation]); - useEffect(() => { - const resetCompleted = resetEffectId.current !== undefined && model.resetCompletion?.effectId === resetEffectId.current; - if (model.activeEffect !== undefined || resetEffectId.current === undefined) return; - resetEffectId.current = undefined; - if (resetCompleted) resetStatusRef.current?.focus(); - else if (requestError !== undefined) requestErrorRef.current?.focus(); - }, [model.activeEffect, model.resetCompletion, requestError]); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent): void => { - if (model.confirmation === undefined) return; - if (event.key === 'Escape') { - event.preventDefault(); - cancelConfirmation(); - return; - } - if (event.key !== 'Tab') return; - const controls = [cancelRef.current, confirmRef.current].filter((control): control is HTMLButtonElement => control !== null); - if (controls.length === 0) return; - const currentIndex = controls.indexOf(document.activeElement as HTMLButtonElement); - const nextIndex = event.shiftKey - ? currentIndex <= 0 ? controls.length - 1 : currentIndex - 1 - : currentIndex === controls.length - 1 ? 0 : currentIndex + 1; - event.preventDefault(); - controls[nextIndex]?.focus(); - }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [controller, model.confirmation, confirmationPending]); - - if (model.status === undefined) return null; - return
    -
    -
    -

    Optional development capability

    Runtime Playground

    Provider-owned React Server Component inspection and replay evidence.

    -

    - {model.status.hmrReady ? 'HMR endpoint ready' : 'HMR endpoint unavailable'} · {model.status.state} -

    -
    -
    -
    Provider state
    {model.status.state}
    -
    HMR endpoint ready
    {String(model.status.hmrReady)}
    -
    Browser HMR clients
    {displayIdentity.hmrClientCount}
    -
    Provider session ID
    {activeVector?.providerSessionId ?? 'Not available'}
    -
    Runtime generation ID
    {activeVector?.runtimeGenerationId ?? 'Not available'}
    -
    Source revision
    {activeVector?.sourceRevision ?? 'Not available'}
    -
    Artifact epoch ID
    {activeVector?.artifactEpochId ?? 'Not packaged'}
    -
    State store ID
    {displayIdentity.stateIdentity?.stateStoreId ?? 'Not available'}
    -
    State version
    {displayIdentity.stateIdentity?.stateVersion ?? 'Not available'}
    -
    Last event sequence
    {model.lastConsumedEventSequence}
    -
    Target
    {model.selectedTarget ?? 'Not available'}
    -
    Profile version
    {profile?.version ?? 'Not available'}
    -
    Evidence
    {profile?.evidence ?? 'Not available'}
    -
    - {model.replayGap === undefined ? undefined :

    Events {model.replayGap.requestedAfterSequence + 1}–{model.replayGap.latestDroppedSequence} were unavailable.

    } - {model.announcements.map((announcement) => announcement.politeness === 'assertive' - ?

    {announcement.message}

    - :

    {announcement.message}

    )} -
    - - - - -

    Simulated locally — not host certification

    -
    -
    - controller.dispatch({ raw, type: 'draft.raw' })} - onSubmit={(next) => { - replaceDraft(next); - controller.dispatch({ type: 'run.request' }); - }} - rawDraft={model.draft.raw} - schema={surface?.inputSchema} - submitLabel="Run" - submitRef={invokingRef} - value={model.draft.input} - /> -
    -
    - -
    -
    - {model.confirmation === undefined ? undefined :
    -

    {model.confirmation.kind === 'reset' ? 'Reset fixture state?' : 'Run mutable runtime surface?'}

    - {model.confirmation.kind === 'reset' ? <> -

    This resets the selected provider-owned state store and then queues one follow-up runtime run.

    -
    -
    State store
    {model.confirmation.request.stateStoreId}
    -
    Fixture seed
    {resetSeedLabel(model.confirmation.request)}
    -
    - :

    This sends one provider-owned runtime request.

    } - - -
    } -
    - {requestError === undefined ? undefined :

    {requestError}

    } - {model.previousProviderLastGood === undefined ? undefined :
    -

    Previous provider session

    -

    Last-good output from the prior provider session is retained for comparison. It is session-only / ephemeral — not durable artifact history.

    -

    Operation: {operationKindLabel(model.surfaces.find((entry) => entry.id === model.previousProviderLastGood?.run.surfaceId))} · {model.previousProviderLastGood.run.surfaceId}

    -
    {previousProviderOutput(model.previousProviderLastGood.run)}
    -
    } -
    -

    Run history

      {model.history.map((entry) =>
    1. - - - - -
    2. )}
    -
    - Loading runtime evidence…

    }> - - {flightDownloadError === undefined ? undefined :

    {flightDownloadError}

    } - controller.dispatch({ tab, type: 'selection.tab' })} - run={run} - status={model.status} - surface={evidenceSurface} - tab={model.selectedTab} - traceExpansion={{ - expandedIds: model.expandedTraceSpanIds, - onToggle: (spanId) => controller.dispatch({ spanId, type: 'trace.toggle' }), - }} - /> -
    -
    -
    -
    -
    ; -}; diff --git a/packages/workbench/src/runtime-stage.tsx b/packages/workbench/src/runtime-stage.tsx deleted file mode 100644 index 03dea2167..000000000 --- a/packages/workbench/src/runtime-stage.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; - -import type { DevRuntimeInspectionEnvelope, DevRuntimeRun, DevRuntimeStatus, DevRuntimeSurface } from '../../agent-bundle/src/contracts/runtime.ts'; -import type { RuntimeProfileOption } from './runtime-model.ts'; -import type { RuntimeAppPreviewLifecycleRegistrar } from './runtime-playground.tsx'; - -export interface RuntimeAppPreviewProps { - readonly profile: RuntimeProfileOption; - readonly profileId: string; - readonly registerLifecycle?: RuntimeAppPreviewLifecycleRegistrar; - readonly run: DevRuntimeRun; - readonly surface: DevRuntimeSurface; -} - -export type RuntimeAppPreviewRenderer = (props: RuntimeAppPreviewProps) => React.ReactNode; - -export interface RuntimeLiveMcpPageProps extends RuntimeAppPreviewProps { - readonly mcpBinding: NonNullable['mcpBinding']; -} - -/** Later host-owned handoff renderer. It must never mount the live McpPage. */ -export type RuntimeLiveMcpPageRenderer = (props: RuntimeLiveMcpPageProps) => React.ReactNode; - -export type RuntimeLiveMcpPageAdapter = - | Readonly<{ readonly kind: 'disabled' }> - | Readonly<{ readonly kind: 'host-owned'; readonly render: RuntimeLiveMcpPageRenderer }>; - -export interface RuntimeStageProps { - readonly lastGoodRun?: DevRuntimeRun; - readonly liveMcpPageAdapter?: RuntimeLiveMcpPageAdapter; - readonly profile?: RuntimeProfileOption; - readonly profileId?: string; - readonly renderAppPreview?: RuntimeAppPreviewRenderer; - readonly registerAppPreviewLifecycle?: RuntimeAppPreviewLifecycleRegistrar; - readonly run?: DevRuntimeRun; - readonly status?: DevRuntimeStatus; - readonly surface?: DevRuntimeSurface; -} - -const display = (value: unknown): string => { - try { - return JSON.stringify(value, null, 2) ?? String(value); - } catch { - return '[Unserializable runtime value]'; - } -}; - -const outputCard = (label: string, value: unknown, className: string): React.ReactNode =>
    -

    {label}

    - {value === undefined ?

    No {label.toLowerCase()} was returned for this run.

    :
    {display(value)}
    } -
    ; - -const sameRuntimeIdentity = (left: DevRuntimeRun['vector'], right: DevRuntimeRun['vector']): boolean => - left.providerSessionId === right.providerSessionId && - left.runtimeGenerationId === right.runtimeGenerationId && - left.stateStoreId === right.stateStoreId && - left.stateVersion === right.stateVersion; - -const renderedApp = ( - run: DevRuntimeRun | undefined, - surface: DevRuntimeSurface | undefined, - profile: RuntimeProfileOption | undefined, - profileId: string | undefined, - renderer: RuntimeAppPreviewRenderer | undefined, - registerLifecycle: RuntimeAppPreviewLifecycleRegistrar | undefined, -): React.ReactNode | undefined => { - if (run?.status !== 'succeeded' || run.result.app === undefined || surface === undefined || profile === undefined || profileId === undefined || renderer === undefined) { - return undefined; - } - try { - return renderer({ - profile, - profileId, - ...(registerLifecycle === undefined ? {} : { registerLifecycle }), - run, - surface, - }); - } catch { - return undefined; - } -}; - -const renderedLiveMcpPage = ( - run: DevRuntimeRun | undefined, - surface: DevRuntimeSurface | undefined, - profile: RuntimeProfileOption | undefined, - profileId: string | undefined, - adapter: RuntimeLiveMcpPageAdapter | undefined, - registerLifecycle: RuntimeAppPreviewLifecycleRegistrar | undefined, -): React.ReactNode | undefined => { - if (adapter?.kind !== 'host-owned' || run?.status !== 'succeeded' || run.result.app === undefined || surface === undefined || profile === undefined || profileId === undefined) { - return undefined; - } - const app = run.result.app; - const mcpBinding = app.mcpBinding; - if (Object.keys(mcpBinding).length === 0 || run.surfaceId !== surface.id || profile.id !== profileId) return undefined; - try { - return adapter.render({ - mcpBinding, - profile, - profileId, - ...(registerLifecycle === undefined ? {} : { registerLifecycle }), - run, - surface, - }); - } catch { - return undefined; - } -}; - -export const RuntimeStage = ({ lastGoodRun, liveMcpPageAdapter, profile, profileId, renderAppPreview, registerAppPreviewLifecycle, run, status, surface }: RuntimeStageProps): React.ReactNode => { - const retainedLastGood = run?.status === 'failed' ? lastGoodRun : undefined; - const evidenceRun = run?.status === 'succeeded' ? run : retainedLastGood; - const result = evidenceRun?.status === 'succeeded' ? evidenceRun.result : undefined; - const app = renderedApp(evidenceRun, surface, profile, profileId, renderAppPreview, registerAppPreviewLifecycle); - const liveMcpPage = renderedLiveMcpPage(run, surface, profile, profileId, liveMcpPageAdapter, registerAppPreviewLifecycle); - const activeVector = status?.activeVector; - const evidenceCurrent = evidenceRun !== undefined && activeVector !== undefined && sameRuntimeIdentity(evidenceRun.vector, activeVector); - const lastGood = lastGoodRun ?? (run?.status === 'succeeded' ? run : undefined); - - return
    -
    - {run === undefined ?

    No runtime output selected.

    : retainedLastGood !== undefined - ?

    Selected run failed in runtime generation {run.vector.runtimeGenerationId}. Retained last-good output is shown below.

    - : evidenceCurrent - ?

    All outputs are from the current runtime generation ({run.vector.runtimeGenerationId}). No stale views.

    - :

    Selected output is from runtime generation {run.vector.runtimeGenerationId}; current generation is {activeVector?.runtimeGenerationId ?? 'unavailable'}.

    } - {lastGood === undefined ? undefined :

    Last good: {lastGood.vector.runtimeGenerationId}{run !== undefined && !sameRuntimeIdentity(run.vector, lastGood.vector) ? ' (shown separately)' : ''}

    } - {retainedLastGood === undefined ? undefined :

    Retained last-good output ({evidenceCurrent ? 'current evidence' : 'stale evidence'}): {retainedLastGood.vector.runtimeGenerationId}.

    } -
    - {run?.status === 'failed' ?
    -

    Runtime run failed

    - {run.diagnostics.map((diagnostic, index) =>

    {diagnostic.phase} {diagnostic.code}: {diagnostic.message}

    )} -
    : undefined} -
    - {outputCard('Agent-visible output', result?.agentVisible, 'runtime-stage-output--agent')} - {outputCard('Native response', result?.native, 'runtime-stage-output--native')} - {outputCard('Model-visible output', result?.modelVisible, 'runtime-stage-output--model')} - {app} - {liveMcpPage} -
    -
    ; -}; diff --git a/packages/workbench/src/runtime-view-contracts.ts b/packages/workbench/src/runtime-view-contracts.ts new file mode 100644 index 000000000..e8a26beed --- /dev/null +++ b/packages/workbench/src/runtime-view-contracts.ts @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; + +import type { + DevRuntimeInspectionEnvelope, + DevRuntimeRun, + DevRuntimeSurface, +} from '../../agent-bundle/src/contracts/runtime.ts'; +import type { RuntimeProfileOption } from './runtime-model.ts'; + +export interface RuntimeAppPreviewLifecycle { + close(): Promise; +} + +export type RuntimeAppPreviewLifecycleRegistrar = ( + handle: RuntimeAppPreviewLifecycle, +) => () => void; + +export interface RuntimeAppPreviewProps { + readonly profile: RuntimeProfileOption; + readonly profileId: string; + readonly registerLifecycle?: RuntimeAppPreviewLifecycleRegistrar; + readonly run: DevRuntimeRun; + readonly surface: DevRuntimeSurface; +} + +export type RuntimeAppPreviewRenderer = ( + props: RuntimeAppPreviewProps, +) => ReactNode; + +export interface RuntimeLiveMcpPageProps extends RuntimeAppPreviewProps { + readonly mcpBinding: + NonNullable['mcpBinding']; +} + +export type RuntimeLiveMcpPageRenderer = ( + props: RuntimeLiveMcpPageProps, +) => ReactNode; + +export type RuntimeLiveMcpPageAdapter = + | Readonly<{ readonly kind: 'disabled' }> + | Readonly<{ + readonly kind: 'host-owned'; + readonly render: RuntimeLiveMcpPageRenderer; + }>; diff --git a/packages/workbench/tests/dev-server-backend.test.ts b/packages/workbench/tests/dev-server-backend.test.ts new file mode 100644 index 000000000..d3a18d9be --- /dev/null +++ b/packages/workbench/tests/dev-server-backend.test.ts @@ -0,0 +1,115 @@ +import { expect, it } from '@rstest/core'; + +import type { RouteInvocation } from '../../agent-bundle/src/contracts/invocations.ts'; +import type { ProjectEventMessage } from '../../agent-bundle/src/contracts/project.ts'; +import type { ApplicationLeaf } from '../src/application/application-tree-model.ts'; +import { createDevServerBackend } from '../src/application/dev-server-backend.ts'; +import { InvocationClient } from '../src/application/invocation-client.ts'; +import type { ForegroundRequestAuthority } from '../src/mcp/mcp-route-client.ts'; + +const invocation = Object.freeze({ + completedAt: '2026-09-05T07:00:01.000Z', + context: Object.freeze({ + actor: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + host: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + invocation: Object.freeze({ kind: 'workbench' as const }), + lineage: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + session: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + workspace: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + }), + diagnostics: Object.freeze([]), + events: Object.freeze([]), + id: 'invocation-a', + input: Object.freeze({ title: 'Dune' }), + kind: 'tool' as const, + manifestDigest: 'manifest-a', + projection: Object.freeze({}), + providers: Object.freeze([]), + routeId: 'tool:curator/search_audible', + source: 'src/search.tsx', + sourceRevision: 'source-a', + startedAt: '2026-09-05T07:00:00.000Z', + status: 'succeeded' as const, + timings: Object.freeze([]), +}) satisfies RouteInvocation; + +const summary = ({ + completedAt: invocation.completedAt, + diagnostics: invocation.diagnostics, + id: invocation.id, + input: invocation.input, + kind: invocation.kind, + manifestDigest: invocation.manifestDigest, + routeId: invocation.routeId, + source: invocation.source, + sourceRevision: invocation.sourceRevision, + startedAt: invocation.startedAt, + status: invocation.status, + timings: invocation.timings, +}); + +const leaf = Object.freeze({ + config: Object.freeze([]), + execution: 'invoke' as const, + key: '/routes/mcp/curator/tool/search_audible', + label: 'Search Audible', + ref: Object.freeze({ kind: 'tool' as const, name: 'search_audible', server: 'curator' }), + routeId: invocation.routeId, +}) satisfies ApplicationLeaf; + +it('delegates invocation reads and filters global history to the selected route', async () => { + const paths: string[] = []; + const foreground = { + protectedRequest: async (path: string, init: RequestInit = {}) => { + paths.push(path); + if (path.includes('?limit=')) { + return Response.json({ invocations: [summary, { ...summary, id: 'other', routeId: 'script:sync' }] }); + } + return Response.json({ invocation }); + }, + } as ForegroundRequestAuthority; + const backend = createDevServerBackend({ + client: new InvocationClient({ foreground }), + events: { subscribe: () => () => undefined }, + }); + + expect(backend.accepts(leaf)).toBe(true); + expect(backend.accepts({ ...leaf, execution: 'document' })).toBe(false); + await expect(backend.invoke(leaf, { input: invocation.input, routeId: invocation.routeId })).resolves.toEqual(invocation); + await expect(backend.history(leaf)).resolves.toEqual([summary]); + await expect(backend.read(invocation.id)).resolves.toEqual(invocation); + expect(paths).toEqual([ + '/api/routes/invocations', + '/api/routes/invocations?limit=50', + '/api/routes/invocations/invocation-a', + ]); +}); + +it('forwards only route invocation project events', () => { + let eventListener: ((event: ProjectEventMessage) => void) | undefined; + let unsubscribed = false; + const backend = createDevServerBackend({ + client: new InvocationClient({ + foreground: { protectedRequest: async () => Response.json({ invocation }) } as ForegroundRequestAuthority, + }), + events: { + subscribe: (listener) => { + eventListener = listener; + return () => { unsubscribed = true; }; + }, + }, + }); + const received: unknown[] = []; + const unsubscribe = backend.subscribe((entry) => received.push(entry)); + + eventListener?.({ type: 'source.status' } as ProjectEventMessage); + eventListener?.({ + occurredAt: invocation.completedAt, + payload: { invocation: summary }, + sequence: 4, + type: 'route.invocation', + } as unknown as ProjectEventMessage); + expect(received).toEqual([summary]); + unsubscribe(); + expect(unsubscribed).toBe(true); +}); diff --git a/packages/workbench/tests/invocation-client.test.ts b/packages/workbench/tests/invocation-client.test.ts new file mode 100644 index 000000000..64e32e33f --- /dev/null +++ b/packages/workbench/tests/invocation-client.test.ts @@ -0,0 +1,125 @@ +import { expect, it } from '@rstest/core'; + +import type { RouteInvocation } from '../../agent-bundle/src/contracts/invocations.ts'; +import { InvocationClient, InvocationClientError } from '../src/application/invocation-client.ts'; +import type { ForegroundRequestAuthority } from '../src/mcp/mcp-route-client.ts'; + +const unavailable = () => Object.freeze({ + reason: 'not-provided' as const, + state: 'unavailable' as const, +}); + +const invocation = Object.freeze({ + completedAt: '2026-09-05T07:00:01.000Z', + context: Object.freeze({ + actor: unavailable(), + host: unavailable(), + invocation: Object.freeze({ kind: 'workbench' as const, surface: 'search_audible' }), + lineage: unavailable(), + session: unavailable(), + workspace: unavailable(), + }), + diagnostics: Object.freeze([]), + document: Object.freeze({ + root: Object.freeze({ + children: Object.freeze([{ kind: 'text' as const, text: 'Found Dune' }]), + kind: 'result' as const, + }), + status: 'success' as const, + version: 1 as const, + }), + events: Object.freeze([{ + document: Object.freeze({ + root: Object.freeze({ kind: 'text' as const, text: 'Found Dune' }), + status: 'success' as const, + version: 1 as const, + }), + sequence: 0, + type: 'complete' as const, + }]), + id: 'invocation-a', + input: Object.freeze({ title: 'Dune' }), + kind: 'tool' as const, + manifestDigest: 'manifest-a', + projection: Object.freeze({ mcp: Object.freeze({ content: Object.freeze([]) }) }), + providers: Object.freeze([{ + durationMs: 1, + id: 'catalog', + name: 'Catalog', + status: 'mounted' as const, + }]), + result: Object.freeze({ count: 1 }), + routeId: 'tool:curator/search_audible', + source: 'src/mcp/curator/tools/search_audible.tsx', + sourceRevision: 'source-a', + startedAt: '2026-09-05T07:00:00.000Z', + status: 'succeeded' as const, + timings: Object.freeze([{ + durationMs: 1, + phase: 'render', + startedAt: '2026-09-05T07:00:00.000Z', + }]), +}) satisfies RouteInvocation; + +const foreground = (handler: (path: string, init: RequestInit) => Response | Promise): ForegroundRequestAuthority => ({ + protectedRequest: async (path, init = {}) => handler(path, init), +}); + +it('strictly decodes invoke, list, and read responses', async () => { + const requests: Array = []; + const client = new InvocationClient({ foreground: foreground((path, init) => { + requests.push([path, init]); + return Response.json(path.includes('?limit=') + ? { invocations: [{ ...invocation, context: undefined, document: undefined, events: undefined, projection: undefined, providers: undefined, result: undefined }] } + : { invocation }); + }) }); + + await expect(client.invoke({ input: { title: 'Dune' }, routeId: invocation.routeId })).resolves.toEqual(invocation); + await expect(client.list(7)).resolves.toEqual([{ + completedAt: invocation.completedAt, + diagnostics: [], + id: invocation.id, + input: { title: 'Dune' }, + kind: 'tool', + manifestDigest: 'manifest-a', + routeId: invocation.routeId, + source: invocation.source, + sourceRevision: 'source-a', + startedAt: invocation.startedAt, + status: 'succeeded', + timings: invocation.timings, + }]); + await expect(client.read('invocation a')).resolves.toEqual(invocation); + expect(requests.map(([path]) => path)).toEqual([ + '/api/routes/invocations', + '/api/routes/invocations?limit=7', + '/api/routes/invocations/invocation%20a', + ]); + expect(requests[0]?.[1]).toMatchObject({ + body: JSON.stringify({ input: { title: 'Dune' }, routeId: invocation.routeId }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); +}); + +it('preserves coded HTTP diagnostics', async () => { + const client = new InvocationClient({ foreground: foreground(() => Response.json({ + diagnostic: { code: 'AB8232', message: 'No published build.' }, + }, { status: 409 })) }); + + await expect(client.invoke({ routeId: invocation.routeId })).rejects.toMatchObject({ + code: 'AB8232', + message: 'No published build.', + status: 409, + }); +}); + +it('rejects malformed success payloads and unsafe invocation ids', async () => { + const client = new InvocationClient({ foreground: foreground(() => Response.json({ + invocation: { ...invocation, unexpected: true }, + })) }); + + await expect(client.invoke({ routeId: invocation.routeId })).rejects.toBeInstanceOf(InvocationClientError); + await expect(client.invoke({ routeId: invocation.routeId })).rejects.toMatchObject({ code: 'AB8230' }); + await expect(client.read('../other')).rejects.toMatchObject({ code: 'AB8230' }); +}); diff --git a/packages/workbench/tests/invocation-model.test.ts b/packages/workbench/tests/invocation-model.test.ts new file mode 100644 index 000000000..1a3e7b650 --- /dev/null +++ b/packages/workbench/tests/invocation-model.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, expect, it } from '@rstest/core'; + +import type { RouteInvocation } from '../../agent-bundle/src/contracts/invocations.ts'; +import type { ApplicationLeaf } from '../src/application/application-tree-model.ts'; +import type { InvocationBackend } from '../src/application/invocation-backend.ts'; +import { + invocationSummaryOf, + readLastInput, + reduceInvocationState, + selectBackend, + writeLastInput, +} from '../src/application/invocation-model.ts'; + +const invocation = Object.freeze({ + completedAt: '2026-09-05T07:00:01.000Z', + context: Object.freeze({ + actor: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + host: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + invocation: Object.freeze({ kind: 'workbench' as const }), + lineage: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + session: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + workspace: Object.freeze({ reason: 'not-provided' as const, state: 'unavailable' as const }), + }), + diagnostics: Object.freeze([]), + events: Object.freeze([]), + id: 'invocation-a', + input: Object.freeze({ title: 'Dune' }), + kind: 'tool' as const, + manifestDigest: 'manifest-a', + projection: Object.freeze({}), + providers: Object.freeze([]), + routeId: 'tool:curator/search_audible', + source: 'src/search.tsx', + sourceRevision: 'source-a', + startedAt: '2026-09-05T07:00:00.000Z', + status: 'succeeded' as const, + timings: Object.freeze([]), +}) satisfies RouteInvocation; + +const leaf = Object.freeze({ + config: Object.freeze([]), + execution: 'invoke' as const, + key: '/routes/mcp/curator/tool/search_audible', + label: 'Search Audible', + ref: Object.freeze({ kind: 'tool' as const, name: 'search_audible', server: 'curator' }), + routeId: invocation.routeId, +}) satisfies ApplicationLeaf; + +const backend = (kind: InvocationBackend['kind'], accepts: boolean): InvocationBackend => ({ + accepts: () => accepts, + history: async () => [], + invoke: async () => invocation, + kind, + read: async () => invocation, + subscribe: () => () => undefined, +}); + +beforeEach(() => { + const values = new Map(); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + key: (index: number) => [...values.keys()][index] ?? null, + get length() { return values.size; }, + removeItem: (key: string) => { values.delete(key); }, + setItem: (key: string, value: string) => { values.set(key, value); }, + } satisfies Storage, + }); +}); + +afterEach(() => { + Reflect.deleteProperty(globalThis, 'sessionStorage'); +}); + +it('reduces invocation lifecycle states without retaining stale failures', () => { + const running = reduceInvocationState({ status: 'idle' }, { + request: { input: { title: 'Dune' }, routeId: invocation.routeId }, + type: 'invoke.started', + }); + expect(running).toMatchObject({ status: 'running' }); + expect(reduceInvocationState(running, { invocation, type: 'invoke.succeeded' })).toEqual({ + invocation, + status: 'succeeded', + }); + expect(reduceInvocationState(running, { + error: new Error('render failed'), + type: 'invoke.failed', + })).toMatchObject({ error: expect.any(Error), status: 'failed' }); + expect(reduceInvocationState(running, { type: 'reset' })).toEqual({ status: 'idle' }); +}); + +it('stores strict JSON last-input snapshots by leaf key and tolerates unavailable storage', () => { + writeLastInput(leaf.key, { regions: ['us'], title: 'Dune' }); + expect(readLastInput(leaf.key)).toEqual({ regions: ['us'], title: 'Dune' }); + globalThis.sessionStorage.setItem(`agent-bundle:invocation-input:${leaf.key}`, '{"title":NaN}'); + expect(readLastInput(leaf.key)).toBeUndefined(); + expect(() => writeLastInput(leaf.key, undefined)).not.toThrow(); +}); + +it('selects the first accepting backend and creates exact summaries', () => { + const selected = selectBackend([ + backend('runtime', false), + backend('dev-server', true), + backend('runtime', true), + ], leaf); + + expect(selected?.kind).toBe('dev-server'); + expect(invocationSummaryOf(invocation)).toEqual({ + completedAt: invocation.completedAt, + diagnostics: [], + id: invocation.id, + input: invocation.input, + kind: invocation.kind, + manifestDigest: invocation.manifestDigest, + routeId: invocation.routeId, + source: invocation.source, + sourceRevision: invocation.sourceRevision, + startedAt: invocation.startedAt, + status: invocation.status, + timings: [], + }); +}); diff --git a/packages/workbench/tests/runtime-backend.test.ts b/packages/workbench/tests/runtime-backend.test.ts new file mode 100644 index 000000000..0e6091e59 --- /dev/null +++ b/packages/workbench/tests/runtime-backend.test.ts @@ -0,0 +1,167 @@ +import { expect, it } from '@rstest/core'; + +import type { + DevRuntimeInvocationRequest, + DevRuntimeRun, + DevRuntimeSurface, +} from '../../agent-bundle/src/contracts/runtime.ts'; +import type { ApplicationLeaf } from '../src/application/application-tree-model.ts'; +import { createRuntimeBackend, type RuntimeInvocationClient } from '../src/application/runtime-backend.ts'; +import type { RuntimePlaygroundController } from '../src/runtime-controller.ts'; + +const vector = Object.freeze({ + artifactEpochId: 'epoch-a', + providerSessionId: 'provider-a', + runtimeGenerationId: 'generation-a', + sourceRevision: 'source-a', + stateStoreId: 'state-a', + stateVersion: 1, +}); + +const surface = Object.freeze({ + defaultTarget: 'portable', + fixtures: Object.freeze([]), + id: 'mcp.search_audible', + kind: 'mcp-tool' as const, + label: 'Search Audible', + readOnly: true, + targets: Object.freeze(['portable']), +}) satisfies DevRuntimeSurface; + +const run = Object.freeze({ + completedAt: '2026-09-05T07:00:01.000Z', + id: 'runtime-run-a', + input: Object.freeze({ title: 'Dune' }), + result: Object.freeze({ + agentVisible: Object.freeze({ count: 1 }), + state: Object.freeze({ + identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }), + }), + trace: Object.freeze([{ + durationMs: 4, + id: 'render', + phase: 'render', + startedAt: '2026-09-05T07:00:00.000Z', + status: 'succeeded' as const, + }]), + tree: Object.freeze([]), + }), + startedAt: '2026-09-05T07:00:00.000Z', + status: 'succeeded' as const, + surfaceId: surface.id, + target: 'portable', + vector, +}) satisfies DevRuntimeRun; + +const document = Object.freeze({ + root: Object.freeze({ + children: Object.freeze([{ kind: 'text' as const, text: 'Found Dune' }]), + kind: 'result' as const, + }), + status: 'success' as const, + version: 1 as const, +}); +const events = Object.freeze([{ document, sequence: 0, type: 'complete' as const }]); + +const leaf = Object.freeze({ + config: Object.freeze([]), + execution: 'invoke' as const, + key: '/routes/mcp/curator/tool/search_audible', + label: 'Search Audible', + ref: Object.freeze({ kind: 'tool' as const, name: 'search_audible', server: 'curator' }), + routeId: 'tool:curator/search_audible', + source: 'src/mcp/curator/tools/search_audible.tsx', +}) satisfies ApplicationLeaf; + +const fixture = () => { + const requests: DevRuntimeInvocationRequest[] = []; + const actions: unknown[] = []; + let listener: ((model: RuntimePlaygroundController['model']) => void) | undefined; + const runtimeClient: RuntimeInvocationClient = { + createRun: async (request) => { + requests.push(request); + return run; + }, + readRun: async () => run, + readRunDocument: async () => events, + }; + const model = { + history: Object.freeze([run]), + status: Object.freeze({ + activeVector: vector, + descriptor: Object.freeze({ environmentVariables: Object.freeze([]), id: 'rsc', label: 'RSC', schemaVersion: 1 as const }), + diagnostics: Object.freeze([]), + hmrReady: true, + state: 'active' as const, + }), + surfaces: Object.freeze([surface]), + } as unknown as RuntimePlaygroundController['model']; + const controller = { + dispatch: (action: unknown) => { actions.push(action); }, + model, + subscribe: (next: (value: RuntimePlaygroundController['model']) => void) => { + listener = next; + return () => { listener = undefined; }; + }, + } as unknown as RuntimePlaygroundController; + return { actions, controller, listener: () => listener, requests, runtimeClient }; +}; + +it('matches runtime surfaces and maps a completed run into the shared invocation envelope', async () => { + const setup = fixture(); + const backend = createRuntimeBackend(setup); + + expect(backend.accepts(leaf)).toBe(true); + expect(backend.accepts({ ...leaf, ref: { kind: 'resource', name: 'search_audible', server: 'curator' } })).toBe(false); + const invocation = await backend.invoke(leaf, { + correlationId: 'correlation-a', + input: { title: 'Dune' }, + routeId: leaf.routeId, + }); + + expect(setup.requests).toEqual([{ + expectedGenerationId: 'generation-a', + input: { title: 'Dune' }, + surfaceId: surface.id, + target: 'portable', + }]); + expect(setup.actions).toEqual([{ run, type: 'run.received' }]); + expect(invocation).toMatchObject({ + correlationId: 'correlation-a', + diagnostics: [], + document, + events, + id: run.id, + input: run.input, + kind: 'tool', + manifestDigest: 'generation-a', + projection: {}, + providers: [], + result: { count: 1 }, + routeId: leaf.routeId, + source: leaf.source, + sourceRevision: 'source-a', + status: 'succeeded', + timings: [{ durationMs: 4, phase: 'render', startedAt: run.startedAt }], + }); +}); + +it('maps runtime history, reads snapshots, and forwards newly completed runs', async () => { + const setup = fixture(); + const backend = createRuntimeBackend(setup); + expect(backend.accepts(leaf)).toBe(true); + + await expect(backend.history(leaf)).resolves.toEqual([ + expect.objectContaining({ id: run.id, routeId: leaf.routeId }), + ]); + await expect(backend.read(run.id)).resolves.toMatchObject({ document, id: run.id, routeId: leaf.routeId }); + + const received: unknown[] = []; + const unsubscribe = backend.subscribe((summary) => received.push(summary)); + setup.listener()?.({ + ...setup.controller.model, + history: Object.freeze([{ ...run, id: 'runtime-run-b' }]), + }); + expect(received).toEqual([expect.objectContaining({ id: 'runtime-run-b', routeId: leaf.routeId })]); + unsubscribe(); +}); diff --git a/packages/workbench/tests/runtime-contract-compile.test.ts b/packages/workbench/tests/runtime-contract-compile.test.ts index 720572ea6..35ec14f6f 100644 --- a/packages/workbench/tests/runtime-contract-compile.test.ts +++ b/packages/workbench/tests/runtime-contract-compile.test.ts @@ -25,10 +25,8 @@ import { ForegroundRouteClient } from '../src/mcp/mcp-route-client.ts'; import { McpAppPreview, type McpAppPreviewClient, type McpAppPreviewProps } from '../src/mcp/mcp-app-preview.tsx'; import type { McpJsonInputProps } from '../src/mcp/mcp-json-input.tsx'; import { McpProtocolEvidence, type McpProtocolEvidenceProps } from '../src/mcp/mcp-page.tsx'; -import type { RuntimeEvidenceProps } from '../src/runtime-evidence.tsx'; import { RuntimeClient, RuntimeClientError, type RuntimeBootstrap } from '../src/runtime-client.ts'; -import type { RuntimeInspectorProps } from '../src/runtime-inspector.tsx'; -import { createRuntimePlaygroundController, type RuntimePlaygroundProps } from '../src/runtime-playground.tsx'; +import { createRuntimePlaygroundController } from '../src/runtime-controller.ts'; import { createRuntimeModel, effectFor, @@ -37,7 +35,7 @@ import { type RuntimePendingEffect, type RuntimeProfileOption, } from '../src/runtime-model.ts'; -import type { RuntimeAppPreviewRenderer, RuntimeStageProps } from '../src/runtime-stage.tsx'; +import type { RuntimeAppPreviewRenderer } from '../src/runtime-view-contracts.ts'; const vector = { artifactEpochId: 'epoch-a', @@ -195,9 +193,6 @@ const controlledInput: McpJsonInputProps = { id: 'runtime-input', label: 'Runtime input', onChange: () => undefined, onRawDraftChange: () => undefined, onSubmit: () => undefined, rawDraft: '{"city":', value: { city: 'London' }, }; const protocolEvidence: McpProtocolEvidenceProps = { ariaLabel: 'Provider protocol', protocol: inspection.protocol, trace: [trace] }; -const runtimeEvidence: RuntimeEvidenceProps = { evidence: { kind: 'protocol', protocol: inspection.protocol, trace: [trace] } }; -const stageProps: RuntimeStageProps = { profile: profiles[0], profileId: 'portable', renderAppPreview: appPreviewRenderer, run, surface }; -const inspectorProps: RuntimeInspectorProps = { run, surface, tab: 'tree' }; const runtimeBootstrap = { history: [run], @@ -220,7 +215,6 @@ const runtimePlaygroundController = createRuntimePlaygroundController({ }, profiles, }); -const runtimePlaygroundProps: RuntimePlaygroundProps = { controller: runtimePlaygroundController }; it('compiles RuntimeClient against the exact provider wire contract', async () => { const foreground = new ForegroundRouteClient({ fetch: async () => Response.json(statusResponse) }); @@ -244,20 +238,16 @@ it('compiles RuntimeClient against the exact provider wire contract', async () = controlledInput, error, invocation, - runtimeEvidence, - inspectorProps, McpProtocolEvidence, protocolEvidence, replay, reset, runtimeModel, runtimePlaygroundController, - runtimePlaygroundProps, runResponse, runsResponse, stateResponse, statusResponse, - stageProps, surfacesResponse, effect, }).toBeDefined(); diff --git a/packages/workbench/tests/runtime-controller.test.ts b/packages/workbench/tests/runtime-controller.test.ts new file mode 100644 index 000000000..52a700cbb --- /dev/null +++ b/packages/workbench/tests/runtime-controller.test.ts @@ -0,0 +1,222 @@ +import { expect, it } from '@rstest/core'; + +import type { + DevRuntimeInvocationRequest, + DevRuntimeReplayRequest, + DevRuntimeRun, + DevRuntimeStateIdentity, + DevRuntimeStateResetRequest, + DevRuntimeStatus, + DevRuntimeSurface, +} from '../../agent-bundle/src/contracts/runtime.ts'; +import type { ProjectEventMessage } from '../../agent-bundle/src/contracts/runtime.ts'; +import type { RuntimeBootstrap } from '../src/runtime-client.ts'; +import { + createRuntimeEventBuffer, + createRuntimePlaygroundController, + runtimeBootstrapRetryPlan, + type RuntimePlaygroundClient, +} from '../src/runtime-controller.ts'; +import type { RuntimeProfileOption } from '../src/runtime-model.ts'; + +const vector = Object.freeze({ + artifactEpochId: 'epoch-a', + providerSessionId: 'provider-a', + runtimeGenerationId: 'generation-a', + sourceRevision: 'source-a', + stateStoreId: 'state-a', + stateVersion: 1, +}); +const status = Object.freeze({ + activeVector: vector, + descriptor: Object.freeze({ + environmentVariables: Object.freeze([]), + id: 'rsc', + label: 'RSC', + schemaVersion: 1 as const, + }), + diagnostics: Object.freeze([]), + hmrReady: true, + lastGoodVector: vector, + state: 'active' as const, +}) satisfies DevRuntimeStatus; +const surface = Object.freeze({ + defaultTarget: 'portable', + fixtures: Object.freeze([{ + id: 'fixture-a', + label: 'Fixture A', + seed: Object.freeze({ city: 'London' }), + }]), + id: 'hook.claude', + kind: 'hook' as const, + label: 'Claude hook', + readOnly: true, + targets: Object.freeze(['portable']), +}) satisfies DevRuntimeSurface; +const run = (id: string): DevRuntimeRun => Object.freeze({ + completedAt: '2026-09-05T07:00:01.000Z', + id, + input: Object.freeze({ city: 'London' }), + result: Object.freeze({ + state: Object.freeze({ + identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }), + }), + trace: Object.freeze([]), + tree: Object.freeze([]), + }), + startedAt: '2026-09-05T07:00:00.000Z', + status: 'succeeded' as const, + surfaceId: surface.id, + target: 'portable', + vector, +}); +const profiles = Object.freeze([{ + claimsRealHostParity: false, + evidence: 'simulated', + id: 'portable', + label: 'Portable', + version: '1', +}] satisfies readonly RuntimeProfileOption[]); +const bootstrap = (history: readonly DevRuntimeRun[] = []): RuntimeBootstrap => + Object.freeze({ + history, + kind: 'available' as const, + providerSessionId: 'provider-a', + status, + surfaces: Object.freeze([surface]), + }); + +const clientFor = () => { + const requests: Array< + DevRuntimeInvocationRequest | + DevRuntimeReplayRequest | + DevRuntimeStateResetRequest | + string + > = []; + const client: RuntimePlaygroundClient = { + bootstrap: async () => bootstrap(), + createRun: async (request) => { + requests.push(request); + return run('created'); + }, + readRun: async (id) => { + requests.push(id); + return run(id); + }, + readRunDocument: async () => Object.freeze([]), + readRunFlight: async () => new Blob(), + replayRun: async (request) => { + requests.push(request); + return run('replayed'); + }, + resetState: async (request): Promise => { + requests.push(request); + return Object.freeze({ stateStoreId: 'state-a', stateVersion: 2 }); + }, + }; + return { client, requests }; +}; + +const runtimeEvent = (sequence: number, runId: string): ProjectEventMessage => + Object.freeze({ + occurredAt: '2026-09-05T07:00:00.000Z', + payload: Object.freeze({ + providerSessionId: 'provider-a', + runId, + type: 'runtime.run.completed' as const, + }), + sequence, + type: 'runtime.event' as const, + }); + +it('keeps bootstrap retries bounded without closing an installed receiver', () => { + expect(runtimeBootstrapRetryPlan(0, false)).toEqual({ + closePreControllerIngress: false, + delay: 250, + retryCount: 1, + }); + expect(runtimeBootstrapRetryPlan(2, false)).toEqual({ + closePreControllerIngress: true, + delay: undefined, + retryCount: 2, + }); + expect(runtimeBootstrapRetryPlan(2, true)).toEqual({ + closePreControllerIngress: false, + delay: undefined, + retryCount: 2, + }); +}); + +it('executes a read-only run once and merges it into replay history', async () => { + const fixture = clientFor(); + const controller = createRuntimePlaygroundController({ + bootstrap: bootstrap(), + client: fixture.client, + profiles, + }); + + controller.dispatch({ type: 'run.request' }); + await controller.whenIdle(); + + expect(fixture.requests).toEqual([{ + expectedGenerationId: 'generation-a', + fixtureId: 'fixture-a', + input: { city: 'London' }, + surfaceId: 'hook.claude', + target: 'portable', + }]); + expect(controller.model.history.map((entry) => entry.id)).toEqual(['created']); +}); + +it('reads a terminal runtime event once', async () => { + const fixture = clientFor(); + const controller = createRuntimePlaygroundController({ + bootstrap: bootstrap(), + client: fixture.client, + profiles, + }); + + await controller.receive(runtimeEvent(1, 'observed')); + await controller.receive(runtimeEvent(1, 'observed')); + await controller.whenIdle(); + + expect(fixture.requests).toEqual(['observed']); +}); + +it('buffers runtime ingress in FIFO order until a controller is installed', async () => { + const buffer = createRuntimeEventBuffer({ maximumPendingEvents: 2 }); + const received: ProjectEventMessage[] = []; + buffer.receive(runtimeEvent(1, 'one')); + buffer.receive(runtimeEvent(2, 'two')); + buffer.install({ + receive: async (event) => { received.push(event); }, + }); + await buffer.whenIdle(); + buffer.receive(runtimeEvent(3, 'three')); + await buffer.whenIdle(); + + expect(received.map((event) => + event.type === 'runtime.event' ? event.payload.runId : event.type, + )).toEqual(['one', 'two', 'three']); +}); + +it('ignores late client work after close', async () => { + let resolve!: (value: DevRuntimeRun) => void; + const pending = new Promise((next) => { resolve = next; }); + const fixture = clientFor(); + const controller = createRuntimePlaygroundController({ + bootstrap: bootstrap(), + client: { ...fixture.client, createRun: async () => pending }, + profiles, + }); + const observed: unknown[] = []; + controller.subscribe((model) => observed.push(model)); + + controller.dispatch({ type: 'run.request' }); + controller.close(); + resolve(run('late')); + await pending; + + expect(observed).toHaveLength(1); + expect(controller.model.history).toEqual([]); +}); diff --git a/packages/workbench/tests/runtime-document-atoms-disposal.test.ts b/packages/workbench/tests/runtime-document-atoms-disposal.test.ts deleted file mode 100644 index e168f7ed7..000000000 --- a/packages/workbench/tests/runtime-document-atoms-disposal.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { createServer } from 'node:http'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { join, normalize, relative } from 'node:path'; - -import { createRsbuild } from '@rsbuild/core'; -import { chromium } from 'playwright'; -import { describe, expect, it } from '@rstest/core'; - -import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; -import { browserLaunchOptions } from './support/workbench-e2e.ts'; - -declare global { - interface Window { - __runtimeDocumentAtoms: { - mount(mode: 'never' | 'resolving'): void; - readonly stats: { - neverAborted: number; - neverCalls: number; - resolvingCalls: number; - unmounts: number; - }; - unmount(): void; - }; - } -} - -const contentType = (path: string): string => path.endsWith('.css') - ? 'text/css' - : path.endsWith('.js') - ? 'text/javascript' - : 'text/html'; - -const startStaticServer = async (root: string) => { - const server = createServer((request, response) => { - const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname; - const file = pathname === '/' ? 'runtime-document-atoms-fixture.html' : pathname.slice(1); - const path = normalize(join(root, file)); - if (relative(root, path).startsWith('..')) { - response.writeHead(404).end(); - return; - } - void readFile(path).then((body) => response.writeHead(200, { 'content-type': contentType(path) }).end(body), () => response.writeHead(404).end()); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (address === null || typeof address === 'string') throw new Error('Runtime Document atom fixture did not expose a TCP address.'); - return { server, url: `http://127.0.0.1:${address.port}` }; -}; - -const fixtureSource = (root: string): string => ` - import { RegistryProvider } from '@effect/atom-react'; - import React, { useState } from 'react'; - import { createRoot } from 'react-dom/client'; - import { RuntimeInspector } from ${JSON.stringify(join(root, 'packages/workbench/src/runtime-inspector.tsx'))}; - - const run = { - completedAt: '2026-08-15T12:00:01.000Z', - id: 'run-document-disposal', - input: {}, - result: { - flight: { bytes: 24, preview: 'Flight payload', truncated: false }, - protocol: {}, - state: { identity: { stateStoreId: 'state-document', stateVersion: 1 } }, - trace: [], - tree: [], - }, - startedAt: '2026-08-15T12:00:00.000Z', - status: 'succeeded', - surfaceId: 'tool/document', - target: 'portable', - vector: { - providerSessionId: 'provider', - runtimeGenerationId: 'generation', - sourceRevision: 'source', - stateStoreId: 'state-document', - stateVersion: 1, - }, - } as const; - const agentDocument = { - root: { children: [{ kind: 'markdown', text: '# Disposal document' }], kind: 'result' }, - status: 'success', - version: 1, - } as const; - const stats = { neverAborted: 0, neverCalls: 0, resolvingCalls: 0, unmounts: 0 }; - const neverLoader = (_runId: string, signal?: AbortSignal) => { - stats.neverCalls += 1; - return new Promise(() => { - signal?.addEventListener('abort', () => { stats.neverAborted += 1; }, { once: true }); - }); - }; - const resolvingLoader = async () => { - stats.resolvingCalls += 1; - return [ - { document: agentDocument, sequence: 0, type: 'shell' }, - { document: agentDocument, sequence: 1, type: 'complete' }, - ] as const; - }; - - const Fixture = () => { - const [state, setState] = useState<{ mounted: boolean; mode: 'never' | 'resolving' }>({ - mode: 'never', - mounted: false, - }); - window.__runtimeDocumentAtoms = { - mount: (mode: 'never' | 'resolving') => { setState({ mode, mounted: true }); }, - stats, - unmount: () => { - stats.unmounts += 1; - setState((current) => ({ ...current, mounted: false })); - }, - }; - return state.mounted - ? - :

    Inspector unmounted

    ; - }; - - createRoot(document.getElementById('root')!).render( - , - ); -`; - -describe('Runtime Document atoms', () => { - it('interrupts disposed requests and remains reusable across repeated mounts', async () => { - const root = process.cwd(); - const temp = await mkdtemp(join(root, 'packages/workbench/.runtime-document-atoms-')); - const entry = join(temp, 'runtime-document-atoms-fixture.tsx'); - const output = join(temp, 'dist'); - await writeFile(entry, fixtureSource(root)); - const rsbuild = await createRsbuild({ - config: createWorkbenchFixtureConfig({ distRoot: output, entry: { 'runtime-document-atoms-fixture': entry } }), - cwd: root, - }); - const buildResult = await rsbuild.build(); - await buildResult.close(); - const { server, url } = await startStaticServer(output); - const browser = await chromium.launch(browserLaunchOptions); - try { - const page = await browser.newPage(); - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.stack ?? error.message)); - await page.goto(url, { timeout: 5_000, waitUntil: 'domcontentloaded' }); - await page.getByText('Inspector unmounted', { exact: true }).waitFor({ timeout: 5_000 }); - - for (let cycle = 1; cycle <= 5; cycle += 1) { - await page.evaluate(() => window.__runtimeDocumentAtoms.mount('never')); - await expect.poll(() => page.evaluate(() => window.__runtimeDocumentAtoms.stats.neverCalls)).toBe(cycle); - await page.evaluate(() => window.__runtimeDocumentAtoms.unmount()); - await expect.poll(() => page.evaluate(() => window.__runtimeDocumentAtoms.stats.neverAborted)).toBe(cycle); - } - const neverStats = await page.evaluate(() => window.__runtimeDocumentAtoms.stats); - expect(neverStats.neverAborted).toBe(neverStats.unmounts); - - for (let cycle = 1; cycle <= 5; cycle += 1) { - await page.evaluate(() => window.__runtimeDocumentAtoms.mount('resolving')); - await page.getByRole('heading', { name: 'Disposal document' }).waitFor({ timeout: 5_000 }); - expect(await page.evaluate(() => window.__runtimeDocumentAtoms.stats.resolvingCalls)).toBeLessThanOrEqual(cycle); - await page.evaluate(() => window.__runtimeDocumentAtoms.unmount()); - await page.getByText('Inspector unmounted', { exact: true }).waitFor({ timeout: 5_000 }); - } - expect(await page.evaluate(() => window.__runtimeDocumentAtoms.stats)).toEqual({ - neverAborted: 5, - neverCalls: 5, - resolvingCalls: 5, - unmounts: 10, - }); - expect(errors).toEqual([]); - } finally { - await browser.close(); - await new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error))); - await rm(temp, { force: true, recursive: true }); - } - }, 60_000); -}); - diff --git a/packages/workbench/tests/runtime-inspector.test.ts b/packages/workbench/tests/runtime-inspector.test.ts deleted file mode 100644 index 29046b2d5..000000000 --- a/packages/workbench/tests/runtime-inspector.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createServer } from 'node:http'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { join, normalize, relative } from 'node:path'; - -import { createRsbuild } from '@rsbuild/core'; -import { chromium } from 'playwright'; -import { describe, expect, it } from '@rstest/core'; - -import { createWorkbenchFixtureConfig } from './support/workbench-fixture-config.ts'; -import { browserLaunchOptions } from './support/workbench-e2e.ts'; - -const contentType = (path: string): string => path.endsWith('.css') - ? 'text/css' - : path.endsWith('.js') - ? 'text/javascript' - : 'text/html'; - -const startStaticServer = async (root: string) => { - const server = createServer((request, response) => { - const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname; - const file = pathname === '/' ? 'runtime-inspector-fixture.html' : pathname.slice(1); - const path = normalize(join(root, file)); - if (relative(root, path).startsWith('..')) { - response.writeHead(404).end(); - return; - } - void readFile(path).then((body) => response.writeHead(200, { 'content-type': contentType(path) }).end(body), () => response.writeHead(404).end()); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - if (address === null || typeof address === 'string') throw new Error('Runtime Inspector fixture did not expose a TCP address.'); - return { server, url: `http://127.0.0.1:${address.port}` }; -}; - -const fixtureSource = (root: string): string => ` - import { RegistryProvider } from '@effect/atom-react'; - import React from 'react'; - import { createRoot } from 'react-dom/client'; - import { RuntimeInspector } from ${JSON.stringify(join(root, 'packages/workbench/src/runtime-inspector.tsx'))}; - - const surface = { fixtures: [], id: 'tool/customer', kind: 'mcp-tool', label: 'Get customer', readOnly: false, targets: ['portable'] } as const; - const run = { - completedAt: '2026-08-15T12:00:01.000Z', id: 'run-customer', input: { customer_id: 'cust_12345' }, - result: { - flight: { bytes: 24, preview: 'Flight payload', truncated: false }, - protocol: { jsonrpc: '2.0', method: 'tools/call' }, - state: { identity: { stateStoreId: 'state-customer', stateVersion: 2 }, snapshot: { customer_id: 'cust_12345' } }, - trace: [ - { id: 'render', phase: 'rsc-render', startedAt: '2026-08-15T12:00:00.000Z', status: 'succeeded' }, - { id: 'decode', parentId: 'render', phase: 'flight-decode', startedAt: '2026-08-15T12:00:01.000Z', status: 'succeeded' }, - ], - tree: [{ children: [{ children: [], id: 'heading', kind: 'text', label: 'Customer Lookup' }], id: 'root', kind: 'component', label: 'CustomerLookupApp', props: { id: 'cust_12345' } }], - }, - startedAt: '2026-08-15T12:00:00.000Z', status: 'succeeded', surfaceId: 'tool/customer', target: 'portable', - vector: { providerSessionId: 'provider', runtimeGenerationId: 'generation', sourceRevision: 'source', stateStoreId: 'state-customer', stateVersion: 2 }, - } as const; - const agentDocument = { - root: { children: [{ kind: 'markdown', text: '# Customer document' }], kind: 'result' }, - status: 'success', version: 1, - } as const; - createRoot(document.getElementById('root')!).render( - - [ - { document: agentDocument, sequence: 0, type: 'shell' }, - { completed: 1, message: 'Loaded', sequence: 1, total: 1, type: 'progress' }, - { document: agentDocument, sequence: 2, type: 'complete' }, - ]} - run={run} - surface={surface} - /> - , - ); -`; - -describe('Runtime inspector', () => { - it('renders the six accessible panels, decoded tree, shared protocol, and provider-only render trace in the production bundle', async () => { - const root = process.cwd(); - const temp = await mkdtemp(join(root, 'packages/workbench/.runtime-inspector-')); - const entry = join(temp, 'runtime-inspector-fixture.tsx'); - const output = join(temp, 'dist'); - await writeFile(entry, fixtureSource(root)); - const rsbuild = await createRsbuild({ - config: createWorkbenchFixtureConfig({ distRoot: output, entry: { 'runtime-inspector-fixture': entry } }), - cwd: root, - }); - const buildResult = await rsbuild.build(); - await buildResult.close(); - const { server, url } = await startStaticServer(output); - const browser = await chromium.launch(browserLaunchOptions); - try { - const page = await browser.newPage(); - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.stack ?? error.message)); - await page.goto(url, { timeout: 5_000, waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(250); - if (errors.length > 0) throw new Error(errors.join('\n')); - await page.getByText('Decoded React tree', { exact: true }).waitFor({ timeout: 5_000 }); - expect(await page.getByRole('tab').allTextContents()).toEqual(['Tree', 'Result', 'Document', 'Flight', 'Protocol', 'State', 'Diagnostics']); - expect(await page.locator('[role="tree"]').count()).toBe(1); - expect(await page.locator('[role="treeitem"]').count()).toBe(2); - expect(await page.locator('[role="treeitem"]').first().getAttribute('aria-level')).toBe('1'); - expect(await page.locator('[role="treeitem"]').first().getAttribute('aria-expanded')).toBe('true'); - expect(await page.getByText('MCP App preview', { exact: true }).count()).toBe(0); - - await page.getByRole('button', { name: 'Show component props' }).click(); - expect(await page.locator('[role="treeitem"] pre code').textContent()).toContain('cust_12345'); - await page.getByRole('button', { name: 'Collapse all' }).click(); - expect(await page.locator('[role="treeitem"]').count()).toBe(1); - expect(await page.locator('[role="treeitem"]').first().getAttribute('aria-expanded')).toBe('false'); - await page.getByRole('button', { name: 'Expand all' }).click(); - expect(await page.locator('[role="treeitem"]').count()).toBe(2); - - await page.getByRole('tab', { name: 'Document' }).click(); - await page.getByRole('heading', { name: 'Customer document' }).waitFor({ timeout: 5_000 }); - expect(await page.getByLabel('Agent Document', { exact: true }).textContent()).toContain('Version 1 · success'); - expect(await page.getByLabel('Agent Document', { exact: true }).textContent()).toContain('Loaded · 1 / 1'); - - await page.getByRole('tab', { name: 'Protocol' }).click(); - await page.getByText('Provider MCP protocol', { exact: true }).waitFor({ timeout: 5_000 }); - expect(await page.getByText('tools/call', { exact: false }).count()).toBeGreaterThan(0); - - await page.getByRole('tab', { name: 'Diagnostics' }).click(); - await page.getByText('Render trace', { exact: true }).waitFor({ timeout: 5_000 }); - const trace = await page.locator('[aria-label="Runtime render trace"]').textContent(); - expect(trace!.indexOf('rsc-render')).toBeLessThan(trace!.indexOf('flight-decode')); - expect(trace).not.toContain('W17'); - expect(await page.getByText('Replay', { exact: true }).count()).toBe(0); - expect(errors).toEqual([]); - } finally { - await browser.close(); - await new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error))); - await rm(temp, { force: true, recursive: true }); - } - }, 30_000); -}); diff --git a/packages/workbench/tests/runtime-mcp-handoff.test.ts b/packages/workbench/tests/runtime-mcp-handoff.test.ts index 4865ff865..28baebe46 100644 --- a/packages/workbench/tests/runtime-mcp-handoff.test.ts +++ b/packages/workbench/tests/runtime-mcp-handoff.test.ts @@ -7,7 +7,7 @@ import { type McpPreviewDepartureOptions, type RuntimeHandoffLifecycle, } from '../src/mcp/runtime-mcp-handoff.ts'; -import type { RuntimeAppPreviewProps } from '../src/runtime-stage.tsx'; +import type { RuntimeAppPreviewProps } from '../src/runtime-view-contracts.ts'; const deferred = () => { let reject: (reason?: unknown) => void = () => undefined; diff --git a/packages/workbench/tests/runtime-playground-capture-cleanup.test.ts b/packages/workbench/tests/runtime-playground-capture-cleanup.test.ts deleted file mode 100644 index 51d74b4f3..000000000 --- a/packages/workbench/tests/runtime-playground-capture-cleanup.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { expect, test } from '@rstest/core'; - -// @ts-expect-error The executable capture script is intentionally imported as the cleanup-boundary test seam. -import { atomically, cleanupCaptureResources, captureFailureAfterCleanup, formatCaptureFailure } from '../scripts/capture-runtime-playground.mjs'; - -test('settles every capture cleanup action without masking the primary failure', async () => { - const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-runtime-capture-cleanup-')); - const temporary = join(outputRoot, '.desktop.png.temporary'); - const primary = new Error('primary capture failed'); - const order: string[] = []; - let fixtureCloseCount = 0; - try { - await expect(atomically(temporary, async (path: string) => { - await writeFile(path, 'partial output', 'utf8'); - throw primary; - })).rejects.toBe(primary); - - const cleanup = await cleanupCaptureResources({ - browser: { - close: async () => { - order.push('browser.close'); - throw new Error('browser close rejected'); - }, - }, - fixture: { - close: async () => { - fixtureCloseCount += 1; - order.push('fixture.close'); - throw new Error('fixture close rejected with fixture-secret'); - }, - }, - restores: [ - async () => { - order.push('restore-one'); - throw new Error('restore one rejected'); - }, - async () => { - order.push('restore-two'); - }, - ], - }); - - expect(order).toEqual(['restore-one', 'restore-two', 'browser.close', 'fixture.close']); - expect(fixtureCloseCount).toBe(1); - expect(cleanup).toEqual({ attemptedRestores: 2, failedSteps: ['restore-1', 'browser.close', 'fixture.close'] }); - await expect(stat(temporary)).rejects.toMatchObject({ code: 'ENOENT' }); - - const failure = captureFailureAfterCleanup(primary, cleanup); - expect(failure).toBeInstanceOf(AggregateError); - expect(failure).toMatchObject({ message: 'primary capture failed' }); - expect((failure as AggregateError).errors[0]).toBe(primary); - expect((failure as AggregateError).errors[1]).toMatchObject({ message: 'Capture cleanup failed: restore-1, browser.close, fixture.close.' }); - const formatted = formatCaptureFailure(failure); - expect(formatted).toBe('primary capture failed\nCapture cleanup failed: restore-1, browser.close, fixture.close.'); - expect(formatted).not.toContain('restore one rejected'); - expect(formatted).not.toContain('browser close rejected'); - expect(formatted).not.toContain('fixture-secret'); - } finally { - await rm(outputRoot, { force: true, recursive: true }); - } -}); - -test('bounds a wedged cleanup step instead of holding the capture process open', async () => { - const cleanup = await cleanupCaptureResources({ - browser: { close: async () => new Promise(() => {}) }, - fixture: { close: async () => {} }, - restores: [async () => new Promise(() => {})], - stepTimeout: 50, - }); - expect(cleanup).toEqual({ attemptedRestores: 1, failedSteps: ['restore-1', 'browser.close'] }); -}); diff --git a/packages/workbench/tests/runtime-playground-capture.test.ts b/packages/workbench/tests/runtime-playground-capture.test.ts deleted file mode 100644 index 449f1788b..000000000 --- a/packages/workbench/tests/runtime-playground-capture.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { execFile as executeFile } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - -import { expect, test } from '@rstest/core'; - -const execFile = promisify(executeFile); -const workspaceRoot = process.cwd(); -const captureScript = join(workspaceRoot, 'packages', 'workbench', 'scripts', 'capture-runtime-playground.mjs'); -const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - -type VerticalBounds = Readonly<{ - readonly bottom: number; - readonly top: number; - readonly viewportHeight: number; -}>; - -type CaptureEvidence = Readonly<{ - readonly appMarkerVisible: boolean; - readonly appRefreshPreservedDocument: boolean; - readonly appVisibleAfter: boolean; - readonly appVisibleBefore: boolean; - readonly appVisibleRecovered: boolean; - readonly compactRunGeneration: string; - readonly compactRunId: string; - readonly compileErrorDiagnosticsVisible: boolean; - readonly compileErrorGeneration: string; - readonly compileErrorHistoryUnchanged: boolean; - readonly compileErrorLastGoodVisible: boolean; - readonly compileErrorLayout: Readonly<{ - readonly diagnostics: VerticalBounds; - readonly lastGood: VerticalBounds; - }>; - readonly compileErrorRunId: string; - readonly documentTimeOriginAfter: number; - readonly documentTimeOriginBefore: number; - readonly desktopControlColumns: number; - readonly generationAfter: string; - readonly generationBefore: string; - readonly generationRecovered: string; - readonly hmrWithoutReload: boolean; - readonly lastGoodGenerationDuringError: string; - readonly lastGoodPreserved: boolean; - readonly providerSessionId: string; - readonly recovered: boolean; - readonly runAfter: string; - readonly runBefore: string; - readonly sandboxOpaqueOrigin: boolean; - readonly viewports: Readonly<{ - readonly desktop: Readonly<{ readonly height: number; readonly width: number }>; - }>; -}>; - -const expectPng = async (path: string, width: number, height: number): Promise => { - const [contents, details] = await Promise.all([readFile(path), stat(path)]); - expect(details.size).toBeGreaterThan(pngSignature.length); - expect(contents.subarray(0, pngSignature.length)).toEqual(pngSignature); - expect(contents.readUInt32BE(16)).toBe(width); - expect(contents.readUInt32BE(20)).toBe(height); -}; - -test('captures identity-backed HMR, last-good, recovery, and desktop browser evidence', { timeout: 600_000 }, async () => { - const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-runtime-capture-')); - const outputs = Object.freeze({ - compileError: join(outputRoot, 'compile-error.png'), - desktop: join(outputRoot, 'desktop.png'), - evidence: join(outputRoot, 'evidence.json'), - hmrAfter: join(outputRoot, 'hmr-after.png'), - hmrBefore: join(outputRoot, 'hmr-before.png'), - recovered: join(outputRoot, 'recovered.png'), - }); - try { - const { stdout } = await execFile(process.execPath, [captureScript, - '--desktop', outputs.desktop, - '--hmr-before', outputs.hmrBefore, - '--hmr-after', outputs.hmrAfter, - '--compile-error', outputs.compileError, - '--recovered', outputs.recovered, - '--evidence', outputs.evidence, - ], { cwd: workspaceRoot }); - - await Promise.all([ - expectPng(outputs.desktop, 1440, 900), - expectPng(outputs.hmrBefore, 1440, 900), - expectPng(outputs.hmrAfter, 1440, 900), - expectPng(outputs.compileError, 1440, 900), - expectPng(outputs.recovered, 1440, 900), - ]); - expect((await readdir(outputRoot)).sort()).toEqual([ - 'compile-error.png', - 'desktop.png', - 'evidence.json', - 'hmr-after.png', - 'hmr-before.png', - 'recovered.png', - ]); - - const evidence = JSON.parse(await readFile(outputs.evidence, 'utf8')) as CaptureEvidence; - const cliEvidence = JSON.parse(stdout.trim().split(/\r?\n/u).at(-1) ?? ''); - expect(cliEvidence).toMatchObject({ - appVisibleAfter: true, - appVisibleBefore: true, - appVisibleRecovered: true, - hmrWithoutReload: true, - sandboxOpaqueOrigin: true, - }); - expect(evidence).toMatchObject({ - appMarkerVisible: true, - appRefreshPreservedDocument: true, - appVisibleAfter: true, - appVisibleBefore: true, - appVisibleRecovered: true, - compileErrorDiagnosticsVisible: true, - compileErrorHistoryUnchanged: true, - compileErrorLastGoodVisible: true, - desktopControlColumns: 4, - hmrWithoutReload: true, - lastGoodPreserved: true, - recovered: true, - sandboxOpaqueOrigin: true, - viewports: { - desktop: { height: 900, width: 1440 }, - }, - }); - expect(evidence.providerSessionId).toEqual(expect.any(String)); - expect(evidence.providerSessionId.length).toBeGreaterThan(0); - expect(evidence.compactRunId).toEqual(expect.any(String)); - expect(evidence.compactRunGeneration).toEqual(expect.any(String)); - expect(evidence.compileErrorRunId).toBe(evidence.compactRunId); - expect(evidence.compileErrorGeneration).toBe(evidence.compactRunGeneration); - for (const bounds of [evidence.compileErrorLayout.lastGood, evidence.compileErrorLayout.diagnostics]) { - expect(bounds.top).toBeGreaterThanOrEqual(0); - expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewportHeight); - expect(bounds.viewportHeight).toBeGreaterThan(0); - } - expect(evidence.generationAfter).not.toBe(evidence.generationBefore); - expect(evidence.runAfter).not.toBe(evidence.runBefore); - expect(evidence.documentTimeOriginAfter).toBe(evidence.documentTimeOriginBefore); - expect(evidence.generationRecovered).not.toBe(evidence.lastGoodGenerationDuringError); - expect(JSON.stringify(evidence)).not.toContain(outputRoot); - } finally { - await rm(outputRoot, { force: true, recursive: true }); - } -}); diff --git a/packages/workbench/tests/runtime-playground-hmr.e2e.test.ts b/packages/workbench/tests/runtime-playground-hmr.e2e.test.ts deleted file mode 100644 index b94b12ce1..000000000 --- a/packages/workbench/tests/runtime-playground-hmr.e2e.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { expect, test, type PlaywrightOptions } from '@rstest/playwright'; - -import { startRuntimePlaygroundFixture } from './helpers/runtime-playground-fixture.ts'; -import { replaceWatchedSource } from './support/watched-files.ts'; -import { browserLaunchOptions, browserTrace, workbenchUrl } from './support/workbench-e2e.ts'; -import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; - -const browserTimeout = 30_000 * timeScale; - -const e2e = test.extend({ - playwright: { - launchOptions: browserLaunchOptions, - contextOptions: { viewport: { height: 900, width: 1440 } }, - trace: browserTrace, - } satisfies PlaywrightOptions, -}); - -const hookInput = Object.freeze({ - cwd: '/tmp', - hook_event_name: 'PostToolUse', - session_id: 'runtime-playground-hmr', - tool_input: Object.freeze({ file_path: 'runtime-playground-hmr.txt' }), - tool_name: 'Write', - tool_use_id: 'runtime-playground-hmr-tool', -}); - -const runtimeStatusImage = Object.freeze({ - data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - mimeType: 'image/png', - type: 'image', -}); - -type RuntimeVector = Readonly<{ - readonly providerSessionId: string; - readonly runtimeGenerationId: string; - readonly sourceRevision: string; - readonly stateStoreId: string; - readonly stateVersion: number; -}>; - -type RuntimeStatusRun = Readonly<{ - readonly id: string; - readonly result: Readonly<{ - readonly agentVisible?: unknown; - readonly modelVisible: unknown; - readonly protocol: unknown; - readonly state: Readonly<{ - readonly identity: Readonly<{ - readonly stateStoreId: string; - readonly stateVersion: number; - }>; - readonly snapshot: Readonly<{ - readonly edits: readonly Readonly<{ - readonly host: string; - readonly path: string; - readonly sessionId: string; - readonly toolName: string; - }> []; - readonly stateVersion: number; - }>; - }>; - readonly trace: readonly Readonly<{ - readonly id: string; - readonly phase: string; - readonly startedAt: string; - readonly status: string; - }>[]; - }>; - readonly status: string; - readonly surfaceId: string; - readonly target: string; - readonly vector: RuntimeVector; -}>; - -const expectRuntimeStatusEvidence = (run: RuntimeStatusRun, expected: Readonly<{ - readonly providerSessionId: string; - readonly runtimeGenerationId: string; - readonly sourceRevision: string; - readonly stateStoreId: string; - readonly stateVersion: number; -}>, textPrefix = 'Runtime state contains'): void => { - const editCount = expected.stateVersion; - const editNoun = editCount === 1 ? 'edit' : 'edits'; - const content = [ - { text: `${textPrefix} ${editCount} ${editNoun}.`, type: 'text' }, - runtimeStatusImage, - ]; - expect(run).toMatchObject({ status: 'succeeded', surfaceId: 'mcp.runtime_status' }); - expect(run.vector).toEqual(expected); - expect(run.result.modelVisible).toEqual(content); - expect(run.result.protocol).toEqual({ - content, - structuredContent: { editCount, stateVersion: expected.stateVersion }, - }); - const trace = run.result.trace; - expect(trace.map((span) => ({ id: span.id, phase: span.phase, status: span.status }))).toEqual([ - { id: 'normalize', phase: 'normalize', status: 'succeeded' }, - { id: 'worker', phase: 'worker', status: 'succeeded' }, - { id: 'flight', phase: 'flight', status: 'succeeded' }, - { id: 'decode', phase: 'decode', status: 'succeeded' }, - { id: 'lower', phase: 'lower', status: 'succeeded' }, - ]); - const startedAt = trace[0]?.startedAt; - expect(startedAt).toBeDefined(); - for (const span of trace) { - expect(Object.keys(span).sort()).toEqual(['id', 'phase', 'startedAt', 'status']); - expect(Date.parse(span.startedAt)).toBeGreaterThanOrEqual(0); - expect(span.startedAt).toBe(startedAt); - expect('details' in span).toBe(false); - expect('durationMs' in span).toBe(false); - expect('parentId' in span).toBe(false); - } -}; - -e2e('activates an edited RSC generation and replays the selected hook without replacing the document', { timeout: 180_000 }, async ({ page }) => { - const fixture = await startRuntimePlaygroundFixture(); - const source = await readFile(fixture.serverComponentSource, 'utf8'); - const pageErrors: Error[] = []; - page.on('pageerror', (error) => pageErrors.push(error)); - const context = page.context(); - const forbiddenRequests: string[] = []; - const runtimeRunPosts: string[] = []; - const forbiddenPrefixes = ['/api/mcp/sessions', '/api/runtime/mcp/sessions', '/api/mcp/apps', '/api/runtime/apps']; - const recordRequest = (request: { method(): string; url(): string }): void => { - const requestUrl = new URL(request.url()); - const pathname = requestUrl.pathname; - if (forbiddenPrefixes.some((prefix) => pathname.startsWith(prefix))) forbiddenRequests.push(pathname); - if (requestUrl.origin === fixture.url && pathname === '/api/runtime/runs' && request.method() === 'POST') { - runtimeRunPosts.push(pathname); - } - }; - context.on('request', recordRequest); - let clientPage: Awaited> | undefined; - let clientSurface: Awaited> | undefined; - try { - await page.goto(workbenchUrl(fixture.url, 'runtime')); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - const runtimeSessionToken = await page.evaluate(async () => { - const response = await fetch('/api/project/session', { credentials: 'same-origin' }); - const body: unknown = await response.json(); - if (!response.ok || typeof body !== 'object' || body === null || typeof (body as { readonly token?: unknown }).token !== 'string') { - throw new Error(`Runtime session bootstrap failed with ${response.status}.`); - } - return (body as { readonly token: string }).token; - }); - const runtimeJson = async (path: string): Promise => page.evaluate(async ({ route, token }) => { - const response = await fetch(route, { - credentials: 'same-origin', - headers: { 'x-agent-bundle-session': token }, - }); - if (!response.ok) throw new Error(`Runtime request ${route} failed with ${response.status}.`); - return response.json(); - }, { route: path, token: runtimeSessionToken }); - const identity = page.locator('[data-runtime-provider-session]'); - await expect(identity).toHaveAttribute('data-runtime-hmr-ready', 'true', { timeout: browserTimeout }); - const hmrClientCount = page.locator('[aria-label="Runtime identity"] > div').filter({ has: page.locator('dt', { hasText: 'Browser HMR clients' }) }).locator('dd'); - const surface = page.getByLabel('Runtime surface'); - await surface.selectOption('mcp.edit-timeline'); - await expect(identity).toHaveAttribute('data-runtime-hmr-client-count', 'Unknown'); - await expect(hmrClientCount).toHaveText('Unknown'); - clientSurface = await fixture.openRuntimeClientSurface('mcp.edit-timeline'); - if (clientSurface === undefined) throw new Error('Runtime client surface was not available.'); - clientPage = await context.newPage(); - clientPage.on('pageerror', (error) => pageErrors.push(error)); - const bootstrapResponse = await clientPage.goto(clientSurface.bootstrapUrl, { waitUntil: 'domcontentloaded' }); - expect(bootstrapResponse?.status()).toBe(200); - await expect.poll(async () => identity.getAttribute('data-runtime-hmr-client-count'), { timeout: browserTimeout }).toBe('1'); - await expect(hmrClientCount).toHaveText('1'); - - const initialProviderStatus = await runtimeJson('/api/runtime/status') as Readonly<{ - readonly status: Readonly<{ readonly activeVector: RuntimeVector }>; - }>; - const initialActiveVector = initialProviderStatus.status.activeVector; - const activatedStateVersion = initialActiveVector.stateVersion + 2; - const activatedEditNoun = activatedStateVersion === 1 ? 'edit' : 'edits'; - const activatedHookText = `Recorded fixture-claude-post-tool-use.txt from claude. Live runtime state now contains ${activatedStateVersion} ${activatedEditNoun}.`; - const repairedHookText = `Recorded fixture-claude-post-tool-use.txt from claude. Repaired runtime state now contains ${activatedStateVersion} ${activatedEditNoun}.`; - await surface.selectOption('hook.claude'); - const profile = page.getByLabel('Runtime profile'); - await profile.selectOption('portable'); - const raw = page.locator('#runtime-input-raw'); - await raw.fill(JSON.stringify(hookInput)); - const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(1); - - await surface.selectOption('mcp.runtime_status'); - await page.getByRole('radio', { name: 'Raw JSON' }).check(); - await raw.fill('{}'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(2); - expect(runtimeRunPosts).toEqual(['/api/runtime/runs', '/api/runtime/runs']); - const historyBeforeHmr = await runtimeJson('/api/runtime/runs?limit=50') as Readonly<{ - readonly providerSessionId: string; - readonly runs: readonly RuntimeStatusRun[]; - }>; - const statusBeforeHmr = historyBeforeHmr.runs.find((run) => run.surfaceId === 'mcp.runtime_status'); - const hookBeforeHmr = historyBeforeHmr.runs.find((run) => run.surfaceId === 'hook.claude'); - if (statusBeforeHmr === undefined || hookBeforeHmr === undefined) { - throw new Error('Expected the hook mutation and Runtime status runs before HMR.'); - } - const providerStatusBeforeHmr = await runtimeJson('/api/runtime/status') as Readonly<{ - readonly status: Readonly<{ readonly activeVector: RuntimeVector }>; - }>; - expect(statusBeforeHmr.vector).toEqual(providerStatusBeforeHmr.status.activeVector); - expect(statusBeforeHmr.vector.providerSessionId).toBe(historyBeforeHmr.providerSessionId); - expect(hookBeforeHmr).toMatchObject({ - result: { - agentVisible: 'Recorded runtime-playground-hmr.txt from claude. Shared state now contains 1 edit.', - state: { - identity: { - stateStoreId: initialActiveVector.stateStoreId, - stateVersion: initialActiveVector.stateVersion + 1, - }, - snapshot: { - edits: [expect.objectContaining({ - host: 'claude', - path: '/tmp/runtime-playground-hmr.txt', - sessionId: 'runtime-playground-hmr', - toolName: 'Write', - })], - stateVersion: initialActiveVector.stateVersion + 1, - }, - }, - }, - status: 'succeeded', - vector: { - providerSessionId: initialActiveVector.providerSessionId, - runtimeGenerationId: initialActiveVector.runtimeGenerationId, - stateStoreId: initialActiveVector.stateStoreId, - stateVersion: initialActiveVector.stateVersion + 1, - }, - }); - expect(hookBeforeHmr.result.state.snapshot.edits).toHaveLength(1); - expect(statusBeforeHmr.vector.stateStoreId).toBe(hookBeforeHmr.vector.stateStoreId); - expect(statusBeforeHmr.vector.stateVersion).toBe(hookBeforeHmr.vector.stateVersion); - expectRuntimeStatusEvidence(statusBeforeHmr, providerStatusBeforeHmr.status.activeVector); - expect(statusBeforeHmr.result.state).toEqual(hookBeforeHmr.result.state); - const statusBeforeHmrDetail = await runtimeJson(`/api/runtime/runs/${encodeURIComponent(statusBeforeHmr.id)}`) as Readonly<{ - readonly run: RuntimeStatusRun; - }>; - const immutableStatusBeforeHmr = JSON.parse(JSON.stringify(statusBeforeHmrDetail.run)) as RuntimeStatusRun; - - await surface.selectOption('hook.claude'); - - await raw.fill('{"repair":'); - await expect(page.locator('#runtime-input-raw-error')).toBeVisible(); - const diagnostics = page.getByRole('tab', { name: 'Diagnostics', exact: true }); - const result = page.getByRole('tab', { name: 'Result', exact: true }); - await result.click(); - await expect(result).toHaveAttribute('aria-selected', 'true'); - const selectedHistory = history.locator('button[aria-pressed="true"]'); - await expect(selectedHistory).toHaveCount(1); - const selectedHistoryLabel = await selectedHistory.textContent(); - const before = await identity.evaluate((element) => ({ - attributes: Object.fromEntries([...element.attributes] - .filter((attribute) => attribute.name.startsWith('data-runtime-')) - .map((attribute) => [attribute.name, attribute.value])), - timeOrigin: performance.timeOrigin, - })); - const marker = `runtime-hmr-${Math.random().toString(36).slice(2)}`; - await page.evaluate((value) => { document.documentElement.dataset.runtimeMarker = value; }, marker); - const historyCountBeforeActivation = await history.count(); - const automaticReplayCountBeforeActivation = await history.locator('button[aria-pressed="false"]').count(); - - const hookEditedSource = source.replace('Shared state now contains', 'Live runtime state now contains'); - const editedSource = hookEditedSource.replace('Runtime state contains', 'Live runtime state contains'); - expect(hookEditedSource).not.toBe(source); - expect(editedSource).not.toBe(hookEditedSource); - await replaceWatchedSource(fixture.root, fixture.serverComponentSource, editedSource); - expect(await readFile(fixture.serverComponentSource, 'utf8')).toBe(editedSource); - await expect.poll(async () => identity.getAttribute('data-runtime-generation'), { timeout: browserTimeout }).not.toBe(before.attributes['data-runtime-generation']); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(historyCountBeforeActivation + 1); - const after = await identity.evaluate((element) => Object.fromEntries([...element.attributes] - .filter((attribute) => attribute.name.startsWith('data-runtime-')) - .map((attribute) => [attribute.name, attribute.value]))); - expect(after['data-runtime-artifact-epoch']).toBe(before.attributes['data-runtime-artifact-epoch']); - expect(after['data-runtime-hmr-client-count']).toBe(before.attributes['data-runtime-hmr-client-count']); - expect(after['data-runtime-hmr-ready']).toBe(before.attributes['data-runtime-hmr-ready']); - expect(after['data-runtime-provider-session']).toBe(before.attributes['data-runtime-provider-session']); - expect(after['data-runtime-state-version']).toBe(before.attributes['data-runtime-state-version']); - expect(after['data-runtime-generation']).not.toBe(before.attributes['data-runtime-generation']); - expect(after['data-runtime-source-revision']).not.toBe(before.attributes['data-runtime-source-revision']); - expect(Number(after['data-runtime-event-sequence'])).toBeGreaterThan(Number(before.attributes['data-runtime-event-sequence'])); - await expect(selectedHistory).toHaveText(selectedHistoryLabel ?? ''); - await expect(selectedHistory).toHaveAttribute('aria-pressed', 'true'); - const automaticReplay = history.locator('button[aria-pressed="false"]'); - await expect(automaticReplay).toHaveCount(automaticReplayCountBeforeActivation + 1); - await expect(automaticReplay.first()).toContainText(`generation ${after['data-runtime-generation']?.slice(-8) ?? ''}`); - await automaticReplay.first().click(); - await expect(page.locator('[aria-label="Runtime output stage"] .runtime-stage-output--agent code')).toContainText( - activatedHookText, - { timeout: browserTimeout }, - ); - - const sourceBuildFailed = await identity.evaluate((element) => Object.fromEntries([...element.attributes] - .filter((attribute) => attribute.name.startsWith('data-runtime-')) - .map((attribute) => [attribute.name, attribute.value]))); - const historyBeforeSourceBuildFailure = await history.count(); - await replaceWatchedSource(fixture.root, fixture.serverComponentSource, `${editedSource}\nconst = ;\n`); - await expect.poll(async () => Number(await identity.getAttribute('data-runtime-event-sequence')), { timeout: browserTimeout }) - .toBeGreaterThan(Number(sourceBuildFailed['data-runtime-event-sequence'])); - await expect(page.locator('.runtime-announcement[role="alert"]').last()).toHaveText( - 'Runtime generation failed. The last good result remains available.', - { timeout: browserTimeout }, - ); - await expect(result).toHaveAttribute('aria-selected', 'true', { timeout: browserTimeout }); - await expect(diagnostics).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('[aria-label="Runtime output stage"] .runtime-stage-output--agent code')).toContainText( - activatedHookText, - { timeout: browserTimeout }, - ); - await diagnostics.click(); - const sourceBuildDiagnostic = page.getByLabel('Runtime diagnostics evidence'); - await expect(sourceBuildDiagnostic).toContainText('source/build', { timeout: browserTimeout }); - await expect(sourceBuildDiagnostic).toContainText('AB8206', { timeout: browserTimeout }); - // #572: the diagnostic carries the Rspack error with the failing file and - // line instead of a fixed sentence. - await expect(sourceBuildDiagnostic).toContainText('RSC runtime source build failed:', { timeout: browserTimeout }); - await expect(sourceBuildDiagnostic).toContainText('src/rsc/components.tsx:', { timeout: browserTimeout }); - const afterSourceBuildFailure = await identity.evaluate((element) => Object.fromEntries([...element.attributes] - .filter((attribute) => attribute.name.startsWith('data-runtime-')) - .map((attribute) => [attribute.name, attribute.value]))); - expect(afterSourceBuildFailure['data-runtime-artifact-epoch']).toBe(sourceBuildFailed['data-runtime-artifact-epoch']); - expect(afterSourceBuildFailure['data-runtime-generation']).toBe(sourceBuildFailed['data-runtime-generation']); - expect(afterSourceBuildFailure['data-runtime-hmr-client-count']).toBe(sourceBuildFailed['data-runtime-hmr-client-count']); - expect(afterSourceBuildFailure['data-runtime-hmr-ready']).toBe(sourceBuildFailed['data-runtime-hmr-ready']); - expect(afterSourceBuildFailure['data-runtime-provider-session']).toBe(sourceBuildFailed['data-runtime-provider-session']); - expect(afterSourceBuildFailure['data-runtime-source-revision']).toBe(sourceBuildFailed['data-runtime-source-revision']); - expect(afterSourceBuildFailure['data-runtime-state-version']).toBe(sourceBuildFailed['data-runtime-state-version']); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(historyBeforeSourceBuildFailure); - await expect(page.locator('[aria-label="Runtime output stage"] .runtime-stage-output--agent code')).toContainText( - activatedHookText, - { timeout: browserTimeout }, - ); - await expect(selectedHistory).toHaveAttribute('aria-pressed', 'true', { timeout: browserTimeout }); - - const repairedHookSource = editedSource.replace('Live runtime state now contains', 'Repaired runtime state now contains'); - const repairedSource = repairedHookSource.replace('Live runtime state contains', 'Repaired runtime state contains'); - expect(repairedHookSource).not.toBe(editedSource); - expect(repairedSource).not.toBe(repairedHookSource); - await replaceWatchedSource(fixture.root, fixture.serverComponentSource, repairedSource); - expect(await readFile(fixture.serverComponentSource, 'utf8')).toBe(repairedSource); - await expect.poll(async () => identity.getAttribute('data-runtime-generation'), { timeout: browserTimeout }) - .not.toBe(sourceBuildFailed['data-runtime-generation']); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(historyBeforeSourceBuildFailure + 1); - await expect(sourceBuildDiagnostic).toContainText('No provider diagnostics.'); - await history.locator('button[aria-pressed="false"]').first().click(); - await expect(page.locator('[aria-label="Runtime output stage"] .runtime-stage-output--agent code')).toContainText( - repairedHookText, - ); - await expect(surface).toHaveValue('hook.claude'); - await expect(raw).toHaveValue('{"repair":'); - await expect(page.locator('#runtime-input-raw-error')).toBeVisible(); - await expect(diagnostics).toHaveAttribute('aria-selected', 'true'); - await expect(profile).toHaveValue('portable'); - await expect.poll(() => page.evaluate(() => ({ marker: document.documentElement.dataset.runtimeMarker, timeOrigin: performance.timeOrigin }))).toEqual({ marker, timeOrigin: before.timeOrigin }); - await surface.selectOption('mcp.runtime_status'); - await page.getByRole('radio', { name: 'Raw JSON' }).check(); - await raw.fill('{}'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(historyBeforeSourceBuildFailure + 2); - // Initial hook + status, then exactly one replay for each recovered activation, then final status. - expect(runtimeRunPosts).toEqual([ - '/api/runtime/runs', - '/api/runtime/runs', - '/api/runtime/runs', - '/api/runtime/runs', - '/api/runtime/runs', - ]); - const historyAfterHmr = await runtimeJson('/api/runtime/runs?limit=50') as Readonly<{ - readonly providerSessionId: string; - readonly runs: readonly RuntimeStatusRun[]; - }>; - const statusAfterHmr = historyAfterHmr.runs.find((run) => run.surfaceId === 'mcp.runtime_status' && run.id !== statusBeforeHmr.id); - if (statusAfterHmr === undefined) throw new Error('Expected a new Runtime status run after HMR recovery.'); - const providerStatusAfterHmr = await runtimeJson('/api/runtime/status') as Readonly<{ - readonly status: Readonly<{ readonly activeVector: RuntimeVector }>; - }>; - expect(statusAfterHmr.vector).toEqual(providerStatusAfterHmr.status.activeVector); - expect(statusAfterHmr.vector.providerSessionId).toBe(historyAfterHmr.providerSessionId); - expect(statusAfterHmr.vector.providerSessionId).toBe(statusBeforeHmr.vector.providerSessionId); - expect(statusAfterHmr.vector.runtimeGenerationId).not.toBe(statusBeforeHmr.vector.runtimeGenerationId); - expect(statusAfterHmr.vector.sourceRevision).not.toBe(statusBeforeHmr.vector.sourceRevision); - await expect(identity).toHaveAttribute('data-runtime-generation', statusAfterHmr.vector.runtimeGenerationId); - await expect(identity).toHaveAttribute('data-runtime-source-revision', statusAfterHmr.vector.sourceRevision); - expect(statusAfterHmr.vector.stateStoreId).toBe(statusBeforeHmr.vector.stateStoreId); - expect(statusAfterHmr.vector.stateVersion).toBe(activatedStateVersion); - expectRuntimeStatusEvidence(statusAfterHmr, providerStatusAfterHmr.status.activeVector, 'Repaired runtime state contains'); - expect(statusAfterHmr.result.state).toMatchObject({ - identity: { - stateStoreId: initialActiveVector.stateStoreId, - stateVersion: activatedStateVersion, - }, - snapshot: { - edits: [ - expect.objectContaining({ path: '/tmp/runtime-playground-hmr.txt' }), - expect.objectContaining({ path: '/tmp/fixture-claude-post-tool-use.txt' }), - ], - stateVersion: activatedStateVersion, - }, - }); - expect(statusAfterHmr.result.state.snapshot.edits).toHaveLength(2); - const persistedStatusBeforeHmr = await runtimeJson(`/api/runtime/runs/${encodeURIComponent(statusBeforeHmr.id)}`) as Readonly<{ - readonly run: RuntimeStatusRun; - }>; - expect(persistedStatusBeforeHmr.run).toEqual(immutableStatusBeforeHmr); - - await surface.selectOption('hook.claude'); - await raw.fill('{"repair":'); - await expect(page.locator('#runtime-input-raw-error')).toBeVisible(); - await surface.selectOption('mcp.edit-timeline'); - await expect(identity).toHaveAttribute('data-runtime-hmr-client-count', '1'); - await expect(hmrClientCount).toHaveText('1'); - await clientPage.close(); - clientPage = undefined; - await expect.poll(async () => identity.getAttribute('data-runtime-hmr-client-count'), { timeout: browserTimeout }).toBe('0'); - await expect(hmrClientCount).toHaveText('0'); - await clientSurface.close(); - clientSurface = undefined; - expect(forbiddenRequests).toEqual([]); - expect(pageErrors).toEqual([]); - } finally { - context.off('request', recordRequest); - await clientPage?.close(); - await clientSurface?.close(); - await fixture.close(); - } -}); diff --git a/packages/workbench/tests/runtime-playground.e2e.test.ts b/packages/workbench/tests/runtime-playground.e2e.test.ts deleted file mode 100644 index 1e8dbc062..000000000 --- a/packages/workbench/tests/runtime-playground.e2e.test.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { expect, test, type PlaywrightOptions } from '@rstest/playwright'; - -import { startRuntimePlaygroundFixture } from './helpers/runtime-playground-fixture.ts'; -import { timeScale } from '../../agent-bundle/tests/support/time-scale.ts'; -import { browserLaunchOptions, browserTrace, workbenchUrl } from './support/workbench-e2e.ts'; - -const browserTimeout = 12_000 * timeScale; - -const e2e = test.extend({ - playwright: { - launchOptions: browserLaunchOptions, - contextOptions: { viewport: { height: 900, width: 1440 } }, - trace: browserTrace, - } satisfies PlaywrightOptions, -}); - -e2e('renders the capability-gated Runtime sibling in the real RSC workbench', { timeout: 120_000 }, async ({ page }) => { - const fixture = await startRuntimePlaygroundFixture(); - const pageErrors: Error[] = []; - const foregroundSessionRequests: string[] = []; - const forbiddenRequests: string[] = []; - const resetRequests: unknown[] = []; - const forbiddenPrefixes = ['/api/mcp/sessions', '/api/runtime/mcp/sessions', '/api/mcp/apps', '/api/runtime/apps']; - page.on('pageerror', (error) => pageErrors.push(error)); - page.on('request', (request) => { - const requestUrl = new URL(request.url()); - if (requestUrl.origin === fixture.url && requestUrl.pathname === '/api/project/session') foregroundSessionRequests.push(request.url()); - if (forbiddenPrefixes.some((prefix) => requestUrl.pathname.startsWith(prefix))) forbiddenRequests.push(requestUrl.pathname); - if (requestUrl.origin === fixture.url && requestUrl.pathname === '/api/runtime/state/reset' && request.method() === 'POST') { - resetRequests.push(JSON.parse(request.postData() ?? 'null')); - } - }); - try { - await page.goto(workbenchUrl(fixture.url, 'overview')); - await expect(page.getByRole('link', { exact: true, name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Inspector' })).toHaveCount(0, { timeout: browserTimeout }); - await expect(page.getByRole('link', { name: 'Runtime' })).toBeVisible({ timeout: browserTimeout }); - - // The example declares a semantic event route rendered through generated - // native wrappers, so the simulatable Hooks capability page is available. - await expect(page.getByRole('link', { exact: true, name: 'Hooks' })).toBeVisible({ timeout: browserTimeout }); - for (const sibling of ['artifacts', 'logs'] as const) { - await page.goto(workbenchUrl(fixture.url, sibling)); - await expect(page.locator(`#${sibling}`)).toBeVisible({ timeout: browserTimeout }); - expect( - await page.getByRole('link', { name: 'Runtime' }).count(), - `Runtime navigation disappeared from the ${sibling} page.`, - ).toBe(1); - } - - await page.goto(workbenchUrl(fixture.url, 'mcp')); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByLabel('MCP App preview controls')).toHaveCount(1, { timeout: browserTimeout }); - await page.goto(workbenchUrl(fixture.url, 'runtime')); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - - await page.goto(workbenchUrl(fixture.url, 'runtime')); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('[data-runtime-provider-session]')).toHaveCount(1, { timeout: browserTimeout }); - const runtimeIdentity = await page.evaluate(async () => { - const response = await fetch('/api/runtime/status'); - const { status } = await response.json() as { readonly status: Readonly<{ - readonly activeVector?: Readonly<{ - readonly artifactEpochId?: string; - readonly providerSessionId: string; - readonly runtimeGenerationId: string; - readonly sourceRevision: string; - readonly stateStoreId: string; - readonly stateVersion: number; - }>; - readonly hmrReady: boolean; - readonly lastGoodVector?: unknown; - readonly state: string; - }> | null }; - if (status?.activeVector === undefined || status.lastGoodVector === undefined) throw new Error('Expected active Runtime provider identity.'); - return { activeVector: status.activeVector, hmrReady: status.hmrReady, state: status.state }; - }); - expect(runtimeIdentity.state).toBe('active'); - const identity = page.locator('[data-runtime-provider-session]'); - await expect(identity).toHaveAttribute('data-runtime-artifact-epoch', runtimeIdentity.activeVector.artifactEpochId ?? 'Not packaged'); - await expect(identity).toHaveAttribute('data-runtime-generation', runtimeIdentity.activeVector.runtimeGenerationId); - expect(Number(await identity.getAttribute('data-runtime-event-sequence'))).toBeGreaterThanOrEqual(0); - await expect(identity).toHaveAttribute('data-runtime-hmr-client-count', 'Unknown'); - await expect(identity).toHaveAttribute('data-runtime-hmr-ready', String(runtimeIdentity.hmrReady)); - await expect(identity).toHaveAttribute('data-runtime-provider-session', runtimeIdentity.activeVector.providerSessionId); - await expect(identity).toHaveAttribute('data-runtime-source-revision', runtimeIdentity.activeVector.sourceRevision); - await expect(identity).toHaveAttribute('data-runtime-state-version', String(runtimeIdentity.activeVector.stateVersion)); - await expect.poll(() => foregroundSessionRequests).toHaveLength(1); - - await page.getByLabel('Runtime surface').selectOption('mcp.recent_edits'); - await page.getByLabel('Schema form').check(); - await expect(page.getByLabel('Schema form')).toBeChecked(); - await page.getByLabel('limit').fill('2'); - await page.getByRole('radio', { name: 'Raw JSON' }).check(); - const input = page.locator('#runtime-input-raw'); - await expect(input).toHaveValue(/"limit": 2/); - await input.fill('{"broken":'); - await expect(page.locator('#runtime-input-raw-error')).toBeVisible(); - await page.goto(workbenchUrl(fixture.url, 'mcp')); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByRole('tab', { name: 'Inspector' })).toHaveCount(0); - await page.goto(workbenchUrl(fixture.url, 'runtime')); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.locator('[data-runtime-provider-session]')).toHaveCount(1, { timeout: browserTimeout }); - await expect(input).toHaveValue('{"broken":'); - await expect(page.locator('#runtime-input-raw-error')).toBeVisible(); - - const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); - await page.getByLabel('Runtime surface').selectOption('mcp.runtime_status'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBeGreaterThan(0); - - await page.getByLabel('Runtime surface').selectOption('hook.claude'); - await input.fill(JSON.stringify({ - cwd: '/tmp', - hook_event_name: 'PostToolUse', - session_id: 'runtime-playground', - tool_input: { file_path: 'runtime-playground.txt' }, - tool_name: 'Write', - tool_use_id: 'runtime-playground-tool', - })); - const run = page.getByRole('button', { name: 'Run', exact: true }); - await expect(run).toBeEnabled({ timeout: browserTimeout }); - await run.focus(); - await page.keyboard.press('Enter'); - const confirmation = page.getByRole('dialog'); - await expect(confirmation).toContainText('Run mutable runtime surface?'); - const cancel = confirmation.getByRole('button', { name: 'Cancel' }); - await expect(cancel).toBeFocused({ timeout: browserTimeout }); - await expect(run).toBeDisabled(); - await expect(input).toBeDisabled(); - await expect(page.locator('.runtime-history button').first()).toBeDisabled(); - await expect(page.getByRole('button', { name: 'Replay exact' }).first()).toBeDisabled(); - await expect(page.getByLabel('Runtime surface')).toBeDisabled(); - await page.keyboard.press('Escape'); - await expect(confirmation).toBeHidden(); - await expect(run).toBeFocused({ timeout: browserTimeout }); - - const historyBeforeRun = await history.count(); - await page.keyboard.press('Enter'); - await expect(confirmation).toContainText('Run mutable runtime surface?'); - await confirmation.getByRole('button', { name: 'Confirm' }).click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBeGreaterThan(historyBeforeRun); - - await page.getByLabel('Runtime surface').selectOption('mcp.runtime_status'); - await input.fill('{}'); - await run.focus(); - await page.keyboard.press('Enter'); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBeGreaterThan(historyBeforeRun); - - // #105 stage 2: the hook run's stored Flight decodes server-side into a - // real Agent Document rendered as elements, while the MCP-element status - // run keeps an honest decode diagnostic instead of a fabricated document. - const documentTab = page.getByRole('tab', { name: 'Document', exact: true }); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(3); - await history.nth(1).getByRole('button').first().click(); - await documentTab.click(); - await expect(documentTab).toHaveAttribute('aria-selected', 'true'); - const stage = page.getByLabel('Agent Document', { exact: true }); - await expect(stage).toContainText('Version 1 · success', { timeout: browserTimeout }); - await expect(stage.locator('.agent-document-text')) - .toContainText('Recorded runtime-playground.txt from claude.', { timeout: browserTimeout }); - await expect(page.getByLabel('Agent Document event timeline').getByRole('button', { name: /^Complete/u })) - .toBeVisible({ timeout: browserTimeout }); - await history.nth(0).getByRole('button').first().click(); - await expect(page.getByRole('tabpanel')) - .toContainText('Stored Flight could not be decoded as an Agent Document.', { timeout: browserTimeout }); - - // The hook run's stored Flight is downloadable from the Flight tab. The - // client rejects any response that is not `application/octet-stream` - // and surfaces the rejection as an alert; the real route then clears the - // alert and hands the viewer a `runtime-run-.flight.bin` download. - await history.nth(1).getByRole('button').first().click(); - await page.getByRole('tab', { name: 'Flight', exact: true }).click(); - const download = page.getByRole('button', { name: 'Download Flight payload' }); - await expect(download).toBeVisible({ timeout: browserTimeout }); - const flightAlert = page.locator('.runtime-request-error[role="alert"]'); - const flightRoute = '**/api/runtime/runs/*/flight'; - await page.route(flightRoute, (route) => route.fulfill({ body: 'nope', contentType: 'text/plain', status: 200 })); - await download.click(); - await expect(flightAlert).toHaveText('Runtime Flight response is not valid.', { timeout: browserTimeout }); - await page.unroute(flightRoute); - const downloadEvent = page.waitForEvent('download'); - await download.click(); - const downloaded = await downloadEvent; - expect(downloaded.suggestedFilename()).toMatch(/^runtime-run-.+\.flight\.bin$/u); - await expect(flightAlert).toHaveCount(0, { timeout: browserTimeout }); - - // Real trace spans carry no `details`, so the Diagnostics tab renders the - // render phases without a span-details disclosure. - await page.getByRole('tab', { name: 'Diagnostics', exact: true }).click(); - await expect(page.getByLabel('Runtime render trace')).toContainText('normalize', { timeout: browserTimeout }); - await expect(page.getByRole('button', { name: /span details/u })).toHaveCount(0); - - const reset = page.getByRole('button', { name: 'Reset fixture state' }); - const stateVersionBeforeReset = await identity.getAttribute('data-runtime-state-version'); - await reset.click(); - await expect(confirmation).toContainText('Reset fixture state?'); - await expect(confirmation).toContainText('State store'); - await expect(confirmation).toContainText(runtimeIdentity.activeVector.stateStoreId); - await expect(confirmation).toContainText('Fixture seed'); - await expect(confirmation).toContainText('No fixture seed'); - await expect(reset).toBeDisabled(); - await expect(cancel).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Tab'); - await expect(confirmation.getByRole('button', { name: 'Confirm' })).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Tab'); - await expect(cancel).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Shift+Tab'); - await expect(confirmation.getByRole('button', { name: 'Confirm' })).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Escape'); - await expect(confirmation).toBeHidden(); - await expect(reset).toBeFocused({ timeout: browserTimeout }); - expect(resetRequests).toEqual([]); - const expectedResetRequest = { - expectedGenerationId: runtimeIdentity.activeVector.runtimeGenerationId, - stateStoreId: runtimeIdentity.activeVector.stateStoreId, - }; - - // A rejected reset surfaces as a focused alert, hands the controls back, - // and leaves the state untouched; the next attempt goes through. The - // rejection is the client's own: an invalid state wrapper is refused - // before any provider identity is trusted. - const resetRoute = '**/api/runtime/state/reset'; - await page.route(resetRoute, (route) => route.fulfill({ body: '{}', contentType: 'application/json', status: 200 })); - await reset.click(); - await expect(confirmation).toContainText('State store'); - await confirmation.getByRole('button', { name: 'Confirm' }).click(); - const resetFailure = page.locator('.runtime-request-error[role="alert"]'); - await expect(resetFailure).toHaveText('Runtime route returned an invalid state wrapper.', { timeout: browserTimeout }); - await expect(resetFailure).toBeFocused({ timeout: browserTimeout }); - await expect(page.locator('.runtime-status')).not.toBeFocused(); - await expect(confirmation).toBeHidden(); - await expect(run).toBeEnabled(); - await expect(reset).toBeEnabled(); - await expect(page.getByLabel('Runtime surface')).toBeEnabled(); - await page.getByRole('tab', { name: 'Tree', exact: true }).click(); - await expect(resetFailure).toBeVisible(); - await expect(page.locator('.runtime-status')).not.toBeFocused(); - await expect(identity).toHaveAttribute('data-runtime-state-version', String(stateVersionBeforeReset)); - await page.unroute(resetRoute); - await expect.poll(() => resetRequests).toEqual([expectedResetRequest]); - - await reset.click(); - await expect(confirmation).toContainText('State store'); - await confirmation.getByRole('button', { name: 'Confirm' }).click(); - await expect.poll(() => resetRequests).toHaveLength(2); - expect(resetRequests).toEqual([expectedResetRequest, expectedResetRequest]); - await expect(resetFailure).toHaveCount(0, { timeout: browserTimeout }); - await expect.poll(async () => identity.getAttribute('data-runtime-state-version'), { timeout: browserTimeout }).not.toBe(stateVersionBeforeReset); - await expect(page.locator('.runtime-status')).toBeFocused({ timeout: browserTimeout }); - - const tabs = page.getByRole('tab'); - await expect(tabs).toHaveCount(7); - const stateTab = page.getByRole('tab', { name: 'State', exact: true }); - await stateTab.click(); - await expect(stateTab).toHaveAttribute('aria-selected', 'true'); - await page.goto(workbenchUrl(fixture.url, 'mcp')); - await expect(page.getByRole('heading', { name: 'MCP playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(page.getByLabel('MCP App preview controls')).toHaveCount(1, { timeout: browserTimeout }); - expect(forbiddenRequests).toEqual([]); - expect(pageErrors).toEqual([]); - } finally { - await fixture.close(); - } -}); - -e2e('resets the selected Claude fixture to its seed without replacing prior runtime evidence', { timeout: 120_000 }, async ({ page }) => { - const fixture = await startRuntimePlaygroundFixture(); - const resetRequests: unknown[] = []; - const pageErrors: Error[] = []; - const claudeSeed = { - cwd: '/tmp', - hook_event_name: 'PostToolUse', - session_id: 'fixture-claude-post-tool-use', - tool_input: { file_path: 'fixture-claude-post-tool-use.txt' }, - tool_name: 'Write', - tool_use_id: 'fixture-claude-post-tool-use-write', - }; - page.on('pageerror', (error) => pageErrors.push(error)); - page.on('request', (request) => { - const requestUrl = new URL(request.url()); - if (requestUrl.origin === fixture.url && requestUrl.pathname === '/api/runtime/state/reset' && request.method() === 'POST') { - resetRequests.push(JSON.parse(request.postData() ?? 'null')); - } - }); - try { - await page.goto(workbenchUrl(fixture.url, 'runtime')); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - const identity = page.locator('[data-runtime-provider-session]'); - await expect(identity).toHaveCount(1, { timeout: browserTimeout }); - const runtimeSessionToken = await page.evaluate(async () => { - const response = await fetch('/api/project/session', { credentials: 'same-origin' }); - const body: unknown = await response.json(); - if (!response.ok || typeof body !== 'object' || body === null || typeof (body as { readonly token?: unknown }).token !== 'string') { - throw new Error(`Runtime session bootstrap failed with ${response.status}.`); - } - return (body as { readonly token: string }).token; - }); - const runtimeJson = async (path: string): Promise => page.evaluate(async ({ route, token }) => { - const response = await fetch(route, { - credentials: 'same-origin', - headers: { 'x-agent-bundle-session': token }, - }); - if (!response.ok) throw new Error(`Runtime request ${route} failed with ${response.status}.`); - return response.json(); - }, { route: path, token: runtimeSessionToken }); - const surface = page.getByLabel('Runtime surface'); - const runtimeFixture = page.getByLabel('Runtime fixture'); - const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); - const run = page.getByRole('button', { name: 'Run', exact: true }); - const reset = page.getByRole('button', { name: 'Reset fixture state' }); - const confirmation = page.getByRole('dialog'); - const cancel = confirmation.getByRole('button', { name: 'Cancel' }); - - await surface.selectOption('hook.claude'); - await runtimeFixture.selectOption('claude-post-tool-use-write'); - await expect(runtimeFixture).toHaveValue('claude-post-tool-use-write'); - await run.click(); - await expect(confirmation).toContainText('Run mutable runtime surface?'); - await confirmation.getByRole('button', { name: 'Confirm' }).click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(1); - await expect(run).toBeEnabled({ timeout: browserTimeout }); - - const historyBeforeReset = await runtimeJson('/api/runtime/runs?limit=50') as Readonly<{ - readonly runs: readonly Readonly<{ - readonly fixtureId?: string; - readonly id: string; - readonly input: unknown; - readonly status: string; - readonly surfaceId: string; - readonly target: string; - readonly vector: Readonly<{ readonly stateStoreId: string; readonly stateVersion: number }>; - }> []; - }>; - expect(historyBeforeReset.runs).toHaveLength(1); - expect(historyBeforeReset.runs[0]).toMatchObject({ - fixtureId: 'claude-post-tool-use-write', - input: claudeSeed, - status: 'succeeded', - surfaceId: 'hook.claude', - target: 'claude', - }); - const oldRunIds = historyBeforeReset.runs.map((entry) => entry.id); - const oldRunVectors = Object.fromEntries(await Promise.all(oldRunIds.map(async (runId) => { - const response = await runtimeJson(`/api/runtime/runs/${encodeURIComponent(runId)}`) as Readonly<{ - readonly run: Readonly<{ readonly vector: unknown }>; - }>; - return [runId, JSON.parse(JSON.stringify(response.run.vector))] as const; - }))); - const stateStoreId = historyBeforeReset.runs[0]!.vector.stateStoreId; - const stateVersionBeforeReset = historyBeforeReset.runs[0]!.vector.stateVersion; - const generationId = await identity.getAttribute('data-runtime-generation'); - expect(generationId).not.toBeNull(); - - await reset.click(); - await expect(confirmation).toContainText('Reset fixture state?'); - await expect(confirmation).toContainText(JSON.stringify(claudeSeed)); - await expect(cancel).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Tab'); - await expect(confirmation.getByRole('button', { name: 'Confirm' })).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Tab'); - await expect(cancel).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Shift+Tab'); - await expect(confirmation.getByRole('button', { name: 'Confirm' })).toBeFocused({ timeout: browserTimeout }); - await page.keyboard.press('Escape'); - await expect(confirmation).toBeHidden(); - await expect(reset).toBeFocused({ timeout: browserTimeout }); - expect(resetRequests).toEqual([]); - - await reset.click(); - const resetResponse = page.waitForResponse((response) => { - const requestUrl = new URL(response.url()); - return requestUrl.origin === fixture.url && requestUrl.pathname === '/api/runtime/state/reset' && response.request().method() === 'POST'; - }); - await confirmation.getByRole('button', { name: 'Confirm' }).click(); - await expect.poll(() => resetRequests, { timeout: browserTimeout }).toEqual([{ - expectedGenerationId: generationId, - seed: claudeSeed, - stateStoreId, - }]); - expect((await resetResponse).status()).toBe(200); - await expect(identity).toHaveAttribute('data-runtime-state-version', String(stateVersionBeforeReset + 1), { timeout: browserTimeout }); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(oldRunIds.length + 1); - await expect.poll( - async () => page.locator('.runtime-status').evaluate((element) => element.ownerDocument.activeElement === element), - { timeout: browserTimeout }, - ).toBe(true); - - const historyAfterReset = await runtimeJson('/api/runtime/runs?limit=50') as Readonly<{ - readonly runs: readonly Readonly<{ - readonly id: string; - readonly result?: Readonly<{ readonly state: Readonly<{ readonly snapshot?: unknown }> }>; - readonly vector: Readonly<{ readonly stateStoreId: string; readonly stateVersion: number }>; - }> []; - }>; - expect(historyAfterReset.runs).toHaveLength(oldRunIds.length + 1); - const historyAfterResetIds = historyAfterReset.runs.map((entry) => entry.id); - const resetFollowUp = historyAfterReset.runs.find((entry) => !oldRunIds.includes(entry.id)); - expect(resetFollowUp).toBeDefined(); - expect(historyAfterResetIds).toEqual([resetFollowUp!.id, ...oldRunIds]); - expect(resetFollowUp!.vector).toMatchObject({ stateStoreId, stateVersion: stateVersionBeforeReset + 1 }); - expect(resetFollowUp!.result?.state.snapshot).toMatchObject({ seed: claudeSeed }); - - for (const oldRunId of oldRunIds) { - const response = await runtimeJson(`/api/runtime/runs/${encodeURIComponent(oldRunId)}`) as Readonly<{ - readonly run: Readonly<{ readonly vector: unknown }>; - }>; - expect(response.run.vector).toEqual(oldRunVectors[oldRunId]); - } - - await history.first().getByRole('button').first().click(); - const stateTab = page.getByRole('tab', { name: 'State', exact: true }); - await stateTab.click(); - await expect(stateTab).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByRole('tabpanel')).toContainText('fixture-claude-post-tool-use-write'); - expect(pageErrors).toEqual([]); - } finally { - await fixture.close(); - } -}); - -e2e('retains real MCP runtime history across reload and isolates a fresh provider', { timeout: 180_000 }, async ({ page }) => { - const firstFixture = await startRuntimePlaygroundFixture(); - let secondFixture: Awaited> | undefined; - const context = page.context(); - const forbiddenRequests: string[] = []; - const runtimeRunPosts: string[] = []; - const pageErrors: Error[] = []; - const forbiddenPrefixes = ['/api/mcp/sessions', '/api/runtime/mcp/sessions', '/api/mcp/apps', '/api/runtime/apps']; - const expectedContent = [ - { text: 'Runtime state contains 1 edit.', type: 'text' }, - { - data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - mimeType: 'image/png', - type: 'image', - }, - ]; - const recordRequest = (request: { method(): string; url(): string }): void => { - const requestUrl = new URL(request.url()); - if (forbiddenPrefixes.some((prefix) => requestUrl.pathname.startsWith(prefix))) forbiddenRequests.push(requestUrl.pathname); - if (requestUrl.origin === firstFixture.url && requestUrl.pathname === '/api/runtime/runs' && request.method() === 'POST') { - runtimeRunPosts.push(requestUrl.pathname); - } - }; - context.on('request', recordRequest); - page.on('pageerror', (error) => pageErrors.push(error)); - try { - await page.goto(workbenchUrl(firstFixture.url, 'runtime')); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - const identity = page.locator('[data-runtime-provider-session]'); - await expect(identity).toHaveCount(1, { timeout: browserTimeout }); - const runtimeSessionToken = await page.evaluate(async () => { - const response = await fetch('/api/project/session', { credentials: 'same-origin' }); - const body: unknown = await response.json(); - if (!response.ok || typeof body !== 'object' || body === null || typeof (body as { readonly token?: unknown }).token !== 'string') { - throw new Error(`Runtime session bootstrap failed with ${response.status}.`); - } - return (body as { readonly token: string }).token; - }); - const runtimeJson = async (path: string): Promise => page.evaluate(async ({ route, token }) => { - const response = await fetch(route, { - credentials: 'same-origin', - headers: { 'x-agent-bundle-session': token }, - }); - if (!response.ok) throw new Error(`Runtime request ${route} failed with ${response.status}.`); - return response.json(); - }, { route: path, token: runtimeSessionToken }); - const surface = page.getByLabel('Runtime surface'); - const target = page.getByLabel('Runtime target'); - const input = page.locator('#runtime-input-raw'); - const run = page.getByRole('button', { name: 'Run', exact: true }); - const history = page.getByRole('region', { name: 'Runtime run history' }).locator('ol > li'); - - await surface.selectOption('hook.claude'); - await target.selectOption('claude'); - await input.fill(JSON.stringify({ - cwd: '/tmp', - hook_event_name: 'PostToolUse', - session_id: 'runtime-history-hydration', - tool_input: { file_path: 'runtime-history-hydration.txt' }, - tool_name: 'Write', - tool_use_id: 'runtime-history-hydration-tool', - })); - await run.click(); - await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(1); - - await surface.selectOption('mcp.runtime_status'); - await target.selectOption('portable'); - await page.getByRole('radio', { name: 'Raw JSON' }).check(); - await input.fill('{}'); - await expect(surface).toHaveValue('mcp.runtime_status'); - await expect(target).toHaveValue('portable'); - await expect(input).toHaveValue('{}'); - await run.click(); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(2); - expect(runtimeRunPosts).toEqual(['/api/runtime/runs', '/api/runtime/runs']); - - const firstHistory = await runtimeJson('/api/runtime/runs?limit=50') as Readonly<{ - readonly providerSessionId: string; - readonly runs: readonly Readonly<{ - readonly id: string; - readonly result: Readonly<{ - readonly agentVisible?: unknown; - readonly modelVisible?: unknown; - readonly protocol?: unknown; - readonly trace: readonly Readonly<{ readonly id: string; readonly status: string }> []; - }>; - readonly status: string; - readonly surfaceId: string; - readonly target: string; - readonly vector: Readonly<{ - readonly providerSessionId: string; - readonly runtimeGenerationId: string; - readonly stateStoreId: string; - readonly stateVersion: number; - }>; - }> []; - }>; - const firstStatus = await runtimeJson('/api/runtime/status') as Readonly<{ - readonly status: Readonly<{ readonly activeVector: unknown }>; - }>; - expect(firstHistory.runs).toHaveLength(2); - const firstRunIds = firstHistory.runs.map((entry) => entry.id); - const statusRun = firstHistory.runs.find((entry) => entry.surfaceId === 'mcp.runtime_status'); - const hookRun = firstHistory.runs.find((entry) => entry.surfaceId === 'hook.claude'); - if (statusRun === undefined || hookRun === undefined) throw new Error('Expected distinguishable hook and Runtime status history entries.'); - const expectedSelectedId = statusRun.id; - const expectedSelectedProtocol = { - content: expectedContent, - structuredContent: { editCount: 1, stateVersion: 1 }, - }; - expect(firstRunIds).toEqual([statusRun.id, hookRun.id]); - expect(statusRun).toMatchObject({ status: 'succeeded', surfaceId: 'mcp.runtime_status', target: 'portable' }); - expect(statusRun.vector).toEqual(firstStatus.status.activeVector); - expect(statusRun.vector.providerSessionId).toBe(firstHistory.providerSessionId); - expect(statusRun.result.modelVisible).toEqual(expectedContent); - expect(statusRun.result.protocol).toEqual(expectedSelectedProtocol); - expect(statusRun.result.trace.map((entry) => entry.id)).toEqual(['normalize', 'worker', 'flight', 'decode', 'lower']); - expect(statusRun.result.trace.map((entry) => entry.status)).toEqual(['succeeded', 'succeeded', 'succeeded', 'succeeded', 'succeeded']); - expect(hookRun).toMatchObject({ - result: { agentVisible: 'Recorded runtime-history-hydration.txt from claude. Shared state now contains 1 edit.' }, - status: 'succeeded', - surfaceId: 'hook.claude', - target: 'claude', - vector: { stateStoreId: statusRun.vector.stateStoreId, stateVersion: 1 }, - }); - const historyRunIds = async (): Promise => history.evaluateAll((items) => items.map((item) => { - const id = item.getAttribute('data-runtime-run-id'); - if (id === null) throw new Error('Runtime history item is missing its stable run ID.'); - return id; - })); - expect(await historyRunIds()).toEqual(firstRunIds); - await page.locator(`[data-runtime-run-id="${expectedSelectedId}"]`).getByRole('button').first().click(); - await expect(history.locator('button[aria-pressed="true"]')).toHaveCount(1, { timeout: browserTimeout }); - await expect(page.locator(`[data-runtime-run-id="${expectedSelectedId}"] button[aria-pressed="true"]`)).toHaveCount(1, { timeout: browserTimeout }); - await expect(page.locator('[aria-label="Runtime output stage"] .runtime-stage-output--model code')) - .toHaveText(JSON.stringify(expectedContent, null, 2), { timeout: browserTimeout }); - const protocol = page.getByRole('tab', { name: 'Protocol', exact: true }); - await protocol.click(); - await expect(page.getByLabel('Runtime protocol evidence').getByLabel('Provider MCP protocol').locator('details pre code')) - .toHaveText(JSON.stringify(expectedSelectedProtocol, null, 2), { timeout: browserTimeout }); - - await page.reload(); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(identity).toHaveAttribute('data-runtime-provider-session', firstHistory.providerSessionId); - const reloadedHistory = await runtimeJson('/api/runtime/runs?limit=50') as Readonly<{ - readonly providerSessionId: string; - readonly runs: readonly Readonly<{ readonly id: string }> []; - }>; - expect(reloadedHistory.providerSessionId).toBe(firstHistory.providerSessionId); - expect(reloadedHistory.runs.map((entry) => entry.id)).toEqual(firstRunIds); - expect(reloadedHistory.runs).toHaveLength(firstRunIds.length); - expect(reloadedHistory.runs.length).toBeLessThanOrEqual(50); - await expect.poll(historyRunIds, { timeout: browserTimeout }).toEqual(firstRunIds); - const selectedReloadedHistory = history.filter({ has: page.locator('button[aria-pressed="true"]') }); - await expect(selectedReloadedHistory).toHaveCount(1, { timeout: browserTimeout }); - expect(await selectedReloadedHistory.getAttribute('data-runtime-run-id')).toBe(expectedSelectedId); - await expect(page.locator('[aria-label="Runtime output stage"] .runtime-stage-output--model code')) - .toHaveText(JSON.stringify(expectedContent, null, 2), { timeout: browserTimeout }); - await protocol.click(); - await expect(page.getByLabel('Runtime protocol evidence').getByLabel('Provider MCP protocol').locator('details pre code')) - .toHaveText(JSON.stringify(expectedSelectedProtocol, null, 2), { timeout: browserTimeout }); - - await firstFixture.close(); - secondFixture = await startRuntimePlaygroundFixture(); - await page.goto(workbenchUrl(secondFixture.url, 'runtime')); - await expect(page.getByRole('heading', { name: 'Runtime Playground' })).toBeVisible({ timeout: browserTimeout }); - await expect(identity).toHaveCount(1, { timeout: browserTimeout }); - await expect.poll(() => identity.getAttribute('data-runtime-provider-session'), { timeout: browserTimeout }).not.toBe(firstHistory.providerSessionId); - const freshSessionToken = await page.evaluate(async () => { - const response = await fetch('/api/project/session', { credentials: 'same-origin' }); - const body: unknown = await response.json(); - if (!response.ok || typeof body !== 'object' || body === null || typeof (body as { readonly token?: unknown }).token !== 'string') { - throw new Error(`Runtime session bootstrap failed with ${response.status}.`); - } - return (body as { readonly token: string }).token; - }); - const freshRuntimeJson = async (path: string): Promise => page.evaluate(async ({ route, token }) => { - const response = await fetch(route, { - credentials: 'same-origin', - headers: { 'x-agent-bundle-session': token }, - }); - if (!response.ok) throw new Error(`Runtime request ${route} failed with ${response.status}.`); - return response.json(); - }, { route: path, token: freshSessionToken }); - const freshHistory = await freshRuntimeJson('/api/runtime/runs?limit=50') as Readonly<{ - readonly providerSessionId: string; - readonly runs: readonly Readonly<{ - readonly id: string; - readonly vector: Readonly<{ readonly providerSessionId: string }>; - }> []; - }>; - const freshStatus = await freshRuntimeJson('/api/runtime/status') as Readonly<{ - readonly status: Readonly<{ readonly activeVector?: Readonly<{ readonly providerSessionId: string }> }>; - }>; - expect(freshHistory.providerSessionId).not.toBe(firstHistory.providerSessionId); - expect(freshHistory.runs.filter((entry) => firstRunIds.includes(entry.id))).toEqual([]); - expect(freshHistory.runs.every((entry) => entry.vector.providerSessionId === freshHistory.providerSessionId)).toBe(true); - expect(freshStatus.status.activeVector?.providerSessionId).toBe(freshHistory.providerSessionId); - await expect.poll(async () => history.count(), { timeout: browserTimeout }).toBe(0); - await expect(page.locator('[data-runtime-run-id]')).toHaveCount(0); - await expect(history.locator('button[aria-pressed="true"]')).toHaveCount(0); - await expect(page.locator('[aria-label="Runtime output stage"]')).toContainText('No runtime output selected.'); - expect(forbiddenRequests).toEqual([]); - expect(pageErrors).toEqual([]); - } finally { - context.off('request', recordRequest); - await secondFixture?.close(); - await firstFixture.close(); - } -}); diff --git a/packages/workbench/tests/runtime-playground.test.ts b/packages/workbench/tests/runtime-playground.test.ts deleted file mode 100644 index ab09a2cf6..000000000 --- a/packages/workbench/tests/runtime-playground.test.ts +++ /dev/null @@ -1,919 +0,0 @@ -import { expect, it } from '@rstest/core'; -import { createElement } from 'react'; -import { renderToReadableStream, renderToStaticMarkup } from 'react-dom/server'; - -import type { - DevRuntimeInvocationRequest, - DevRuntimeReplayRequest, - DevRuntimeRun, - DevRuntimeStateIdentity, - DevRuntimeStateResetRequest, - DevRuntimeStatus, - DevRuntimeSurface, -} from '../../agent-bundle/src/dev/runtime-protocol.ts'; -import type { JsonValue, ProjectEventMessage, RuntimeEvent } from '../../agent-bundle/src/dev/types.ts'; -import { RuntimeClientError, type RuntimeBootstrap } from '../src/runtime-client.ts'; -import { - createRuntimeEventBuffer, - createRuntimePlaygroundController, - runtimeBootstrapRetryPlan, - runtimeDataAttributesFor, - runtimePlaygroundLiveMcpPageAdapter, - RuntimePlayground, - type RuntimeAppPreviewLifecycleRegistrar, - type RuntimePlaygroundClient, -} from '../src/runtime-playground.tsx'; -import type { RuntimeProfileOption } from '../src/runtime-model.ts'; -import type { RuntimeAppPreviewRenderer, RuntimeLiveMcpPageAdapter } from '../src/runtime-stage.tsx'; - -const vector = Object.freeze({ - artifactEpochId: 'epoch-a', - providerSessionId: 'provider-a', - runtimeGenerationId: 'generation-a', - sourceRevision: 'source-a', - stateStoreId: 'state-a', - stateVersion: 1, -}); - -const status = Object.freeze({ - activeVector: vector, - descriptor: Object.freeze({ environmentVariables: [], id: 'rsc', label: 'RSC Runtime', schemaVersion: 1 as const }), - diagnostics: Object.freeze([]), - hmrReady: true, - lastGoodVector: vector, - state: 'active' as const, -}) satisfies DevRuntimeStatus; - -const surface = Object.freeze({ - defaultTarget: 'portable', - fixtures: Object.freeze([{ id: 'fixture-a', label: 'Fixture A', seed: Object.freeze({ city: 'London' }) }]), - id: 'hook.claude', - inputSchema: Object.freeze({ - properties: Object.freeze({ city: Object.freeze({ title: 'City', type: 'string' as const }) }), - required: Object.freeze(['city']), - type: 'object' as const, - }), - kind: 'hook' as const, - label: 'Claude hook', - readOnly: true, - targets: Object.freeze(['portable']), -}) satisfies DevRuntimeSurface; - -const mutableSurface = Object.freeze({ ...surface, id: 'mcp.weather', kind: 'mcp-tool' as const, readOnly: false }); - -const run = (id: string, source: Partial>> = {}): DevRuntimeRun => Object.freeze({ - completedAt: '2026-08-15T12:00:01.000Z', - fixtureId: 'fixture-a', - id, - input: Object.freeze({ city: 'London' }), - result: Object.freeze({ - agentVisible: Object.freeze({ city: 'London' }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }) }), - trace: Object.freeze([]), - tree: Object.freeze([]), - }), - startedAt: `2026-08-15T12:${id.padStart(2, '0')}:00.000Z`, - status: 'succeeded', - surfaceId: 'hook.claude', - target: 'portable', - vector, - ...source, -}); - -const profiles = Object.freeze([{ - claimsRealHostParity: false, - evidence: 'simulated', - id: 'portable', - label: 'Portable MCP Apps', - version: 'agent-bundle:mcp-apps:2026-01-26', -}] satisfies readonly RuntimeProfileOption[]); - -const bootstrap = (overrides: Partial> = {}): RuntimeBootstrap => Object.freeze({ - history: Object.freeze([run('01')]), - kind: 'available' as const, - providerSessionId: 'provider-a', - status, - surfaces: Object.freeze([surface]), - ...overrides, -}); - -interface Deferred { - readonly promise: Promise; - reject(reason: unknown): void; - resolve(value: Value): void; -} - -const deferred = (): Deferred => { - let reject!: (reason: unknown) => void; - let resolve!: (value: Value) => void; - const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; reject = nextReject; }); - return { promise, reject, resolve }; -}; - -const clientFor = (overrides: Partial = {}): RuntimePlaygroundClient & { - readonly requests: Array; -} => { - const requests: Array = []; - return { - bootstrap: async () => bootstrap(), - createRun: async (request) => { requests.push(request); return run('created'); }, - readRun: async (id) => { requests.push(id); return run(id); }, - readRunDocument: async (id) => { requests.push(`document:${id}`); return []; }, - readRunFlight: async (id) => { requests.push(`flight:${id}`); return new Blob(['flight'], { type: 'application/octet-stream' }); }, - replayRun: async (request) => { requests.push(request); return run('replayed'); }, - requests, - resetState: async (request) => { requests.push(request); return Object.freeze({ stateStoreId: 'state-a', stateVersion: 2 }); }, - ...overrides, - }; -}; - -const runtimeEvent = (sequence: number, type: RuntimeEvent['type'], runId?: string): ProjectEventMessage => Object.freeze({ - occurredAt: '2026-08-15T12:00:00.000Z', - payload: Object.freeze({ providerSessionId: 'provider-a', ...(runId === undefined ? {} : { runId }), type }), - sequence, - type: 'runtime.event', -}); - -const renderWhenReady = async (node: React.ReactNode): Promise => { - const stream = await renderToReadableStream(node); - await stream.allReady; - return new Response(stream).text(); -}; - -it('keeps unavailable runtime absent and composes no live MCP page adapter', () => { - const controller = createRuntimePlaygroundController({ bootstrap: Object.freeze({ kind: 'unavailable' }), client: clientFor(), profiles }); - - expect(controller.model.status).toBeUndefined(); - expect(controller.model.surfaces).toEqual([]); - expect(runtimeDataAttributesFor(controller.model)).toEqual({}); - expect(runtimePlaygroundLiveMcpPageAdapter).toEqual({ kind: 'disabled' }); -}); - -it('passes an explicit default profile through the Playground controller without reordering profiles', () => { - const chatgpt = Object.freeze({ - claimsRealHostParity: false, - evidence: 'simulated' as const, - id: 'chatgpt', - label: 'ChatGPT Simulation', - version: 'agent-bundle:chatgpt-sim:1', - }); - const orderedProfiles = Object.freeze([chatgpt, profiles[0]!]); - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap(), - client: clientFor(), - defaultProfileId: 'portable', - profiles: orderedProfiles, - }); - - expect(controller.model.profiles.map((entry) => entry.id)).toEqual(['chatgpt', 'portable']); - expect(controller.model.selectedProfileId).toBe('portable'); -}); - -it('renders the available Runtime playground controls, identity, and optional capability evidence', () => { - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor(), profiles }); - - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - - expect(markup).toContain('Runtime Playground'); - expect(markup).toContain('Optional development capability'); - expect(markup).toContain('Runtime identity'); - expect(markup).toContain('Portable MCP Apps · agent-bundle:mcp-apps:2026-01-26 · Simulation'); - expect(markup).toContain('Simulated locally — not host certification'); - expect(markup).toContain('Runtime input input mode'); - expect(markup).toContain('City'); - expect(markup).toContain('Raw JSON'); - expect(markup).toContain('Run history'); - expect(markup).toContain('data-runtime-provider-session="provider-a"'); - expect(markup).toContain('Loading runtime evidence…'); -}); - -it('forwards the one preview renderer and exact lifecycle registrar to its sole Stage boundary without invoking either lifecycle owner', async () => { - const appSurface = Object.freeze({ ...surface, id: 'app/customer', kind: 'mcp-app' as const, label: 'Customer App' }); - const appRun = Object.freeze({ - completedAt: '2026-08-15T12:00:01.000Z', - fixtureId: 'fixture-a', - id: '01', - input: Object.freeze({ city: 'London' }), - result: Object.freeze({ - agentVisible: Object.freeze({ city: 'London' }), - app: Object.freeze({ - mcpBinding: Object.freeze({ - definitionDigest: 'definition', - registryRevision: 1, - serverDigest: 'server', - serverName: 'customer', - sessionId: 'session', - sessionRevision: 1, - target: 'portable', - transportDigest: 'transport', - }), - resourceUri: 'ui://customer/app.html', - surfaceId: 'app/customer', - }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }) }), - trace: Object.freeze([]), - tree: Object.freeze([]), - }), - startedAt: '2026-08-15T12:01:00.000Z', - status: 'succeeded' as const, - surfaceId: 'app/customer', - target: 'portable', - vector, - } satisfies DevRuntimeRun); - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap({ history: Object.freeze([appRun]), surfaces: Object.freeze([appSurface]) }), - client: clientFor(), - profiles, - }); - let rendererCalls = 0; - let handoffCalls = 0; - let receivedHandoff: unknown; - let registrarCalls = 0; - let receivedRegistrar: unknown; - const registrar: RuntimeAppPreviewLifecycleRegistrar = (_handle) => { - registrarCalls += 1; - return () => undefined; - }; - const renderer: RuntimeAppPreviewRenderer = (props): React.ReactNode => { - rendererCalls += 1; - receivedRegistrar = props.registerLifecycle; - return createElement('div', { 'data-runtime-app-sentinel': 'playground' }, 'Injected App'); - }; - const liveMcpPageAdapter = Object.freeze({ - kind: 'host-owned' as const, - render: (props) => { - handoffCalls += 1; - receivedHandoff = props; - return createElement('div', { 'data-runtime-mcp-page-sentinel': 'playground' }, 'Host handoff'); - }, - }) satisfies RuntimeLiveMcpPageAdapter; - - await renderWhenReady(createElement(RuntimePlayground, { - controller, - liveMcpPageAdapter, - registerAppPreviewLifecycle: registrar, - renderAppPreview: renderer, - })); - - expect(rendererCalls).toBe(1); - expect(handoffCalls).toBe(1); - expect(receivedRegistrar).toBe(registrar); - const handoff = receivedHandoff as Record; - const selectedRun = controller.model.history[0]!; - const selectedProfile = controller.model.profiles[0]!; - const selectedSurface = controller.model.surfaces[0]!; - if (selectedRun.status !== 'succeeded') throw new Error('Expected the selected Runtime App run to have succeeded.'); - expect(Object.keys(handoff).sort()).toEqual(['mcpBinding', 'profile', 'profileId', 'registerLifecycle', 'run', 'surface']); - expect(handoff.mcpBinding).toBe(selectedRun.result.app!.mcpBinding); - expect(handoff.profile).toBe(selectedProfile); - expect(handoff.profileId).toBe('portable'); - expect(handoff.registerLifecycle).toBe(registrar); - expect(handoff.run).toBe(selectedRun); - expect(handoff.surface).toBe(selectedSurface); - expect(registrarCalls).toBe(0); -}); - -it('renders each ordered Runtime history item with its stable run ID', () => { - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap({ history: Object.freeze([run('02'), run('01')]) }), - client: clientFor(), - profiles, - }); - - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - - expect(markup).toMatch(/data-runtime-run-id="02"[\s\S]*data-runtime-run-id="01"/u); -}); - -it('renders validation, confirmation, replay-gap, and unavailable-identity states without hiding the Runtime shell', async () => { - const initial = bootstrap({ status: Object.freeze({ ...status, hmrReady: false, state: 'degraded' }) }); - const controller = createRuntimePlaygroundController({ - bootstrap: initial, - client: clientFor({ bootstrap: async () => initial }), - profiles: Object.freeze([]), - }); - controller.dispatch({ raw: '{', type: 'draft.raw' }); - controller.dispatch({ type: 'reset.request' }); - await controller.receive(Object.freeze({ earliestAvailableSequence: 14, latestDroppedSequence: 13, requestedAfterSequence: 10, type: 'replay.gap' as const })); - - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - - expect(markup).toContain('HMR endpoint unavailable'); - expect(markup).toContain('Not available'); - expect(markup).toContain('Draft JSON is invalid. Repair the raw input before running.'); - expect(markup).toContain('Events 11–13 were unavailable.'); - expect(markup).toContain('Reset fixture state?'); - expect(markup).toContain('State store'); - expect(markup).toContain('state-a'); - expect(markup).toContain('Fixture seed'); - expect(markup).toContain('disabled=""'); -}); - -it('disables reset without a reducer-owned state identity and fences duplicate confirmation', async () => { - const noState = bootstrap({ status: Object.freeze({ ...status, activeVector: undefined, lastGoodVector: undefined }) }); - const unavailableReset = createRuntimePlaygroundController({ bootstrap: noState, client: clientFor(), profiles }); - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller: unavailableReset })); - expect(markup).toContain('Reset fixture state'); - - const pending = deferred(); - let resetCalls = 0; - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap(), - client: clientFor({ resetState: async () => { resetCalls += 1; return pending.promise; } }), - profiles, - }); - controller.dispatch({ type: 'reset.request' }); - controller.dispatch({ type: 'confirmation.confirm' }); - controller.dispatch({ type: 'confirmation.confirm' }); - await Promise.resolve(); - - expect(resetCalls).toBe(1); - pending.resolve(Object.freeze({ stateStoreId: 'state-a', stateVersion: 2 })); - await controller.whenIdle(); -}); - -it('derives all runtime identity attributes from provider, ordered HMR, and reset evidence', async () => { - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor(), profiles }); - - expect(runtimeDataAttributesFor(controller.model)).toEqual({ - 'data-runtime-artifact-epoch': 'epoch-a', - 'data-runtime-event-sequence': '0', - 'data-runtime-generation': 'generation-a', - 'data-runtime-hmr-client-count': 'Unknown', - 'data-runtime-hmr-ready': 'true', - 'data-runtime-provider-session': 'provider-a', - 'data-runtime-source-revision': 'source-a', - 'data-runtime-state-version': '1', - }); - await controller.receive(Object.freeze({ - occurredAt: '2026-08-15T12:00:00.000Z', - payload: Object.freeze({ details: Object.freeze({ connectionCount: 3, surfaceId: 'hook.claude' }), providerSessionId: 'provider-a', type: 'runtime.hmr.client-connected' as const }), - sequence: 8, - type: 'runtime.event' as const, - })); - controller.dispatch({ type: 'reset.request' }); - controller.dispatch({ type: 'confirmation.confirm' }); - await controller.whenIdle(); - - expect(runtimeDataAttributesFor(controller.model)).toMatchObject({ - 'data-runtime-event-sequence': '8', - 'data-runtime-hmr-client-count': '3', - 'data-runtime-state-version': '2', - }); -}); - -it('uses a succeeded App client surface, not its invoked surface, for HMR client counts', async () => { - const appSurface = Object.freeze({ - ...mutableSurface, - id: 'mcp.render_edit_timeline', - label: 'Render edit timeline', - }); - const succeeded = run('app-run'); - if (succeeded.status !== 'succeeded') throw new Error('Expected a succeeded App run fixture.'); - const appRun = Object.freeze({ - ...succeeded, - result: Object.freeze({ - agentVisible: Object.freeze({ city: 'London' }), - app: Object.freeze({ - mcpBinding: Object.freeze({ - definitionDigest: 'definition-app', registryRevision: 1, serverDigest: 'server-app', serverName: 'timeline', - sessionId: 'session-app', sessionRevision: 1, target: 'portable', transportDigest: 'transport-app', - }), - resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - surfaceId: 'mcp.edit-timeline', - }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }) }), - trace: Object.freeze([]), - tree: Object.freeze([]), - }), - surfaceId: 'mcp.render_edit_timeline', - }) satisfies DevRuntimeRun; - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap({ history: Object.freeze([appRun]), surfaces: Object.freeze([appSurface]) }), - client: clientFor(), - profiles, - }); - - await controller.receive(Object.freeze({ - occurredAt: '2026-08-15T12:00:00.000Z', - payload: Object.freeze({ details: Object.freeze({ connectionCount: 4, surfaceId: 'mcp.edit-timeline' }), providerSessionId: 'provider-a', type: 'runtime.hmr.client-connected' as const }), - sequence: 8, - type: 'runtime.event' as const, - })); - - expect(runtimeDataAttributesFor(controller.model)).toMatchObject({ - 'data-runtime-hmr-client-count': '4', - }); -}); - -it('retains a succeeded App client surface for HMR while the selected run has failed', async () => { - const appSurface = Object.freeze({ - ...mutableSurface, - id: 'mcp.render_edit_timeline', - label: 'Render edit timeline', - }); - const succeeded = run('app-run'); - if (succeeded.status !== 'succeeded') throw new Error('Expected a succeeded App run fixture.'); - const appRun = Object.freeze({ - ...succeeded, - result: Object.freeze({ - agentVisible: Object.freeze({ city: 'London' }), - app: Object.freeze({ - mcpBinding: Object.freeze({ - definitionDigest: 'definition-app', registryRevision: 1, serverDigest: 'server-app', serverName: 'timeline', - sessionId: 'session-app', sessionRevision: 1, target: 'portable', transportDigest: 'transport-app', - }), - resourceUri: 'ui://rsc-agent-runtime/edit-timeline-v1.html', - surfaceId: 'mcp.edit-timeline', - }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }) }), - trace: Object.freeze([]), - tree: Object.freeze([]), - }), - surfaceId: 'mcp.render_edit_timeline', - }) satisfies DevRuntimeRun; - const failed = Object.freeze({ - completedAt: '2026-08-15T12:01:01.000Z', - diagnostics: Object.freeze([]), - id: 'failed-run', - input: Object.freeze({ city: 'London' }), - startedAt: '2026-08-15T12:01:00.000Z', - status: 'failed' as const, - surfaceId: 'mcp.render_edit_timeline', - target: 'portable', - vector, - }) satisfies DevRuntimeRun; - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap({ history: Object.freeze([failed, appRun]), surfaces: Object.freeze([appSurface]) }), - client: clientFor(), - profiles, - }); - - await controller.receive(Object.freeze({ - occurredAt: '2026-08-15T12:00:00.000Z', - payload: Object.freeze({ details: Object.freeze({ connectionCount: 5, surfaceId: 'mcp.edit-timeline' }), providerSessionId: 'provider-a', type: 'runtime.hmr.client-connected' as const }), - sequence: 8, - type: 'runtime.event' as const, - })); - - expect(runtimeDataAttributesFor(controller.model)).toMatchObject({ - 'data-runtime-hmr-client-count': '5', - }); -}); - -it('does not present unknown HMR clients as zero and keeps the visible state version aligned after reset', async () => { - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap(), - client: clientFor({ createRun: async () => run('02') }), - profiles, - }); - - expect(runtimeDataAttributesFor(controller.model)).toMatchObject({ - 'data-runtime-hmr-client-count': 'Unknown', - }); - - controller.dispatch({ type: 'reset.request' }); - controller.dispatch({ type: 'confirmation.confirm' }); - await controller.whenIdle(); - - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - expect(markup).toContain('data-runtime-state-version="2"'); - expect(markup).toContain('
    State version
    2
    '); -}); - -it('renders available capability without a current vector and preserves fallback identity labels', () => { - const withoutEpoch = Object.freeze({ ...vector, artifactEpochId: undefined }); - const noVectorStatus = Object.freeze({ ...status, activeVector: undefined, hmrReady: false, lastGoodVector: undefined, state: 'compiling' }) satisfies DevRuntimeStatus; - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap({ history: Object.freeze([]), status: noVectorStatus, surfaces: Object.freeze([]) }), - client: clientFor(), - profiles: Object.freeze([]), - }); - const attributes = runtimeDataAttributesFor(Object.freeze({ - ...createRuntimePlaygroundController({ bootstrap: bootstrap({ status: Object.freeze({ ...status, activeVector: withoutEpoch }) }), client: clientFor(), profiles }).model, - selectedSurfaceId: undefined, - stateIdentity: undefined, - })); - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - - expect(attributes).toMatchObject({ - 'data-runtime-artifact-epoch': 'Not packaged', - 'data-runtime-hmr-client-count': 'Unknown', - 'data-runtime-state-version': '1', - }); - expect(markup).toContain('HMR endpoint unavailable'); - expect(markup).toContain('Provider session ID
    Not available'); - expect(markup).not.toContain('data-runtime-provider-session'); -}); - -it('renders provider-default fallback controls and retained announcements without inventing an App client', () => { - const alternateSurface = Object.freeze({ ...surface, id: 'hook.cursor', label: 'Cursor hook' }); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor(), profiles }); - controller.dispatch({ bootstrap: bootstrap({ surfaces: Object.freeze([alternateSurface]) }), type: 'bootstrap.received' }); - - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - - expect(markup).toContain('Selected runtime surface is no longer available; provider defaults were selected.'); - expect(markup).toContain('Cursor hook'); - expect(markup).not.toContain(' { - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor(), profiles }); - - controller.dispatch({ event: runtimeEvent(1, 'runtime.generation.failed'), type: 'event.received' }); - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - - expect(controller.model.selectedTab).toBe('result'); - expect(controller.model.selectedRunId).toBe('01'); - expect(markup).toContain('role="alert"'); - expect(markup).toContain('Runtime generation failed. The last good result remains available.'); -}); - -it('renders previous-provider last-good output separately from session-only runtime history', () => { - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor(), profiles }); - const beforeRestartMarkup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - const providerBVector = Object.freeze({ ...vector, providerSessionId: 'provider-b', runtimeGenerationId: 'generation-b' }); - controller.dispatch({ - bootstrap: bootstrap({ - history: Object.freeze([]), - providerSessionId: 'provider-b', - status: Object.freeze({ ...status, activeVector: providerBVector, lastGoodVector: providerBVector }), - }), - type: 'bootstrap.received', - }); - const markup = renderToStaticMarkup(createElement(RuntimePlayground, { controller })); - - expect(markup).toContain('Previous provider session'); - expect(markup).toContain('Last-good output from the prior provider session'); - expect(markup).toContain('Hook operation'); - expect(beforeRestartMarkup).toContain('Session-only / ephemeral — not durable artifact history'); - expect(markup).toContain('"city": "London"'); -}); - -it('toggles span details for traced spans that carry them and renders no toggle otherwise', async () => { - const traced = run('01', { - result: Object.freeze({ - agentVisible: Object.freeze({ city: 'London' }), - state: Object.freeze({ identity: Object.freeze({ stateStoreId: 'state-a', stateVersion: 1 }) }), - trace: Object.freeze([ - Object.freeze({ details: Object.freeze({ step: 'render' }), id: 'render', phase: 'render', startedAt: '2026-08-15T12:01:00.000Z', status: 'succeeded' as const }), - Object.freeze({ id: 'flight', parentId: 'render', phase: 'flight', startedAt: '2026-08-15T12:01:00.500Z', status: 'succeeded' as const }), - ]), - tree: Object.freeze([]), - }), - }); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap({ history: Object.freeze([traced]) }), client: clientFor(), profiles }); - controller.dispatch({ tab: 'diagnostics', type: 'selection.tab' }); - const toggles = (markup: string): ReadonlyArray> => - (markup.match(/]*>(?:Show|Hide) span details<\/button>/gu) ?? []).map((button) => ({ - expanded: /aria-expanded="([^"]*)"/u.exec(button)?.[1], - label: button.includes('Hide') ? 'Hide span details' : 'Show span details', - })); - - const collapsed = await renderWhenReady(createElement(RuntimePlayground, { controller })); - expect(toggles(collapsed)).toEqual([{ expanded: 'false', label: 'Show span details' }]); - expect(collapsed).toContain('
  • flight'); - expect(collapsed).not.toContain('"step": "render"'); - - controller.dispatch({ spanId: 'render', type: 'trace.toggle' }); - const expanded = await renderWhenReady(createElement(RuntimePlayground, { controller })); - expect(controller.model.expandedTraceSpanIds).toEqual(['render']); - expect(toggles(expanded)).toEqual([{ expanded: 'true', label: 'Hide span details' }]); - expect(expanded).toContain('"step": "render"'); - - controller.dispatch({ spanId: 'render', type: 'trace.toggle' }); - const recollapsed = await renderWhenReady(createElement(RuntimePlayground, { controller })); - expect(controller.model.expandedTraceSpanIds).toEqual([]); - expect(toggles(recollapsed)).toEqual([{ expanded: 'false', label: 'Show span details' }]); - expect(recollapsed).not.toContain('"step": "render"'); -}); - -it('initializes all provider history items without truncating the server-owned fifty item window', () => { - const history = Object.freeze(Array.from({ length: 50 }, (_, index) => run(String(50 - index).padStart(2, '0')))); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap({ history }), client: clientFor(), profiles }); - - expect(controller.model.history).toHaveLength(50); - expect(controller.model.history.map((entry) => entry.id)).toEqual(history.map((entry) => entry.id)); -}); - -it('executes a read-only run exactly once', async () => { - const client = clientFor(); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - - controller.dispatch({ type: 'run.request' }); - await controller.whenIdle(); - - expect(client.requests).toEqual([{ - expectedGenerationId: 'generation-a', fixtureId: 'fixture-a', input: { city: 'London' }, surfaceId: 'hook.claude', target: 'portable', - }]); -}); - -it('waits for confirmation before posting a mutable run', async () => { - const client = clientFor(); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap({ surfaces: Object.freeze([mutableSurface]) }), client, profiles }); - - controller.dispatch({ type: 'run.request' }); - await controller.whenIdle(); - expect(client.requests).toEqual([]); - controller.dispatch({ type: 'confirmation.confirm' }); - await controller.whenIdle(); - - expect(client.requests).toHaveLength(1); -}); - -it('cancels reset, posts the exact reset request, then queues its one follow-up run', async () => { - const client = clientFor(); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - - controller.dispatch({ type: 'reset.request' }); - controller.dispatch({ type: 'confirmation.cancel' }); - await controller.whenIdle(); - expect(client.requests).toEqual([]); - - controller.dispatch({ type: 'reset.request' }); - controller.dispatch({ type: 'confirmation.confirm' }); - await controller.whenIdle(); - - expect(client.requests).toEqual([ - { expectedGenerationId: 'generation-a', seed: { city: 'London' }, stateStoreId: 'state-a' }, - { expectedGenerationId: 'generation-a', fixtureId: 'fixture-a', input: { city: 'London' }, surfaceId: 'hook.claude', target: 'portable' }, - ]); -}); - -it('sends exact and latest replay bodies without broadening either request', async () => { - const client = clientFor(); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - - controller.dispatch({ mode: 'exact', runId: '01', type: 'replay.request' }); - await controller.whenIdle(); - controller.dispatch({ mode: 'latest', runId: '01', type: 'replay.request' }); - await controller.whenIdle(); - - expect(client.requests).toEqual([ - { expectedGenerationId: 'generation-a', mode: 'exact', runId: '01' }, - { expectedGenerationId: 'generation-a', mode: 'latest', runId: '01' }, - ]); -}); - -it('reads a terminal event once and does not duplicate its run read', async () => { - const client = clientFor(); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - const event = runtimeEvent(7, 'runtime.run.completed', 'run-event'); - - await controller.receive(event); - await controller.receive(event); - - expect(client.requests).toEqual(['run-event']); -}); - -it('recovers a conflict through bootstrap without re-posting the conflicted operation', async () => { - let bootstrapCalls = 0; - let createCalls = 0; - const client = clientFor({ - bootstrap: async () => { bootstrapCalls += 1; return bootstrap(); }, - createRun: async (request) => { - createCalls += 1; - throw new RuntimeClientError({ code: 'AB8204', message: `Conflict for ${request.surfaceId}`, phase: 'provider-lifecycle' }); - }, - }); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - - controller.dispatch({ type: 'run.request' }); - await controller.whenIdle(); - - expect(bootstrapCalls).toBe(1); - expect(createCalls).toBe(1); - expect(client.requests).toEqual([]); -}); - -it('processes a replay gap bootstrap before the queued next runtime event', async () => { - let bootstrapCalls = 0; - const client = clientFor({ bootstrap: async () => { bootstrapCalls += 1; return bootstrap(); } }); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - const gap = Object.freeze({ earliestAvailableSequence: 14, latestDroppedSequence: 13, requestedAfterSequence: 10, type: 'replay.gap' as const }); - - const afterGap = controller.receive(gap); - const afterEvent = controller.receive(runtimeEvent(14, 'runtime.run.completed', 'run-after-gap')); - await Promise.all([afterGap, afterEvent]); - - expect(bootstrapCalls).toBe(1); - expect(controller.model.replayGap).toEqual(gap); - expect(client.requests).toEqual(['run-after-gap']); -}); - -it('delivers pre-bootstrap runtime gap, activation, and terminal events before later live events', async () => { - const gate = deferred(); - const received: ProjectEventMessage[] = []; - const buffer = createRuntimeEventBuffer(); - const gap = Object.freeze({ earliestAvailableSequence: 14, latestDroppedSequence: 13, requestedAfterSequence: 10, type: 'replay.gap' as const }); - const activation = runtimeEvent(14, 'runtime.generation.activated'); - const terminal = runtimeEvent(15, 'runtime.run.completed', 'queued-run'); - const live = runtimeEvent(16, 'runtime.run.failed', 'live-run'); - - buffer.receive(gap); - buffer.receive(activation); - buffer.receive(terminal); - await buffer.whenIdle(); - buffer.install({ - receive: async (event) => { - received.push(event); - if (event.type === 'replay.gap') await gate.promise; - }, - }); - buffer.receive(live); - await Promise.resolve(); - expect(received).toEqual([gap]); - - gate.resolve(); - await buffer.whenIdle(); - - expect(received).toEqual([gap, activation, terminal, live]); -}); - -it('bounds pre-controller ingress, retains replay repair, and publishes its receiver only after FIFO drain', async () => { - const gate = deferred(); - const received: ProjectEventMessage[] = []; - const replacement: ProjectEventMessage[] = []; - const buffer = createRuntimeEventBuffer({ maximumPendingEvents: 2 }); - const first = runtimeEvent(10, 'runtime.run.completed', 'first'); - const gap = Object.freeze({ earliestAvailableSequence: 12, latestDroppedSequence: 11, requestedAfterSequence: 9, type: 'replay.gap' as const }); - const second = runtimeEvent(12, 'runtime.run.completed', 'second'); - const third = runtimeEvent(13, 'runtime.run.completed', 'third'); - const ordinary = Object.freeze({ - occurredAt: '2026-08-15T12:00:00.000Z', - payload: Object.freeze({ occurredAt: '2026-08-15T12:00:00.000Z', paths: Object.freeze([]), reason: 'initial' as const }), - sequence: 9, - type: 'source.changed' as const, - }); - - buffer.receive(ordinary); - buffer.receive(first); - buffer.receive(gap); - buffer.receive(second); - buffer.install({ - receive: async (event) => { - received.push(event); - if (event.type === 'replay.gap') await gate.promise; - }, - }); - buffer.install({ receive: async (event) => { replacement.push(event); } }); - buffer.receive(third); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(received).toEqual([gap]); - gate.resolve(); - await buffer.whenIdle(); - expect(received).toEqual([gap, second, third]); - expect(replacement).toEqual([]); - - buffer.close(); - buffer.receive(runtimeEvent(14, 'runtime.run.completed', 'dropped-after-close')); - await buffer.whenIdle(); - expect(received).toEqual([gap, second, third]); -}); - -it('repairs local pre-controller eviction before advancing the runtime cursor through retained events', async () => { - let observedCursor: number | undefined; - const controller = createRuntimePlaygroundController({ - bootstrap: bootstrap(), - client: clientFor({ - bootstrap: async () => { - observedCursor = controller.model.lastConsumedEventSequence; - return bootstrap(); - }, - }), - profiles, - }); - const buffer = createRuntimeEventBuffer({ maximumPendingEvents: 2 }); - - buffer.receive(runtimeEvent(10, 'runtime.hmr.client-connected')); - buffer.receive(runtimeEvent(11, 'runtime.hmr.client-connected')); - buffer.receive(runtimeEvent(12, 'runtime.hmr.client-connected')); - buffer.install(controller); - await buffer.whenIdle(); - await controller.whenIdle(); - - expect(observedCursor).toBe(0); - expect(controller.model.replayGap).toEqual({ earliestAvailableSequence: 11, latestDroppedSequence: 10, requestedAfterSequence: 9, type: 'replay.gap' }); - expect(controller.model.lastConsumedEventSequence).toBe(12); -}); - -it('expands a provider replay gap when bounded ingress later evicts another event', async () => { - const received: ProjectEventMessage[] = []; - const buffer = createRuntimeEventBuffer({ maximumPendingEvents: 2 }); - const gap = Object.freeze({ earliestAvailableSequence: 12, latestDroppedSequence: 11, requestedAfterSequence: 9, type: 'replay.gap' as const }); - const twelve = runtimeEvent(12, 'runtime.hmr.client-connected'); - const thirteen = runtimeEvent(13, 'runtime.hmr.client-connected'); - const fourteen = runtimeEvent(14, 'runtime.hmr.client-connected'); - - buffer.receive(gap); - buffer.receive(twelve); - buffer.receive(thirteen); - buffer.receive(fourteen); - buffer.install({ receive: async (event) => { received.push(event); } }); - await buffer.whenIdle(); - - expect(received).toEqual([ - { earliestAvailableSequence: 13, latestDroppedSequence: 12, requestedAfterSequence: 9, type: 'replay.gap' }, - thirteen, - fourteen, - ]); -}); - -it('closes pre-controller Runtime ingress after the third failed bootstrap without closing an installed receiver', async () => { - const retryPlans = [ - runtimeBootstrapRetryPlan(0, false), - runtimeBootstrapRetryPlan(1, false), - runtimeBootstrapRetryPlan(2, false), - ]; - - expect(retryPlans).toEqual([ - { closePreControllerIngress: false, delay: 250, retryCount: 1 }, - { closePreControllerIngress: false, delay: 500, retryCount: 2 }, - { closePreControllerIngress: true, delay: undefined, retryCount: 2 }, - ]); - expect(runtimeBootstrapRetryPlan(2, true)).toEqual({ closePreControllerIngress: false, delay: undefined, retryCount: 2 }); - - const buffer = createRuntimeEventBuffer({ maximumPendingEvents: 2 }); - const delivered: ProjectEventMessage[] = []; - if (retryPlans[2]!.closePreControllerIngress) buffer.close(); - buffer.receive(runtimeEvent(3, 'runtime.generation.activated')); - buffer.install({ receive: async (event) => { delivered.push(event); } }); - await buffer.whenIdle(); - - expect(delivered).toEqual([]); -}); - -it('ignores a late resolution after unmount', async () => { - const pending = deferred(); - const client = clientFor({ createRun: async () => pending.promise }); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - - controller.dispatch({ type: 'run.request' }); - await Promise.resolve(); - controller.close(); - pending.resolve(run('late')); - await Promise.resolve(); - - expect(controller.model.history.map((entry) => entry.id)).toEqual(['01']); -}); - -it('settles queued event and foreground failures safely when the controller is closed', async () => { - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor(), profiles }); - const queued = controller.receive(runtimeEvent(7, 'runtime.run.completed', 'closed-event')); - - controller.close(); - await queued; - expect(controller.error).toBeUndefined(); - expect(controller.model.history.map((entry) => entry.id)).toEqual(['01']); - - const pending = deferred(); - const foreground = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor({ createRun: async () => pending.promise }), profiles }); - foreground.dispatch({ type: 'run.request' }); - await Promise.resolve(); - foreground.close(); - pending.reject(new Error('late failure')); - await Promise.resolve(); - - expect(foreground.error).toBeUndefined(); - expect(foreground.model.history.map((entry) => entry.id)).toEqual(['01']); -}); - -it('records a reducer failure without allowing a later event to break the FIFO tail', async () => { - const details = Object.create(null) as Record; - Object.defineProperty(details, 'surfaceId', { get: () => { throw new Error('malformed provider event'); } }); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client: clientFor(), profiles }); - const malformed = Object.freeze({ - occurredAt: '2026-08-15T12:00:00.000Z', - payload: Object.freeze({ details, providerSessionId: 'provider-a', type: 'runtime.hmr.client-connected' as const }), - sequence: 7, - type: 'runtime.event' as const, - }); - - await expect(controller.receive(malformed)).rejects.toThrow('malformed provider event'); - await controller.receive(runtimeEvent(8, 'runtime.run.completed', 'after-error')); - - expect(controller.error).toBe('malformed provider event'); - expect(controller.model.history.map((entry) => entry.id)).toContain('after-error'); -}); - -it('notifies active subscribers, settles ordinary client errors, and ignores work after close', async () => { - const client = clientFor({ createRun: async () => { throw 'transient runtime failure'; } }); - const controller = createRuntimePlaygroundController({ bootstrap: bootstrap(), client, profiles }); - const states: string[] = []; - const unsubscribe = controller.subscribe((model) => states.push(model.draft.raw)); - - controller.dispatch({ raw: '{"city":"Paris"}', type: 'draft.raw' }); - unsubscribe(); - controller.dispatch({ type: 'run.request' }); - await controller.whenIdle(); - - expect(states).toEqual(['{"city":"Paris"}']); - expect(controller.error).toBe('Runtime request could not be completed.'); - controller.close(); - controller.dispatch({ raw: '{"city":"Rome"}', type: 'draft.raw' }); - await controller.receive(runtimeEvent(8, 'runtime.run.completed', 'ignored')); - - expect(controller.model.draft.raw).toBe('{"city":"Paris"}'); -}); diff --git a/packages/workbench/tests/runtime-stage.test.ts b/packages/workbench/tests/runtime-stage.test.ts deleted file mode 100644 index 4024c5c9a..000000000 --- a/packages/workbench/tests/runtime-stage.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { createElement } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from '@rstest/core'; - -import type { DevRuntimeRun, DevRuntimeSurface } from '../../agent-bundle/src/dev/runtime-protocol.ts'; -import { - RuntimeStage, - type RuntimeAppPreviewRenderer, - type RuntimeLiveMcpPageAdapter, -} from '../src/runtime-stage.tsx'; -import type { RuntimeProfileOption } from '../src/runtime-model.ts'; -import type { RuntimeAppPreviewLifecycleRegistrar } from '../src/runtime-playground.tsx'; - -const profile = { - claimsRealHostParity: false, - evidence: 'simulated', - id: 'portable', - label: 'Portable MCP Apps', - version: 'agent-bundle:mcp-apps:2026-01-26', -} satisfies RuntimeProfileOption; - -const surface = { - fixtures: [], - id: 'app/customer', - kind: 'mcp-app', - label: 'Customer App', - readOnly: false, - targets: ['portable'], -} satisfies DevRuntimeSurface; - -const run = { - completedAt: '2026-08-15T12:00:01.000Z', - id: 'run-customer', - input: { customer_id: 'cust_12345' }, - result: { - agentVisible: { ok: true }, - app: { - mcpBinding: { - definitionDigest: 'definition', registryRevision: 1, serverDigest: 'server', serverName: 'customer', sessionId: 'session', sessionRevision: 1, target: 'portable', transportDigest: 'transport', - }, - resourceUri: 'ui://customer/app.html', - surfaceId: 'mcp.edit-customer', - }, - modelVisible: { summary: 'Customer is active.' }, - native: { status: 200 }, - state: { identity: { stateStoreId: 'state-customer', stateVersion: 1 } }, - trace: [], - tree: [], - }, - startedAt: '2026-08-15T12:00:00.000Z', - status: 'succeeded', - surfaceId: 'app/customer', - target: 'portable', - vector: { providerSessionId: 'provider', runtimeGenerationId: 'generation', sourceRevision: 'source', stateStoreId: 'state-customer', stateVersion: 1 }, -} satisfies DevRuntimeRun; - -const failedRun = { - completedAt: '2026-08-15T12:00:02.000Z', - diagnostics: [{ code: 'RSC_RENDER_FAILED', message: 'Selected run failed.', phase: 'rsc-render', severity: 'error' }], - id: 'run-failed', - input: { customer_id: 'cust_12345' }, - startedAt: '2026-08-15T12:00:01.000Z', - status: 'failed', - surfaceId: 'app/customer', - target: 'portable', - vector: { ...run.vector, stateVersion: 2 }, -} satisfies DevRuntimeRun; - -const runtimeStatus = (activeVector: DevRuntimeRun['vector']) => ({ - activeVector, - descriptor: { environmentVariables: [], id: 'rsc', label: 'RSC Runtime', schemaVersion: 1 }, - diagnostics: [], - hmrReady: true, - state: 'active', -} as const); - -describe('Runtime stage', () => { - it('forwards one exact lifecycle registrar to the injected preview without becoming its owner', () => { - let registrarCalls = 0; - const registrar: RuntimeAppPreviewLifecycleRegistrar = (_handle) => { - registrarCalls += 1; - return () => undefined; - }; - let rendererCalls = 0; - let receivedRegistrar: unknown; - const renderer: RuntimeAppPreviewRenderer = (props) => { - rendererCalls += 1; - receivedRegistrar = props.registerLifecycle; - return createElement('div', { 'data-runtime-app-sentinel': 'lifecycle' }, 'Injected App'); - }; - - const markup = renderToStaticMarkup(createElement(RuntimeStage, { - profile, - profileId: 'portable', - renderAppPreview: renderer, - run, - registerAppPreviewLifecycle: registrar, - surface, - })); - - expect(rendererCalls).toBe(1); - expect(receivedRegistrar).toBe(registrar); - expect(registrarCalls).toBe(0); - expect((markup.match(/data-runtime-app-sentinel/g) ?? [])).toHaveLength(1); - }); - - it('keeps the existing four preview fields when no lifecycle registrar is supplied', () => { - let keys: readonly string[] = []; - const renderer: RuntimeAppPreviewRenderer = (props) => { - keys = Object.keys(props).sort(); - return createElement('div', { 'data-runtime-app-sentinel': 'without-lifecycle' }); - }; - - renderToStaticMarkup(createElement(RuntimeStage, { profile, profileId: 'portable', renderAppPreview: renderer, run, surface })); - - expect(keys).toEqual(['profile', 'profileId', 'run', 'surface']); - }); - - it('places one injected App renderer beside model-visible output without Runtime App chrome', () => { - const renderer: RuntimeAppPreviewRenderer = () => createElement('div', { 'data-runtime-app-sentinel': 'one' }, 'Injected App'); - const markup = renderToStaticMarkup(createElement(RuntimeStage, { profile, profileId: 'portable', renderAppPreview: renderer, run, surface })); - - expect((markup.match(/data-runtime-app-sentinel/g) ?? [])).toHaveLength(1); - expect(markup).toContain('Model-visible output'); - expect(markup.indexOf('Model-visible output')).toBeLessThan(markup.indexOf('data-runtime-app-sentinel')); - expect(markup).not.toContain('MCP App preview'); - expect(markup).not.toContain(' { - const absent = renderToStaticMarkup(createElement(RuntimeStage, { profile, profileId: 'portable', run, surface })); - const failing = renderToStaticMarkup(createElement(RuntimeStage, { - profile, - profileId: 'portable', - renderAppPreview: () => { throw new Error('preview failed'); }, - run, - surface, - })); - - for (const markup of [absent, failing]) { - expect(markup).toContain('Model-visible output'); - expect(markup).not.toContain('MCP App preview'); - expect(markup).not.toContain(' { - const renderer: RuntimeAppPreviewRenderer = () => createElement('div', { 'data-runtime-app-sentinel': 'preview' }, 'Injected App'); - const disabled = Object.freeze({ kind: 'disabled' as const }) satisfies RuntimeLiveMcpPageAdapter; - - const markup = renderToStaticMarkup(createElement(RuntimeStage, { - liveMcpPageAdapter: disabled, - profile, - profileId: 'portable', - renderAppPreview: renderer, - run, - surface, - })); - - expect((markup.match(/data-runtime-app-sentinel/g) ?? [])).toHaveLength(1); - expect(markup).not.toContain('data-runtime-mcp-page-sentinel'); - }); - - it('renders the host-owned handoff adjacent to one official preview with exact evidence and registrar identities', () => { - let previewCalls = 0; - let handoffCalls = 0; - let received: unknown; - const registrar: RuntimeAppPreviewLifecycleRegistrar = () => () => undefined; - const renderer: RuntimeAppPreviewRenderer = () => { - previewCalls += 1; - return createElement('div', { 'data-runtime-app-sentinel': 'preview' }, 'Injected App'); - }; - const adapter = Object.freeze({ - kind: 'host-owned' as const, - render: (props) => { - handoffCalls += 1; - received = props; - return createElement('div', { 'data-runtime-mcp-page-sentinel': 'handoff' }, 'Host handoff'); - }, - }) satisfies RuntimeLiveMcpPageAdapter; - - const markup = renderToStaticMarkup(createElement(RuntimeStage, { - liveMcpPageAdapter: adapter, - profile, - profileId: 'portable', - registerAppPreviewLifecycle: registrar, - renderAppPreview: renderer, - run, - surface, - })); - - expect(previewCalls).toBe(1); - expect(handoffCalls).toBe(1); - const handoff = received as Record; - expect(Object.keys(handoff).sort()).toEqual(['mcpBinding', 'profile', 'profileId', 'registerLifecycle', 'run', 'surface']); - expect(handoff.mcpBinding).toBe(run.result.app!.mcpBinding); - expect(handoff.profile).toBe(profile); - expect(handoff.profileId).toBe('portable'); - expect(handoff.registerLifecycle).toBe(registrar); - expect(handoff.run).toBe(run); - expect(handoff.surface).toBe(surface); - expect(markup.indexOf('data-runtime-app-sentinel')).toBeLessThan(markup.indexOf('data-runtime-mcp-page-sentinel')); - expect((markup.match(/data-runtime-app-sentinel/g) ?? [])).toHaveLength(1); - expect((markup.match(/data-runtime-mcp-page-sentinel/g) ?? [])).toHaveLength(1); - }); - - it('fails closed for missing App evidence and throwing host handoffs without suppressing the official preview', () => { - let handoffCalls = 0; - const adapter = Object.freeze({ - kind: 'host-owned' as const, - render: () => { - handoffCalls += 1; - throw new Error('handoff failed'); - }, - }) satisfies RuntimeLiveMcpPageAdapter; - const preview: RuntimeAppPreviewRenderer = () => createElement('div', { 'data-runtime-app-sentinel': 'preview' }, 'Injected App'); - const noApp = Object.freeze({ ...run, result: Object.freeze({ ...run.result, app: undefined }) }) satisfies DevRuntimeRun; - - const missing = renderToStaticMarkup(createElement(RuntimeStage, { - liveMcpPageAdapter: adapter, - profile, - profileId: 'portable', - renderAppPreview: preview, - run: noApp, - surface, - })); - const throwing = renderToStaticMarkup(createElement(RuntimeStage, { - liveMcpPageAdapter: adapter, - profile, - profileId: 'portable', - renderAppPreview: preview, - run, - surface, - })); - - expect(handoffCalls).toBe(1); - expect(missing).toContain('Model-visible output'); - expect(missing).not.toContain('data-runtime-app-sentinel'); - expect(missing).not.toContain('data-runtime-mcp-page-sentinel'); - expect(throwing).toContain('Model-visible output'); - expect(throwing).toContain('data-runtime-app-sentinel'); - expect(throwing).not.toContain('data-runtime-mcp-page-sentinel'); - }); - - it('fails closed when the selected App evidence does not match the selected surface or profile', () => { - let handoffCalls = 0; - const adapter = Object.freeze({ - kind: 'host-owned' as const, - render: () => { - handoffCalls += 1; - return createElement('div', { 'data-runtime-mcp-page-sentinel': 'mismatch' }); - }, - }) satisfies RuntimeLiveMcpPageAdapter; - const mismatchedSurface = Object.freeze({ ...surface, id: 'app/other' }); - const mismatchedProfile = Object.freeze({ ...profile, id: 'chatgpt' }); - - const surfaceMismatch = renderToStaticMarkup(createElement(RuntimeStage, { - liveMcpPageAdapter: adapter, - profile, - profileId: 'portable', - run, - surface: mismatchedSurface, - })); - const profileMismatch = renderToStaticMarkup(createElement(RuntimeStage, { - liveMcpPageAdapter: adapter, - profile: mismatchedProfile, - profileId: 'portable', - run, - surface, - })); - - expect(handoffCalls).toBe(0); - expect(surfaceMismatch).not.toContain('data-runtime-mcp-page-sentinel'); - expect(profileMismatch).not.toContain('data-runtime-mcp-page-sentinel'); - }); - - it('retains last-good output and its one injected App after a selected failure', () => { - const renderer: RuntimeAppPreviewRenderer = ({ run: rendered }) => createElement('div', { 'data-runtime-app-sentinel': 'retained' }, rendered.id); - const markup = renderToStaticMarkup(createElement(RuntimeStage, { - lastGoodRun: run, - profile, - profileId: 'portable', - renderAppPreview: renderer, - run: failedRun, - status: runtimeStatus(failedRun.vector), - surface, - })); - - expect(markup).toContain('Runtime run failed'); - expect(markup).toContain('RSC_RENDER_FAILED'); - expect(markup).toContain('Retained last-good output'); - expect(markup).toContain('stale evidence'); - expect(markup).toContain('Customer is active.'); - expect((markup.match(/data-runtime-app-sentinel/g) ?? [])).toHaveLength(1); - expect(markup).toContain('run-customer'); - expect(markup).not.toContain('No model-visible output was returned for this run.'); - }); - - it('never renders a host-owned MCP Page handoff from retained last-good evidence', () => { - let handoffCalls = 0; - const adapter = Object.freeze({ - kind: 'host-owned' as const, - render: () => { - handoffCalls += 1; - return createElement('div', { 'data-runtime-mcp-page-sentinel': 'retained' }); - }, - }) satisfies RuntimeLiveMcpPageAdapter; - const preview: RuntimeAppPreviewRenderer = () => createElement('div', { 'data-runtime-app-sentinel': 'retained' }, 'Injected App'); - - const markup = renderToStaticMarkup(createElement(RuntimeStage, { - lastGoodRun: run, - liveMcpPageAdapter: adapter, - profile, - profileId: 'portable', - renderAppPreview: preview, - run: failedRun, - surface, - })); - - expect(handoffCalls).toBe(0); - expect(markup).toContain('data-runtime-app-sentinel'); - expect(markup).not.toContain('data-runtime-mcp-page-sentinel'); - }); - - it('treats state and provider identity changes as stale while an exact authoritative vector is current', () => { - const current = renderToStaticMarkup(createElement(RuntimeStage, { run, status: runtimeStatus(run.vector), surface })); - const changedState = renderToStaticMarkup(createElement(RuntimeStage, { - run, - status: runtimeStatus({ ...run.vector, stateVersion: run.vector.stateVersion + 1 }), - surface, - })); - const changedProvider = renderToStaticMarkup(createElement(RuntimeStage, { - run, - status: runtimeStatus({ ...run.vector, providerSessionId: 'provider-restarted' }), - surface, - })); - - expect(current).toContain('No stale views.'); - for (const markup of [changedState, changedProvider]) { - expect(markup).toContain('Selected output is from runtime generation'); - expect(markup).not.toContain('No stale views.'); - } - }); - - it('derives retained last-good currentness from the displayed evidence vector', () => { - const current = renderToStaticMarkup(createElement(RuntimeStage, { - lastGoodRun: run, - run: failedRun, - status: runtimeStatus(run.vector), - surface, - })); - const changedState = renderToStaticMarkup(createElement(RuntimeStage, { - lastGoodRun: run, - run: failedRun, - status: runtimeStatus({ ...run.vector, stateVersion: run.vector.stateVersion + 1 }), - surface, - })); - const changedProvider = renderToStaticMarkup(createElement(RuntimeStage, { - lastGoodRun: run, - run: failedRun, - status: runtimeStatus({ ...run.vector, providerSessionId: 'provider-restarted' }), - surface, - })); - - expect(current).toContain('runtime-stage-generation--current'); - expect(current).toContain('Retained last-good output (current evidence)'); - expect(current).not.toContain('stale evidence'); - for (const markup of [changedState, changedProvider]) { - expect(markup).toContain('runtime-stage-generation--stale'); - expect(markup).toContain('Retained last-good output (stale evidence)'); - expect(markup).toContain('RSC_RENDER_FAILED'); - } - }); -}); From 5652f5afac9c467f1a7b830fd874e4f945521956 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:52:26 +0000 Subject: [PATCH 08/43] docs: rewrite Workbench guide for application IA --- LANE-NOTES.md | 76 +++++ .../docs/en/examples/audiobook-curator.mdx | 14 + .../docs/en/examples/hooks-and-scripts.mdx | 32 +-- website/docs/en/examples/mcp-app.mdx | 29 +- website/docs/en/examples/skills-starter.mdx | 23 +- website/docs/en/guide/authoring/hooks.mdx | 6 +- website/docs/en/guide/authoring/index.mdx | 6 +- website/docs/en/guide/authoring/mcp.mdx | 12 +- .../en/guide/authoring/scripts-assets.mdx | 8 +- website/docs/en/guide/authoring/skills.mdx | 5 +- .../docs/en/guide/development/evaluations.mdx | 13 +- website/docs/en/guide/development/index.mdx | 2 +- website/docs/en/guide/development/testing.mdx | 7 +- .../docs/en/guide/development/workbench.mdx | 259 ++++++++++++------ website/docs/en/guide/start/quick-start.mdx | 9 +- website/docs/en/index.mdx | 7 +- website/docs/en/reference/cli.mdx | 2 +- website/docs/en/reference/configuration.mdx | 2 +- website/docs/en/reference/limitations.mdx | 6 +- .../docs/en/reference/runtime-environment.mdx | 2 +- 20 files changed, 356 insertions(+), 164 deletions(-) create mode 100644 LANE-NOTES.md diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..6373a2a1d --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,76 @@ +# L7 — English docs for Workbench IA + +## Pages changed + +- `website/docs/en/guide/development/workbench.mdx` — rewrote the Workbench guide around + Application, Trace, Problems, Advanced, route workspaces, deep links, and invocation APIs. +- `website/docs/en/guide/development/testing.mdx` — documented the new + `inspectWorkbenchSurface()` application-tree return shape and removed page-name APIs. +- `website/docs/en/guide/development/evaluations.mdx` — moved Workbench eval instructions to + Advanced → Evals → Runs / Compare. +- `website/docs/en/guide/development/index.mdx` — replaced the old page/playground inventory with + the new four-area Workbench summary. +- `website/docs/en/guide/start/quick-start.mdx` — updated the interactive development overview to + the Application tree, rendered results, Trace, Problems, and Advanced. +- `website/docs/en/guide/authoring/index.mdx` — moved route-contract inspection from the deleted + Routes page to the selected route inspector. +- `website/docs/en/guide/authoring/hooks.mdx` — moved hook simulation to Application → + Events / Hooks and documented host fixtures and projection details. +- `website/docs/en/guide/authoring/mcp.mdx` — moved task controls to Advanced → Protocol and App + previews to MCP App leaves. +- `website/docs/en/guide/authoring/scripts-assets.mdx` — moved script execution from Playground to + Application → Scripts. +- `website/docs/en/guide/authoring/skills.mdx` — moved rendered Skill inspection to Application → + Skills and its inspector tabs. +- `website/docs/en/examples/audiobook-curator.mdx` — added the `search_audible` rendered-route + development loop and deep link. +- `website/docs/en/examples/hooks-and-scripts.mdx` — rewrote the walkthrough for event/script + leaves, Trace, Problems, and Advanced. +- `website/docs/en/examples/mcp-app.mdx` — rewrote the walkthrough for Application leaves and + Advanced protocol, artifact, and eval views. +- `website/docs/en/examples/skills-starter.mdx` — rewrote the walkthrough for Skill leaves, + Advanced artifact/evals, and Problems repair. +- `website/docs/en/index.mdx` — updated the home-page Workbench feature and development summary. +- `website/docs/en/reference/cli.mdx` — replaced the deleted MCP-page reference with the App + workspace. +- `website/docs/en/reference/configuration.mdx` — renamed the contract-gated Workbench destination + to the route workspace. +- `website/docs/en/reference/limitations.mdx` — removed the deleted Playground product name from + the pre-0.1 record warning. +- `website/docs/en/reference/runtime-environment.mdx` — moved hook simulation environment wording + to the event route workspace. + +## Verification and cross-lane notes + +- No English page embeds a screenshot, so no image needs recapture. +- VERIFY: the URL examples and refresh-safe shell paths were checked against + `packages/agent-bundle/src/dev/routes/application-node.ts`, + `packages/agent-bundle/src/dev/workbench-shell-paths.ts`, and + `packages/workbench/src/shell/workbench-location.ts`. +- VERIFY: the invocation envelope and `route.invocation` event were checked against + `packages/agent-bundle/src/dev/routes/route-invocation.ts`. The route implementation and final + AB8231–AB8235 assignments land in another lane. +- VERIFY: `docs/diagnostics.md` on this lane still gives AB8233–AB8235 their pre-#600 browser + decoder meanings. The integration lane must take the other lane's diagnostic registration + before treating the new Workbench guide's AB8231–AB8235 range as final. +- VERIFY: `packages/agent-bundle/src/test/workbench.ts` still exports `WorkbenchPageName`, + `workbenchPageLabel`, and `pages` on this lane. The testing docs intentionally follow the L10 + cross-lane contract: `{ application, routes, lifecycles, counts, advanced }`. +- TraceDecay MCP discovery failed and its CLI daemon socket was unavailable. The bounded + English-doc search and review therefore used workspace search directly. +- Screenshots to recapture: none. +- Proposed changeset: none; this lane changes only the private documentation site. +- `pnpm build` passed before and after the edits. +- `pnpm docs:site:build` reached the expected locale-drift failure: the English Workbench page + differs from untranslated Chinese in diagnostic codes, fence count, heading count, and table + row count. +- Running the remaining documentation stages without locale drift found one cross-lane failure: + diagnostics coverage rejects `AB8231` until the invocation lane registers AB8231–AB8235. +- With locale drift and that pending diagnostic registration isolated, the Rspress build passed + and the link checker reported `0 broken links / 28122 anchors checked`. + +## Open risks + +- The English/Chinese language-parity gate is expected to fail until L8 mirrors these changes. +- Invocation diagnostics and the public Workbench test surface cannot be source-verified on this + isolated lane until L1/L10 integrate. diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index ea1809c9b..b3a64cc59 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -90,6 +90,20 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck beneath `artifact/` — each host's plugin metadata, Skill, bundled CLI script, and lifecycle-wrapped MCP server — plus the npm package beneath `dist/`. +Start `pnpm example:audiobook` from the repository root for the visual development loop: + +1. Expand **Application → MCP → curator → Tools** and select `search_audible`. +2. Enter a title such as `Dune` in the generated input editor, then select **Run**. +3. Inspect **Rendered** first: it shows the actual Agent Document from the route's production + RSC execution. Structured data, raw document, MCP/CLI projections, and Trace remain available + as secondary tabs. +4. Edit `src/mcp/curator/tools/search_audible.tsx` or a component it renders. After the rebuild + reaches **Idle**, rerun the saved input and inspect the updated rendered result. + +The route is directly addressable at +`/routes/mcp/curator/tool/search_audible`; append `?invocation=` to reopen one of this +session's recorded results. + To exercise the built CLI without packing a tarball, link the built bin from any writable directory already on `PATH`: diff --git a/website/docs/en/examples/hooks-and-scripts.mdx b/website/docs/en/examples/hooks-and-scripts.mdx index 2f0de850d..d0a4fe56a 100644 --- a/website/docs/en/examples/hooks-and-scripts.mdx +++ b/website/docs/en/examples/hooks-and-scripts.mdx @@ -1,5 +1,5 @@ --- -description: 'The Hooks and Scripts example: a session-start hook, two scripts (one blocking exit code), durable Playground traces, and a reversible diagnostic walkthrough.' +description: 'The Hooks and Scripts example: a session-start hook, two scripts, invocation traces, and a reversible diagnostic walkthrough.' --- # Hooks and Scripts @@ -45,29 +45,29 @@ an authored script. ## Working in the Workbench -1. **Overview** relates the authored hook to its emitted artifact, its exercise trace, and its - evaluation pages. Its status is the authoritative current-or-stale epoch state. -2. **Hooks** defaults to the Claude `sessionStart` binding with populated inline canonical JSON, - including `"source": "workbench"`. Run the simulation, then use **Replay saved simulation** to - rerun exactly that epoch-bound input. -3. **Playground** defaults to Script execution, the Claude target, and `verify-release`. Run it - and wait for the session to be finalized: the emitted script reads the packaged +1. The header is the authoritative current-or-stale epoch state. Under + **Application → Events / Hooks**, select `sessionStart`, choose the Claude fixture, and run it. + The event route workspace shows canonical input, including `"source": "workbench"`, and the + projected result. Use its **Replay** tab to rerun the epoch-bound input. +2. Under **Application → Scripts**, select `verify-release` for the Claude target and run it. The + emitted script reads the packaged `release/release-manifest.json` beside its own module and reports release 2.4.0 ready for packaging. -4. Switch the target to portable and select `detect-risk`. It reads `release/risk-register.json`, +3. Switch the target to portable and select `detect-risk`. It reads `release/risk-register.json`, reports high-severity `REL-204`, exits with code 2, and finalizes a durable blocking trace. -5. **Logs** filters those producer records by producer, level, kind, or context; open a record to - inspect raw details. **Artifacts** is the emitted file and provenance view, while - **Comparisons** aligns outcomes only after two recorded eval runs. +4. Follow the runs in **Trace**. Use **Advanced → Raw logs** for uncorrelated producer details, + **Advanced → Artifact** for emitted files and provenance, and + **Advanced → Evals → Compare** after two eval runs exist. ## The reversible diagnostic walkthrough The checked-in project is healthy, so seeing last-good behavior means breaking it on purpose. Temporarily replace the body of `src/hooks/session-start.ts` with a syntactically incomplete -handler, press **Rebuild**, and wait for the completed **Failed** state. The Workbench reports -the new diagnostic while continuing to serve the last-good artifact. Restore the checked-in -handler, press **Rebuild** again, and wait for **Idle**: a new active epoch replaces the stale -state and clears the diagnostic. +handler, press **Rebuild**, and wait for the completed **Failed** state. Open **Problems** from +the header failure count: the Workbench reports the new diagnostic while Application continues +to expose the last-good tree and artifact. Restore the checked-in handler, press **Rebuild** +again, and wait for **Idle**: a new active epoch replaces the stale catalog and clears the +diagnostic. Do not read a Building state as a completed repair — the new active epoch is the evidence. diff --git a/website/docs/en/examples/mcp-app.mdx b/website/docs/en/examples/mcp-app.mdx index 7b098c43b..f738a9731 100644 --- a/website/docs/en/examples/mcp-app.mdx +++ b/website/docs/en/examples/mcp-app.mdx @@ -51,27 +51,28 @@ to see how the surfaces fit together instead of studying one of them alone. ## Working in the Workbench -1. **Overview** opens on the Bundle dashboard. Its Author, Build, Exercise, and Evaluate stages - connect the source capability to its emitted artifact, runtime evidence, and eval result. -2. **Skills** defaults to `service-readiness`; compare its authored status policy and - readiness-report resource with the generated output and its explicit eval coverage. - **Hooks** defaults to a populated Claude `sessionStart` canonical input. -3. **Playground** defaults to Script execution, the Claude target, and `check-service-fixture`. - Run it and wait for the finalized session: the emitted checker resolves the packaged status - fixture beside its emitted module, so it succeeds independently of the shell's working - directory. -4. **Artifacts** with the portable target selected is where `mcp-apps/status.html` appears. - Before two eval runs exist, **Comparisons** deliberately shows +1. Under **Application → Skills**, select `service-readiness`. Its document renders by default; + use the inspector for source/generated output, resources, and eval coverage. +2. Under **Application → Events / Hooks**, select the configured `sessionStart` hook and choose + the Claude fixture. Run it to inspect canonical input, returned context, and the Claude + projection. +3. Under **Application → Scripts**, select `check-service-fixture` and run it. The emitted checker + resolves the packaged status fixture beside its emitted module, so it succeeds independently + of the shell's working directory. +4. Select the `status` App under **Application → MCP → status → Apps**. Its live preview occupies + the route workspace. **Advanced → Artifact**, with portable selected, contains + `mcp-apps/status.html`. +5. Before two eval runs exist, **Advanced → Evals → Compare** deliberately shows `At least two recorded runs are needed before a comparison can be aligned.` — the precise empty state, not an error. -5. **MCP playground** defaults to portable and the `status` server. Open the session, list tools, +6. Open **Advanced → Protocol**, choose portable and the `status` server, open the session, list tools, select `show-status`, choose `payments-api`, and invoke it. Invocation history shows the degraded summary with labelled Availability and P95 latency checks, the latter failing. Open the App preview: the rendered panel shows the same record through the MCP Apps bridge, with a text-labelled amber `degraded` indicator. Inspect the protocol trace, use **Restart MCP session**, then close, reset, and reopen the session to exercise the lifecycle. -6. **Evals** defaults to `mcp-app-status`. Run `status-is-healthy` and inspect the completed - passing trial attributed to `service-readiness`. +7. In **Advanced → Evals → Runs**, select `mcp-app-status`, run `status-is-healthy`, and inspect + the completed passing trial attributed to `service-readiness`. If you edit a source file, rebuild and wait for a Failed or Idle state before judging the result; a Building state is still in progress. diff --git a/website/docs/en/examples/skills-starter.mdx b/website/docs/en/examples/skills-starter.mdx index 122b77773..bed2a13b1 100644 --- a/website/docs/en/examples/skills-starter.mdx +++ b/website/docs/en/examples/skills-starter.mdx @@ -44,18 +44,19 @@ it is needed. ## Working in the Workbench -1. **Overview** opens on the Bundle dashboard: the three Skills, the generated targets, build - health, and the next useful actions. -2. **Skills** lists `dependency-upgrade`, `incident-triage`, and `release-review`. Browse their - linked checklists and report templates, and switch between Source and Generated per target. -3. **Artifacts** defaults to the Claude target. Change the target to compare the portable, Codex, - and Claude output trees and their provenance. -4. **Evals** defaults to the `release-readiness` suite. Run `release-artifact-is-ready` and +1. **Application → Skills** lists `dependency-upgrade`, `incident-triage`, and `release-review`. + Select one to render its document, then use the inspector to browse linked checklists and + report templates or compare Source and Generated output. +2. **Advanced → Artifact** defaults to the Claude target. Change the target to compare the + portable, Codex, and Claude output trees; enable details for provenance. +3. **Advanced → Evals → Runs** defaults to the `release-readiness` suite. Run + `release-artifact-is-ready` and inspect the passing trial; it consumes only the checked-in evidence fixture. -5. To practice repair, make a reversible edit to the release policy, press **Rebuild**, and wait - for a Failed or Idle result rather than a Building state. Restore the checked-in policy and - rebuild. The earlier eval is now stale for the changed build — rerun `release-readiness` to - record current, repaired evidence. +4. To practice repair, make a reversible edit to the release policy, press **Rebuild**, and wait + for a Failed or Idle result rather than a Building state. On failure, open **Problems** from + the header count while Application keeps the last-good tree. Restore the checked-in policy + and rebuild. The earlier eval is now stale for the changed build — rerun `release-readiness` + to record current, repaired evidence. ## Noninteractive checks diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index 5ba1f6f3d..e49392b01 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -398,8 +398,10 @@ validates it as the host would, so a Claude `sessionStart` simulation needs `ses `toolUseId`). `hooks simulate` runs the real emitted wrapper — the same file the host will execute — so the -result you see is the result the host would get. The developer Workbench exposes the same -playground with the raw stdout, stderr, and outcome for each run. +result you see is the result the host would get. In the developer Workbench, select the event +under **Application → Events / Hooks**, choose a Canonical, Claude, Codex, or Cursor fixture, +and run it there. The route workspace shows the result first and keeps canonical/native codec +details in its projection inspector. ## Next diff --git a/website/docs/en/guide/authoring/index.mdx b/website/docs/en/guide/authoring/index.mdx index 452878432..99f05b39e 100644 --- a/website/docs/en/guide/authoring/index.mdx +++ b/website/docs/en/guide/authoring/index.mdx @@ -166,9 +166,9 @@ because they are two authored things that may diverge. Each route names its cont `route.contract`, and the graph lists them as `contracts`, sorted by id and absent when no route has one (`RouteContract` and `RouteContractOrigin` are exported from `agent-bundle/api` beside `CompiledRouteGraph`). `agent-bundle inspect --routes` prints them with the graph, and the -Workbench Routes page shows a route's contract origin and the other routes sharing it. A contract -declared outside the route's own module joins the route's digest identity; graphs whose schemas -are all inline keep their recorded digests. +Workbench route inspector shows a selected leaf's contract origin and the other routes sharing +it. A contract declared outside the route's own module joins the route's digest identity; graphs +whose schemas are all inline keep their recorded digests. The contract carries the input side. `resultSchema` may be imported the same way, but no static result projection exists: its presence is checked statically, its type flows through TypeScript, diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index e0ccc2fb3..27c0fe704 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -452,8 +452,9 @@ generated server's SDK release does not serve that extension, so a client on tha the ordinary contract and no task capability is advertised to it. Whether a pinned host issues task-augmented calls at all is recorded per host in the [host capability matrix](../../reference/hosts.md); at the time of writing none does, so the lifecycle is proven with -the SDK client and the Workbench MCP page, where a tool that advertises task support offers -**Run as task** and the Tasks panel polls `tasks/get`, fetches `tasks/result`, and cancels. +the SDK client and **Advanced → Protocol** in the Workbench, where a tool that advertises task +support offers **Run as task** and the Tasks panel polls `tasks/get`, fetches `tasks/result`, and +cancels. The `mcp-in-memory` level proves the whole contract with the real SDK client: @@ -842,7 +843,8 @@ resolved App configuration when a hatch value does not land where you expected. ### Serving an App standalone -Outside an MCP host, an App is normally reached through the Workbench MCP page. When a plugin +Outside an MCP host, an App is normally reached by selecting its leaf under +**Application → MCP → <server> → Apps** in the Workbench. When a plugin wants an "open the dashboard" command instead — a live view of its own server, in a plain browser tab, from a terminal — `agent-bundle serve-app` hosts one built App standalone: @@ -854,8 +856,8 @@ npx agent-bundle serve-app status/status --artifact artifact \ The command launches the plugin's packed MCP server exactly as `mcp run` does (same artifact resolution, same `.env` layering, same plugin-data root), binds the App to it through the same -host stack the Workbench MCP page uses — sandbox proxy, consent authority, `McpAppBridge` — calls -the App's tool once so it opens populated, and prints the loopback URL. The host binds `127.0.0.1` +host stack the Workbench App leaf uses — sandbox proxy, consent authority, `McpAppBridge` — +calls the App's tool once so it opens populated, and prints the loopback URL. The host binds `127.0.0.1` only, serves one page plus the authenticated `/api/mcp/...` routes behind a per-launch token, and exposes only that server through the bridge; the App document runs on a second loopback origin in the framework's sandbox. It is a local preview host, not a deployment target. Every option is in diff --git a/website/docs/en/guide/authoring/scripts-assets.mdx b/website/docs/en/guide/authoring/scripts-assets.mdx index 88688766b..d0f7b126e 100644 --- a/website/docs/en/guide/authoring/scripts-assets.mdx +++ b/website/docs/en/guide/authoring/scripts-assets.mdx @@ -87,10 +87,10 @@ own progress to stderr colors and sizes it exactly as the framework would, witho ### Running a script -`script.run` is a production-mounted, trusted-local Workbench Playground operation. It runs only -the selected manifest-owned emitted script for the selected target, inside a managed workspace, -and preserves bounded stdout and stderr, the exit code, cancellation, and raw event references. -It cannot be handed a browser-supplied command. +In the Workbench, select the script under **Application → Scripts** and run it from that leaf's +workspace. It runs only the selected manifest-owned emitted script for the selected target, +inside a managed workspace, and preserves bounded stdout and stderr, the exit code, +cancellation, and raw event references. It cannot be handed a browser-supplied command. ## Assets diff --git a/website/docs/en/guide/authoring/skills.mdx b/website/docs/en/guide/authoring/skills.mdx index e9bc1a0bb..a87b23f0e 100644 --- a/website/docs/en/guide/authoring/skills.mdx +++ b/website/docs/en/guide/authoring/skills.mdx @@ -166,8 +166,9 @@ npx agent-bundle inspect --root . --skills ``` The skill focus shows each discovered Skill, its provenance (`conventional` or `config`), its -resources, and the per-target lowering decisions. In the developer Workbench, the Skills page -renders the emitted document for each host. +resources, and the per-target lowering decisions. In the developer Workbench, select the Skill +under **Application → Skills**. Its emitted document is the default view; source/generated +differences, frontmatter, resources, and eval coverage are inspector tabs. Raw HTML, JSX/MDX, and Mermaid inside Skill Markdown are inert in the Workbench renderer. That is a deliberate containment boundary, not a rendering gap. diff --git a/website/docs/en/guide/development/evaluations.mdx b/website/docs/en/guide/development/evaluations.mdx index a366631df..bdd4798e0 100644 --- a/website/docs/en/guide/development/evaluations.mdx +++ b/website/docs/en/guide/development/evaluations.mdx @@ -160,20 +160,17 @@ evidence — a grader that broke is not a plugin that misbehaved. Semantic grading requires a native Claude harness and a signed-in Claude Code session. Deterministic and Codex selections are refused when it is configured. -## The Eval page +## Workbench Evals -The Workbench Eval page admits a selected run, reports live progress, and can cancel it through -the run lifecycle. Each trial exposes its persisted raw evidence when present, plus recorded CLI, -invocation, grader, and usage provenance. +Open **Advanced → Evals → Runs** to admit a selected run, follow live progress, or cancel it +through the run lifecycle. Each trial exposes its persisted raw evidence when present, plus +recorded CLI, invocation, grader, and usage provenance. -Comparison cells show recorded provenance and usage and only include aligned case, fixture, +**Advanced → Evals → Compare** shows recorded provenance and usage and only includes aligned case, fixture, harness, invocation, host and model, CLI, and grader facets; unmatched facets are labeled non-comparable or unverified. Trial duration is persisted; provider token usage is shown only when the native stream reported it. -Playground can promote selected durable outcome and assertion evidence from a trace into a draft -eval case — see [Developer Workbench](./workbench.mdx). - ## Native harnesses The deterministic harness needs nothing installed. The native Claude and Codex harnesses run the diff --git a/website/docs/en/guide/development/index.mdx b/website/docs/en/guide/development/index.mdx index dd196202b..86d448494 100644 --- a/website/docs/en/guide/development/index.mdx +++ b/website/docs/en/guide/development/index.mdx @@ -46,7 +46,7 @@ TypeScript program compiles it: keep `".agent-bundle/routes.d.ts"` in `tsconfig. | Surface | What it is | What it proves | | --- | --- | --- | -| [Developer Workbench](./workbench.mdx) | The loopback UI `dev` serves: diagnostics, Skills, artifact provenance, MCP and hook playgrounds, Playground traces, eval runs. | Nothing by itself — it is where you *look at* and *exercise* real generated output. | +| [Developer Workbench](./workbench.mdx) | The loopback UI `dev` serves: one Application tree and route workspace, this session's invocation Trace, Problems, and Advanced tooling. | Nothing by itself — it is where you *look at* and *exercise* real generated output. | | [Testing](./testing.mdx) | `agent-bundle/rstest` and `agent-bundle/test`, plus the framework-owned contract matrix. | Recorded proof levels, from a route module rendering to a bundle spawned from an installed host layout. | | [Evaluations](./evaluations.mdx) | Typed eval suites run through deterministic, Claude, or Codex harnesses. | Whether an agent actually reaches your plugin, with `pass` / `fail` / `inconclusive` and a declared minimum evidence bar. | diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index ae3fba1ff..850f16a9b 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -212,11 +212,16 @@ and prints it in every failure, because a pass at one level is never a receipt f | `dev-epoch` | `runDevEpochContractMatrix` | An epoch-pinned generated stdio process opened through the Workbench session service; the caller owns the epoch lease and process lifetime, and MCP App routes are covered (surface plus `ui://` sweep). | | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | A plain or rendered argv vector resolved and run through the routed CLI's own shell — including rendered Markdown, explicit TTY, JSON, and NDJSON modes — in-process. | | `script-dispatch` | `runScript`, `scriptJson`, `scriptNdjson` | A conventional `src/scripts/*` module run through its generated executable's contract, without bundling: a rendered `.tsx` script through the rendered-script shell in-process (piped Markdown, TTY, `--json`, `--ndjson`, with the project's conventional providers mounted), a plain `.ts` script as a Node process of its own through the `main` envelope, over the source under Node's own TypeScript loading (`--experimental-transform-types` on Node 22 and 24; `--strip-types` on Node 26, which removed the transform flag, so TypeScript-only syntax such as `enum` fails there as it does under `node file.ts`) — real `process.exit`, exit code, stdout, stderr, optional `stdin`. `testManifest().scripts` lists the scripts that ship; a nested (`AB4808`) or conflicting (`AB4809`) script is never a target. | -| `workbench-surface` | `inspectWorkbenchSurface` | What the dev server would hand the Workbench for this project — route manifest, grouped route catalog, state declaration, lifecycle-replay fixtures, page availability — from the same compiler pass, with no browser and no dev server; a project the compiler rejects reports `manifest-unavailable` with its error diagnostics. | +| `workbench-surface` | `inspectWorkbenchSurface` | What the dev server would hand the Workbench for this project — the `application` tree, routes, lifecycle-replay fixtures, capability counts, and Advanced sections — from the same compiler pass, with no browser and no dev server; a project the compiler rejects reports `manifest-unavailable` with its error diagnostics. | | `packed-stdio` | `openPackedMcpServer`, `runPackedContractMatrix` | A built artifact's generated entry running as a real process over stdio. | | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })`, `runPackedContractMatrix` | The packed stdio process still runs after project source and configuration are removed and verified absent. | | `host-install` | `openInstalledHostMcpServer`, `runInstalledHostContractMatrix` | A built bundle staged into an isolated host root, discovered in the emitted host format, and spawned from the installed layout. | +`inspectWorkbenchSurface()` returns +`{ application, routes, lifecycles, counts, advanced }`. `application` is the same tree shape the +Workbench renders, so tests assert leaves and paths instead of a fixed list of pages. +`WorkbenchPageName` and `workbenchPageLabel` are no longer exported. + Two further levels sit alongside these nine, for eleven in all. `agent-bundle/test/browser` supplies `mountBrowserApp` for the browser-safe `browser-app` level — production-compiled MCP App HTML mounted over the product bridge in a real browser page — and `simulated` reuses the installed-host helper diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 0e18f863b..69303f1c7 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -1,5 +1,5 @@ --- -description: 'The agent-bundle developer Workbench: loopback boundaries, epoch-pinned MCP sessions, Playground traces, development host installs, and the optional Agent API.' +description: 'Explore, invoke, render, trace, and diagnose an agent-bundle application in the loopback developer Workbench.' --- # Developer Workbench @@ -25,88 +25,171 @@ These are contracts, not defaults: - **Trusted-local operations only.** The browser never supplies a command, a working directory, a native model, or a credential. See [Security](../../reference/security.mdx). -## What it shows - -| Page | Contents | -| --- | --- | -| Overview | Project identity, normalized model, and diagnostics. | -| Routes | The compiled route catalog from the same compiler pass as `inspect --routes`: each route's source module, config summary, and a generated input editor; a route with a static contract names the contract's declaring module and any other routes sharing it. | -| Skills | Every Skill document, including each host's lowered output. | -| Artifacts | The artifact tree with provenance and epoch comparison. | -| MCP | An artifact-bound playground with the raw protocol trace, MCP App previews, and a launcher for the standalone MCP Inspector. | -| Hooks | A playground that runs the emitted hook wrapper. | -| Playground | A durable, ordered trace with replay and export. | -| Evals | Eval runs and run comparisons. | -| Logs | Concise events plus raw stdout, stderr, and protocol streams, grouped by producer: normalization, build, diagnostics, MCP, hook, host trial, and grader. | - -A failed MCP App view compile is a build failure like any other. The Overview **Diagnostics** -table shows one `AB4770` row per Rspack error: the Message column carries -`MCP App "" failed to compile: ::: ` and the Source column the -failing file, in place of the former -`AB7100 "Unable to compile the build: Rspack build failed."` row whose only source was the config -file. Logs records the same diagnostics — code and source path — under the `build.failed` entry. -No `artifact.available` follows, so the last good epoch stays active and every bound MCP session -keeps serving it; save a fix and the next rebuild publishes. Compile warnings (`AB4771`) and the -size advisory (`AB4772`) ride the succeeded epoch's diagnostics the same way. - -## MCP sessions bind to an epoch - -A Workbench MCP session binds `{ epochId, target, serverName }` when it is opened and never moves -to a new epoch automatically. That is what makes a protocol trace meaningful: every frame in it -came from one generated server built from one set of inputs. - -- **Restart MCP session** respawns that generated server on its *selected* epoch. -- To use a newly published epoch, open a **new** session. -- Compatible MCP Apps preview through the same bound session. The dev loop compiles views - unminified, so the browser devtools show readable App source; `agent-bundle build` ships them - minified (see - [Development vs production builds](../authoring/mcp.mdx#development-vs-production-builds)). - The same host stack serves one App standalone in a plain browser tab through - `agent-bundle serve-app`; see - [Serving an App standalone](../authoring/mcp.mdx#serving-an-app-standalone). - -## Standalone MCP Inspector - -The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is a separate localhost app -with its own token URL. The Workbench never embeds it; the **MCP Inspector** section of the MCP page -launches it on demand and hands you a link. - -- **Open MCP Inspector** asks the dev server to run `npx --yes @modelcontextprotocol/inspector` in - the project root, with `MCP_AUTO_OPEN_ENABLED=false` so the Inspector does not open a window of - its own. The first launch downloads the package and can take up to 30 seconds. The browser never - chooses the command, its arguments, its working directory, or its environment. -- Once the Inspector prints its tokenized localhost URL, the dev server returns it and the page - renders **Open MCP Inspector in a new tab**. The link opens a new tab with - `rel="noopener noreferrer"`. If the Inspector is already running when the page loads, the link - appears immediately. -- For a `streamable-http` session the link is an Inspector 2.x deep link: `serverUrl` is the - session's server URL (credentials and query parameters already stripped), `transport=http`, and - `autoConnect` carries the Inspector's own token, so the Inspector connects to that server on - load. -- Inspector 2.x no longer accepts a command in the URL — upstream removed `serverCommand` and - `serverArgs` — so a `stdio` session cannot be deep-linked. Add the command inside the Inspector - instead; **Download Inspector config** exports the selected session's resolved command, - arguments, and non-secret environment for exactly that. -- Failures surface inline on the page: `AB8112` when the Inspector could not be launched, exited - before publishing a URL, or did not publish one within the 30-second startup budget; `AB8113` - when the Inspector routes are not available. - -## Playground owns its trace - -Only actions started in Playground join its ordered durable trace. Hook and MCP page operations -stay independent even while a Playground session is open — a trace records a deliberate sequence, -not everything that happened to be clicked. - -From a Playground trace you can replay or export the raw evidence, or promote selected durable -outcome and assertion evidence into a draft eval case. - -`script.run` is a production-mounted, trusted-local Playground operation: it runs only the -selected manifest-owned emitted script for the selected target, in a managed workspace, and -preserves bounded stdout and stderr, the exit code, cancellation, and raw event references. See -[Scripts and assets](../authoring/scripts-assets.mdx). - -Native prompts choose a server catalog selection — case, fixture, host, and pinned model — for -the selected epoch, rather than accepting a browser-supplied command or model. +## Workbench navigation + +The primary navigation has four destinations in this release: + +- **Application** — the compiled application tree and the workspace for its selected leaf. +- **Trace** — invocations observed in this foreground development session. +- **Problems** — current compiler, runtime, and contract diagnostics. +- **Advanced** — Evals, Artifact, Protocol, Host diagnostics, and Raw logs. + +The header stays visible across them. It shows the project name, build state and epoch, +contract-gate and diagnostic failure count (linked to Problems), and foreground connection +state. Host **Sessions** arrive in a later release and are not part of this Workbench. + +## Application tree + +Application is one tree derived from the compiled application graph: + +```text +MCP + + Tools + Resources + Prompts + Apps +Events / Hooks +CLI +Scripts +Skills +Rules / Commands +``` + +Each leaf is an authored or configured plugin surface. Generated MCP servers group tools, +resources, prompts, and Apps beneath their server. Event routes and configured hooks appear +under Events / Hooks. Routed commands form the CLI branch. Conventional and explicitly +configured scripts share Scripts. Skills, host rules, and host commands remain visible even +when they do not have an executable route id. + +Config-only hooks and scripts are not hidden merely because there is no matching file-convention +route. The tree combines the route manifest with the compiler inspection that owns those +declarations. A stale or unavailable compiler catalog is called out in the tree instead of being +presented as an empty application. + +## Route workspace + +Selecting a leaf opens one workspace rather than sending you to a surface-specific page. For an +executable route, the input editor is generated from its schema and offers applicable fixtures +and the last input kept for that leaf. **Run** executes the production route path on the +foreground server. + +Results open on **Rendered**, the browser rendering of the production Agent Document and its +streamed progress or Suspense replacements. Secondary result tabs are **Structured result**, +**Raw AgentDocument**, **MCP projection**, **CLI projection** when available, and **Trace**. +The inspector opens only when requested and contains **Source**, **Schema**, **Context**, +**Providers**, **Execution timings**, **Projection**, and **Raw protocol**. + +The browser does not import or execute arbitrary route modules. The server runs the same RSC +route path used by generated executables and sends its semantic render-event stream and final +Agent Document to the Workbench. + +### Event routes + +An event leaf has fixture choices **Canonical**, **Claude**, **Codex**, and **Cursor**. Running a +host fixture decodes the native envelope exactly as that host wrapper would, then shows canonical +input, request context, providers, the returned decision or document, and projected host results. +Canonical-to-host mapping and native input/output are secondary projection details. + +Observed event receipts appear in the leaf's **Replay** tab and in Trace. Select a receipt there +to rerun its epoch-bound input; there is no separate lifecycle destination. + +### App and Skill leaves + +An App leaf places the sandboxed MCP App preview in the center of the workspace, bound to its +server. A bound App can also be reached from its tool's projection details. + +A Skill leaf renders the emitted Skill document by default. Source/generated differences, +frontmatter, resources, and eval coverage live in the inspector. Raw HTML, JSX/MDX, and Mermaid +inside Skill Markdown remain inert. + +## Trace + +Trace lists this foreground development session's route invocations and updates when a +`route.invocation` project event arrives. Select an entry to inspect it, or follow its route link +to load that invocation snapshot in the route workspace. This release does not claim a durable +cross-session trace or embedded host session. + +## Problems and stale-catalog repair + +Problems collects the current diagnostics. A failed build publishes no new epoch, so the header +shows the failed state while Application continues to expose the last good catalog and artifact. +For example, an MCP App compile failure contributes one `AB4770` row per Rspack error, including +the failing source and location; `AB4771` warnings and the `AB4772` size advisory belong to a +successful epoch. + +Repair a stale catalog in this order: + +1. Open **Problems** from the header failure count and fix the reported source. +2. Rebuild, then wait for the completed **Idle** state; **Building** is not repair evidence. +3. Return to **Application**. The new published epoch replaces the stale tree and clears repaired + diagnostics. + +## Advanced + +- **Evals** has **Runs** and **Compare** views for admitting, cancelling, inspecting, and comparing + eval runs. +- **Artifact** defaults to a simple emitted-file tree. Select a file and enable its details to see + hashes, modes, and provenance. +- **Protocol** is the low-level MCP session inspector. It retains protocol traces, task controls, + consent, restart/cancel actions, and the standalone + [MCP Inspector](https://github.com/modelcontextprotocol/inspector) launcher. +- **Host diagnostics** is limited to installed state, version, path, whether the current plugin is + attached, actionable errors, and one MCP handshake indicator. +- **Raw logs** contains producer streams for framework-level diagnosis. Trace is the normal route + execution view. + +An MCP protocol session remains pinned to `{ epochId, target, serverName }`. Restarting it respawns +that generated server on the selected epoch; open a new session to use a newly published epoch. +Compatible Apps preview through the same bound session. See +[Development vs production builds](../authoring/mcp.mdx#development-vs-production-builds). + +The standalone MCP Inspector still launches as a separate localhost app. Streamable HTTP sessions +can deep-link into Inspector 2.x; stdio sessions use the downloadable resolved command, +arguments, and non-secret environment. `AB8112` reports launch failure or timeout and `AB8113` +reports unavailable launcher routes. + +## URL model + +Workbench uses paths and browser history, not `#page` hashes: + +```text +/routes/mcp//tool/ +/routes/mcp//resource/ +/routes/mcp//prompt/ +/routes/mcp//app/ +/routes/events/ +/routes/cli/ +/routes/scripts/ +/routes/skills/ +/routes/commands/ +/routes/rules/ +/trace/ +/problems +/advanced/
    +``` + +The Advanced section names are `evals`, `artifact`, `protocol`, `hosts`, and `logs`. +Append `?invocation=` to an Application route to load a recorded invocation. The dev server +serves the Workbench shell for these paths, so route, trace, Problems, and Advanced deep links +survive refresh. + +## Route invocation API + +The route workspace uses one authenticated, origin-guarded foreground API: + +- `POST /api/routes/invocations` accepts a route invocation request and returns its completed + invocation envelope. +- `GET /api/routes/invocations?limit=50` returns newest-first summaries for Trace. +- `GET /api/routes/invocations/` returns one invocation. +- `/api/project/events` publishes completed summaries as `route.invocation` events. + +The envelope carries canonical input, request context, providers, ordered render events, the +final Agent Document, structured result, projections, diagnostics, and execution timings when +available. A represented `Agent.Error` remains a rendered result; malformed requests, unknown +routes, unavailable epochs, execution timeouts, and invalid responses use `AB8231`–`AB8235`. +See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and +recovery guidance. ## The same session programmatically @@ -281,7 +364,15 @@ disconnected.`, then the refusal once the reconnect's own 200 bootstrap is turne foreground still refuses mutations carrying that `Origin` with `AB8003`. The foreground URL keeps working regardless. +## What changed + +- Overview status moved into the persistent header and Problems. +- Routes, Hooks, Skills, and MCP previews moved into Application leaves. +- Event replay moved into event routes and Trace; script execution moved into Script leaves. +- Evals and comparisons became Advanced → Evals → Runs / Compare; artifact, protocol, host + diagnostics, and raw logs also moved under Advanced. + ## Next - [Testing](./testing.mdx) — the proof levels behind the surfaces the Workbench exercises. -- [Evaluations](./evaluations.mdx) — eval runs, comparisons, and the Eval page. +- [Evaluations](./evaluations.mdx) — eval runs and comparisons under Advanced. diff --git a/website/docs/en/guide/start/quick-start.mdx b/website/docs/en/guide/start/quick-start.mdx index 1cf94ba34..3595f3e94 100644 --- a/website/docs/en/guide/start/quick-start.mdx +++ b/website/docs/en/guide/start/quick-start.mdx @@ -88,10 +88,11 @@ npx agent-bundle dev --root . # local workbench with live rebu ``` `build` validates the project and writes the artifact, plus the `bin`/`lib` package build when -declared. `dev` serves the loopback developer Workbench and rebuilds as inputs change: project -overview and diagnostics, Skill documents, the artifact tree with provenance and epoch -comparison, an artifact-bound MCP playground with the raw protocol trace, a hook playground that -runs the emitted wrapper, and eval runs. +declared. `dev` serves the loopback developer Workbench and rebuilds as inputs change. Use its +Application tree to select a tool, event, CLI command, script, App, Skill, rule, or command; run +executable leaves and inspect the rendered Agent Document first. Trace shows this session's +invocations, Problems holds diagnostics, and Advanced contains evals, artifact inspection, +protocol inspection, host diagnostics, and raw logs. ## Inspect what the compiler decided diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index cb9f085fb..58e7e8887 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -46,7 +46,7 @@ features: span: 4 - icon: 🖥️ title: Local Workbench - details: agent-bundle dev serves a loopback Workbench with diagnostics, the artifact tree, an MCP playground with the raw protocol trace, and a hook playground. + details: agent-bundle dev serves a loopback application tree and route workspace with rendered results, invocation traces, diagnostics, and advanced protocol and artifact tools. link: /guide/development/workbench span: 4 - icon: 🔬 @@ -197,8 +197,9 @@ by convention and which were claimed by config. ### Develop `agent-bundle dev` rebuilds on every change and serves the developer -[Workbench](/guide/development/workbench) on loopback: diagnostics, Skill documents, the artifact -tree with provenance, and playgrounds that drive the emitted MCP server and hook wrappers. +[Workbench](/guide/development/workbench) on loopback: select any plugin surface in one +Application tree, run executable leaves, inspect rendered results, and follow invocation traces +and diagnostics. ### Prove diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 4c644ddaa..acc0b106d 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -79,7 +79,7 @@ agent-bundle serve-app / [--artifact ] [--tool ] \ Serves one built MCP App in a plain browser tab, outside any MCP host and without the Workbench: the command launches the plugin's packed MCP server exactly as `mcp run` does, binds -the App to it through the same host stack the Workbench MCP page uses (sandbox proxy, consent +the App to it through the same host stack the Workbench App workspace uses (sandbox proxy, consent authority, bridge), calls the App's tool once so it opens populated, and prints the loopback URL. Its output is a contract: once the host is listening, stdout carries exactly one line, `MCP App at (tool ; Ctrl-C stops the server)` — `` as given on the command diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 2bec05454..7a8f7c6f9 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -178,7 +178,7 @@ Development-only settings that never become part of a built artifact. | Field | Meaning | | --- | --- | | `dev.agentApi` | Exposes the authenticated, loopback-only Agent API from `agent-bundle dev`. The `--agent-api` / `--no-agent-api` flags override it. | -| `dev.contracts.fixtures` | **Required when `dev.contracts` is set.** Project-relative module whose default export maps route ids to contract fixtures. Declaring `dev.contracts` switches `agent-bundle dev` from adopting every epoch directly to gating host-facing adoption on the development contract matrix: an epoch whose checks fail still publishes to the Workbench playground, but live host connections and development installs keep the last passing epoch (`AB7211`). A malformed block, a fixtures module that escapes the project root, cannot load, or exports the wrong shape is `AB7210`. | +| `dev.contracts.fixtures` | **Required when `dev.contracts` is set.** Project-relative module whose default export maps route ids to contract fixtures. Declaring `dev.contracts` switches `agent-bundle dev` from adopting every epoch directly to gating host-facing adoption on the development contract matrix: an epoch whose checks fail still publishes to the Workbench route workspace, but live host connections and development installs keep the last passing epoch (`AB7211`). A malformed block, a fixtures module that escapes the project root, cannot load, or exports the wrong shape is `AB7210`. | | `dev.contracts.server` | The MCP server the matrix checks. Optional only when the project compiles exactly one server. | | `dev.runtime.provider` | The development runtime provider module: it exports `createDevRuntimeProvider` ([`CreateDevRuntimeProvider`](../api/types/api.CreateDevRuntimeProvider.md)), returning a [`DevRuntimeProvider`](../api/interfaces/api.DevRuntimeProvider.md) whose `start` takes a [`DevRuntimeStartContext`](../api/interfaces/api.DevRuntimeStartContext.md) and returns a [`DevRuntimeSession`](../api/interfaces/api.DevRuntimeSession.md). The whole protocol a provider implements ships from `agent-bundle/api` — the inspection envelope ([`DevRuntimeInspectionEnvelope`](../api/interfaces/api.DevRuntimeInspectionEnvelope.md)), the MCP server descriptor ([`DevRuntimeMcpServerDescriptor`](../api/interfaces/api.DevRuntimeMcpServerDescriptor.md)), the errors it throws ([`DevRuntimeUnavailableError`](../api/classes/api.DevRuntimeUnavailableError.md), [`DevRuntimeGenerationConflictError`](../api/classes/api.DevRuntimeGenerationConflictError.md)), and the generation store and MCP registry a session drives, as contracts ([`DevRuntimeGenerationStore`](../api/interfaces/api.DevRuntimeGenerationStore.md), [`DevRuntimeProviderMcpRegistry`](../api/interfaces/api.DevRuntimeProviderMcpRegistry.md)) with their constructors ([`createRuntimeGenerationStore`](../api/functions/api.createRuntimeGenerationStore.md), [`createRuntimeMcpRegistry`](../api/functions/api.createRuntimeMcpRegistry.md)). | diff --git a/website/docs/en/reference/limitations.mdx b/website/docs/en/reference/limitations.mdx index 1a223d6a9..19df37797 100644 --- a/website/docs/en/reference/limitations.mdx +++ b/website/docs/en/reference/limitations.mdx @@ -9,9 +9,9 @@ Stated plainly, so that a gap is never mistaken for a bug. ## Durable records Development snapshots and exports written by pre-0.1 builds — before the unversioned -durable-record cutover — are **not migrated**. Rebuild artifacts and discard those preview Eval and -Playground records before upgrading. Current readers reject the superseded shapes instead of -guessing at compatibility. +durable-record cutover — are **not migrated**. Rebuild artifacts and discard those preview Eval +and development-session records before upgrading. Current readers reject the superseded shapes +instead of guessing at compatibility. ## The development server diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index bcc06e7fa..28e088773 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -42,7 +42,7 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_HOST` | Generated hook wrappers | Pins the declared host explicitly instead of detecting it. | -| `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench hook playground sets it. | +| `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench event route workspace sets it. | | `AGENT_BUNDLE_NATIVE_HOST_CONTRACTS` | Contributor test suites | `1` compares the installed host CLI contract. | | `AGENT_BUNDLE_NATIVE_CLAUDE_SMOKE` | Contributor test suites | `1` runs the signed-in Claude native smoke. | | `AGENT_BUNDLE_NATIVE_CODEX_SMOKE` | Contributor test suites | `1` runs the signed-in Codex native smoke. | From 640a1d2684bb064c7fd46be357dacd74574a2f8d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:54:45 +0000 Subject: [PATCH 09/43] feat(dev): add route invocation service --- LANE-NOTES.md | 118 +++ docs/diagnostics.md | 1 + packages/agent-bundle/src/dev/events.ts | 1 + .../agent-bundle/src/dev/foreground-server.ts | 19 +- .../src/dev/logs/dev-log-kinds.ts | 4 +- .../src/dev/logs/dev-log-producers.ts | 2 + .../src/dev/routes/route-invocation-child.ts | 111 +++ .../src/dev/routes/route-invocation-routes.ts | 181 +++++ .../dev/routes/route-invocation-service.ts | 698 ++++++++++++++++++ packages/agent-bundle/src/dev/types.ts | 2 + .../tests/route-invocation-dev-server.test.ts | 188 +++++ .../tests/route-invocation-service.test.ts | 101 +++ rstest.integration-tests.ts | 1 + 13 files changed, 1423 insertions(+), 4 deletions(-) create mode 100644 LANE-NOTES.md create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-child.ts create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-routes.ts create mode 100644 packages/agent-bundle/src/dev/routes/route-invocation-service.ts create mode 100644 packages/agent-bundle/tests/route-invocation-dev-server.test.ts create mode 100644 packages/agent-bundle/tests/route-invocation-service.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..b19871e43 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,118 @@ +# L1 — dev-server route invocation service + +## Files added + +- `packages/agent-bundle/src/dev/routes/route-invocation-service.ts` + - Strict request decoding, route resolution through the injected current + manifest service, two-child admission semaphore, 60 s timeout, 200-entry + newest-first history, summary projection, host canonicalization/projection, + MCP/CLI projection, and child lifecycle. + - Exports `RouteInvocationService`, `RouteInvocationPreparedProject`, + `RouteInvocationFixture`, `InvocationRingBuffer`, + `parseRouteInvocationRequest`, and `invocationSummary`. +- `packages/agent-bundle/src/dev/routes/route-invocation-routes.ts` + - Protected POST/list/read HTTP boundary and `route.invocation` publication. +- `packages/agent-bundle/src/dev/routes/route-invocation-child.ts` + - React-server child entry. It installs lazy jiti loaders for routes, + providers, layouts, and state from the prepared test-runtime manifest, + then renders through `renderRouteEvents`. +- `packages/agent-bundle/tests/route-invocation-service.test.ts` + - Strict request, summary, and ring-buffer contracts. +- `packages/agent-bundle/tests/route-invocation-dev-server.test.ts` + - Real dev-server tool/event child renders, provider mounting, MCP/host + projections, list/read round trip, SSE, and SPA fallback. + +## Files changed + +- `packages/agent-bundle/src/dev/foreground-server.ts` + - Mounts invocation routes before the broad route-manifest matcher. + - Adds extensionless Workbench-shell GET fallback to `index.html`. +- `packages/agent-bundle/src/dev/types.ts` + - Adds `route.invocation` to `ProjectEventPayloadMap`. +- `packages/agent-bundle/src/dev/events.ts` + - Admits `route.invocation` on `ProjectEventHub`. +- `packages/agent-bundle/src/dev/logs/dev-log-kinds.ts` +- `packages/agent-bundle/src/dev/logs/dev-log-producers.ts` + - Required exhaustive consumers of the expanded project-event union. +- `rstest.integration-tests.ts` + - Registers the child-spawning integration test. +- `docs/diagnostics.md` + - Registers `AB8231`, `AB8232`, and replacement free codes + `AB8236`–`AB8238`; `AB8233`–`AB8235` were already assigned. + +The foundation invocation contract and +`packages/agent-bundle/src/contracts/invocations.ts` required no field or +re-export changes. + +## Diagnostic codes + +- `AB8231`: unknown route id or invocation id. +- `AB8232`: no published build / invocation manifest unavailable. +- `AB8236`: render child timed out or crashed (replacement for occupied + `AB8233`). +- `AB8237`: malformed invocation request (replacement for occupied `AB8234`). +- `AB8238`: fixture id unknown (replacement for occupied `AB8235`). + +## Cross-lane / integrator requests + +1. `packages/agent-bundle/src/dev/workbench-server.ts` owns the prepared + project closure and must create the production service next to + `routeManifest`, then pass it as `routeInvocations` to + `startForegroundServer`. Construct `RouteInvocationPreparedProject.manifest` + with `testManifestFromRouteGraph` from the *existing* + `latestValidPreparedProject` (`routeGraph`, model/plugin/apps/scripts/state, + config path, targets, root). This is a projection of the same compiler pass, + not `compileTestManifest()` and not a second discovery. The integration test + uses the existing `testing.startForegroundServer` seam because this file is + outside L1 ownership. +2. The current `RouteManifestRoute` contract has no fixture list despite the + PR brief referring to manifest fixtures. Whichever lane adds those fixture + descriptors should pass their strict JSON seeds as + `RouteInvocationPreparedProject.fixtures[routeId]`; the service already + enforces unknown ids with `AB8238`. +3. The current `renderRouteEvents` result exposes the document, events, + structured result, and provenance, but not provider mount receipts or + separate provider/handler/render durations. L1 records the measured + end-to-end child render duration, projection duration, mounted provider + inventory, and explicit zero-duration provider/handler subphases. If exact + phase telemetry is required for PR 1 acceptance, extend the shared render + harness/result in its owning lane and populate the child response from + those receipts. +4. The new child intentionally does not replace + `playground/lifecycle-render-child.ts` in this lane because the lifecycle + service/protocol files are outside L1 ownership. Both use the same + `renderRouteEvents` production kernel; a follow-up consolidation can make + lifecycle replay call the generalized child protocol. + +## Verification + +Passed: + +```text +pnpm build +npx tsc --noEmit +pnpm lint +npx rstest --config rstest.unit.config.ts packages/agent-bundle/tests/route-invocation-service.test.ts +npx rstest --config rstest.integration.config.ts packages/agent-bundle/tests/route-invocation-dev-server.test.ts +``` + +Results: 3 unit tests and 1 integration test passed. + +TraceDecay MCP discovery and its required CLI fallback were attempted before +source exploration/review, but the installed daemon socket was unavailable; +targeted source reads and the prescribed test gate were used instead. + +## Open risks + +- Production `startDevServer` wiring is intentionally pending integrator + application of request 1; `ForegroundServer` itself is fully mounted and + covered. +- Exact provider/handler phase timings await request 3. +- Resource, prompt, CLI, script, timeout, and queue paths are implemented but + the lane's required real-server acceptance test exercises tool and event + routes. + +## Proposed changeset line + +Add Workbench route invocation endpoints with production render projections +and diagnostics AB8231, AB8232, and AB8236–AB8238 (#600). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d5bbc2ef5..7e708c63d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -44,6 +44,7 @@ even when no error diagnostic was reported. | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | | `AB8219`–`AB8223` | Workbench live MCP probe route (user-initiated, read-only initialize + tools/list): `AB8219` invalid path, `AB8220` invalid request/method, `AB8221` probe target not found, `AB8222` response over the 16 MiB budget, `AB8223` probe unavailable. | | `AB8233`–`AB8235` | Workbench browser-side strict decoders rejecting a dev-server response: `AB8233` lifecycle replay, `AB8234` host discovery, `AB8235` MCP probe report. | +| `AB8231`–`AB8232`, `AB8236`–`AB8238` | Workbench route invocation service (`/api/routes/invocations`): `AB8231` unknown route or invocation id, `AB8232` no published build / invocation manifest unavailable, `AB8236` render child timed out or crashed, `AB8237` malformed invocation request, and `AB8238` unknown fixture id. (`AB8233`–`AB8235` were already assigned to browser-side decoders.) | | `AB8110`–`AB8113` | Workbench standalone MCP Inspector routes (`/api/inspector/status`, `/api/inspector/launch`): `AB8110` invalid path, `AB8111` invalid request shape or query, `AB8112` the Inspector could not be launched (spawn failure, exit before publishing a URL, or the 30 s startup budget elapsed; 502), `AB8113` routes not available (404 when the launcher is not composed, 503 after shutdown). | | `AB8120`–`AB8123` | Workbench route manifest (`/api/routes/manifest`): `AB8120` invalid path, `AB8121` not available (404/409/503), `AB8122` query string on the request, `AB8123` the browser client could not decode the response (see below). | | `AB8024`–`AB8025` | Live host MCP proxy: epoch drift behind a host connection and dev-server unavailability (see below). | diff --git a/packages/agent-bundle/src/dev/events.ts b/packages/agent-bundle/src/dev/events.ts index 1d4344685..3aaa5deee 100644 --- a/packages/agent-bundle/src/dev/events.ts +++ b/packages/agent-bundle/src/dev/events.ts @@ -82,6 +82,7 @@ const eventTypes = new Set([ 'artifact.status', 'dev.contract.status', 'dev.host.sync', + 'route.invocation', 'runtime.event', ]); diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 9aef368e9..ff3352257 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import type { AddressInfo, Socket } from 'node:net'; -import { basename } from 'node:path'; +import { basename, extname } from 'node:path'; import { validateOriginHeader } from '@modelcontextprotocol/server'; @@ -24,9 +24,11 @@ import { RuntimeMcpRoutes } from './runtime-mcp-routes.ts'; import { RuntimeRoutes, type AgentDocumentRuntimeModule } from './runtime-routes.ts'; import type { DevRuntimeSession } from './runtime-provider.ts'; import { PlaygroundRoutes, type PlaygroundRouteService } from './playground/playground-routes.ts'; +import { RouteInvocationRoutes, type RouteInvocationRouteService } from './routes/route-invocation-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'; +import { isWorkbenchShellPath } from './workbench-shell-paths.ts'; import { diagnostic, isJsonRequest, @@ -185,6 +187,8 @@ export interface ForegroundServerOptions { * navigation from this one compiler pass; it never re-discovers routes. */ readonly routeManifest?: RouteManifestRouteService; + /** Route execution over the same prepared compiler pass as `routeManifest`. */ + readonly routeInvocations?: RouteInvocationRouteService; /** Optional runtime session; its lifecycle remains Workbench-owned. */ readonly runtime?: DevRuntimeSession; /** Read-only Skill document/resource service for the workbench. */ @@ -411,6 +415,7 @@ export class ForegroundServer { readonly #playgroundRoutes: PlaygroundRoutes; readonly #port: number; readonly #routeManifestRoutes: RouteManifestRoutes; + readonly #routeInvocationRoutes: RouteInvocationRoutes; readonly #server: Server; readonly #skillDocuments: SkillDocumentService | undefined; readonly #sockets = new Set(); @@ -519,6 +524,11 @@ export class ForegroundServer { authorize: (request) => this.#assertMutationSession(request), ...(options.routeManifest === undefined ? {} : { service: options.routeManifest }), }); + this.#routeInvocationRoutes = new RouteInvocationRoutes({ + authorize: (request) => this.#assertMutationSession(request), + eventHub: options.eventHub, + ...(options.routeInvocations === undefined ? {} : { service: options.routeInvocations }), + }); this.#evalRoutes = new EvalRoutes({ authorize: (request) => this.#assertMutationSession(request), ...(options.evals === undefined ? {} : { service: options.evals }), @@ -666,6 +676,7 @@ export class ForegroundServer { this.#playgroundRoutes.close(); this.#inspectorRoutes.close(); this.#artifactRoutes.close(); + this.#routeInvocationRoutes.close(); this.#routeManifestRoutes.close(); this.#lifecycleReplayRoutes.close(); const releaseEvals = this.#evalRoutes.close(); @@ -757,6 +768,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 (await this.#routeInvocationRoutes.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; @@ -988,7 +1000,10 @@ export class ForegroundServer { if (method !== 'GET' && method !== 'HEAD') { return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); } - const path = decodedAssetPath(request.url); + const pathname = rawPathname(request.url); + const path = method === 'GET' && isWorkbenchShellPath(pathname) && extname(pathname) === '' + ? 'index.html' + : decodedAssetPath(request.url); const asset = await this.#assets?.read(path); if (asset === undefined) return responseDiagnostic(response, diagnostic('AB8007', 'Route was not found.', 404)); response.writeHead(200, { 'content-type': asset.contentType }); diff --git a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts index d66f540d8..35db01ec4 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-kinds.ts @@ -21,7 +21,7 @@ export const devLogKinds = Object.freeze({ build: Object.freeze(['artifact.available', 'build.failed', 'build.started'] as const), diagnostic: Object.freeze([ 'artifact.available.diagnostic', 'artifact.status.diagnostic', 'build.failed.diagnostic', 'build.started.diagnostic', - 'dev.contract.status.diagnostic', 'dev.host.sync.diagnostic', 'invalidation.diagnostic', 'runtime.event.diagnostic', + 'dev.contract.status.diagnostic', 'dev.host.sync.diagnostic', 'invalidation.diagnostic', 'route.invocation.diagnostic', 'runtime.event.diagnostic', 'source.changed.diagnostic', 'source.status.diagnostic', ] as const), eval: Object.freeze(['eval.run.completed', 'eval.run.failed', 'eval.run.started'] as const), @@ -37,7 +37,7 @@ export const devLogKinds = Object.freeze({ playground: Object.freeze(['playground.event.appended'] as const), project: Object.freeze([ 'artifact.status', 'dev.contract.status', 'dev.host.sync', 'dev.shutdown.completed', 'dev.shutdown.started', 'invalidation', - 'project.events.replay-gap', 'project.invalid-source', 'project.load', 'project.prepared', 'runtime.event', + 'project.events.replay-gap', 'project.invalid-source', 'project.load', 'project.prepared', 'route.invocation', 'runtime.event', 'source.changed', 'source.status', ] as const), } satisfies { readonly [TProducer in DevLogProducer]: readonly string[] }); diff --git a/packages/agent-bundle/src/dev/logs/dev-log-producers.ts b/packages/agent-bundle/src/dev/logs/dev-log-producers.ts index e2940e05e..3c4166d9d 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-producers.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-producers.ts @@ -41,6 +41,7 @@ const summaryFor = (event: ProjectEvent): string => { if (event.type === 'artifact.status') return 'Project artifact status was updated.'; if (event.type === 'dev.contract.status') return 'Development contract matrix settled.'; if (event.type === 'dev.host.sync') return 'Development host install was synchronized.'; + if (event.type === 'route.invocation') return 'Workbench route invocation completed.'; return 'Project runtime event was published.'; }; @@ -104,6 +105,7 @@ const recordEvent = (sink: DevLogSink, message: ProjectEventMessage): void => { case 'dev.contract.status': case 'dev.host.sync': case 'invalidation': + case 'route.invocation': case 'runtime.event': case 'source.changed': case 'source.status': diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-child.ts b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts new file mode 100644 index 000000000..56105259f --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -0,0 +1,111 @@ +import * as AgentRuntime from '@agent-bundle/runtime'; +import { createJiti } from 'jiti'; +import * as React from 'react'; + +import { + AGENT_TEST_REGISTRY_VERSION, + registerTestRoutes, + type AgentLayoutModuleLoader, + type AgentProviderModuleLoader, + type AgentStateModuleLoader, +} from '../../test/registry.ts'; +import { renderRouteEvents } from '../../test/render.ts'; +import type { AgentRouteModule, AgentRouteModuleLoader } from '../../test/types.ts'; +import type { + RouteInvocationChildRequest, + RouteInvocationChildResponse, + RouteInvocationChildResult, +} from './route-invocation-service.ts'; + +const jiti = createJiti(import.meta.url, { + fsCache: false, + interopDefault: false, + jsx: { runtime: 'automatic' }, + moduleCache: false, + nativeModules: ['typescript'], + virtualModules: { + '@agent-bundle/runtime': AgentRuntime, + react: React, + }, +}); + +const load = (source: string): (() => Promise) => + async () => jiti.import(source); + +const installManifest = (request: RouteInvocationChildRequest): void => { + const manifest = request.manifest; + registerTestRoutes({ + layoutLoaders: Object.fromEntries( + manifest.layouts.map((layout) => [layout.id, load>>(layout.source)]), + ), + loaders: Object.fromEntries( + Object.values(manifest.routes).map((route) => [route.id, load(route.source) as AgentRouteModuleLoader]), + ), + manifest, + providerLoaders: Object.fromEntries( + (manifest.providers ?? []).map((provider) => [ + provider.id, + load>>(provider.source), + ]), + ), + ...(manifest.state === undefined + ? {} + : { stateLoader: load>>(manifest.state.source) }), + version: AGENT_TEST_REGISTRY_VERSION, + }); +}; + +const respond = (response: RouteInvocationChildResponse): Promise => new Promise((resolvePromise, rejectPromise) => { + if (process.send === undefined) { + rejectPromise(new Error('Route invocation child requires a Node IPC channel.')); + return; + } + process.send(response, (error) => { + if (error === null) resolvePromise(); + else rejectPromise(error); + }); +}); + +const render = async (request: RouteInvocationChildRequest): Promise => { + installManifest(request); + const startedAt = performance.now(); + const input = request.input; + const rendered = await renderRouteEvents(request.routeId, { + ...(request.args === undefined ? {} : { args: request.args }), + context: { + actor: request.context.actor, + host: request.context.host, + invocation: request.context.invocation, + lineage: request.context.lineage, + session: request.context.session, + workspace: request.context.workspace, + }, + input, + manifest: request.manifest, + }); + return Object.freeze({ + document: rendered.document, + events: rendered.events, + input, + renderDurationMs: performance.now() - startedAt, + ...(rendered.result === undefined ? {} : { result: rendered.result as never }), + }); +}; + +process.once('message', (request: RouteInvocationChildRequest) => { + void render(request) + .then((result) => respond({ result, type: 'result' })) + .catch((error: unknown) => respond({ + error: { + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : 'Error', + }, + type: 'error', + })) + .then(() => process.disconnect?.()) + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + process.disconnect?.(); + }); +}); diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts b/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts new file mode 100644 index 000000000..24e99163c --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts @@ -0,0 +1,181 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import type { ProjectEventHub } from '../events.ts'; +import { + decodedOpaqueSegment, + diagnostic, + rawPathname, + readJsonBody, + requestError, + responseDiagnostic, + responseJson, +} from '../http.ts'; +import type { + RouteInvocation, + RouteInvocationListResponse, + RouteInvocationRequest, + RouteInvocationResponse, +} from './route-invocation.ts'; +import { + invocationSummary, + parseRouteInvocationRequest, + ROUTE_INVOCATION_CHILD_FAILURE_CODE, + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + type RouteInvocationRequestError, +} from './route-invocation-service.ts'; + +export interface RouteInvocationRouteService { + invoke(request: RouteInvocationRequest): Promise; + list(limit?: number): RouteInvocationListResponse['invocations']; + read(id: string): RouteInvocation | undefined; +} + +export interface RouteInvocationRoutesOptions { + readonly authorize: (request: IncomingMessage) => void; + readonly eventHub: ProjectEventHub; + readonly service?: RouteInvocationRouteService; +} + +type InvocationPath = + | Readonly<{ readonly kind: 'collection' }> + | Readonly<{ readonly id: string; readonly kind: 'item' }>; + +const invocationPath = (requestTarget: string | undefined): InvocationPath | undefined => { + const pathname = rawPathname(requestTarget); + if (pathname !== '/api/routes/invocations' && !pathname.startsWith('/api/routes/invocations/')) return undefined; + if (pathname === '/api/routes/invocations') return Object.freeze({ kind: 'collection' }); + const parts = pathname.split('/'); + if (parts.length !== 5 || parts[4] === undefined) { + throw requestError(diagnostic( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation path is not valid.', + 400, + )); + } + return Object.freeze({ + id: decodedOpaqueSegment(parts[4], { + code: ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + maxLength: 128, + message: 'Route invocation path is not valid.', + rejectBlank: true, + }), + kind: 'item', + }); +}; + +const listLimit = (requestTarget: string | undefined): number => { + const values = new URL(requestTarget ?? '/', 'http://localhost').searchParams.getAll('limit'); + if (values.length === 0) return 50; + const [value] = values; + if (values.length !== 1 || value === undefined || !/^[1-9]\d*$/u.test(value)) { + throw requestError(diagnostic( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation list limit must be one integer between 1 and 200.', + 400, + )); + } + const limit = Number(value); + if (!Number.isSafeInteger(limit) || limit > 200) { + throw requestError(diagnostic( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation list limit must be one integer between 1 and 200.', + 400, + )); + } + return limit; +}; + +const noQuery = (requestTarget: string | undefined): void => { + if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) { + throw requestError(diagnostic( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation request has an invalid shape.', + 400, + )); + } +}; + +const unavailable = (): never => { + throw requestError(diagnostic('AB8232', 'Route invocation service is not available.', 409)); +}; + +export class RouteInvocationRoutes { + readonly #authorize: (request: IncomingMessage) => void; + readonly #eventHub: ProjectEventHub; + readonly #service: RouteInvocationRouteService | undefined; + #closed = false; + + constructor(options: RouteInvocationRoutesOptions) { + this.#authorize = options.authorize; + this.#eventHub = options.eventHub; + this.#service = options.service; + } + + close(): void { + this.#closed = true; + } + + async handle(request: IncomingMessage, response: ServerResponse): Promise { + const path = invocationPath(request.url); + if (path === undefined) return false; + this.#authorize(request); + if (this.#closed) return unavailable(); + const service = this.#service; + if (service === undefined) return unavailable(); + const method = request.method ?? 'GET'; + if (path.kind === 'collection' && method === 'POST') { + noQuery(request.url); + const body = await readJsonBody(request, { + invalidShape: () => { + throw requestError(diagnostic( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation request has an invalid shape.', + 400, + )); + }, + read: { + code: ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + limit: 64 * 1024, + message: 'Route invocation request exceeds 64 KiB.', + }, + }); + let invocation: RouteInvocation; + try { + invocation = await service.invoke(parseRouteInvocationRequest(body)); + } catch (error) { + const failure = error as Partial; + if (typeof failure.code === 'string' && typeof failure.message === 'string' && typeof failure.status === 'number') { + throw requestError(diagnostic(failure.code, failure.message, failure.status)); + } + throw error; + } + this.#eventHub.publish({ + payload: { invocation: invocationSummary(invocation) }, + type: 'route.invocation', + }); + const bodyResponse: RouteInvocationResponse = { invocation }; + responseJson(response, bodyResponse, { + status: invocation.diagnostics.some((entry) => entry.code === ROUTE_INVOCATION_CHILD_FAILURE_CODE) + && invocation.diagnostics[0]?.message.includes('timed out') + ? 503 + : 200, + }); + return true; + } + if (path.kind === 'collection' && method === 'GET') { + responseJson(response, { invocations: service.list(listLimit(request.url)) } satisfies RouteInvocationListResponse); + return true; + } + if (path.kind === 'item' && method === 'GET') { + noQuery(request.url); + const invocation = service.read(path.id); + if (invocation === undefined) { + throw requestError(diagnostic('AB8231', `Route invocation ${JSON.stringify(path.id)} was not found.`, 404)); + } + responseJson(response, { invocation } satisfies RouteInvocationResponse); + return true; + } + responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); + return true; + } +} diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts new file mode 100644 index 000000000..05cc6b116 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -0,0 +1,698 @@ +import { randomBytes } from 'node:crypto'; +import { fork } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { documentToCallToolResult } from '@agent-bundle/runtime'; + +import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts'; +import type { TargetHookContract } from '../../adapters/hook-contract.ts'; +import { projectCliDocumentToMarkdown } from '../../cli-entry.ts'; +import type { Diagnostic } from '../../core/diagnostics.ts'; +import { deepFreeze } from '../../core/freeze.ts'; +import { + hasOnlyOwnKeys, + isJsonRecord, + isRecord, + snapshotStrictJsonValue, + type JsonObject, + type JsonValue, +} from '../../core/strict-json.ts'; +import type { + RequestContextProvenance, + RequestProvenanceAxis, + RequestProvenanceUnavailableReason, +} from '../../contracts/request-provenance.ts'; +import { createCanonicalEventProps, projectEventDocument } from '../../events/projection.ts'; +import type { CanonicalAgentEvent } from '../../routes/public.ts'; +import type { AgentBundleTestManifest } from '../../test/manifest.ts'; +import type { + RouteInvocation, + RouteInvocationEventHost, + RouteInvocationKind, + RouteInvocationProvider, + RouteInvocationRequest, + RouteInvocationSummary, + RouteInvocationTiming, +} from './route-invocation.ts'; +import type { RouteManifest, RouteManifestRoute } from './route-manifest.ts'; +import type { RouteManifestRouteService } from './route-manifest-routes.ts'; + +export const ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE = 'AB8231'; +export const ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE = 'AB8232'; +export const ROUTE_INVOCATION_CHILD_FAILURE_CODE = 'AB8236'; +export const ROUTE_INVOCATION_MALFORMED_REQUEST_CODE = 'AB8237'; +export const ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE = 'AB8238'; + +const defaultHistoryLimit = 200; +const defaultTimeoutMs = 60_000; +const defaultConcurrency = 2; +const concreteHosts = new Set(['claude', 'codex', 'cursor']); +const invocationKinds = new Set(['cli', 'event-route', 'prompt', 'resource', 'script', 'tool']); + +export interface RouteInvocationFixture { + readonly id: string; + readonly input: JsonValue; + readonly label: string; +} + +/** + * Execution-only material from the same prepared compiler pass that produced + * the browser manifest. The service never compiles or discovers a second + * graph; the child receives this immutable harness manifest solely to install + * route, provider, layout, and state loaders. + */ +export interface RouteInvocationPreparedProject { + readonly fixtures?: Readonly>; + readonly manifest: AgentBundleTestManifest; + readonly targets: readonly RouteInvocationEventHost[]; +} + +export interface RouteInvocationServiceOptions { + readonly concurrency?: number; + readonly historyLimit?: number; + readonly manifest: RouteManifestRouteService; + readonly now?: () => Date; + readonly prepared: () => RouteInvocationPreparedProject; + readonly registry?: TargetRegistry; + readonly renderChild?: ( + request: RouteInvocationChildRequest, + signal: AbortSignal, + ) => Promise; + readonly timeoutMs?: number; +} + +export interface RouteInvocationChildRequest { + readonly args?: readonly string[]; + readonly context: RequestContextProvenance; + readonly input: JsonValue; + readonly manifest: AgentBundleTestManifest; + readonly routeId: string; +} + +export interface RouteInvocationChildResult { + readonly document: NonNullable; + readonly events: RouteInvocation['events']; + /** The input handed to the route after hosted-event canonicalization. */ + readonly input: JsonValue; + readonly renderDurationMs: number; + readonly result?: JsonValue; +} + +export type RouteInvocationChildResponse = + | Readonly<{ readonly result: RouteInvocationChildResult; readonly type: 'result' }> + | Readonly<{ + readonly error: Readonly<{ readonly message: string; readonly name: string }>; + readonly type: 'error'; + }>; + +export class RouteInvocationRequestError extends Error { + readonly code: + | typeof ROUTE_INVOCATION_MALFORMED_REQUEST_CODE + | typeof ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE + | typeof ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE + | typeof ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE; + readonly status: 400 | 404 | 409; + + constructor( + code: RouteInvocationRequestError['code'], + message: string, + status: RouteInvocationRequestError['status'], + ) { + super(message); + this.name = 'RouteInvocationRequestError'; + this.code = code; + this.status = status; + } +} + +const malformed = (): never => { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + 'Route invocation request has an invalid shape.', + 400, + ); +}; + +const boundedString = (value: unknown, maxLength = 4_096): value is string => + typeof value === 'string' && value.length > 0 && value.length <= maxLength && value.trim() === value && !value.includes('\0'); + +const eventOptions = (value: unknown): RouteInvocationRequest['event'] => { + if (!isRecord(value) || !hasOnlyOwnKeys(value, ['fixtureId', 'host'])) return malformed(); + const fixtureId = value.fixtureId; + const host = value.host; + if (fixtureId !== undefined && !boundedString(fixtureId)) return malformed(); + if (host !== undefined && (typeof host !== 'string' || !concreteHosts.has(host as RouteInvocationEventHost))) { + return malformed(); + } + return Object.freeze({ + ...(fixtureId === undefined ? {} : { fixtureId }), + ...(host === undefined ? {} : { host: host as RouteInvocationEventHost }), + }); +}; + +/** Strict wire decoder used by both the HTTP boundary and unit callers. */ +export const parseRouteInvocationRequest = ( + value: Readonly>, +): RouteInvocationRequest => { + if (!hasOnlyOwnKeys(value, ['args', 'correlationId', 'event', 'input', 'routeId'])) return malformed(); + const routeId = value.routeId; + const correlationId = value.correlationId; + const args = value.args; + if (!boundedString(routeId)) return malformed(); + if (correlationId !== undefined && !boundedString(correlationId, 256)) return malformed(); + if (args !== undefined && (!Array.isArray(args) || args.length > 1_024 || args.some((argument) => !boundedString(argument, 16_384)))) { + return malformed(); + } + let input: JsonValue | undefined; + if (Object.hasOwn(value, 'input')) { + try { + input = snapshotStrictJsonValue(value.input); + } catch { + return malformed(); + } + } + const event = value.event === undefined ? undefined : eventOptions(value.event); + return deepFreeze({ + ...(args === undefined ? {} : { args: [...args] as readonly string[] }), + ...(correlationId === undefined ? {} : { correlationId }), + ...(event === undefined ? {} : { event }), + ...(input === undefined ? {} : { input }), + routeId, + }); +}; + +/** Removes stream/document-heavy fields for history and project events. */ +export const invocationSummary = (invocation: RouteInvocation): RouteInvocationSummary => { + const { + context: _context, + document: _document, + events: _events, + projection: _projection, + providers: _providers, + result: _result, + ...summary + } = invocation; + return deepFreeze(summary); +}; + +export class InvocationRingBuffer { + readonly #capacity: number; + readonly #values: RouteInvocation[] = []; + + constructor(capacity = defaultHistoryLimit) { + if (!Number.isSafeInteger(capacity) || capacity < 1) throw new RangeError('Invocation history capacity must be positive.'); + this.#capacity = capacity; + } + + push(invocation: RouteInvocation): void { + this.#values.push(invocation); + if (this.#values.length > this.#capacity) this.#values.shift(); + } + + read(id: string): RouteInvocation | undefined { + return this.#values.findLast((invocation) => invocation.id === id); + } + + list(limit = this.#capacity): readonly RouteInvocationSummary[] { + if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError('Invocation history limit must be positive.'); + return Object.freeze(this.#values.slice(-Math.min(limit, this.#capacity)).reverse().map(invocationSummary)); + } +} + +class InvocationSemaphore { + readonly #limit: number; + readonly #waiting: Array<() => void> = []; + #active = 0; + + constructor(limit: number) { + if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError('Invocation concurrency must be positive.'); + this.#limit = limit; + } + + async run(operation: () => Promise): Promise { + if (this.#active >= this.#limit) { + await new Promise((resolvePromise) => this.#waiting.push(resolvePromise)); + } + this.#active += 1; + try { + return await operation(); + } finally { + this.#active -= 1; + this.#waiting.shift()?.(); + } + } +} + +const allManifestRoutes = (manifest: RouteManifest): readonly RouteManifestRoute[] => Object.freeze([ + ...manifest.servers.flatMap((server) => server.routes), + ...(manifest.cli?.routes ?? []), + ...manifest.events, + ...manifest.scripts, +]); + +const diagnostic = (code: string, message: string): Diagnostic => + Object.freeze({ code, message, severity: 'error' }); + +const unavailable = ( + reason: RequestProvenanceUnavailableReason, +): RequestProvenanceAxis => + Object.freeze({ reason, state: 'unavailable' }); + +const contextFor = ( + route: RouteManifestRoute, + root: string, + host: RouteInvocationEventHost | undefined, +): RequestContextProvenance => deepFreeze({ + actor: unavailable('not-provided'), + host: host === undefined + ? unavailable('host-omitted') + : { source: 'derived', state: 'available', value: { name: host } }, + invocation: { + kind: route.kind === 'event-route' + ? 'event' + : route.kind === 'cli' ? 'cli' : route.kind === 'script' ? 'script' : 'tool', + operationId: route.id, + surface: route.event ?? route.id.slice(route.id.lastIndexOf('/') + 1), + }, + lineage: unavailable('no-shared-runtime'), + session: unavailable('not-provided'), + workspace: { source: 'derived', state: 'available', value: { root } }, +}); + +const childPath = (): string => { + const current = fileURLToPath(import.meta.url); + const here = dirname(current); + const candidates = current.endsWith('.ts') + ? [ + join(here, 'route-invocation-child.ts'), + join(here, '..', '..', '..', 'dist', 'route-invocation-child.js'), + ] + : [ + join(here, 'route-invocation-child.js'), + join(here, 'route-invocation-child.ts'), + resolve(process.cwd(), 'packages/agent-bundle/src/dev/routes/route-invocation-child.ts'), + ]; + const found = candidates.find(existsSync); + if (found === undefined) throw new Error('Unable to locate the route invocation render child.'); + return found; +}; + +const isChildResponse = (value: unknown): value is RouteInvocationChildResponse => { + if (!isRecord(value)) return false; + if (value.type === 'result') return isRecord(value.result); + return value.type === 'error' && isRecord(value.error) + && typeof value.error.name === 'string' && typeof value.error.message === 'string'; +}; + +const renderInChild = ( + request: RouteInvocationChildRequest, + signal: AbortSignal, +): Promise => { + if (signal.aborted) return Promise.reject(signal.reason); + const executable = childPath(); + const jitiRegister = join(dirname(createRequire(import.meta.url).resolve('jiti/package.json')), 'lib', 'jiti-register.mjs'); + const child = fork(executable, [], { + cwd: request.manifest.projectRoot, + execArgv: ['--conditions=react-server', ...(executable.endsWith('.ts') ? ['--import', jitiRegister] : [])], + serialization: 'json', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + child.stdout?.on('data', (chunk: Uint8Array) => process.stderr.write(chunk)); + child.stderr?.on('data', (chunk: Uint8Array) => process.stderr.write(chunk)); + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const cleanup = (): void => { + signal.removeEventListener('abort', abort); + child.removeListener('error', fail); + child.removeListener('exit', exited); + child.removeListener('message', receive); + }; + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + cleanup(); + action(); + }; + const abort = (): void => { + child.kill('SIGKILL'); + settle(() => rejectPromise(signal.reason)); + }; + const fail = (error: Error): void => settle(() => rejectPromise(error)); + const exited = (code: number | null, exitSignal: NodeJS.Signals | null): void => + settle(() => rejectPromise(new Error( + `Route invocation child exited before replying (code ${String(code)}, signal ${String(exitSignal)}).`, + ))); + const receive = (message: unknown): void => { + if (!isChildResponse(message)) return settle(() => rejectPromise(new Error('Route invocation child returned an invalid response.'))); + if (message.type === 'error') { + const error = new Error(message.error.message); + error.name = message.error.name; + return settle(() => rejectPromise(error)); + } + settle(() => resolvePromise(message.result)); + }; + signal.addEventListener('abort', abort, { once: true }); + child.once('error', fail); + child.once('exit', exited); + child.once('message', receive); + child.send(request, (error) => { + if (error !== null) fail(error); + }); + }); +}; + +const eventContract = ( + registry: TargetRegistry, + host: RouteInvocationEventHost, + event: CanonicalAgentEvent, +): Readonly<{ contract: TargetHookContract; hostContractRevision: string; nativeEvent: string }> | undefined => { + const contract = registry.hookContract(host); + const nativeEvent = contract?.eventRouteNames?.[event]; + const hostContractRevision = contract?.hostContractRevision; + if (contract === undefined || !boundedString(nativeEvent) || !boundedString(hostContractRevision)) return undefined; + return Object.freeze({ contract, hostContractRevision, nativeEvent }); +}; + +const eventInput = ( + route: RouteManifestRoute, + input: JsonValue, + host: RouteInvocationEventHost | undefined, + registry: TargetRegistry, +): JsonValue => { + if (!isJsonRecord(input)) return malformed(); + if (host === undefined) return Object.freeze({ canonical: input, native: {} }); + const mapped = eventContract(registry, host, route.event as CanonicalAgentEvent); + if (mapped === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_MALFORMED_REQUEST_CODE, + `Route event ${JSON.stringify(route.event)} is not supported by ${host}.`, + 400, + ); + } + const props = createCanonicalEventProps( + route.event as CanonicalAgentEvent, + input, + host, + mapped.nativeEvent, + mapped.hostContractRevision, + new AbortController().signal, + ); + return snapshotStrictJsonValue({ + canonical: props.canonical, + native: props.native, + }); +}; + +const providerProjection = ( + manifest: RouteManifest, + durationMs: number, + status: RouteInvocationProvider['status'], +): readonly RouteInvocationProvider[] => Object.freeze(manifest.providers.map((provider) => Object.freeze({ + durationMs, + id: provider.id, + name: provider.name, + status, +}))); + +const timing = (phase: string, startedAt: Date, durationMs: number): RouteInvocationTiming => + Object.freeze({ durationMs, phase, startedAt: startedAt.toISOString() }); + +const jsonObject = (value: unknown): JsonObject | undefined => { + if (value === undefined) return undefined; + const snapshot = snapshotStrictJsonValue(value); + return isJsonRecord(snapshot) ? snapshot : undefined; +}; + +const resultExitCode = (policy: 'result' | 'zero', result: JsonValue | undefined): number => { + if (policy === 'zero') return 0; + if (result === undefined || !isJsonRecord(result)) return 1; + const value = result.exitCode; + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255 ? value : 1; +}; + +const invocationProjection = ( + route: RouteManifestRoute, + request: RouteInvocationRequest, + input: JsonValue, + result: JsonValue | undefined, + document: NonNullable, + manifest: RouteManifest, + prepared: RouteInvocationPreparedProject, + registry: TargetRegistry, +): RouteInvocation['projection'] => { + if (route.kind === 'tool') { + return deepFreeze({ mcp: documentToCallToolResult(document, { structuredContent: result }) as JsonObject }); + } + if (route.kind === 'resource' || route.kind === 'prompt') { + return deepFreeze({ ...(jsonObject(result) === undefined ? {} : { mcp: jsonObject(result) }) }); + } + if (route.kind === 'cli' || route.kind === 'script') { + const command = manifest.cli?.commands?.find((candidate) => candidate.routeId === route.id); + const policy = route.kind === 'script' ? 'zero' : command?.exitCode ?? 'zero'; + return deepFreeze({ + cli: { + exitCode: resultExitCode(policy, result), + ...(result === undefined ? {} : { json: result }), + text: projectCliDocumentToMarkdown(document), + }, + }); + } + if (route.kind === 'event-route') { + const selected = request.event?.host === undefined ? prepared.targets : [request.event.host]; + const hosts = selected.map((host) => { + const mapped = eventContract(registry, host, route.event as CanonicalAgentEvent); + if (mapped === undefined) { + return { + diagnostics: [diagnostic( + 'route.invocation.projection.unsupported', + `Event ${JSON.stringify(route.event)} cannot be projected to ${host}.`, + )], + host, + }; + } + try { + const native = projectEventDocument( + document, + route.event as CanonicalAgentEvent, + host, + mapped.nativeEvent, + request.event?.host === host && isJsonRecord(input) ? input : undefined, + ); + return { diagnostics: [], host, ...(native === undefined ? {} : { native: jsonObject(native) }) }; + } catch (error) { + return { + diagnostics: [diagnostic( + 'route.invocation.projection.failed', + error instanceof Error ? error.message : String(error), + )], + host, + }; + } + }); + return deepFreeze({ hosts }); + } + return {}; +}; + +const failedInvocation = (input: { + readonly code: string; + readonly completedAt: Date; + readonly context: RequestContextProvenance; + readonly id: string; + readonly manifest: RouteManifest; + readonly message: string; + readonly request: RouteInvocationRequest; + readonly route: RouteManifestRoute; + readonly startedAt: Date; +}): RouteInvocation => { + const renderedInput = input.request.input; + const canonical = input.route.kind === 'event-route' && renderedInput !== undefined && isJsonRecord(renderedInput) + ? renderedInput.canonical + : undefined; + return deepFreeze({ + completedAt: input.completedAt.toISOString(), + context: input.context, + ...(input.request.correlationId === undefined ? {} : { correlationId: input.request.correlationId }), + diagnostics: [diagnostic(input.code, input.message)], + events: [], + id: input.id, + input: canonical ?? renderedInput ?? {}, + kind: input.route.kind as RouteInvocationKind, + manifestDigest: input.manifest.digest, + projection: {}, + providers: providerProjection(input.manifest, 0, 'failed'), + routeId: input.route.id, + source: input.route.source, + sourceRevision: input.manifest.sourceRevision, + startedAt: input.startedAt.toISOString(), + status: 'failed', + timings: [timing('render', input.startedAt, input.completedAt.getTime() - input.startedAt.getTime())], + }); +}; + +export class RouteInvocationService { + readonly #history: InvocationRingBuffer; + readonly #manifest: RouteManifestRouteService; + readonly #now: () => Date; + readonly #prepared: () => RouteInvocationPreparedProject; + readonly #registry: TargetRegistry; + readonly #renderChild: NonNullable; + readonly #semaphore: InvocationSemaphore; + readonly #timeoutMs: number; + + constructor(options: RouteInvocationServiceOptions) { + this.#history = new InvocationRingBuffer(options.historyLimit); + this.#manifest = options.manifest; + this.#now = options.now ?? (() => new Date()); + this.#prepared = options.prepared; + this.#registry = options.registry ?? createDefaultRegistry(); + this.#renderChild = options.renderChild ?? renderInChild; + this.#semaphore = new InvocationSemaphore(options.concurrency ?? defaultConcurrency); + this.#timeoutMs = options.timeoutMs ?? defaultTimeoutMs; + if (!Number.isSafeInteger(this.#timeoutMs) || this.#timeoutMs < 1) throw new RangeError('Invocation timeout must be positive.'); + } + + list(limit?: number): readonly RouteInvocationSummary[] { + return this.#history.list(limit); + } + + read(id: string): RouteInvocation | undefined { + return this.#history.read(id); + } + + async invoke(request: RouteInvocationRequest): Promise { + let manifest: RouteManifest; + let prepared: RouteInvocationPreparedProject; + try { + manifest = this.#manifest.manifest(); + prepared = this.#prepared(); + } catch { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + 'No published build and route manifest are available.', + 409, + ); + } + const route = allManifestRoutes(manifest).find((candidate) => candidate.id === request.routeId); + if (route === undefined || !invocationKinds.has(route.kind as RouteInvocationKind)) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_UNKNOWN_ROUTE_CODE, + `Route ${JSON.stringify(request.routeId)} is not available for invocation.`, + 404, + ); + } + if ( + (request.event !== undefined && route.kind !== 'event-route') + || (request.args !== undefined && route.kind !== 'cli') + ) { + return malformed(); + } + const fixtureId = request.event?.fixtureId; + const fixture = fixtureId === undefined + ? undefined + : prepared.fixtures?.[route.id]?.find((candidate) => candidate.id === fixtureId); + if (fixtureId !== undefined && fixture === undefined) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_UNKNOWN_FIXTURE_CODE, + `Fixture ${JSON.stringify(fixtureId)} is not available for route ${JSON.stringify(route.id)}.`, + 400, + ); + } + const rawInput = request.input ?? fixture?.input ?? {}; + const input = route.kind === 'event-route' + ? eventInput(route, rawInput, request.event?.host, this.#registry) + : rawInput; + const id = `inv_${this.#now().getTime().toString(36)}${randomBytes(8).toString('hex')}`; + const startedAt = this.#now(); + const context = contextFor(route, prepared.manifest.projectRoot, request.event?.host); + const invocation = await this.#semaphore.run(async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(new DOMException('Route invocation timed out.', 'TimeoutError')), this.#timeoutMs); + let child: RouteInvocationChildResult; + try { + child = await this.#renderChild({ + ...(request.args === undefined ? {} : { args: request.args }), + context, + input, + manifest: prepared.manifest, + routeId: route.id, + }, controller.signal); + } catch (error) { + const completedAt = this.#now(); + return failedInvocation({ + code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, + completedAt, + context, + id, + manifest, + message: controller.signal.aborted + ? 'Route invocation child timed out.' + : `Route invocation child failed: ${error instanceof Error ? error.message : String(error)}`, + request: { ...request, input }, + route, + startedAt, + }); + } finally { + clearTimeout(timeout); + } + const projectionStartedAt = this.#now(); + const projection = invocationProjection( + route, + request, + rawInput, + child.result, + child.document, + manifest, + prepared, + this.#registry, + ); + const completedAt = this.#now(); + const canonical = route.kind === 'event-route' + ? (child.input as JsonObject).canonical + : undefined; + return deepFreeze({ + completedAt: completedAt.toISOString(), + context, + ...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }), + diagnostics: [], + document: child.document, + ...(canonical !== undefined && isJsonRecord(canonical) + ? { + event: { + // Project events reject repeated object references. Keep the + // event detail detached from the identical public `input`. + canonical: jsonObject(canonical)!, + event: route.event!, + ...(request.event?.host === undefined ? {} : { host: request.event.host, native: rawInput as JsonObject }), + }, + } + : {}), + events: child.events, + id, + input: canonical ?? child.input, + kind: route.kind as RouteInvocationKind, + manifestDigest: manifest.digest, + projection, + providers: providerProjection(manifest, 0, 'mounted'), + ...(child.result === undefined ? {} : { result: child.result }), + routeId: route.id, + source: route.source, + sourceRevision: manifest.sourceRevision, + startedAt: startedAt.toISOString(), + status: 'succeeded', + timings: [ + timing('providers', startedAt, 0), + ...manifest.providers.map((provider) => timing(`provider:${provider.name}`, startedAt, 0)), + timing('handler', startedAt, 0), + timing('render', startedAt, child.renderDurationMs), + timing('projection', projectionStartedAt, completedAt.getTime() - projectionStartedAt.getTime()), + ], + }); + }); + this.#history.push(invocation); + return invocation; + } +} diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index ab14012ef..fc2a96346 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -1,5 +1,6 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; +import type { RouteInvocationEventPayload } from './routes/route-invocation.ts'; export type JsonPrimitive = boolean | null | number | string; export type JsonArray = readonly JsonValue[]; @@ -315,6 +316,7 @@ export interface ProjectEventPayloadMap { readonly 'dev.contract.status': DevContractStatusEvent; readonly 'dev.host.sync': DevHostSyncEvent; readonly invalidation: Invalidation; + readonly 'route.invocation': RouteInvocationEventPayload; readonly 'runtime.event': RuntimeEvent; readonly 'source.changed': Invalidation; readonly 'source.status': SourceStatus; diff --git a/packages/agent-bundle/tests/route-invocation-dev-server.test.ts b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts new file mode 100644 index 000000000..28b50b48c --- /dev/null +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -0,0 +1,188 @@ +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import type { + RouteInvocationListResponse, + RouteInvocationResponse, +} from '../src/dev/routes/route-invocation.ts'; +import { RouteInvocationService } from '../src/dev/routes/route-invocation-service.ts'; +import { startForegroundServer } from '../src/dev/foreground-server.ts'; +import { compileTestManifest } from '../src/test/manifest.ts'; +import { createWorkbenchAssetSource } from '../src/dev/workbench-assets.ts'; +import { startDevServer } from '../src/dev/workbench-server.ts'; +import { createProjectFixture } from './helpers/project-fixture.ts'; +import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; + +const readEvent = async (response: Response, type: string): Promise> => { + const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); + let buffered = ''; + for (;;) { + const next = await reader.read(); + if (next.done) throw new Error(`Project event stream ended before ${type}.`); + buffered += next.value; + const frames = buffered.split('\n\n'); + buffered = frames.pop() ?? ''; + for (const frame of frames) { + if (!frame.includes(`event: ${type}\n`)) continue; + const data = frame.split('\n').find((line) => line.startsWith('data: ')); + if (data !== undefined) return JSON.parse(data.slice('data: '.length)) as Record; + } + } +}; + +it('invokes compiled tool and event routes through the foreground server', { timeout: 60_000 }, async () => { + const project = await createProjectFixture({ + config: [ + 'export default {', + " plugin: { name: 'route-invocation-dev-server', version: '1.0.0' },", + " targets: ['claude'],", + '};', + '', + ].join('\n'), + files: { + 'package.json': '{"type":"module"}\n', + 'src/events/tool/after.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + "export const config = { runtime: 'standalone' };", + '', + 'export default async function AfterTool({ canonical }) {', + " return createElement(Agent.Result, null, createElement(Agent.Context, null, `Observed ${canonical.payload.toolName}.`));", + '}', + '', + ].join('\n'), + 'src/mcp/status/tools/report.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one service.' };", + "export const inputSchema = z.object({ service: z.string().min(1) }).strict();", + 'export const resultSchema = z.object({ service: z.string() }).strict();', + '', + 'export default async function Report({ input }) {', + " return createElement(Agent.Result, { value: { service: input.service } }, createElement(Agent.Text, null, `Service ${input.service}`));", + '}', + '', + ].join('\n'), + 'src/providers/clock.ts': [ + 'export default () => ({ now: 0 });', + '', + ].join('\n'), + }, + prefix: 'agent-bundle-route-invocation-dev-server-', + }); + const assetsRoot = join(project.root, 'workbench'); + let server: Awaited> | undefined; + await mkdir(assetsRoot, { recursive: true }); + await Promise.all([ + symlink(agentBundleNodeModules, join(project.root, 'node_modules'), 'dir'), + writeFile(join(assetsRoot, 'index.html'), 'Route invocation'), + ]); + try { + const testManifest = await compileTestManifest({ root: project.root }); + server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: assetsRoot }), + open: false, + port: 0, + root: project.root, + testing: { + startForegroundServer: async (options) => startForegroundServer({ + ...options, + routeInvocations: new RouteInvocationService({ + manifest: options.routeManifest!, + prepared: () => ({ manifest: testManifest, targets: ['claude'] }), + }), + }), + }, + }); + const bootstrap = await fetch(`${server.url}/api/project/session`, { + headers: { 'sec-fetch-site': 'same-origin' }, + }); + const session = await bootstrap.json() as { readonly token: string }; + const headers = { + 'content-type': 'application/json', + origin: server.url, + 'x-agent-bundle-session': session.token, + }; + await expect.poll( + async () => fetch(`${server!.url}/api/routes/manifest`, { headers }).then((response) => response.status), + { timeout: 10_000 }, + ).toBe(200); + + const cookie = bootstrap.headers.get('set-cookie')!.split(';', 1)[0]!; + const stream = await fetch(`${server.url}/api/project/events`, { + headers: { cookie, origin: server.url }, + }); + const toolResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ input: { service: 'catalog' }, routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(toolResponse.status).toBe(200); + const tool = await toolResponse.json() as RouteInvocationResponse; + expect(tool.invocation.status).toBe('succeeded'); + expect(tool.invocation.events.at(-1)?.type).toBe('complete'); + expect(tool.invocation.document).toBeDefined(); + expect(tool.invocation.projection.mcp).toBeDefined(); + expect(tool.invocation.providers).toEqual([ + expect.objectContaining({ name: 'clock', status: 'mounted' }), + ]); + + const eventResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ + event: { host: 'claude' }, + input: { + cwd: project.root, + hook_event_name: 'PostToolUse', + session_id: 'session-1', + tool_input: {}, + tool_name: 'Write', + tool_response: { ok: true }, + tool_use_id: 'use-1', + transcript_path: join(project.root, 'transcript.json'), + }, + routeId: 'event:tool/after', + }), + headers, + method: 'POST', + }); + const eventFailure = eventResponse.status === 200 ? undefined : await eventResponse.clone().text(); + expect(eventResponse.status, eventFailure).toBe(200); + const event = await eventResponse.json() as RouteInvocationResponse; + expect(event.invocation.status).toBe('succeeded'); + expect(event.invocation.events.at(-1)?.type).toBe('complete'); + expect(event.invocation.document).toBeDefined(); + expect(event.invocation.projection.hosts?.[0]).toMatchObject({ host: 'claude' }); + + const listedResponse = await fetch(`${server.url}/api/routes/invocations?limit=2`, { headers }); + const listed = await listedResponse.json() as RouteInvocationListResponse; + expect(listed.invocations.map((invocation) => invocation.id)).toEqual([ + event.invocation.id, + tool.invocation.id, + ]); + const read = await fetch(`${server.url}/api/routes/invocations/${tool.invocation.id}`, { headers }); + await expect(read.json()).resolves.toEqual(tool); + + const published = await readEvent(stream, 'route.invocation'); + expect(published).toMatchObject({ + payload: { invocation: { routeId: 'tool:status/report', status: 'succeeded' } }, + type: 'route.invocation', + }); + + const shell = await fetch(`${server.url}/routes/mcp/status/tool/report`); + expect(shell.status).toBe(200); + expect(await shell.text()).toContain('Route invocation'); + const missingApi = await fetch(`${server.url}/api/nope`); + expect(missingApi.status).toBe(404); + await expect(missingApi.json()).resolves.toEqual({ + diagnostic: { code: 'AB8007', message: 'Route was not found.' }, + }); + } finally { + await server?.close().catch(() => undefined); + await rm(project.root, { force: true, maxRetries: 5, recursive: true, retryDelay: 50 }); + } +}); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts new file mode 100644 index 000000000..8106b176f --- /dev/null +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -0,0 +1,101 @@ +import { expect, it } from '@rstest/core'; + +import type { RouteInvocation } from '../src/dev/routes/route-invocation.ts'; +import { + InvocationRingBuffer, + RouteInvocationRequestError, + invocationSummary, + parseRouteInvocationRequest, +} from '../src/dev/routes/route-invocation-service.ts'; + +const invocation = (id: string, completedAt: string): RouteInvocation => ({ + completedAt, + context: { + actor: { reason: 'not-provided', state: 'unavailable' }, + host: { reason: 'host-omitted', state: 'unavailable' }, + invocation: { kind: 'workbench', operationId: 'tool:fixture/echo', surface: 'echo' }, + lineage: { reason: 'no-shared-runtime', state: 'unavailable' }, + session: { reason: 'not-provided', state: 'unavailable' }, + workspace: { source: 'derived', state: 'available', value: { root: '/project' } }, + }, + diagnostics: [], + document: { + root: { children: [{ kind: 'text', text: id }], kind: 'result' }, + status: 'success', + version: 1, + }, + events: [], + id, + input: {}, + kind: 'tool', + manifestDigest: 'digest', + projection: {}, + providers: [], + routeId: 'tool:fixture/echo', + source: 'src/mcp/fixture/tools/echo.tsx', + sourceRevision: 'revision', + startedAt: completedAt, + status: 'succeeded', + timings: [], +}); + +it('strictly validates invocation request fields and event options', () => { + expect(parseRouteInvocationRequest({ + correlationId: 'browser-1', + input: { query: 'Dune' }, + routeId: 'tool:curator/search_audible', + })).toEqual({ + correlationId: 'browser-1', + input: { query: 'Dune' }, + routeId: 'tool:curator/search_audible', + }); + expect(parseRouteInvocationRequest({ + event: { fixtureId: 'starter', host: 'claude' }, + routeId: 'event:tool/after', + })).toEqual({ + event: { fixtureId: 'starter', host: 'claude' }, + routeId: 'event:tool/after', + }); + + for (const value of [ + {}, + { routeId: '' }, + { routeId: 'tool:x/y', unknown: true }, + { args: ['ok', 1], routeId: 'cli:x' }, + { event: { host: 'other' }, routeId: 'event:tool/after' }, + { event: { fixtureId: '' }, routeId: 'event:tool/after' }, + ]) { + expect(() => parseRouteInvocationRequest(value)).toThrow(RouteInvocationRequestError); + } +}); + +it('projects summaries without retaining heavy invocation payloads', () => { + const summary = invocationSummary(invocation('inv_one', '2026-09-05T00:00:00.000Z')); + + expect(summary).toMatchObject({ + id: 'inv_one', + routeId: 'tool:fixture/echo', + status: 'succeeded', + }); + expect(summary).not.toHaveProperty('context'); + expect(summary).not.toHaveProperty('document'); + expect(summary).not.toHaveProperty('events'); + expect(summary).not.toHaveProperty('projection'); + expect(summary).not.toHaveProperty('providers'); + expect(summary).not.toHaveProperty('result'); +}); + +it('retains a bounded newest-first invocation history', () => { + const history = new InvocationRingBuffer(2); + history.push(invocation('inv_one', '2026-09-05T00:00:01.000Z')); + history.push(invocation('inv_two', '2026-09-05T00:00:02.000Z')); + history.push(invocation('inv_three', '2026-09-05T00:00:03.000Z')); + + expect(history.list()).toEqual([ + expect.objectContaining({ id: 'inv_three' }), + expect.objectContaining({ id: 'inv_two' }), + ]); + expect(history.list(1)).toEqual([expect.objectContaining({ id: 'inv_three' })]); + expect(history.read('inv_one')).toBeUndefined(); + expect(history.read('inv_two')?.id).toBe('inv_two'); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 506c0acb7..b35cd983e 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -71,6 +71,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/public-api.test.ts', 'packages/agent-bundle/tests/publint-gate.test.ts', 'packages/agent-bundle/tests/route-contract-imports.test.ts', + 'packages/agent-bundle/tests/route-invocation-dev-server.test.ts', 'packages/agent-bundle/tests/route-register-typegen.test.ts', 'packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts', 'packages/agent-bundle/tests/rstest-meta-consumer.test.ts', From b8ca8cda467232ea013f630c6d200ea0d8e02c5c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:55:53 +0000 Subject: [PATCH 10/43] wb600: align docs and changeset with the registered invocation diagnostic codes --- .changeset/wb600-application-explorer.md | 2 +- website/docs/en/guide/development/workbench.mdx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.changeset/wb600-application-explorer.md b/.changeset/wb600-application-explorer.md index 7c18a4298..03eea233a 100644 --- a/.changeset/wb600-application-explorer.md +++ b/.changeset/wb600-application-explorer.md @@ -2,4 +2,4 @@ 'agent-bundle': minor --- -Redesign the Workbench as an application explorer (#600 PR 1). The dev server gains one route invocation API — `POST /api/routes/invocations` renders any compiled route (MCP tool, resource, prompt, CLI route, script, or event route with a canonical or Claude/Codex/Cursor payload) through the production runtime and returns the render-event stream, final Agent Document, structured result, request context, providers, timings, and the MCP/CLI/host projections; `GET /api/routes/invocations[/]` lists and replays this session's invocations and every completion is published as a `route.invocation` project event (diagnostics `AB8231`–`AB8235`). The foreground server serves the Workbench shell for its deep-link paths (`/routes/**`, `/trace`, `/problems`, `/sessions`, `/advanced`). Breaking for `agent-bundle/test`: `inspectWorkbenchSurface()` now reports the Application tree (`application`, with `workbenchLeafPath(leaf)`) and the populated Advanced sections instead of `pages`; `WorkbenchPageName` and `workbenchPageLabel` are removed. (#PR) +Redesign the Workbench as an application explorer (#600 PR 1). The dev server gains one route invocation API — `POST /api/routes/invocations` renders any compiled route (MCP tool, resource, prompt, CLI route, script, or event route with a canonical or Claude/Codex/Cursor payload) through the production runtime and returns the render-event stream, final Agent Document, structured result, request context, providers, timings, and the MCP/CLI/host projections; `GET /api/routes/invocations[/]` lists and replays this session's invocations and every completion is published as a `route.invocation` project event (diagnostics `AB8231`, `AB8232`, `AB8236`–`AB8238`). The foreground server serves the Workbench shell for its deep-link paths (`/routes/**`, `/trace`, `/problems`, `/sessions`, `/advanced`). Breaking for `agent-bundle/test`: `inspectWorkbenchSurface()` now reports the Application tree (`application`, with `workbenchLeafPath(leaf)`) and the populated Advanced sections instead of `pages`; `WorkbenchPageName` and `workbenchPageLabel` are removed. (#PR) diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 026b5eaab..7574ceaad 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -186,8 +186,9 @@ The route workspace uses one authenticated, origin-guarded foreground API: The envelope carries canonical input, request context, providers, ordered render events, the final Agent Document, structured result, projections, diagnostics, and execution timings when -available. A represented `Agent.Error` remains a rendered result; malformed requests, unknown -routes, unavailable epochs, execution timeouts, and invalid responses use `AB8231`–`AB8235`. +available. A represented `Agent.Error` remains a rendered result; unknown routes or invocation +ids (`AB8231`), unavailable epochs (`AB8232`), render timeouts or crashes (`AB8236`), malformed +requests (`AB8237`), and unknown fixture ids (`AB8238`) are reported as diagnostics. See the [diagnostics reference](../../reference/diagnostics.md) for the individual triggers and recovery guidance. From 11e3a08eb3fb880b6ecf7df5333cbf3364a4c8e1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 07:58:01 +0000 Subject: [PATCH 11/43] wb600: delete leftover Workbench pages and cut Host diagnostics plus Artifact Overview, Routes, Hooks, Lifecycles, and Playground pages go away; Comparisons folds into the Evals Compare tab. Host diagnostics and Artifact keep only the facts the Advanced section still shows. --- LANE-NOTES.md | 185 +++ .../src/artifacts/artifacts-page.css | 2 + .../src/artifacts/artifacts-page.tsx | 196 ++- .../src/discovery/discovery-model.ts | 260 ++-- .../src/discovery/discovery-page.css | 5 + .../src/discovery/discovery-page.tsx | 431 ++----- .../comparison-client.ts | 0 .../evals-compare-model.ts} | 0 .../evals-compare.css} | 7 +- .../evals-compare.tsx} | 16 +- packages/workbench/src/evals/evals-page.css | 6 + packages/workbench/src/evals/evals-page.tsx | 56 +- packages/workbench/src/hooks/hooks-model.ts | 119 +- packages/workbench/src/hooks/hooks-page.css | 42 - packages/workbench/src/hooks/hooks-page.tsx | 335 ----- .../src/lifecycles/lifecycles-model.ts | 93 -- .../src/lifecycles/lifecycles-page.css | 51 - .../src/lifecycles/lifecycles-page.tsx | 401 ------ packages/workbench/src/overview-page.tsx | 94 -- .../src/playground/playground-client.ts | 516 -------- .../src/playground/playground-model.ts | 315 ----- .../src/playground/playground-page.css | 59 - .../src/playground/playground-page.tsx | 1104 ----------------- packages/workbench/src/routes/routes-model.ts | 1 + packages/workbench/src/routes/routes-page.css | 57 - packages/workbench/src/routes/routes-page.tsx | 380 ------ .../workbench/tests/artifacts-page.test.ts | 23 +- .../workbench/tests/comparison-client.test.ts | 2 +- .../workbench/tests/discovery-model.test.ts | 123 +- packages/workbench/tests/eval-client.test.ts | 2 +- ...vals-compare-client-scope-browser.test.ts} | 6 +- ...el.test.ts => evals-compare-model.test.ts} | 2 +- ...ons-page.test.ts => evals-compare.test.ts} | 10 +- packages/workbench/tests/evals-page.test.ts | 10 + .../lifecycles-page-browser-fixture.tsx | 228 ---- packages/workbench/tests/hooks-model.test.ts | 173 +-- packages/workbench/tests/hooks-page.test.ts | 244 ---- .../workbench/tests/lifecycles-model.test.ts | 97 +- .../tests/lifecycles-page.browser.test.tsx | 198 --- .../workbench/tests/lifecycles-page.test.ts | 155 --- .../workbench/tests/overview-page.test.ts | 98 -- .../workbench/tests/playground-client.test.ts | 451 ------- .../workbench/tests/playground-model.test.ts | 153 --- .../workbench/tests/playground-page.test.ts | 537 -------- .../workbench/tests/project-client.test.ts | 28 +- .../tests/route-editor-atoms-disposal.test.ts | 119 +- packages/workbench/tests/routes-model.test.ts | 15 - packages/workbench/tests/routes-page.test.ts | 372 ------ rstest.integration-tests.ts | 3 +- 49 files changed, 770 insertions(+), 7010 deletions(-) create mode 100644 LANE-NOTES.md rename packages/workbench/src/{comparisons => evals}/comparison-client.ts (100%) rename packages/workbench/src/{comparisons/comparisons-model.ts => evals/evals-compare-model.ts} (100%) rename packages/workbench/src/{comparisons/comparisons-page.css => evals/evals-compare.css} (90%) rename packages/workbench/src/{comparisons/comparisons-page.tsx => evals/evals-compare.tsx} (96%) delete mode 100644 packages/workbench/src/hooks/hooks-page.css delete mode 100644 packages/workbench/src/hooks/hooks-page.tsx delete mode 100644 packages/workbench/src/lifecycles/lifecycles-page.css delete mode 100644 packages/workbench/src/lifecycles/lifecycles-page.tsx delete mode 100644 packages/workbench/src/overview-page.tsx delete mode 100644 packages/workbench/src/playground/playground-client.ts delete mode 100644 packages/workbench/src/playground/playground-model.ts delete mode 100644 packages/workbench/src/playground/playground-page.css delete mode 100644 packages/workbench/src/playground/playground-page.tsx delete mode 100644 packages/workbench/src/routes/routes-page.css delete mode 100644 packages/workbench/src/routes/routes-page.tsx rename packages/workbench/tests/{comparisons-page-client-scope-browser.test.ts => evals-compare-client-scope-browser.test.ts} (97%) rename packages/workbench/tests/{comparisons-model.test.ts => evals-compare-model.test.ts} (99%) rename packages/workbench/tests/{comparisons-page.test.ts => evals-compare.test.ts} (96%) delete mode 100644 packages/workbench/tests/fixtures/lifecycles-page-browser-fixture.tsx delete mode 100644 packages/workbench/tests/hooks-page.test.ts delete mode 100644 packages/workbench/tests/lifecycles-page.browser.test.tsx delete mode 100644 packages/workbench/tests/lifecycles-page.test.ts delete mode 100644 packages/workbench/tests/overview-page.test.ts delete mode 100644 packages/workbench/tests/playground-client.test.ts delete mode 100644 packages/workbench/tests/playground-model.test.ts delete mode 100644 packages/workbench/tests/playground-page.test.ts delete mode 100644 packages/workbench/tests/routes-page.test.ts diff --git a/LANE-NOTES.md b/LANE-NOTES.md new file mode 100644 index 000000000..b39de6644 --- /dev/null +++ b/LANE-NOTES.md @@ -0,0 +1,185 @@ +# L6 — deletions, test cleanup, Advanced-section cuts + +Lane: `lane/wb600-pr1-deletions-cuts` +Worktree: `/fast/projects/agent-bundle-wt/wb600-pr1-deletions-cuts` +Base: `7a0364205` (merge-base with `wb600-pr1-shell`; this branch is an ancestor of the shell docs merge) + +No changeset. Workbench is private. No `packages/agent-bundle` source edits. + +## Gate + +- `pnpm build` — pass (after STUBS commit; L5's `main.tsx` still imports deleted pages) +- `npx tsc --project packages/workbench/tsconfig.json --noEmit` — pass with stubs; **without** the STUBS commit, errors are only in `packages/workbench/src/main.tsx` (L5 rewrites that file): + - `TS2307` cannot find module `./comparisons/comparison-client.ts` + - `TS2307` cannot find module `./comparisons/comparisons-page.tsx` + - `TS2307` cannot find module `./hooks/hooks-page.tsx` + - `TS2307` cannot find module `./lifecycles/lifecycles-page.tsx` + - `TS2307` cannot find module `./playground/playground-client.ts` + - `TS2307` cannot find module `./playground/playground-page.tsx` + - `TS2307` cannot find module `./routes/routes-page.tsx` + - `TS2307` cannot find module `./overview-page.tsx` + - `TS7006` Parameter `catalog` implicitly has an `any` type (`main.tsx` ~604, `playgroundClient.catalog(...).then((catalog) => ...)`) +- `pnpm lint` — pass +- Targeted unit tests — 13 files / 180 tests pass: + `artifacts-page`, `artifacts-model`, `discovery-model`, `discovery-client`, `evals-compare`, `evals-compare-model`, `evals-page`, `hooks-model`, `lifecycles-model`, `routes-model`, `eval-client`, `comparison-client`, `project-client` (+ `route-editor-atoms-disposal` left in the integration list) + +## Diff summary + +Lane work (this commit, no stubs) vs `HEAD` / the merge-base with `wb600-pr1-shell`: + +``` +48 files changed, 585 insertions(+), 7010 deletions(-) +``` + +`git diff --shortstat wb600-pr1-shell...HEAD` after both commits is that plus `LANE-NOTES.md` plus the STUBS commit (re-adds the eight mount files L5 will drop). + +Without stubs, `npx tsc --project packages/workbench/tsconfig.json --noEmit` errors are exactly: + +``` +packages/workbench/src/main.tsx(14,34): error TS2307: Cannot find module './comparisons/comparison-client.ts' +packages/workbench/src/main.tsx(15,33): error TS2307: Cannot find module './comparisons/comparisons-page.tsx' +packages/workbench/src/main.tsx(22,27): error TS2307: Cannot find module './hooks/hooks-page.tsx' +packages/workbench/src/main.tsx(24,32): error TS2307: Cannot find module './lifecycles/lifecycles-page.tsx' +packages/workbench/src/main.tsx(51,34): error TS2307: Cannot find module './playground/playground-client.ts' +packages/workbench/src/main.tsx(56,8): error TS2307: Cannot find module './playground/playground-page.tsx' +packages/workbench/src/main.tsx(64,28): error TS2307: Cannot find module './routes/routes-page.tsx' +packages/workbench/src/main.tsx(67,64): error TS2307: Cannot find module './overview-page.tsx' +packages/workbench/src/main.tsx(604,73): error TS7006: Parameter 'catalog' implicitly has an 'any' type. +``` + +## Deletion ledger + +LOC is `wc -l` of the file at `HEAD` before this lane (the last committed original). + +| file | LOC deleted | capability | where it now lives | +|---|---:|---|---| +| `src/overview-page.tsx` | 94 | Overview cards, `StateMark`, host-adoption strip, bundle-workflow dashboard | intentionally not preserved: build status + failures move into the shell header (L5). `overview-model.ts` kept (L5) | +| `tests/overview-page.test.ts` | 98 | Overview page unit tests | deleted with the page | +| `src/routes/routes-page.tsx` | 380 | Route catalog UI, input editor mount, MCP-tool prefill from the page | catalog + editor helpers stay in `routes/routes-model.ts`; L3 extracts `RouteInputEditor`. Page CSS/UI deleted | +| `src/routes/routes-page.css` | 57 | Routes page layout | deleted with the page | +| `tests/routes-page.test.ts` | 372 | Routes page unit tests | deleted; `route-editor-atoms-disposal.test.ts` now mounts a local draft editor against `routeEditorStateAtom` | +| `src/hooks/hooks-page.tsx` | 335 | Hook-simulate playground page | `canonicalHookInput` / `canonicalHookInputFor` moved into `hooks/hooks-model.ts` (same change). L3 event workspace owns simulate | +| `src/hooks/hooks-page.css` | 42 | Hooks page layout | deleted with the page | +| `tests/hooks-page.test.ts` | 244 | Hooks page unit tests | deleted with the page | +| `src/lifecycles/lifecycles-page.tsx` | 401 | Observed-receipt replay page | replay/lineage helpers stay in `lifecycles/lifecycles-model.ts`. L3 Replay tab / Trace owns the surface | +| `src/lifecycles/lifecycles-page.css` | 51 | Lifecycles page layout | deleted with the page | +| `tests/lifecycles-page.test.ts` | 155 | Lifecycles page unit tests | deleted with the page | +| `tests/lifecycles-page.browser.test.tsx` | 198 | Browser pool for the lifecycles page | deleted; removed from `rstest.integration-tests.ts` | +| `tests/fixtures/lifecycles-page-browser-fixture.tsx` | 228 | Fixture entry for that browser test | deleted with the test | +| `src/playground/playground-page.tsx` | 1104 | Script/hook/skill/MCP/native playground destination | intentionally not preserved as a page: script.run → Script leaf, hook.simulate → Event leaf, skill.inspect → Skill leaf, mcp.call-tool → Tool leaf (L3); native.prompt → Sessions (PR 3). Engine `src/runtime-playground.tsx` kept (L4) | +| `src/playground/playground-page.css` | 59 | Playground page layout | deleted with the page | +| `src/playground/playground-client.ts` | 516 | Browser client for `/api/playground/*` | server routes **kept** (see follow-up). Client deleted; STUBS commit re-adds a minimal class so `main.tsx` typechecks | +| `src/playground/playground-model.ts` | 315 | Playground presentation model | intentionally not preserved: page-only view state | +| `tests/playground-page.test.ts` | 537 | Playground page unit tests | deleted with the page | +| `tests/playground-client.test.ts` | 451 | Playground client unit tests | deleted with the client | +| `tests/playground-model.test.ts` | 153 | Playground model unit tests | deleted with the model | +| `src/comparisons/comparisons-page.tsx` | 284 | Baseline vs candidate eval comparison page | `src/evals/evals-compare.tsx` (`EvalsCompare`). `EvalsPage` has Runs · Compare tabs | +| `src/comparisons/comparisons-page.css` | 42 | Comparison page layout | `src/evals/evals-compare.css` | +| `src/comparisons/comparisons-model.ts` | 226 | Comparison presentation model | `src/evals/evals-compare-model.ts` | +| `src/comparisons/comparison-client.ts` | 197 | `/api/evals/compare` client | `src/evals/comparison-client.ts` | +| `tests/comparisons-page.test.ts` | 241 | Comparison page unit tests | `tests/evals-compare.test.ts` | +| `tests/comparisons-model.test.ts` | 285 | Comparison model unit tests | `tests/evals-compare-model.test.ts` | +| `tests/comparisons-page-client-scope-browser.test.ts` | 184 | Client-scope browser test | `tests/evals-compare-client-scope-browser.test.ts` (still listed in `rstest.integration-tests.ts`) | + +No `rstest.browser*.config.ts` in this repo (only `examples/mcp-app/rstest.browser-app.config.ts`). + +## Cuts (rewritten in place) + +| file | old → new LOC | what was cut | what remains | +|---|---|---|---| +| `src/discovery/discovery-page.tsx` | 579 → 310 | Finding tables, bundle/store/probe dumps, redacted launch dumps, tools/list catalog | Heading **Host diagnostics**. Per host (Claude Code, Codex, Cursor): installed · version · executable path · current-plugin attach (epoch/proxy) · actionable errors with existing Re-run buttons · one MCP handshake indicator | +| `src/discovery/discovery-model.ts` | 263 → 171 | Presentation builders that only fed those dumps | `hostDiagnosticsViewFor` (replaces `hostDiscoveryViewFor`). Client decoders in `discovery-client.ts` **not** trimmed — server still sends the full report | +| `src/artifacts/artifacts-page.tsx` | 288 → 274 | Runtime hook / MCP server tables | Heading **Artifact**. Default tree is path + size; per-file Details toggle shows hash / mode / provenance. Target selector + epoch compare kept (bytes only) | +| `src/artifacts/artifacts-model.ts` | unchanged API | Tables no longer rendered | `artifactRuntimeViewFor` still exported for the tree lane / `ArtifactInspection` | +| `src/evals/evals-page.tsx` | +Runs/Compare tabs | — | Optional `comparisonClient`. Compare without a client: “Comparison client is not available in this session.” | + +## Kept modules — production importer check + +`git grep -l '' -- ':!repos'` after the deletions: + +| module | production importer | note | +|---|---|---| +| `hooks/hook-client.ts` | `main.tsx`, `application/workspace-contracts.ts` | keep | +| `hooks/hooks-model.ts` | **none** (tests only) | keep for **L3** event workspace (`hookOptionsFor`, `canonicalHookInput*`, row helpers) | +| `lifecycles/lifecycle-client.ts` | `main.tsx`, `application/workspace-contracts.ts` | keep | +| `lifecycles/lifecycles-model.ts` | **none** (tests only) | keep for **L3** Replay tab (`lifecycleOptionsFor`, lineage/replay-source helpers) | +| `routes/routes-model.ts` | `main.tsx`, `mcp/mcp-page.tsx`, `application/application-tree-model.ts`, `workbench-capabilities.ts`, `routes/route-editor-atoms.ts` | catalog + `createRouteInputDraft` / `validateRouteInput` / schema helpers kept. Navigation prefill (`mcpToolPrefillFromNavigationState`, `mcpToolPrefillNavigationState`) kept because `main.tsx` still imports them — **L5** should drop both with hash routing | +| `routes/route-manifest-client.ts` | `main.tsx`, `workbench-capabilities.ts` | keep | +| `routes/route-editor-atoms.ts` | **none** (tests + integration fixture only) | keep for **L3** `RouteInputEditor` | +| `discovery-client.ts` / `discovery-model.ts` / `discovery-page.tsx` | `main.tsx` + page | keep; L5 remounts the same `DiscoveryPage` as Host diagnostics | +| `artifacts-page.tsx` / `artifacts-model.ts` / `artifact-client.ts` | `main.tsx` + page | keep; L5 remounts as Artifact | +| `evals/comparison-client.ts` / `evals-compare.tsx` | `evals-page.tsx` | keep. `main.tsx` still imports the old `comparisons/` path until L5 | + +Symbols git-grepped before removal from models: page-only view-state (`hookPlaygroundViewFor`, `lifecyclesViewFor`, playground view helpers) had no remaining importer. + +## Playground server routes — follow-up, not deleted + +`packages/agent-bundle/src/dev/playground/playground-routes.ts` still serves (evidence: `route()` at ~122–137 and tests): + +- `GET /api/playground` +- `GET /api/playground/catalog` +- `POST /api/playground/runs` +- `POST /api/playground/runs/:id/cancel` +- `GET /api/playground/sessions/:id` +- `GET …/export` · `GET …/replay` · `GET …/stream` · `POST …/draft-eval` + +Still used by agent-bundle tests (`playground-routes.test.ts`, `playground-orchestration-service.test.ts`, `dev-workbench.test.ts`, `packed-consumer.test.ts`, `hook-playground-service.test.ts`, `script-playground-service.test.ts`), `foreground-server.ts`, `playground-orchestration-service.ts`, `contracts/playground.ts`, plus workbench e2e (`playground-real.e2e.test.ts`, `packed-release.e2e.test.ts`, `tests/support/packed-outage-ledger.ts`). **Leave them.** `/api/hooks` and `/api/lifecycles` stay for `hook-client` / `lifecycle-client`. + +## Do-not-edit references (L9 / L10 own these) + +`packages/workbench/tests/support/**` has no `#hooks` / `#routes` page-name hits. Leave these files alone: + +- `tests/workbench-screen.test.ts` — `#hooks`, `#routes`, `#lifecycles` hash routing +- `tests/examples-real.e2e.test.ts` — `#overview`, `#playground-*`, `.routes-page-heading`, `workbenchPageLabel` +- `tests/playground-real.e2e.test.ts` — `#playground`, `#playground-*` controls +- `tests/overview.e2e.test.ts` — `#overview`, “Runtime Playground” +- `tests/packed-release.e2e.test.ts` — `#playground-*`, `#comparisons` +- `tests/mcp-app-real.e2e.test.ts` — `#overview`, “Runtime Playground” +- `tests/runtime-playground*.e2e.test.ts` / `tests/runtime-playground.test.ts` — “Runtime Playground” (engine kept) +- `tests/support/packed-outage-ledger.ts`, `tests/support/example-acceptance.ts` — `/api/playground/*` +- `packages/agent-bundle/tests/workbench-surface.test.ts` — `workbenchPageLabel` +- `packages/agent-bundle/tests/prepack.test.ts` — `#hooks/*` is an **import map**, not a Workbench hash + +## Cross-lane requests + +**L5 (`main.tsx`, `workbench-screen.tsx`, `workbench-capabilities.ts`)** + +- Drop imports of deleted pages (`overview-page`, `routes-page`, `hooks-page`, `lifecycles-page`, `playground-*`, `comparisons/*`). +- Drop the STUBS commit on integration (`STUBS (drop on integration)`). +- Mount `EvalsPage` with `comparisonClient` so Compare is live; import `ComparisonClient` from `evals/comparison-client.ts`. +- Host diagnostics = existing `DiscoveryPage`. Artifact = existing `ArtifactsPage`. +- Drop `mcpToolPrefillFromNavigationState` / `mcpToolPrefillNavigationState` with hash routing. +- `EvalsScreen` today does `` without `comparisonClient` — wire it when ComparisonsScreen goes away. + +**L3 (event / route workspace)** + +- Import `hooks-model` + `hook-client`, `lifecycles-model` + `lifecycle-client`. +- Import `routes-model` editor helpers + `route-editor-atoms.ts` (no production importer after the Routes page deletion). +- `canonicalHookInput` / `canonicalHookInputFor` live in `hooks-model.ts` now, not `hooks-page.tsx`. + +**L4** + +- `src/runtime-playground.tsx` and `tests/runtime-playground*.test.ts` were not touched. Keep the engine; delete only the destination. + +**L9 / L10** + +- Rewrite e2e / `workbenchPageLabel` / hash URLs listed above. Do not edit them in this lane. + +## STUBS commit (drop on integration) + +Second commit titled `STUBS (drop on integration)` re-adds empty mounts so rsbuild/`main.tsx` still compile until L5 lands: + +- `src/overview-page.tsx` — `StateMark`, `HostAdoptionSection`, `BundleWorkflow` +- `src/routes/routes-page.tsx` — `RoutesPage` +- `src/hooks/hooks-page.tsx` — `HooksPage` +- `src/lifecycles/lifecycles-page.tsx` — `LifecyclesPage` +- `src/playground/playground-client.ts` — `PlaygroundClient({ foreground }).catalog(epochId, signal)` +- `src/playground/playground-page.tsx` — `PlaygroundPage`, `createPlaygroundCatalogLifecycle`, `playgroundScriptsForEpoch` +- `src/comparisons/comparison-client.ts` — re-export from `evals/comparison-client.ts` +- `src/comparisons/comparisons-page.tsx` — `ComparisonsPage = EvalsCompare` + +These are not the delivered pages. + +## Proposed changeset line (integrator) + +None. Workbench is private; this lane did not change a publishable package. diff --git a/packages/workbench/src/artifacts/artifacts-page.css b/packages/workbench/src/artifacts/artifacts-page.css index a5cf03383..a3d633b87 100644 --- a/packages/workbench/src/artifacts/artifacts-page.css +++ b/packages/workbench/src/artifacts/artifacts-page.css @@ -26,6 +26,8 @@ .artifact-table th, .artifact-table td { border-bottom: 1px solid #e4e8ef; padding: 8px 14px 8px 12px; text-align: left; vertical-align: top; white-space: nowrap; } .artifact-table thead th { color: #596372; font-size: 12px; font-weight: 750; letter-spacing: .03em; text-transform: uppercase; } .artifact-table tbody th { font-weight: 600; } +.artifact-table button { background: #fff; border: 1px solid #8794a6; border-radius: 4px; color: #23466f; cursor: pointer; font-size: 12px; font-weight: 750; min-height: 28px; padding: 0 10px; } +.artifact-file-details { background: #f7f9fc; } .artifact-digest { color: #5c6676; font: 12px/1.5 "SFMono-Regular", Consolas, "Liberation Mono", monospace; overflow-wrap: anywhere; } .artifact-source-inputs { list-style: none; margin: 0; padding: 0; } .artifact-source-inputs li { margin: 0 0 4px; } diff --git a/packages/workbench/src/artifacts/artifacts-page.tsx b/packages/workbench/src/artifacts/artifacts-page.tsx index 9739fa4ae..4efd83719 100644 --- a/packages/workbench/src/artifacts/artifacts-page.tsx +++ b/packages/workbench/src/artifacts/artifacts-page.tsx @@ -7,7 +7,6 @@ import type { ArtifactEpochDiff, ArtifactInspection } from '../../../agent-bundl import { ArtifactClient, ArtifactClientError } from './artifact-client.ts'; import { artifactViewFor, - type ArtifactDetailRow, type ArtifactTreeRow, type ArtifactView, } from './artifacts-model.ts'; @@ -65,28 +64,94 @@ export const compareArtifactEpochs = async ( return client.diff(base, epochId); }; -const DetailRows = ({ label, rows }: { - readonly label: string; - readonly rows: readonly ArtifactDetailRow[]; -}) =>
    -

    {label}

    -
    - {rows.map((detail) =>
    {detail.label}
    {detail.value}
    )} -
    -
    ; +const provenanceFor = (view: ArtifactView, path: string): readonly string[] => + view.provenance.find((entry) => entry.outputPath === path)?.sourceInputs.map((input) => input.path) ?? []; + +const TreeRow = ({ + detailsOpen, + onToggle, + provenance, + row, +}: { + readonly detailsOpen: boolean; + readonly onToggle: () => void; + readonly provenance: readonly string[]; + readonly row: ArtifactTreeRow; +}) => { + if (row.entry === 'directory') { + return + + {row.name} + + {row.path} + — + + ; + } + return <> + + + {row.name} + + {row.path} + {row.bytes === undefined ? '—' : `${row.bytes}`} + + + + + {detailsOpen + ? + +
    +
    SHA-256
    {row.sha256 ?? '—'}
    +
    Mode
    {row.mode ?? '—'}
    +
    +
    Provenance
    +
    {provenance.length === 0 ? '—' : provenance.join(', ')}
    +
    +
    + + + : undefined} + ; +}; -const TreeRow = ({ row }: { readonly row: ArtifactTreeRow }) => - - {row.name} - - {row.path} - {row.kind ?? 'directory'} - {row.bytes === undefined ? '—' : `${row.bytes}`} - {row.mode ?? '—'} - {row.sha256 ?? '—'} -; +const ArtifactTree = ({ view }: { readonly view: ArtifactView }) => { + const [openPaths, setOpenPaths] = useState>(new Set()); + const toggle = (path: string): void => { + setOpenPaths((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + return
    +

    Emitted files

    + {view.tree.length === 0 + ?

    This target emitted no files.

    + : + + + + {view.tree.map((row) => toggle(row.path)} + provenance={provenanceFor(view, row.path)} + row={row} + />)} +
    NamePathSizeDetails
    } +
    ; +}; -/** Epoch identity, the emitted file tree, runtime metadata, and declared provenance of one epoch. */ +/** Epoch summary, emitted file tree, and optional per-file details. */ export const ArtifactInspectionView = ({ view }: ArtifactInspectionViewProps) =>

    {view.summary}

    {view.diagnostics.length === 0 ? undefined :
    @@ -97,84 +162,7 @@ export const ArtifactInspectionView = ({ view }: ArtifactInspectionViewProps) => {diagnostic.recovery === undefined ? undefined : {diagnostic.recovery}}

    )}
    } - {view.state !== 'ready' ? undefined : <> - -
    -

    Artifact tree

    - {view.tree.length === 0 - ?

    This target emitted no files.

    - : - - - - {view.tree.map((row) => )} -
    NamePathKindBytesModeSHA-256
    } -
    -
    -

    Runtime

    -

    Hooks

    - {view.hooks.length === 0 - ?

    This build contains no Hooks.

    - : - - - - {view.hooks.map((hook) => - - - - - - )} -
    HookWrapper pathTimeoutBytesSHA-256
    {hook.label}{hook.path}{hook.timeout === undefined ? '—' : `${hook.timeout}s`}{hook.bytes}{hook.sha256}
    } -

    MCP servers

    - {view.mcpServers.length === 0 - ?

    This build contains no MCP servers.

    - : - - - - {view.mcpServers.map((server) => - - - - )} -
    ServerManifest pathEntry paths
    {server.label}{server.manifestPath}{server.entryPaths.length === 0 ? '—' : server.entryPaths.join(', ')}
    } -

    Executables

    - {view.executables.length === 0 - ?

    This build contains no executable files.

    - : - - - - {view.executables.map((executable) => - - - - - - )} -
    PathKindModeBytesSHA-256
    {executable.path}{executable.kind}{executable.mode ?? '—'}{executable.bytes}{executable.sha256}
    } -
    -
    -

    Provenance

    - {view.provenance.length === 0 - ?

    This build contains no direct source provenance.

    - : - - {view.provenance.map((entry) => - - - )} -
    Output pathSource inputs
    {entry.outputPath} - {entry.sourceInputs.length === 0 ? '—' :
      - {entry.sourceInputs.map((input) =>
    • - {input.path} {input.sha256} -
    • )} -
    } -
    } -
    - } + {view.state !== 'ready' ? undefined : }
    ; /** The counted added, removed, changed, and unchanged files between a base epoch and the active one. */ @@ -190,21 +178,19 @@ export const ArtifactEpochDiffView = ({ view }: ArtifactEpochDiffViewProps) => < ?

    No files were {group.change}.

    : - + {group.rows.map((row) => - - )}
    PathBase bytesBase SHA-256Candidate bytesCandidate SHA-256
    PathBase bytesCandidate bytes
    {row.path} {row.beforeBytes === undefined ? '—' : `${row.beforeBytes}`}{row.beforeSha256 ?? '—'} {row.afterBytes === undefined ? '—' : `${row.afterBytes}`}{row.afterSha256 ?? '—'}
    } )} }
    ; -/** Inspects one immutable published epoch and compares it against an authored base epoch. */ +/** Inspects one immutable published epoch as an emitted-file tree. */ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => { const [baseDraft, setBaseDraft] = useState(''); const [busy, setBusy] = useState(false); @@ -249,8 +235,8 @@ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => { return
    -

    Artifacts

    -

    Inspect generated files, runtime metadata, provenance, and changes between published builds.

    +

    Artifact

    +

    Emitted files for the published build. Hash, mode, and provenance sit behind each file's details toggle.

    {error === undefined ? undefined :

    {error}

    } diff --git a/packages/workbench/src/discovery/discovery-model.ts b/packages/workbench/src/discovery/discovery-model.ts index 1aa8a96e8..0e4909199 100644 --- a/packages/workbench/src/discovery/discovery-model.ts +++ b/packages/workbench/src/discovery/discovery-model.ts @@ -1,71 +1,45 @@ import type { - DiscoveryBundleFinding, DiscoveryDiagnostic, - DiscoveryEndpointReport, - DiscoveryFinding, - DiscoveryFindingState, DiscoveryHost, DiscoveryHostReport, - DiscoveryInventoryStatus, DiscoveryProbe, HostDiscoveryReport, - McpProbeReport, McpProbeStatus, } from './discovery-client.ts'; export type DiscoveryPresentationTone = 'error' | 'info' | 'neutral' | 'positive' | 'warning'; +export type PluginAttachState = 'attached' | 'detached' | 'stale' | 'unknown'; + export interface DiscoveryPresentation { readonly label: string; readonly tone: DiscoveryPresentationTone; } -export interface DiscoveryFindingView { - readonly finding: DiscoveryFinding; - readonly presentation: DiscoveryPresentation; -} - -export interface DiscoveryInventoryView { - readonly findings: readonly DiscoveryFindingView[]; - readonly presentation: DiscoveryPresentation; - readonly status: DiscoveryInventoryStatus; -} - -export interface DiscoveryBundleView { - readonly finding: DiscoveryBundleFinding | undefined; - readonly presentation: DiscoveryPresentation; +export interface PluginAttachView { + readonly epochId?: string; + readonly label: string; + readonly state: PluginAttachState; } -export interface DiscoveryHostView { - readonly bundle: DiscoveryBundleView; - readonly diagnostics: readonly DiscoveryDiagnostic[]; +export interface HostDiagnosticsCard { + readonly attach: PluginAttachView; + readonly errors: readonly DiscoveryDiagnostic[]; + readonly executablePath?: string; + readonly handshakeServer?: string; readonly host: DiscoveryHost; - readonly inventory: DiscoveryInventoryView; + readonly installed: boolean; readonly label: string; readonly probe: DiscoveryProbe; readonly probePresentation: DiscoveryPresentation; + readonly version?: string; } -export interface DiscoveryEndpointView { - readonly report: DiscoveryEndpointReport; - readonly findings: readonly DiscoveryFindingView[]; - readonly presentation: DiscoveryPresentation; -} - -export interface HostDiscoveryView { - readonly build: DiscoveryPresentation; - readonly diagnostics: readonly DiscoveryDiagnostic[]; - readonly endpoints: DiscoveryEndpointView; - readonly hosts: readonly DiscoveryHostView[]; +export interface HostDiagnosticsView { + readonly hosts: readonly HostDiagnosticsCard[]; readonly report: HostDiscoveryReport; } -export interface McpProbeView { - readonly capabilityNames: readonly string[]; - readonly presentation: DiscoveryPresentation; - readonly report: McpProbeReport; -} - const presentation = ( label: string, tone: DiscoveryPresentationTone, @@ -74,9 +48,9 @@ const presentation = ( export const probePresentationFor = (probe: DiscoveryProbe): DiscoveryPresentation => { switch (probe.status) { case 'available': - return presentation('Available', 'neutral'); + return presentation('Installed', 'positive'); case 'failed': - return presentation('Probe failed', 'neutral'); + return presentation('Probe failed', 'warning'); case 'unavailable': return presentation('Not installed', 'neutral'); default: { @@ -89,11 +63,11 @@ export const probePresentationFor = (probe: DiscoveryProbe): DiscoveryPresentati export const mcpProbePresentationFor = (status: McpProbeStatus): DiscoveryPresentation => { switch (status) { case 'ok': - return presentation('Connected', 'positive'); + return presentation('Handshake ok', 'positive'); case 'timed-out': - return presentation('Timed out', 'neutral'); + return presentation('Handshake timed out', 'neutral'); case 'unreachable': - return presentation('Unreachable', 'neutral'); + return presentation('Handshake unreachable', 'neutral'); default: { const exhaustive: never = status; return exhaustive; @@ -101,64 +75,31 @@ export const mcpProbePresentationFor = (status: McpProbeStatus): DiscoveryPresen } }; -export const mcpProbeViewFor = (report: McpProbeReport): McpProbeView => Object.freeze({ - capabilityNames: Object.freeze( - report.status === 'ok' ? Object.keys(report.snapshot?.capabilities ?? {}) : [], - ), - presentation: mcpProbePresentationFor(report.status), - report, -}); - -export const inventoryPresentationFor = ( - host: DiscoveryHost, - status: DiscoveryInventoryStatus, -): DiscoveryPresentation => { - switch (status) { - case 'known': - return presentation('Known inventory', 'neutral'); - case 'skipped': - return presentation('Inventory scan skipped', 'neutral'); - case 'unknown': - return presentation(`Unknown — ${host} owns its registry`, 'neutral'); +export const hostLabelFor = (host: DiscoveryHost): string => { + switch (host) { + case 'claude': + return 'Claude Code'; + case 'codex': + return 'Codex'; + case 'cursor': + return 'Cursor'; default: { - const exhaustive: never = status; + const exhaustive: never = host; return exhaustive; } } }; -export const findingPresentationFor = (state: DiscoveryFindingState): DiscoveryPresentation => { +const attachPresentationFor = (state: PluginAttachState): string => { switch (state) { - case 'conflicted': - return presentation('Conflicted', 'warning'); - case 'corrupt': - return presentation('Corrupt', 'warning'); - case 'disabled': - return presentation('Disabled', 'warning'); - case 'drifted': - return presentation('Drifted', 'warning'); - case 'failed': - return presentation('Failed', 'error'); - case 'installed': - return presentation('Installed', 'neutral'); - case 'interrupted-install': - return presentation('Interrupted install', 'warning'); - case 'live': - return presentation('Live', 'neutral'); - case 'missing': - return presentation('Missing', 'neutral'); - case 'registered': - return presentation('Registered', 'neutral'); - case 'skipped': - return presentation('Skipped', 'neutral'); - case 'stale-lock': - return presentation('Stale lock', 'warning'); - case 'stale-socket': - return presentation('Stale socket', 'warning'); + case 'attached': + return 'Current dev plugin attached'; + case 'detached': + return 'Current dev plugin not attached'; + case 'stale': + return 'Installed plugin is stale versus this build'; case 'unknown': - return presentation('Unknown', 'neutral'); - case 'unregistered': - return presentation('Unregistered', 'neutral'); + return 'Plugin attach state is unknown'; default: { const exhaustive: never = state; return exhaustive; @@ -166,92 +107,59 @@ export const findingPresentationFor = (state: DiscoveryFindingState): DiscoveryP } }; -const endpointPresentationFor = (report: DiscoveryEndpointReport): DiscoveryPresentation => { - switch (report.status) { - case 'failed': - return presentation('Endpoint scan failed', 'error'); - case 'healthy': - return presentation('Healthy', 'neutral'); - case 'skipped': - return presentation(`Endpoint scan skipped — ${report.directory}`, 'neutral'); - case 'warnings': - return presentation('Warnings', 'warning'); - default: { - const exhaustive: never = report.status; - return exhaustive; - } - } -}; - -export const hostLabelFor = (host: DiscoveryHost): string => { - switch (host) { - case 'claude': - return 'Claude'; - case 'codex': - return 'Codex'; - case 'cursor': - return 'Cursor'; - default: { - const exhaustive: never = host; - return exhaustive; - } +const pluginAttachFor = (host: DiscoveryHostReport): PluginAttachView => { + const bundle = host.bundle; + if (bundle === undefined) { + return Object.freeze({ + label: attachPresentationFor(host.probe.status === 'unavailable' ? 'detached' : 'unknown'), + state: host.probe.status === 'unavailable' ? 'detached' : 'unknown', + }); } -}; - -const findingViewFor = (finding: DiscoveryFinding): DiscoveryFindingView => Object.freeze({ - finding, - presentation: findingPresentationFor(finding.state), -}); - -const hostViewFor = ( - report: DiscoveryHostReport, - bundleSource: string | undefined, -): DiscoveryHostView => { - const inventoryPresentation = inventoryPresentationFor(report.host, report.inventory.status); - const bundlePresentation = bundleSource === undefined - ? presentation('No built bundle is available for drift checks', 'info') - : report.bundle === undefined - ? presentation('No installed bundle reported', 'neutral') - : findingPresentationFor(report.bundle.state); + const epochId = bundle.version; + const state: PluginAttachState = bundle.state === 'drifted' || bundle.state === 'conflicted' + ? 'stale' + : bundle.state === 'installed' || bundle.state === 'registered' || bundle.state === 'live' + ? 'attached' + : bundle.state === 'missing' || bundle.state === 'unregistered' || bundle.state === 'disabled' + ? 'detached' + : 'unknown'; return Object.freeze({ - bundle: Object.freeze({ - finding: report.bundle, - presentation: bundlePresentation, - }), - diagnostics: report.diagnostics, - host: report.host, - inventory: Object.freeze({ - findings: Object.freeze(report.inventory.findings.map(findingViewFor)), - presentation: inventoryPresentation, - status: report.inventory.status, - }), - label: hostLabelFor(report.host), - probe: report.probe, - probePresentation: probePresentationFor(report.probe), + ...(epochId === undefined ? {} : { epochId }), + label: attachPresentationFor(state), + state, }); }; -const allDiagnosticsFor = (report: HostDiscoveryReport): readonly DiscoveryDiagnostic[] => Object.freeze([ - ...report.diagnostics, - ...report.hosts.flatMap((host) => [ - ...host.diagnostics, - ...(host.bundle?.durableState?.diagnostics ?? []), - ]), - ...report.endpoints.diagnostics, -]); +const executablePathFor = (host: DiscoveryHostReport): string | undefined => + host.inventory.findings.find((finding) => finding.path !== undefined)?.path + ?? host.bundle?.bundleRoot + ?? host.bundle?.path; + +const versionFor = (host: DiscoveryHostReport): string | undefined => + host.probe.version ?? host.bundle?.version ?? host.inventory.findings.find((finding) => finding.version !== undefined)?.version; + +const actionableErrorsFor = (host: DiscoveryHostReport): readonly DiscoveryDiagnostic[] => + Object.freeze(host.diagnostics.filter((diagnostic) => diagnostic.severity === 'error' || diagnostic.severity === 'warning')); + +const handshakeServerFor = (host: DiscoveryHostReport): string | undefined => + host.bundle?.mcpServers?.[0]?.name; + +const hostCardFor = (host: DiscoveryHostReport): HostDiagnosticsCard => Object.freeze({ + attach: pluginAttachFor(host), + errors: actionableErrorsFor(host), + ...(executablePathFor(host) === undefined ? {} : { executablePath: executablePathFor(host) }), + ...(handshakeServerFor(host) === undefined ? {} : { handshakeServer: handshakeServerFor(host) }), + host: host.host, + installed: host.probe.status === 'available', + label: hostLabelFor(host.host), + probe: host.probe, + probePresentation: probePresentationFor(host.probe), + ...(versionFor(host) === undefined ? {} : { version: versionFor(host) }), +}); -/** Pure read-model projection for host, bundle, endpoint, and diagnostic sections. */ -export const hostDiscoveryViewFor = (report: HostDiscoveryReport): HostDiscoveryView => Object.freeze({ - build: report.bundleSource === undefined - ? presentation('No built bundle is available for drift checks', 'info') - : presentation(report.bundleSource, 'neutral'), - diagnostics: allDiagnosticsFor(report), - endpoints: Object.freeze({ - findings: Object.freeze(report.endpoints.findings.map(findingViewFor)), - presentation: endpointPresentationFor(report.endpoints), - report: report.endpoints, - }), - hosts: Object.freeze(report.hosts.map((host) => hostViewFor(host, report.bundleSource))), +/** Per-host install, attach, and handshake facts for Advanced / Host diagnostics. */ +export const hostDiagnosticsViewFor = (report: HostDiscoveryReport): HostDiagnosticsView => Object.freeze({ + hosts: Object.freeze(report.hosts.map(hostCardFor)), report, }); diff --git a/packages/workbench/src/discovery/discovery-page.css b/packages/workbench/src/discovery/discovery-page.css index c9ce5927f..cf67d15be 100644 --- a/packages/workbench/src/discovery/discovery-page.css +++ b/packages/workbench/src/discovery/discovery-page.css @@ -67,6 +67,11 @@ .discovery-diagnostic > div span { font-size: 10px; font-weight: 800; text-transform: uppercase; } .discovery-diagnostic p { color: #374151; font-size: 12px; margin: 6px 0 0; } .discovery-diagnostic small { color: #687386; display: block; font-size: 10px; margin-top: 6px; } +.discovery-host-actions, .discovery-handshake-section { margin-top: 17px; } +.discovery-host-actions h3, .discovery-handshake-section h3 { color: #334155; font-size: 11px; font-weight: 800; letter-spacing: .06em; margin: 0 0 10px; text-transform: uppercase; } +.discovery-host-errors { display: grid; gap: 9px; list-style: none; margin: 0; padding: 0; } +.discovery-host-errors button { background: #0b5bd3; border: 1px solid #06459e; border-radius: 4px; color: #fff; cursor: pointer; font-size: 12px; font-weight: 750; margin-top: 8px; min-height: 31px; padding: 0 11px; } +.discovery-handshake { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; } .discovery-mcp-servers { border-top: 1px solid #d9dee7; margin-top: 14px; padding-top: 13px; } .discovery-mcp-servers > .discovery-section-heading > span { color: #687386; font-size: 10px; font-weight: 700; } .discovery-mcp-servers > ul { display: grid; gap: 10px; list-style: none; margin: 0; padding: 0; } diff --git a/packages/workbench/src/discovery/discovery-page.tsx b/packages/workbench/src/discovery/discovery-page.tsx index 42ba2de1f..d71735c8e 100644 --- a/packages/workbench/src/discovery/discovery-page.tsx +++ b/packages/workbench/src/discovery/discovery-page.tsx @@ -16,21 +16,16 @@ import { import { type DiscoveryClient, type DiscoveryDiagnostic, - type DiscoveryFinding, type DiscoveryHost, - type McpProbeLaunch, - type McpProbeReport, type HostDiscoveryReport, } from './discovery-client.ts'; import { - hostDiscoveryViewFor, + hostDiagnosticsViewFor, + hostLabelFor, isStaleReport, - mcpProbeViewFor, - type DiscoveryBundleView, - type DiscoveryFindingView, - type DiscoveryHostView, + mcpProbePresentationFor, type DiscoveryPresentation, - hostLabelFor, + type HostDiagnosticsCard, } from './discovery-model.ts'; import './discovery-page.css'; @@ -73,153 +68,7 @@ const StatusBadge = ({ presentation }: Readonly<{ {presentation.label} ; -const FindingTable = ({ findings }: Readonly<{ - readonly findings: readonly DiscoveryFindingView[]; -}>) => - - - - - - - - - - {findings.map(({ finding, presentation }, index) => - - - - - )} - -
    NameVersionPathState
    {valueOrDash(finding.name ?? finding.entry ?? finding.manifest)}{valueOrDash(finding.version)}{valueOrDash(finding.path)}
    ; - -const DurableState = ({ finding }: Readonly<{ - readonly finding: DiscoveryFinding; -}>) => finding.durableState === undefined ? undefined :
    -

    Durable state

    -
    -
    Status
    {finding.durableState.status}
    -
    Directory
    {finding.durableState.directory}
    -
    Stores
    {String(finding.durableState.summary.stores)}
    -
    Bytes
    {String(finding.durableState.summary.bytes)}
    -
    - {finding.durableState.findings.length === 0 ? undefined :
      - {finding.durableState.findings.map((store) =>
    • - {store.path} - {store.file} · {String(store.bytes)} bytes · {store.mtime} -
    • )} -
    } -
    ; - -const launchSummary = (launch: McpProbeLaunch): ReactNode => { - switch (launch.kind) { - case 'stdio': { - const environment = Object.entries(launch.env); - return <> - {[launch.command, ...launch.args].join(' ')} - {launch.cwd === undefined ? undefined :

    Working directory: {launch.cwd}

    } - {environment.length === 0 - ?

    No environment entries.

    - :
      - {environment.map(([name, value]) =>
    • {name}={value}
    • )} -
    } - ; - } - case 'streamable-http': - return {launch.url}; - default: { - const exhaustive: never = launch; - return exhaustive; - } - } -}; - -const McpProbeResult = ({ report }: Readonly<{ readonly report: McpProbeReport }>) => { - const view = mcpProbeViewFor(report); - const metadata =
    -
    Duration
    {String(report.durationMs)} ms
    -
    Generated at
    {report.generatedAt}
    -
    ; - const launch =
    -
    Redacted launch summary
    - {launchSummary(report.launch)} -
    ; - - switch (report.status) { - case 'ok': { - const snapshot = report.snapshot; - if (snapshot === undefined) return undefined; - return
    -
    -

    Live probe result

    - -
    -
    -
    Protocol
    {snapshot.protocolVersion}
    -
    Server name
    {snapshot.serverInfo.name}
    -
    Title
    {valueOrDash(snapshot.serverInfo.title)}
    -
    Version
    {snapshot.serverInfo.version}
    -
    - {view.capabilityNames.length === 0 ? undefined :
    -
    Capabilities
    -
    {view.capabilityNames.map((capability) => - {capability})}
    -
    } - {snapshot.instructions === undefined ? undefined :
    -
    Instructions
    -

    {snapshot.instructions}

    -
    } -
    -
    Read-only tool catalog
    - {snapshot.tools.length === 0 - ?

    No tools were reported.

    - : - - - - - - - - - {snapshot.tools.map((tool) => - - - - )} - -
    NameTitleDescription
    {tool.name}{valueOrDash(tool.title)}{valueOrDash(tool.description)}
    } - {snapshot.toolsTruncated - ?

    The tool catalog was truncated by the probe response limit.

    - : undefined} -
    - {launch} - {metadata} -
    ; - } - case 'timed-out': - case 'unreachable': - return
    -
    -

    Live probe result

    - -
    - {report.failure === undefined ? undefined : <> -

    {report.failure.kind}

    -

    {report.failure.detail}

    - } - {launch} - {metadata} -
    ; - default: { - const exhaustive: never = report.status; - return exhaustive; - } - } -}; - -const McpServerProbe = ({ host, refreshKey, serverName }: Readonly<{ +const HandshakeIndicator = ({ host, refreshKey, serverName }: Readonly<{ readonly host: DiscoveryHost; readonly refreshKey: number; readonly serverName: string; @@ -258,12 +107,12 @@ const McpServerProbe = ({ host, refreshKey, serverName }: Readonly<{ if (state === undefined) { return ; } switch (state.state) { @@ -271,38 +120,37 @@ const McpServerProbe = ({ host, refreshKey, serverName }: Readonly<{ return

    Consent required

    - This read-only live probe performs an MCP initialize handshake and tools/list against - the installed bundle's {serverName} server. It starts the server - process or connects to its endpoint on this machine. Nothing is stored, and this - surface cannot call tools. + This read-only live probe performs one MCP initialize handshake against + the installed bundle's {serverName} server. Nothing is stored, + and this surface cannot call tools.

    - +
    ; case 'probing': return

    Probing {serverName}…

    ; case 'settled': - return <> - + return
    + - ; +
    ; case 'failed': return <>
    -

    Live probe unavailable

    +

    Handshake unavailable

    {state.code} {state.message}

    +
  • )} + ; -const HostCard = ({ refreshKey, view }: Readonly<{ +const HostCard = ({ + onRefresh, + refreshKey, + view, +}: Readonly<{ + readonly onRefresh: () => void; readonly refreshKey: number; - readonly view: DiscoveryHostView; + readonly view: HostDiagnosticsCard; }>) =>

    Local host

    -

    {hostLabelFor(view.host)}

    +

    {view.label}

    -
    Version
    {valueOrDash(view.probe.version)}
    -
    Evidence
    {valueOrDash(view.probe.evidence)}
    -
    -
    -
    -

    Installed inventory

    - -
    - {view.inventory.status === 'known' - ? view.inventory.findings.length === 0 - ?

    No installed items were reported.

    - : - :

    {view.inventory.presentation.label}

    } -
    - -
    ; - -const RuntimeIdentity = ({ finding }: Readonly<{ readonly finding: DiscoveryFinding }>) => { - const runtime = finding.runtime; - if (runtime === undefined) return

    Runtime identity not reported.

    ; - switch (runtime.status) { - case 'available': - return
    -
    Instance ID
    {runtime.instanceId}
    -
    Artifact epoch
    {runtime.artifactEpoch}
    -
    Availability
    {runtime.availability}
    -
    PID
    {String(runtime.pid)}
    -
    ; - case 'unsupported': - return

    Runtime identity is unsupported by this endpoint.

    ; - case 'unavailable': - return

    Runtime identity became unavailable during discovery.

    ; - case 'failed': - return

    Runtime identity probe failed.

    ; - default: { - const exhaustive: never = runtime; - return exhaustive; - } - } -}; - -const EndpointFindings = ({ findings }: Readonly<{ - readonly findings: readonly DiscoveryFindingView[]; -}>) => findings.length === 0 - ?

    No runtime endpoint findings were reported.

    - :
      - {findings.map(({ finding, presentation }, index) =>
    • - - {valueOrDash(finding.path)} - -
    • )} -
    ; - -const Diagnostics = ({ diagnostics }: Readonly<{ - readonly diagnostics: readonly DiscoveryDiagnostic[]; -}>) =>
    -
    +
    Installed
    {view.installed ? 'Yes' : 'No'}
    +
    Version
    {valueOrDash(view.version)}
    +
    Executable path
    {valueOrDash(view.executablePath)}
    -

    Report evidence

    -

    Diagnostics

    +
    Dev plugin
    +
    + {view.attach.label} + {view.attach.epochId === undefined ? undefined : <> · {view.attach.epochId}} +
    - {String(diagnostics.length)} total -
    - {diagnostics.length === 0 - ?

    No discovery diagnostics were reported.

    - :
      - {diagnostics.map((diagnostic, index) =>
    1. -
      - {diagnostic.code} - {diagnostic.severity} -
      -

      {diagnostic.message}

      -

      Recovery: {diagnostic.recovery}

      - {diagnostic.target === undefined ? undefined : Target: {diagnostic.target}} -
    2. )} -
    } + +
    +

    Actionable errors

    + +
    +
    +

    MCP handshake

    + {view.handshakeServer === undefined + ?

    No MCP server is declared for this host.

    + : } +
    ; const DiscoveryReport = ({ manifestDigest, onRefresh, refreshKey, report }: Readonly<{ @@ -481,17 +234,11 @@ const DiscoveryReport = ({ manifestDigest, onRefresh, refreshKey, report }: Read readonly refreshKey: number; readonly report: HostDiscoveryReport; }>) => { - const view = hostDiscoveryViewFor(report); + const view = hostDiagnosticsViewFor(report); const stale = isStaleReport(manifestDigest, report); return <>
    -
    -
    Generated at
    {report.generatedAt}
    - {report.manifestDigest === undefined ? undefined :
    -
    Manifest digest
    {report.manifestDigest}
    -
    } -
    {stale ?
    @@ -500,30 +247,14 @@ const DiscoveryReport = ({ manifestDigest, onRefresh, refreshKey, report }: Read
    : undefined} -
    -

    Build drift source

    - -
    - {view.hosts.map((host) => )} + {view.hosts.map((host) => )}
    -
    -
    -
    -

    Runtime health

    -

    Endpoints

    -
    - -
    -
    -
    Directory
    {report.endpoints.directory}
    -
    Live
    {String(report.endpoints.summary.live)}
    -
    Stale locks
    {String(report.endpoints.summary.staleLocks)}
    -
    Stale sockets
    {String(report.endpoints.summary.staleSockets)}
    -
    - -
    - ; }; @@ -536,7 +267,7 @@ const DiscoveryResult = ({ manifestDigest, onRefresh, refreshKey }: Readonly<{ const errorPanel = (reason: unknown): ReactNode => { const error = errorDetails(reason); return
    -

    Host discovery unavailable

    +

    Host diagnostics unavailable

    {error.code} {error.message}

    ; }; @@ -549,11 +280,11 @@ const DiscoveryResult = ({ manifestDigest, onRefresh, refreshKey }: Readonly<{ refreshKey={refreshKey} report={value} />, - onWaiting: () =>

    Loading host discovery

    , + onWaiting: () =>

    Loading host diagnostics

    , }); }; -/** Read-only browser view of local hosts, installed bundles, drift, and runtime endpoints. */ +/** Per-host install, attach, and handshake diagnostics. */ export const DiscoveryPage = ({ client, manifestDigest }: DiscoveryPageProps) => { const loader = useMemo(() => (signal) => client.discover(signal), [client]); const probeLoader = useMemo( @@ -564,16 +295,16 @@ export const DiscoveryPage = ({ client, manifestDigest }: DiscoveryPageProps) => const probeLoaderReady = useDiscoveryProbeLoader(probeLoader); const [refreshKey, refresh] = useDiscoveryRefresh(); - return
    + return
    -

    Local environment

    -

    Hosts

    -

    Read-only discovery of local agent hosts, installed bundles, drift against the current build, and runtime endpoints.

    +

    Advanced

    +

    Host diagnostics

    +

    Installed hosts, the current dev plugin attach state, actionable errors, and one MCP handshake per host.

    {loaderReady && probeLoaderReady ? - :

    Loading host discovery

    } + :

    Loading host diagnostics

    }
    ; }; diff --git a/packages/workbench/src/comparisons/comparison-client.ts b/packages/workbench/src/evals/comparison-client.ts similarity index 100% rename from packages/workbench/src/comparisons/comparison-client.ts rename to packages/workbench/src/evals/comparison-client.ts diff --git a/packages/workbench/src/comparisons/comparisons-model.ts b/packages/workbench/src/evals/evals-compare-model.ts similarity index 100% rename from packages/workbench/src/comparisons/comparisons-model.ts rename to packages/workbench/src/evals/evals-compare-model.ts diff --git a/packages/workbench/src/comparisons/comparisons-page.css b/packages/workbench/src/evals/evals-compare.css similarity index 90% rename from packages/workbench/src/comparisons/comparisons-page.css rename to packages/workbench/src/evals/evals-compare.css index 3d9e2eccd..0541fb11e 100644 --- a/packages/workbench/src/comparisons/comparisons-page.css +++ b/packages/workbench/src/evals/evals-compare.css @@ -1,5 +1,4 @@ -.comparisons-content { box-sizing: border-box; margin: 0 auto; max-width: 1180px; min-width: 0; padding: 35px 34px 64px; width: 100%; } -.comparisons-page-heading p { color: #596372; font-size: 15px; margin: 8px 0 0; } +.comparisons-content { box-sizing: border-box; min-width: 0; width: 100%; } .comparison-controls { border-top: 1px solid #d9dee7; display: grid; gap: 8px; justify-items: start; padding-top: 24px; } .comparison-controls label { color: #596372; font-size: 12px; font-weight: 750; letter-spacing: .03em; margin-top: 12px; text-transform: uppercase; } .comparison-controls select { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; font-size: 14px; font-weight: 600; max-width: 100%; min-height: 37px; padding: 0 32px 0 10px; } @@ -36,7 +35,3 @@ .comparison-evidence--smoke { color: #96660a; } .comparison-not-comparable { color: #4f5866; font-size: 12px; font-weight: 750; letter-spacing: .03em; margin: 0 0 8px; text-transform: uppercase; } -@media (max-width: 820px) { - .comparisons-content { padding: 27px 20px 45px; } - .comparison-controls select { width: 100%; } -} diff --git a/packages/workbench/src/comparisons/comparisons-page.tsx b/packages/workbench/src/evals/evals-compare.tsx similarity index 96% rename from packages/workbench/src/comparisons/comparisons-page.tsx rename to packages/workbench/src/evals/evals-compare.tsx index 0774e0064..b7e4c9a72 100644 --- a/packages/workbench/src/comparisons/comparisons-page.tsx +++ b/packages/workbench/src/evals/evals-compare.tsx @@ -8,9 +8,9 @@ import { type ComparisonMatrixRow, type ComparisonMetricCell, type ComparisonsView, -} from './comparisons-model.ts'; -import type { EvalClient } from '../evals/eval-client.ts'; -import './comparisons-page.css'; +} from './evals-compare-model.ts'; +import type { EvalClient } from './eval-client.ts'; +import './evals-compare.css'; export interface ComparisonControlsProps { readonly busy: boolean; @@ -24,7 +24,7 @@ export interface ComparisonMatrixProps { readonly view: ComparisonsView; } -export interface ComparisonsPageProps { +export interface EvalsCompareProps { readonly comparisonClient: ComparisonClient; readonly evalClient: EvalClient; } @@ -186,7 +186,7 @@ export const ComparisonMatrix = ({ view }: ComparisonMatrixProps) =>
    ; /** Aligns two recorded eval runs and shows the reliability matrix of every shared condition. */ -export const ComparisonsPage = ({ comparisonClient, evalClient }: ComparisonsPageProps) => { +export const EvalsCompare = ({ comparisonClient, evalClient }: EvalsCompareProps) => { const [baseRunId, setBaseRunId] = useState(); const [busy, setBusy] = useState(); const [candidateRunId, setCandidateRunId] = useState(); @@ -261,12 +261,6 @@ export const ComparisonsPage = ({ comparisonClient, evalClient }: ComparisonsPag }; return
    -
    -
    -

    Comparisons

    -

    Aligned baseline and candidate runs, with the actual k/n beside pass@k and pass^k.

    -
    -
    {currentError === undefined ? undefined :

    {currentError}

    } {view.state === 'insufficient-runs' ?

    {view.summary}

    diff --git a/packages/workbench/src/evals/evals-page.css b/packages/workbench/src/evals/evals-page.css index 1b673de51..11c7fd735 100644 --- a/packages/workbench/src/evals/evals-page.css +++ b/packages/workbench/src/evals/evals-page.css @@ -1,5 +1,11 @@ .evals-content { box-sizing: border-box; margin: 0 auto; max-width: 1180px; min-width: 0; padding: 35px 34px 64px; width: 100%; } .evals-page-heading p { color: #596372; font-size: 15px; margin: 8px 0 0; } +.evals-tabs { display: flex; gap: 8px; margin: 18px 0 0; } +.evals-tab { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #23466f; cursor: pointer; font-size: 13px; font-weight: 750; min-height: 36px; padding: 0 16px; } +.evals-tab:hover { background: #edf2f7; } +.evals-tab--active { background: #0b5bd3; border-color: #06459e; color: #fff; } +.evals-tab--active:hover { background: #084eb9; } +.evals-runs { margin-top: 8px; } .eval-controls { border-top: 1px solid #d9dee7; display: grid; gap: 8px; justify-items: start; padding-top: 24px; width: 100%; } .eval-controls label { color: #596372; font-size: 12px; font-weight: 750; letter-spacing: .03em; margin-top: 12px; text-transform: uppercase; } .eval-controls select { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; font-size: 14px; font-weight: 600; max-width: 100%; min-height: 37px; padding: 0 32px 0 10px; width: min(100%, 560px); } diff --git a/packages/workbench/src/evals/evals-page.tsx b/packages/workbench/src/evals/evals-page.tsx index 7a31589cb..ff9974a69 100644 --- a/packages/workbench/src/evals/evals-page.tsx +++ b/packages/workbench/src/evals/evals-page.tsx @@ -1,6 +1,8 @@ import { errorMessage as messageFrom } from '../client-helpers.ts'; import React, { useEffect, useRef, useState } from 'react'; +import type { ComparisonClient } from './comparison-client.ts'; +import { EvalsCompare } from './evals-compare.tsx'; import type { EvalArtifact, EvalClient, EvalHarness, EvalRunAdmission, EvalRunStart } from './eval-client.ts'; import type { EvalRunEvent, EvalRunRecord, EvalRunResult, EvalSuiteListing } from '../../../agent-bundle/src/contracts/eval.ts'; import { @@ -45,8 +47,11 @@ export interface EvalRunReportProps { readonly view: EvalRunView; } +export type EvalsPageTab = 'compare' | 'runs'; + export interface EvalsPageProps { readonly client: EvalClient; + readonly comparisonClient?: ComparisonClient; } const trialsError = 'Trials must be a whole number between 1 and 100.'; @@ -852,13 +857,7 @@ const EvalsClientPage = ({ client }: EvalsPageProps) => { } }; - return
    -
    -
    -

    Evals

    -

    Authored suites, their cases, and the evidence every trial recorded.

    -
    -
    + return
    {error === undefined ? undefined :

    {error}

    } {cancellationNote === undefined ? undefined :

    {cancellationNote}

    } {view.state === 'empty' || view.state === 'loading' @@ -886,6 +885,43 @@ const EvalsClientPage = ({ client }: EvalsPageProps) => {
    ; }; -/** Runs authored suites and shows the evidence every trial recorded. */ -export const EvalsPage = ({ client }: EvalsPageProps) => - ; +const EvalsShell = ({ client, comparisonClient }: EvalsPageProps) => { + const [tab, setTab] = useState('runs'); + return
    +
    +
    +

    Evals

    +

    Authored suites, their cases, and aligned baseline versus candidate runs.

    +
    +
    +
    + + +
    + {tab === 'runs' + ? + : comparisonClient === undefined + ?

    Comparison client is not available in this session.

    + : } +
    ; +}; + +/** Runs authored suites and aligns two recorded runs under Compare. */ +export const EvalsPage = ({ client, comparisonClient }: EvalsPageProps) => + ; diff --git a/packages/workbench/src/hooks/hooks-model.ts b/packages/workbench/src/hooks/hooks-model.ts index 4b718e925..ec41bd21e 100644 --- a/packages/workbench/src/hooks/hooks-model.ts +++ b/packages/workbench/src/hooks/hooks-model.ts @@ -1,22 +1,19 @@ import type { HookPlaygroundBinding, HookPlaygroundCanonicalIntent, - HookPlaygroundDiagnostic, HookPlaygroundDiagnosticResult, HookPlaygroundHook, HookPlaygroundHostMapping, - HookPlaygroundReplay, HookPlaygroundSimulation, } from '../../../agent-bundle/src/contracts/hooks.ts'; -import { deeplyFrozenHookValue } from './hook-client.ts'; +import type { JsonObject } from '../../../agent-bundle/src/contracts/runtime.ts'; import { deepFreeze } from '../freeze.ts'; - export type HookPlaygroundResult = HookPlaygroundDiagnosticResult | HookPlaygroundSimulation | undefined; -export type HookListState = 'error' | 'loading' | 'ready'; +export type CanonicalHookEvent = HookPlaygroundHook['hook']['event']; -export type HookPlaygroundState = 'diagnostics' | 'empty' | 'list-error' | 'loading' | 'no-epoch' | 'ready' | 'simulated'; +export type CanonicalHookInput = JsonObject; export interface HookDetailRow { readonly label: string; @@ -32,33 +29,6 @@ export interface HookOption { readonly timeout?: number; } -export interface HookPlaygroundViewOptions { - readonly epochId: string | undefined; - readonly hooks: readonly HookPlaygroundHook[]; - readonly listState?: HookListState; - readonly result: HookPlaygroundResult; - readonly selectedKey: string | undefined; -} - -export interface HookPlaygroundView { - readonly canonicalInput: Readonly> | undefined; - readonly canonicalResult: Readonly> | undefined; - readonly diagnostics: readonly HookPlaygroundDiagnostic[]; - readonly hooks: readonly HookOption[]; - readonly intent: readonly HookDetailRow[]; - readonly mapping: readonly HookDetailRow[]; - readonly nativeInput: Readonly> | undefined; - readonly nativeOutput: Readonly> | undefined; - readonly replay: HookPlaygroundReplay | undefined; - readonly selected: HookOption | undefined; - readonly state: HookPlaygroundState; - readonly summary: string; -} - -const noRows: readonly HookDetailRow[] = Object.freeze([]); - -const noDiagnostics: readonly HookPlaygroundDiagnostic[] = Object.freeze([]); - const row = (label: string, value: string): HookDetailRow => Object.freeze({ label, value }); const readableHookLabel = (value: string): string => { @@ -66,6 +36,46 @@ const readableHookLabel = (value: string): string => { return `${words.charAt(0).toUpperCase()}${words.slice(1).toLowerCase()}`; }; +const canonicalHookInputs: Readonly> = deepFreeze({ + afterTool: { + cwd: '/workspace', + sessionId: 'workbench-preview', + toolInput: Object.freeze({}), + toolName: 'shell', + toolResponse: Object.freeze({}), + toolUseId: 'workbench-preview-tool', + transcriptPath: '/workspace/transcript.json', + }, + beforeTool: { + cwd: '/workspace', + sessionId: 'workbench-preview', + toolInput: Object.freeze({}), + toolName: 'shell', + toolUseId: 'workbench-preview-tool', + transcriptPath: '/workspace/transcript.json', + }, + sessionStart: { + cwd: '/workspace', + sessionId: 'workbench-preview', + source: 'workbench', + transcriptPath: '/workspace/transcript.json', + }, + stop: { + cwd: '/workspace', + lastAssistantMessage: 'Workbench preview completed.', + sessionId: 'workbench-preview', + stopHookActive: false, + transcriptPath: '/workspace/transcript.json', + }, +}); + +/** Provides one event-shaped document that can run a generated Hook without host-contract guesswork. */ +export const canonicalHookInput = (event: CanonicalHookEvent): CanonicalHookInput => canonicalHookInputs[event]; + +/** Returns a runnable example only for the canonical Hook events understood by the Workbench. */ +export const canonicalHookInputFor = (event: string): CanonicalHookInput | undefined => + Object.hasOwn(canonicalHookInputs, event) ? canonicalHookInputs[event as CanonicalHookEvent] : undefined; + export const hookOptionKeyFor = (binding: HookPlaygroundBinding): string => `${binding.target}/${binding.hook}`; export const hookOptionsFor = (hooks: readonly HookPlaygroundHook[]): readonly HookOption[] => deepFreeze( @@ -95,46 +105,3 @@ export const hostMappingRowsFor = (mapping: HookPlaygroundHostMapping): readonly row('Wrapper path', mapping.wrapperPath), row('Native projection', mapping.nativeProjection), ]); - -const summaryFor = (state: HookPlaygroundState, simulation: HookPlaygroundSimulation | undefined): string => { - if (state === 'no-epoch') return 'No successful build is available, so no generated Hook can be simulated.'; - if (state === 'loading') return 'Loading generated Hooks from the current build.'; - if (state === 'list-error') return 'Generated Hooks could not be loaded from the current build.'; - if (state === 'empty') return 'The current build has no generated Hooks.'; - if (state === 'diagnostics') return 'The hook playground returned diagnostics instead of a simulation.'; - if (state === 'simulated' && simulation !== undefined) { - return `Simulated ${simulation.canonicalIntent.hook} on ${simulation.binding.target} from the selected build.`; - } - return 'Select a generated hook and run a simulation to see its canonical and native trace.'; -}; - -/** Derives every Hook page section from the listed hooks and the latest simulation or diagnostics. */ -export const hookPlaygroundViewFor = (options: HookPlaygroundViewOptions): HookPlaygroundView => { - const detached = deeplyFrozenHookValue(options) as HookPlaygroundViewOptions; - const hooks = hookOptionsFor(detached.hooks); - const result = detached.result; - const simulation = result === undefined || 'diagnostics' in result ? undefined : result; - const diagnostics = result !== undefined && 'diagnostics' in result ? result.diagnostics : noDiagnostics; - const listState = detached.listState ?? 'ready'; - const state: HookPlaygroundState = detached.epochId === undefined ? 'no-epoch' - : listState === 'loading' ? 'loading' - : listState === 'error' ? 'list-error' - : hooks.length === 0 ? 'empty' - : simulation !== undefined ? 'simulated' - : diagnostics.length > 0 ? 'diagnostics' - : 'ready'; - return Object.freeze({ - canonicalInput: simulation?.canonicalIntent.input, - canonicalResult: simulation?.canonicalResult, - diagnostics, - hooks, - intent: simulation === undefined ? noRows : canonicalIntentRowsFor(simulation.canonicalIntent), - mapping: simulation === undefined ? noRows : hostMappingRowsFor(simulation.hostMapping), - nativeInput: simulation?.nativeInput, - nativeOutput: simulation?.nativeOutput, - replay: simulation?.replay, - selected: hooks.find((option) => option.key === detached.selectedKey) ?? hooks[0], - state, - summary: summaryFor(state, simulation), - }); -}; diff --git a/packages/workbench/src/hooks/hooks-page.css b/packages/workbench/src/hooks/hooks-page.css deleted file mode 100644 index f3bf556bd..000000000 --- a/packages/workbench/src/hooks/hooks-page.css +++ /dev/null @@ -1,42 +0,0 @@ -.hooks-content { margin: 0 auto; max-width: 1180px; min-width: 0; padding: 35px 34px 64px; width: 100%; } -.hooks-page-heading p { color: #596372; font-size: 15px; margin: 8px 0 0; } -.hook-controls { border-top: 1px solid #d9dee7; display: grid; gap: 8px; justify-items: start; padding-top: 24px; } -.hook-controls label { color: #596372; font-size: 12px; font-weight: 750; letter-spacing: .03em; margin-top: 12px; text-transform: uppercase; } -.hook-input-mode { border: 0; display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 0; min-width: 0; padding: 0; } -.hook-input-mode legend { color: #596372; font-size: 12px; font-weight: 750; letter-spacing: .03em; padding: 0; text-transform: uppercase; width: 100%; } -.hook-input-mode label { align-items: center; display: flex; gap: 6px; margin: 0; text-transform: none; } -.hook-input-mode input { margin: 0; } -.hook-controls select { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; font-size: 14px; font-weight: 600; max-width: 100%; min-height: 37px; padding: 0 32px 0 10px; } -.hook-controls textarea { background: #fff; border: 1px solid #bfc8d5; border-radius: 4px; color: #1e2938; font: 13px/1.55 "SFMono-Regular", Consolas, "Liberation Mono", monospace; max-width: 100%; min-height: 190px; padding: 11px; width: 640px; } -.hook-controls textarea[aria-invalid="true"] { border-color: #c01d26; } -.hook-controls > p[role="alert"] { color: #b31b23; font-size: 13px; margin: 0; } -.hook-input-guidance { color: #596372; font-size: 13px; margin: 0; max-width: 640px; } -.hook-actions { display: flex; gap: 12px; margin-top: 16px; } -.hook-actions button { background: #0b5bd3; border: 1px solid #06459e; border-radius: 4px; color: #fff; cursor: pointer; font-weight: 700; min-height: 43px; padding: 0 22px; } -.hook-actions button:hover:not(:disabled) { background: #084eb9; } -.hook-actions button:disabled { background: #7f95b8; border-color: #7f95b8; cursor: not-allowed; } -.hook-advanced-input { border-top: 1px solid #d9dee7; margin-top: 18px; max-width: 680px; padding-top: 14px; width: 100%; } -.hook-advanced-input summary { color: #344052; cursor: pointer; font-size: 13px; font-weight: 750; } -.hook-advanced-input > label { display: block; } -.hook-advanced-input > p[role="alert"] { color: #b31b23; font-size: 13px; margin: 0; } -.hook-simulation { margin-top: 30px; } -.hook-summary { color: #4f5866; font-size: 15px; margin: 0 0 22px; } -.hook-diagnostics { background: #fff7f7; border-left: 3px solid #c01d26; color: #78242a; margin: 0 0 22px; padding: 9px 14px; } -.hook-diagnostics h2 { color: #78242a; margin-bottom: 6px; } -.hook-diagnostics p { font-size: 13px; margin: 5px 0; } -.hook-diagnostic-metadata { display: block; font-size: 12px; margin-top: 4px; } -.hook-detail { border-top: 1px solid #d9dee7; margin-bottom: 21px; padding-top: 18px; } -.hook-detail-rows { display: grid; gap: 0; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); margin: 0; } -.hook-detail-rows div { border-right: 1px solid #d9dee7; min-width: 0; padding: 12px 14px 12px 0; } -.hook-detail-rows div:last-child { border-right: 0; } -.hook-detail-rows dt { color: #596372; font-size: 12px; font-weight: 750; margin: 0 0 6px; } -.hook-detail-rows dd { font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } -.hook-json { background: #101822; border: 1px solid #25364b; color: #edf3fb; font: 13px/1.55 "SFMono-Regular", Consolas, "Liberation Mono", monospace; margin: 0; overflow-x: auto; padding: 18px; white-space: pre-wrap; } - -@media (max-width: 820px) { - .hooks-content { padding: 27px 20px 45px; } - .hook-controls textarea { width: 100%; } - .hook-detail-rows { grid-template-columns: 1fr; } - .hook-detail-rows div { border-bottom: 1px solid #d9dee7; border-right: 0; } - .hook-detail-rows div:last-child { border-bottom: 0; } -} diff --git a/packages/workbench/src/hooks/hooks-page.tsx b/packages/workbench/src/hooks/hooks-page.tsx deleted file mode 100644 index ef09dc9ad..000000000 --- a/packages/workbench/src/hooks/hooks-page.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import { isAbortError, errorMessage as messageFrom } from '../client-helpers.ts'; -import React, { useEffect, useRef, useState } from 'react'; - -import type { - HookPlaygroundBinding, - HookPlaygroundHook, - HookPlaygroundReplay, -} from '../../../agent-bundle/src/contracts/hooks.ts'; - -import { - parseRawJsonRecord, - serializeJsonRecord, - type ImmutableJsonRecord, -} from '../mcp/mcp-json-input.tsx'; -import type { HookClient, HookSimulationResult } from './hook-client.ts'; -import { - hookPlaygroundViewFor, - type HookDetailRow, - type HookPlaygroundResult, - type HookPlaygroundView, -} from './hooks-model.ts'; -import './hooks-page.css'; -import { deepFreeze } from '../freeze.ts'; - - -export interface HookSimulationViewProps { - readonly view: HookPlaygroundView; -} - -export interface HooksPageProps { - readonly client: HookClient; - readonly epochId: string | undefined; -} - -const draftError = 'Canonical hook input must be a JSON object.'; - -type CanonicalHookEvent = HookPlaygroundHook['hook']['event']; - -const canonicalHookInputs: Readonly> = deepFreeze({ - afterTool: { - cwd: '/workspace', - sessionId: 'workbench-preview', - toolInput: Object.freeze({}), - toolName: 'shell', - toolResponse: Object.freeze({}), - toolUseId: 'workbench-preview-tool', - transcriptPath: '/workspace/transcript.json', - }, - beforeTool: { - cwd: '/workspace', - sessionId: 'workbench-preview', - toolInput: Object.freeze({}), - toolName: 'shell', - toolUseId: 'workbench-preview-tool', - transcriptPath: '/workspace/transcript.json', - }, - sessionStart: { - cwd: '/workspace', - sessionId: 'workbench-preview', - source: 'workbench', - transcriptPath: '/workspace/transcript.json', - }, - stop: { - cwd: '/workspace', - lastAssistantMessage: 'Workbench preview completed.', - sessionId: 'workbench-preview', - stopHookActive: false, - transcriptPath: '/workspace/transcript.json', - }, -}); - -/** Provides one event-shaped document that can run a generated Hook without host-contract guesswork. */ -export const canonicalHookInput = (event: CanonicalHookEvent): ImmutableJsonRecord => canonicalHookInputs[event]; - -/** Returns a runnable example only for the canonical Hook events understood by the Workbench. */ -export const canonicalHookInputFor = (event: string): ImmutableJsonRecord | undefined => - Object.hasOwn(canonicalHookInputs, event) ? canonicalHookInputs[event as CanonicalHookEvent] : undefined; - -const errorMessage = (reason: unknown): string => messageFrom(reason, 'The hook playground request could not be completed.'); - -export type HookInputMode = 'fixture' | 'inline'; - -type HookRequestKind = 'list' | 'run'; - -interface HookRequest { - readonly generation: number; - readonly kind: HookRequestKind; - readonly signal: AbortSignal; -} - -/** Owns request cancellation and makes late completions harmless after a page epoch or run changes. */ -export class HookRequestLifecycle { - readonly #active = new Map(); - #generation = 0; - - begin(kind: HookRequestKind): HookRequest { - this.#active.get(kind)?.controller.abort(); - const controller = new AbortController(); - const request = Object.freeze({ generation: this.#generation, kind, signal: controller.signal }); - this.#active.set(kind, { controller, request }); - return request; - } - - complete(request: HookRequest): void { - if (this.#active.get(request.kind)?.request === request) this.#active.delete(request.kind); - } - - invalidate(): void { - this.#generation += 1; - for (const { controller } of this.#active.values()) controller.abort(); - this.#active.clear(); - } - - isCurrent(request: HookRequest): boolean { - return request.generation === this.#generation && !request.signal.aborted && this.#active.get(request.kind)?.request === request; - } -} - -export const runHookSimulation = async ( - client: HookClient, - binding: HookPlaygroundBinding, - input: ImmutableJsonRecord, - mode: HookInputMode = 'inline', - signal?: AbortSignal, -): Promise => client.simulate({ - epochId: binding.epochId, - hook: binding.hook, - input: mode === 'fixture' ? { fixture: input } : { inline: input }, - target: binding.target, -}, signal); - -/** A saved replay carries its own epoch binding, so the page never rebinds it to the selected epoch. */ -export const runHookReplay = async ( - client: HookClient, - replay: HookPlaygroundReplay, - signal?: AbortSignal, -): Promise => client.replay(replay, signal); - -const DetailRows = ({ label, rows }: { - readonly label: string; - readonly rows: readonly HookDetailRow[]; -}) =>
    -

    {label}

    -
    - {rows.map((detail) =>
    {detail.label}
    {detail.value}
    )} -
    -
    ; - -const JsonBlock = ({ empty, label, value }: { - readonly empty: string; - readonly label: string; - readonly value: Readonly> | undefined; -}) =>
    -

    {label}

    - {value === undefined - ?

    {empty}

    - :
    {serializeJsonRecord(value as ImmutableJsonRecord)}
    } -
    ; - -/** The canonical intent, host mapping, and native codec trace of the latest hook run. */ -export const HookSimulationView = ({ view }: HookSimulationViewProps) =>
    -

    {view.summary}

    - {view.diagnostics.length === 0 ? undefined :
    -

    Hook playground diagnostics

    - {view.diagnostics.map((diagnostic, index) =>

    - {diagnostic.code} {diagnostic.message} - Severity: {diagnostic.severity} · Event: {diagnostic.event} · Target: {diagnostic.target} -

    )} -
    } - {view.state !== 'simulated' ? undefined : <> - - - - - - - } -
    ; - -/** Lists the hooks of one immutable epoch and runs the emitted wrapper against authored canonical input. */ -export const HooksPage = ({ client, epochId }: HooksPageProps) => { - const [busy, setBusy] = useState(false); - const [draft, setDraft] = useState(() => serializeJsonRecord({})); - const [error, setError] = useState(); - const [hooks, setHooks] = useState([]); - const [inputMode, setInputMode] = useState('inline'); - const [listedEpochId, setListedEpochId] = useState(); - const [listState, setListState] = useState<'error' | 'loading' | 'ready'>(() => epochId === undefined ? 'ready' : 'loading'); - const [result, setResult] = useState(); - const [selectedKey, setSelectedKey] = useState(); - const draftIsDirty = useRef(false); - const lifecycle = useRef(new HookRequestLifecycle()).current; - const currentEpochIsListed = listedEpochId === epochId; - const view = hookPlaygroundViewFor({ - epochId, - hooks: currentEpochIsListed ? hooks : [], - listState: epochId === undefined ? 'ready' : currentEpochIsListed ? listState : 'loading', - result: currentEpochIsListed ? result : undefined, - selectedKey, - }); - const parsed = parseRawJsonRecord(draft); - - useEffect(() => { - if (view.selected === undefined || draftIsDirty.current) return; - setDraft(serializeJsonRecord(canonicalHookInput(view.selected.event as CanonicalHookEvent))); - }, [view.selected?.event]); - - useEffect(() => { - lifecycle.invalidate(); - setBusy(false); - setError(undefined); - setResult(undefined); - setListedEpochId(undefined); - if (epochId === undefined) { - setHooks([]); - setListState('ready'); - return () => lifecycle.invalidate(); - } - setHooks([]); - setListState('loading'); - const request = lifecycle.begin('list'); - void client.list({ epochId }, request.signal).then( - (next) => { - if (!lifecycle.isCurrent(request)) return; - lifecycle.complete(request); - setHooks(next); - setListedEpochId(epochId); - setListState('ready'); - }, - (reason) => { - if (!lifecycle.isCurrent(request)) return; - lifecycle.complete(request); - if (isAbortError(reason)) return; - setHooks([]); - setListedEpochId(epochId); - setListState('error'); - setError(errorMessage(reason)); - }, - ); - return () => lifecycle.invalidate(); - }, [client, epochId, lifecycle]); - - const run = async ( - action: (signal: AbortSignal) => Promise, - ): Promise => { - const request = lifecycle.begin('run'); - setBusy(true); - setError(undefined); - try { - const next = await action(request.signal); - if (!lifecycle.isCurrent(request)) return; - setResult(next); - } catch (reason) { - if (lifecycle.isCurrent(request) && !isAbortError(reason)) setError(errorMessage(reason)); - } finally { - if (lifecycle.isCurrent(request)) { - setBusy(false); - lifecycle.complete(request); - } - } - }; - - const simulate = async (): Promise => { - const binding = view.selected?.binding; - if (binding === undefined || parsed === null) return; - await run((signal) => runHookSimulation(client, binding, parsed, inputMode, signal)); - }; - - const replay = async (): Promise => { - const saved = view.replay; - if (saved === undefined) return; - await run((signal) => runHookReplay(client, saved, signal)); - }; - - return
    -
    -
    -

    Hooks

    -

    Choose a generated Hook, review its host mapping, and simulate a canonical event.

    -
    -
    - {error === undefined ? undefined :

    {error}

    } - {view.state === 'no-epoch' - ?

    {view.summary}

    - : <> -
    - - -

    Uses a safe starter event. Customize the payload only when testing a specific host event.

    -
    - - -
    -
    - Advanced input -
    - Canonical input mode - - -
    - -