diff --git a/.changeset/wb600-application-explorer.md b/.changeset/wb600-application-explorer.md new file mode 100644 index 000000000..11af3d5de --- /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`, `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. (#629) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 861605eee..9c2f5993c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -55,34 +55,6 @@ jobs: if-no-files-found: ignore retention-days: 7 - # Evidence-capture pool (rstest.evidence.config.ts): documentation-artifact - # browser journeys whose behavioural contracts the integration pool already - # proves per PR (see nightlyEvidenceTestFiles in rstest.integration-tests.ts). - # The pool's capture writes its screenshots and evidence.json to a temporary - # directory it removes on exit, so a green run leaves no artifact; the - # tracked copies in docs/assets are regenerated by hand (see - # examples/rsc-agent-runtime/README.md). Budget: install + build + the - # example's cold rsbuild in globalSetup + a 480 s capture watchdog. - evidence: - name: Evidence capture (Node 22.19) - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-workspace - with: - node-version: 22.19.0 - playwright-browser: chromium - - run: pnpm test:evidence - - if: failure() - name: Preserve Playwright traces of failed browser tests - uses: actions/upload-artifact@v7 - with: - name: playwright-traces-evidence - path: .rstest/playwright-traces - if-no-files-found: ignore - retention-days: 7 - # Official MCP server conformance runner (rstest.mcp-conformance.config.ts) # against one generated fixture over loopback HTTP. The expected-failure # allowlist lives in packages/agent-bundle/tests/fixtures/ diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 6becc7620..56eadb58f 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/examples/hooks-and-scripts/README.md b/examples/hooks-and-scripts/README.md index 6c1bce5de..7e69d9f72 100644 --- a/examples/hooks-and-scripts/README.md +++ b/examples/hooks-and-scripts/README.md @@ -20,25 +20,25 @@ 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 4dd7fafd6..b2ee8b34b 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 d62f304fc..1064bc19b 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/package.json b/package.json index 3dc82ed34..50ae82898 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "test:projection": "rstest --config rstest.projection.config.ts", "test:integration": "pnpm build && pnpm test:integration:run", "test:integration:run": "AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.integration.config.ts", - "test:evidence": "pnpm build && AGENT_BUNDLE_WORKBENCH_PREBUILT=1 AGENT_BUNDLE_PACKAGE_PREBUILT=1 rstest --config rstest.evidence.config.ts", "test:mcp-conformance": "pnpm build && AGENT_BUNDLE_MCP_CONFORMANCE=1 rstest --config rstest.mcp-conformance.config.ts", "test:native-host": "pnpm build && AGENT_BUNDLE_NATIVE_HOST_CONTRACTS=1 rstest --config rstest.native-host.config.ts", "test:watch": "rstest --config rstest.config.ts --watch", diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index a9dd13f58..8468fba9d 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -175,6 +175,7 @@ export default defineConfig({ // route module may import at run time without pulling the compiler // into its generated bundle. routes: './src/routes/public.ts', + 'route-invocation-child': './src/dev/routes/route-invocation-child.ts', rstest: './src/rstest/index.ts', 'terminal-capability': './src/terminal-capability.ts', test: './src/test/index.ts', 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/contracts/invocations.ts b/packages/agent-bundle/src/contracts/invocations.ts new file mode 100644 index 000000000..2140addb3 --- /dev/null +++ b/packages/agent-bundle/src/contracts/invocations.ts @@ -0,0 +1,26 @@ +/** + * 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 { + RouteInvocationCliProjection, + RouteInvocationEvent, + RouteInvocationEventHost, + RouteInvocationEventOptions, + RouteInvocationEventPayload, + RouteInvocationHostProjection, + RouteInvocationKind, + RouteInvocationListResponse, + RouteInvocationProjection, + RouteInvocationProvider, + RouteInvocationProviderStatus, + RouteInvocationRequest, + RouteInvocationStatus, + RouteInvocationSummary, + RouteInvocationTiming, +} from '../dev/routes/route-invocation.ts'; +export type { + RouteInvocation, + RouteInvocationResponse, +} from '../dev/routes/route-invocation-result.ts'; diff --git a/packages/agent-bundle/src/contracts/workbench-shell.ts b/packages/agent-bundle/src/contracts/workbench-shell.ts new file mode 100644 index 000000000..ef05ed221 --- /dev/null +++ b/packages/agent-bundle/src/contracts/workbench-shell.ts @@ -0,0 +1,19 @@ +export { + applicationNodeKey, + applicationNodePath, + applicationNodeRefForPathSegments, + applicationNodeRefForRouteId, + isApplicationMcpNodeKind, + routeIdForApplicationNodeRef, + sameApplicationNodeRef, +} from '../dev/routes/application-node.ts'; +export type { + ApplicationMcpNodeKind, + ApplicationNodeKind, + ApplicationNodeRef, +} from '../dev/routes/application-node.ts'; +export { + isWorkbenchShellPath, + workbenchShellAreas, +} from '../dev/workbench-shell-paths.ts'; +export type { WorkbenchShellArea } from '../dev/workbench-shell-paths.ts'; diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index 6c3d0f123..cf4b92038 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -86,6 +86,7 @@ export interface DevCoordinatorOptions { readonly initialPreparedProject?: PreparedProject; readonly ignoredPaths?: readonly string[]; readonly onPreparedProject?: (prepared: PreparedProject) => Promise; + readonly onPublishedProject?: (prepared: PreparedProject, epoch: ArtifactEpoch) => void; readonly outputPaths?: readonly string[]; /** Rebuilds the framework-owned package build (bin/lib) after successful artifact rebuilds. */ readonly packageBuildService?: DevPackageBuilder; @@ -209,6 +210,7 @@ export class DevCoordinator { readonly #ignoredPaths: readonly string[]; readonly #outputPaths: readonly string[]; readonly #onPreparedProject: ((prepared: PreparedProject) => Promise) | undefined; + readonly #onPublishedProject: ((prepared: PreparedProject, epoch: ArtifactEpoch) => void) | undefined; readonly #packageBuildService: DevPackageBuilder; readonly #prepareCommand: 'build' | 'dev'; readonly #projectService: ProjectPreparer; @@ -248,6 +250,7 @@ export class DevCoordinator { this.#now = options.now ?? (() => new Date()); this.#nextPreparedProject = options.initialPreparedProject; this.#onPreparedProject = options.onPreparedProject; + this.#onPublishedProject = options.onPublishedProject; this.#packageBuildService = options.packageBuildService ?? new DevPackageBuildService(); this.#outputPaths = Object.freeze([...new Set([ ...(options.outputPaths ?? ['dist']), @@ -563,6 +566,7 @@ export class DevCoordinator { startedAt: running.startedAt, }); this.#activeEpoch = result.epoch; + this.#onPublishedProject?.(prepared, result.epoch); const artifact = artifactStatusFor(this.#activeEpoch, source.revision); this.#status = freezeProjectStatus({ artifact, 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 ef2a2c233..22121291a 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,10 +24,12 @@ 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 { WebHostRoutes, type WebHostEpochSource } from './web-host-routes.ts'; +import { isWorkbenchShellPath } from './workbench-shell-paths.ts'; import { diagnostic, isJsonRequest, @@ -83,7 +85,7 @@ export class ForegroundServerError extends Error { export interface ForegroundServerCloseFailure { readonly error: unknown; - readonly resource: 'agent-api' | 'coordinator' | 'eval-routes' | 'eval-service' | 'hook-playground' | 'logs' | 'mcp-apps' | 'server'; + readonly resource: 'agent-api' | 'coordinator' | 'eval-routes' | 'eval-service' | 'hook-playground' | 'logs' | 'mcp-apps' | 'route-invocations' | 'server'; } export interface ForegroundServerStartFailure { @@ -190,6 +192,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. */ @@ -416,6 +420,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(); @@ -534,6 +539,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 }), @@ -682,6 +692,8 @@ export class ForegroundServer { this.#playgroundRoutes.close(); this.#inspectorRoutes.close(); this.#artifactRoutes.close(); + const releaseRouteInvocations = this.#routeInvocationRoutes.close(); + void releaseRouteInvocations.catch(() => undefined); this.#routeManifestRoutes.close(); this.#lifecycleReplayRoutes.close(); const releaseEvals = this.#evalRoutes.close(); @@ -726,13 +738,14 @@ export class ForegroundServer { return closeServer(this.#server); })() : Promise.resolve(); - const [server, coordinator, evalRoutes, evalService, hookPlayground, logs] = await Promise.allSettled([ + const [server, coordinator, evalRoutes, evalService, hookPlayground, logs, routeInvocations] = await Promise.allSettled([ releaseServer, releaseCoordinator, releaseEvals, releaseEvalService, releaseHookPlayground, releaseLogs, + releaseRouteInvocations, ]); const failures: ForegroundServerCloseFailure[] = []; if (agentApi.status === 'rejected') failures.push(Object.freeze({ error: agentApi.reason, resource: 'agent-api' })); @@ -747,6 +760,9 @@ export class ForegroundServer { failures.push(Object.freeze({ error: hookPlayground.reason, resource: 'hook-playground' })); } if (logs.status === 'rejected') failures.push(Object.freeze({ error: logs.reason, resource: 'logs' })); + if (routeInvocations.status === 'rejected') { + failures.push(Object.freeze({ error: routeInvocations.reason, resource: 'route-invocations' })); + } return Object.freeze(failures); } @@ -773,6 +789,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; @@ -1017,7 +1034,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/application-node.ts b/packages/agent-bundle/src/dev/routes/application-node.ts new file mode 100644 index 000000000..4549b5b73 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/application-node.ts @@ -0,0 +1,176 @@ +/** + * 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 => { + if (values.length === 0) return undefined; + const decoded: string[] = []; + for (const value of values) { + const part = decode(value); + if (part === undefined) return undefined; + decoded.push(part); + } + return Object.freeze(decoded); +}; + +/** 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/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/src/dev/routes/route-invocation-child.ts b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts new file mode 100644 index 000000000..85d4f8f9e --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-child.ts @@ -0,0 +1,157 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import * as AgentRuntime from '@agent-bundle/runtime'; +import { createJiti, type JitiOptions, type TransformOptions } from 'jiti'; +import * as React from 'react'; + +import type { JsonObject } from '../../core/strict-json.ts'; +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'; + +/** + * Classic JSX runtime, as in the playground's lifecycle render child: the + * automatic runtime would import `react/jsx-runtime`, which jiti resolves + * without the child's `--conditions=react-server`, binding the client runtime + * to the server `react` and throwing inside React (#441). Compiled JSX calls + * `React.createElement` instead, on the route's own `react` import or on the + * global below for modules that do not import it. + */ +(globalThis as typeof globalThis & { React?: typeof React }).React = React; + +const jitiOptions: JitiOptions = { + fsCache: false, + interopDefault: false, + jsx: { runtime: 'classic' }, + moduleCache: false, + nativeModules: ['typescript'], + virtualModules: { + '@agent-bundle/runtime': AgentRuntime, + react: React, + }, +}; + +const relativeJsSpecifier = /(['"])(\.\.?\/[^'"\n]*)\.js\1/gu; + +/** + * Project code imports its TypeScript siblings by their emitted `.js` name + * (`moduleResolution: NodeNext`); the build resolves those through Rspack's + * `extensionAlias`. jiti only retries `.js` as `.ts`, so a `.js` specifier + * whose source is a `.tsx` component never resolves. Point it at the file on + * disk before the transform sees the module. + */ +const rewriteTsxSpecifiers = ({ filename, source }: TransformOptions): string => { + if (filename === undefined) return source; + const directory = dirname(filename); + return source.replace(relativeJsSpecifier, (match, quote: string, specifier: string) => { + const stem = resolve(directory, specifier); + if (existsSync(`${stem}.js`) || existsSync(`${stem}.ts`) || !existsSync(`${stem}.tsx`)) return match; + return `${quote}${specifier}.tsx${quote}`; + }); +}; + +const baseJiti = createJiti(import.meta.url, jitiOptions); +const jiti = createJiti(import.meta.url, { + ...jitiOptions, + transform: (options) => ({ code: baseJiti.transform({ ...options, source: rewriteTsxSpecifiers(options) }) }), +}); + +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, + ...(request.manifest.routes[request.routeId]?.kind === 'tool' + ? { + mcp: AgentRuntime.documentToCallToolResult(rendered.document, { + structuredContent: rendered.result, + }) as JsonObject, + } + : {}), + 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-result.ts b/packages/agent-bundle/src/dev/routes/route-invocation-result.ts new file mode 100644 index 000000000..8d1386eda --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-result.ts @@ -0,0 +1,26 @@ +import type { AgentDocument, AgentRenderEvent } from '@agent-bundle/runtime'; + +import type { JsonValue } from '../../core/strict-json.ts'; +import type { RequestContextProvenance } from '../../contracts/request-provenance.ts'; +import type { + RouteInvocationProjection, + RouteInvocationProvider, + RouteInvocationSummary, +} from './route-invocation.ts'; + +export interface RouteInvocation extends RouteInvocationSummary { + readonly context: RequestContextProvenance; + /** The final Agent Document; absent when rendering failed before a document existed. */ + readonly document?: AgentDocument; + /** The production `shell | progress | replace | error | complete` stream, in order. */ + readonly events: readonly AgentRenderEvent[]; + 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; +} + +/** `GET /api/routes/invocations/` and `POST /api/routes/invocations`. */ +export interface RouteInvocationResponse { + readonly invocation: RouteInvocation; +} 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..52fa516f1 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-routes.ts @@ -0,0 +1,185 @@ +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, + RouteInvocationResponse, +} from './route-invocation-result.ts'; +import type { + RouteInvocationListResponse, + RouteInvocationRequest, +} 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 { + close?(): Promise | void; + 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(): Promise { + this.#closed = true; + return Promise.resolve(this.#service?.close?.()); + } + + 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..34ef96dc5 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -0,0 +1,832 @@ +import { randomBytes } from 'node:crypto'; +import { fork, type ChildProcess } 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 type { AgentDocument } 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 { sleep } from '../../core/async.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 { taskkill, terminateProcessTree, waitForProcessTreeExit } from '../../services/process-tree.ts'; +import type { AgentBundleTestManifest, TestableScriptDescriptor } from '../../test/manifest.ts'; +import type { ScriptPlaygroundResult, ScriptPlaygroundRunRequest } from '../playground/script-playground-service.ts'; +import type { RouteInvocation } from './route-invocation-result.ts'; +import type { + 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 childTerminationGraceMs = 250; +const childTerminationPollMs = 10; +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 { + /** + * The published build a plain script runs from, and a target whose layout + * emits the `scripts/` directory. Absent while no build is published. + */ + readonly artifact?: Readonly<{ epochId: string; target: string }>; + readonly fixtures?: Readonly>; + readonly manifest: AgentBundleTestManifest; + readonly targets: readonly RouteInvocationEventHost[]; +} + +export interface RouteInvocationScriptRunner { + run(request: ScriptPlaygroundRunRequest): Promise; +} + +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 scripts?: RouteInvocationScriptRunner; + 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; + /** Runtime-owned MCP projection, computed inside the runtime-bound child. */ + readonly mcp?: JsonObject; + 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 plainScriptFor = (prepared: RouteInvocationPreparedProject, route: RouteManifestRoute): TestableScriptDescriptor | undefined => + route.kind === 'script' + ? prepared.manifest.scripts.find((script) => script.routeId === route.id && !script.rendered) + : undefined; + +/** + * Plain scripts have no route component for the Agent renderer. Run the + * emitted executable and project its output into the invocation result. + */ +const runPlainScript = async ( + scripts: RouteInvocationScriptRunner | undefined, + prepared: RouteInvocationPreparedProject, + script: TestableScriptDescriptor, + input: JsonValue, + signal: AbortSignal, +): Promise => { + if (scripts === undefined) throw new Error('No script runner is available for a plain script.'); + if (prepared.artifact === undefined) throw new Error('A plain script runs from the published build; none is published.'); + const startedAt = performance.now(); + const run = await scripts.run({ + epochId: prepared.artifact.epochId, + scriptId: script.routeId, + signal, + target: prepared.artifact.target, + }); + const document: AgentDocument = { + root: { kind: 'text', text: run.stdout }, + status: run.exitCode === 0 ? 'success' : 'represented-error', + version: 1, + }; + return deepFreeze({ + document, + events: [{ document, sequence: 1, type: 'complete' }], + input, + renderDurationMs: performance.now() - startedAt, + result: { exitCode: run.exitCode, stderr: run.stderr, stdout: run.stdout }, + }); +}; + +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 alreadyExited = (child: ChildProcess): boolean => + child.pid === undefined || typeof child.exitCode === 'number' || typeof child.signalCode === 'string'; + +const waitForExit = (child: ChildProcess): Promise => new Promise((resolvePromise) => { + if (alreadyExited(child)) { + resolvePromise(); + return; + } + child.once('exit', () => resolvePromise()); +}); + +const terminateTree = (child: ChildProcess, signal: NodeJS.Signals): Promise => + terminateProcessTree(child, signal, { + onTreeTerminationFailure: () => undefined, + platform: process.platform, + taskkill, + }); + +const treeExited = (child: ChildProcess): Promise => waitForProcessTreeExit(child, { + platform: process.platform, + pollMilliseconds: childTerminationPollMs, + timeoutMilliseconds: childTerminationGraceMs, +}); + +const terminateChild = async (child: ChildProcess): Promise => { + await terminateTree(child, 'SIGTERM'); + const graceful = await Promise.race([ + waitForExit(child).then(() => true), + sleep(childTerminationGraceMs).then(() => false), + ]); + if (graceful && await treeExited(child)) return; + await terminateTree(child, 'SIGKILL'); + await waitForExit(child); + await treeExited(child); +}; + +const renderInChild = async ( + request: RouteInvocationChildRequest, + signal: AbortSignal, +): Promise => { + if (signal.aborted) throw 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, + detached: process.platform !== 'win32', + 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)); + const response = new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const cleanup = (): void => { + signal.removeEventListener('abort', abort); + child.removeListener('exit', exited); + child.removeListener('message', receive); + }; + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + cleanup(); + action(); + }; + const abort = (): void => 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 }); + // Kept for the child's whole life: a `kill()` that fails after the reply + // still emits `error`, and an unobserved one would crash the dev server. + child.on('error', fail); + child.once('exit', exited); + child.once('message', receive); + child.send(request, (error) => { + if (error !== null) fail(error); + }); + }); + try { + return await response; + } finally { + await terminateChild(child); + } +}; + +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, + mcp: JsonObject | undefined, + document: NonNullable, + manifest: RouteManifest, + prepared: RouteInvocationPreparedProject, + registry: TargetRegistry, +): RouteInvocation['projection'] => { + if (route.kind === 'tool') { + if (mcp === undefined) throw new Error('Route invocation child omitted the tool MCP projection.'); + return deepFreeze({ mcp }); + } + 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); + // A plain script's exit code is its process status, carried in `result`; + // a rendered script exits zero like a rendered CLI command. + const policy = route.kind === 'script' + ? (plainScriptFor(prepared, route) === undefined ? 'zero' : 'result') + : 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 #controllers = new Set(); + readonly #history: InvocationRingBuffer; + readonly #manifest: RouteManifestRouteService; + readonly #now: () => Date; + readonly #pending = new Set>(); + readonly #prepared: () => RouteInvocationPreparedProject; + readonly #registry: TargetRegistry; + readonly #renderChild: NonNullable; + readonly #scripts: RouteInvocationScriptRunner | undefined; + readonly #semaphore: InvocationSemaphore; + readonly #timeoutMs: number; + #closed = false; + + 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.#scripts = options.scripts; + 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 close(): Promise { + this.#closed = true; + for (const controller of this.#controllers) { + controller.abort(new DOMException('Route invocation service closed.', 'AbortError')); + } + await Promise.allSettled([...this.#pending]); + } + + async invoke(request: RouteInvocationRequest): Promise { + let manifest: RouteManifest; + let prepared: RouteInvocationPreparedProject; + try { + manifest = this.#manifest.manifest(); + prepared = this.#prepared(); + } catch (error) { + if (error instanceof RouteInvocationRequestError) throw error; + 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 running = this.#semaphore.run(async () => { + const controller = new AbortController(); + this.#controllers.add(controller); + if (this.#closed) { + controller.abort(new DOMException('Route invocation service closed.', 'AbortError')); + } + const timeout = setTimeout(() => controller.abort(new DOMException('Route invocation timed out.', 'TimeoutError')), this.#timeoutMs); + let child: RouteInvocationChildResult; + const plainScript = plainScriptFor(prepared, route); + try { + child = plainScript === undefined + ? await this.#renderChild({ + ...(request.args === undefined ? {} : { args: request.args }), + context, + input, + manifest: prepared.manifest, + routeId: route.id, + }, controller.signal) + : await runPlainScript(this.#scripts, prepared, plainScript, input, controller.signal); + } catch (error) { + const completedAt = this.#now(); + return failedInvocation({ + code: ROUTE_INVOCATION_CHILD_FAILURE_CODE, + completedAt, + context, + id, + manifest, + message: controller.signal.reason instanceof DOMException && controller.signal.reason.name === 'TimeoutError' + ? 'Route invocation child timed out.' + : controller.signal.aborted + ? 'Route invocation child stopped because the service closed.' + : `${plainScript === undefined ? 'Route invocation child' : 'Script run'} failed: ${error instanceof Error ? error.message : String(error)}`, + request: { ...request, input }, + route, + startedAt, + }); + } finally { + clearTimeout(timeout); + this.#controllers.delete(controller); + } + const projectionStartedAt = this.#now(); + const projection = invocationProjection( + route, + request, + rawInput, + child.result, + child.mcp, + 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.#pending.add(running); + let invocation: RouteInvocation; + try { + invocation = await running; + } finally { + this.#pending.delete(running); + } + this.#history.push(invocation); + return invocation; + } +} 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..922e55669 --- /dev/null +++ b/packages/agent-bundle/src/dev/routes/route-invocation.ts @@ -0,0 +1,127 @@ +/** + * 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). + * + * Runtime-free. The peer-typed result fields live in + * `route-invocation-result.ts`, so project-event declarations can expose + * invocation summaries without requiring the optional runtime peer. + */ +import type { Diagnostic } from '../../core/diagnostics.ts'; +import type { JsonObject, JsonValue } from '../../core/strict-json.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; +} + +/** One row of `GET /api/routes/invocations`: the envelope without its streams, for lists and the trace. */ +export interface RouteInvocationSummary { + readonly completedAt: string; + 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[]; + readonly event?: RouteInvocationEvent; + 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 routeId: string; + readonly source: string; + readonly sourceRevision: string; + readonly startedAt: string; + readonly status: RouteInvocationStatus; + readonly timings: readonly RouteInvocationTiming[]; +} + +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/types.ts b/packages/agent-bundle/src/dev/types.ts index 120b1b2be..2936017d0 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[]; @@ -320,6 +321,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/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 46938d414..3170df9e3 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -50,8 +50,15 @@ import { PlaygroundOrchestrationService } from './playground/playground-orchestr import { PlaygroundStore as PlaygroundService } from './playground/playground-store.ts'; import { createDevPlatformRuntime } from './platform-run.ts'; import type { DevPlatformRuntime } from './platform-runtime.ts'; -import { ProjectService } from './project-service.ts'; +import { ProjectService, type PreparedProject } from './project-service.ts'; import { emptyCompiledRouteGraph } from '../routes/graph.ts'; +import { testManifestFromRouteGraph } from '../test/manifest.ts'; +import type { RouteInvocationEventHost } from './routes/route-invocation.ts'; +import { + ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + RouteInvocationRequestError, + RouteInvocationService, +} from './routes/route-invocation-service.ts'; import { routeManifestFor } from './routes/route-manifest.ts'; import type { RouteManifestRouteService } from './routes/route-manifest-routes.ts'; import { DevRuntimeController } from './runtime-controller.ts'; @@ -605,6 +612,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun let latestValidPreparedProject = initialPreparedProject.source.state === 'ready' && initialPreparedProject.model !== undefined ? initialPreparedProject : undefined; + let latestPublishedPreparedProject: PreparedProject | undefined; const topologyProviderSessionId = randomUUID(); let runtimeTopologyChanged = false; let status: () => ProjectStatus = () => deepFreeze({ @@ -746,6 +754,9 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun }); } }, + onPublishedProject: (prepared) => { + latestPublishedPreparedProject = prepared; + }, outputPaths: [ 'dist', initialPreparedProject.artifactDistPath, @@ -841,13 +852,14 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun projectRoot: root, storageRoot: join(root, '.agent-bundle', 'playground'), }); + const scriptPlayground = new ScriptPlaygroundService({ epochStore, registry, platformRuntime }); const playground = new PlaygroundOrchestrationService({ coordinator, epochStore, hookPlayground, mcpSessions, native: new NativePlaygroundService({ projectRoot: root, platformRuntime }), - scripts: new ScriptPlaygroundService({ epochStore, registry, platformRuntime }), + scripts: scriptPlayground, skillDocuments, trace, }); @@ -856,7 +868,7 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun // route discovery never runs a second time for the browser. const routeManifest: RouteManifestRouteService = { manifest: () => { - const prepared = latestValidPreparedProject; + const prepared = latestPublishedPreparedProject; if ( prepared === undefined || prepared.model === undefined || @@ -866,12 +878,68 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun } return routeManifestFor( prepared.routeGraph ?? emptyCompiledRouteGraph, - prepared.source.revision, + status().source.revision ?? prepared.source.revision, prepared.model.state, prepared.model.notices, ); }, }; + const routeInvocations = new RouteInvocationService({ + manifest: routeManifest, + prepared: () => { + const prepared = latestPublishedPreparedProject; + if (prepared === undefined || prepared.model === undefined) { + throw new Error('No valid prepared project is available for route invocation.'); + } + const artifact = status().artifact; + if ( + artifact.state !== 'active' || + prepared.source.revision === undefined || + artifact.activeEpoch.projectRevision !== prepared.source.revision + ) { + throw new RouteInvocationRequestError( + ROUTE_INVOCATION_MANIFEST_UNAVAILABLE_CODE, + 'The source is newer than the published build. Rebuild before invoking routes.', + 409, + ); + } + const targets = prepared.model.targets + .map((target) => target.name) + .filter((target): target is RouteInvocationEventHost => + target === 'claude' || target === 'codex' || target === 'cursor'); + // Emitted scripts live once at the composite root; any selected target + // whose layout has a scripts directory reads the same file. + const scriptTarget = prepared.model.targets + .map((target) => target.name) + .find((target) => registry.artifactLayout(target).scripts !== undefined); + return Object.freeze({ + ...(scriptTarget === undefined ? {} : { artifact: { epochId: artifact.activeEpoch.id, target: scriptTarget } }), + manifest: testManifestFromRouteGraph({ + apps: prepared.model.mcpApps, + configPath: prepared.configPath, + diagnostics: prepared.diagnostics, + graph: prepared.routeGraph ?? emptyCompiledRouteGraph, + plugin: { + name: prepared.model.metadata.name, + ...(prepared.model.metadata.packageName === undefined + ? {} + : { packageName: prepared.model.metadata.packageName }), + ...(prepared.model.metadata.packageVersion === undefined + ? {} + : { packageVersion: prepared.model.metadata.packageVersion }), + version: prepared.model.metadata.version, + }, + projectRoot: prepared.root, + scripts: prepared.model.scripts, + ...(prepared.model.state === undefined ? {} : { state: prepared.model.state }), + targets, + }), + targets, + }); + }, + registry, + scripts: scriptPlayground, + }); const agentApi = agentApiEnabled ? new AgentApi({ artifacts, @@ -925,12 +993,22 @@ const startDevServerSession = async (options: StartDevServerOptions, platformRun playground, port: options.port, routeManifest, + routeInvocations, ...(runtime === undefined ? {} : { runtime }), skillDocuments, ...(options.workbenchDevOrigins === undefined || options.workbenchDevOrigins.length === 0 ? {} : { workbenchDevOrigins: options.workbenchDevOrigins }), }); + if (latestPublishedPreparedProject === undefined) { + const artifact = status().artifact; + if ( + (artifact.state === 'active' || artifact.state === 'stale') && + initialPreparedProject.source.revision === artifact.activeEpoch.projectRevision + ) { + latestPublishedPreparedProject = initialPreparedProject; + } + } clientSurfaces.bindHostOrigin(foreground.url); // Linearize Workbench-owned runtime proxy acquisition before Foreground // begins its asynchronous App/SSE drain. The coordinator repeats this fence 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/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..f5e53991f 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,13 @@ 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 ApplicationTreeManifestSources, + 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 +43,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 +76,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 +108,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 +126,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 +147,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 +166,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 +225,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 +241,48 @@ 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?: NonNullable; 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 +291,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 +397,40 @@ export const inspectWorkbenchSurface = async ( targets: targets.length, }), graph, + inspection: { + hooks: model.hooks.flatMap((hook) => hook.targets + .filter((target) => targets.includes(target)) + .map((target) => ({ + event: hook.eventRoute?.event ?? hook.event, + id: hook.id, + name: hook.name, + path: hook.provenance.sourcePath, + target, + }))), + mcpServers: model.mcpServers.flatMap((server) => server.targets + .filter((target) => targets.includes(target)) + .map((target) => ({ + kind: server.transport, + name: server.name, + target, + }))), + scripts: model.scripts.flatMap((script) => script.targets + .filter((target) => targets.includes(target)) + .map((target) => ({ + file: { path: script.provenance.sourcePath }, + id: script.id, + name: script.name, + target, + }))), + }, 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/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/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 4ee9e0977..1705ba166 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -27,7 +27,7 @@ it('copies stable prebuilt workbench assets and the exact app-renderer license i await buildPackage(); await expect(access(join(packageRoot, 'dist', 'workbench', 'index.html'))).resolves.toBeUndefined(); - await expect(readFile(join(packageRoot, 'dist', 'workbench', 'static', 'js', 'index.js'), 'utf8')).resolves.toContain('Bundle dashboard'); + await expect(readFile(join(packageRoot, 'dist', 'workbench', 'static', 'js', 'index.js'), 'utf8')).resolves.toContain('Workbench navigation'); await expect(readFile(join(packageRoot, 'dist', 'workbench', 'THIRD_PARTY_NOTICES'), 'utf8')).resolves.toContain('MCP Inspector'); await expect(readFile(join(packageRoot, 'dist', 'workbench', appRendererLicense), 'utf8')).resolves.toBe( await readFile(join(workbenchRoot, appRendererLicense), 'utf8'), @@ -100,6 +100,89 @@ it('serves prebuilt workbench assets from an installed tarball without the repos } }, 60_000); +it('packages both react-server render children and renders a route invocation from an installed tarball', async () => { + await buildPackage(); + await expect(access(join(packageRoot, 'dist', 'lifecycle-render-child.js'))).resolves.toBeUndefined(); + await expect(access(join(packageRoot, 'dist', 'route-invocation-child.js'))).resolves.toBeUndefined(); + + const [agentBundle, runtime, markdownStream] = await Promise.all([ + sharedPackedTarball('agent-bundle'), + sharedPackedTarball('runtime'), + sharedPackedTarball('markdown-stream'), + ]); + const listing = await execFile('tar', ['-tf', agentBundle.tarball]); + expect(listing.stdout).toContain('package/dist/lifecycle-render-child.js'); + expect(listing.stdout).toContain('package/dist/route-invocation-child.js'); + + const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-consumer-')); + const project = join(consumer, 'project'); + try { + await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); + await execFile('npm', ['install', ...cachedNpmInstallArguments, + agentBundle.tarball, + runtime.tarball, + markdownStream.tarball, + 'react@19.2.8', + 'react-dom@19.2.8', + 'zod@4.4.3', + ], { cwd: consumer, env: installedEnvironment() }); + await mkdir(join(project, 'src', 'mcp', 'status', 'tools'), { recursive: true }); + await Promise.all([ + writeFile(join(project, 'package.json'), '{"type":"module"}\n'), + writeFile(join(project, 'agent-bundle.config.ts'), "export default { plugin: { name: 'packed-route-invocation', version: '1.0.0' }, targets: ['claude'] };\n"), + writeFile(join(project, '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')), + ]); + + const script = [ + "import { startDevServer } from 'agent-bundle';", + `const session = await startDevServer({ open: false, port: 0, root: ${JSON.stringify(project)} });`, + 'try {', + " const bootstrap = await fetch(`${session.url}/api/project/session`, { headers: { 'sec-fetch-site': 'same-origin' } });", + ' const { token } = await bootstrap.json();', + " const headers = { 'content-type': 'application/json', origin: session.url, 'x-agent-bundle-session': token };", + ' const deadline = Date.now() + 60_000;', + ' while ((await fetch(`${session.url}/api/routes/manifest`, { headers })).status !== 200) {', + " if (Date.now() > deadline) throw new Error('The route manifest never became available.');", + ' await new Promise((resolve) => setTimeout(resolve, 250));', + ' }', + ' const response = await fetch(`${session.url}/api/routes/invocations`, {', + " body: JSON.stringify({ input: { service: 'catalog' }, routeId: 'tool:status/report' }),", + ' headers,', + " method: 'POST',", + ' });', + ' console.log(JSON.stringify({ body: await response.json(), status: response.status }));', + '} finally { await session.close(); }', + ].join('\n'); + const invoked = await execFile(process.execPath, ['--input-type=module', '--eval', script], { cwd: consumer, env: installedEnvironment() }); + expect(JSON.parse(invoked.stdout)).toMatchObject({ + body: { + invocation: { + diagnostics: [], + projection: { mcp: { structuredContent: { service: 'catalog' } } }, + result: { service: 'catalog' }, + status: 'succeeded', + }, + }, + status: 200, + }); + } finally { + await rm(consumer, { force: true, recursive: true }); + } +}, 180_000); + it('runs the Agent API from an omit-dev installed tarball with its runtime MCP dependencies', async () => { const { tarball } = await sharedPackedTarball('agent-bundle'); const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-agent-api-consumer-')); diff --git a/packages/agent-bundle/tests/dist-optional-peer-imports.test.ts b/packages/agent-bundle/tests/dist-optional-peer-imports.test.ts new file mode 100644 index 000000000..c3e1c4faa --- /dev/null +++ b/packages/agent-bundle/tests/dist-optional-peer-imports.test.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +const packageRoot = join(process.cwd(), 'packages/agent-bundle'); +const staticRuntimeImport = /\b(?:import|export)\s+[^;\n]*?\sfrom\s*["']@agent-bundle\/runtime(?:\/[^"']*)?["']/u; + +it('keeps optional runtime peers out of eagerly loaded public bundles', async () => { + for (const entry of ['api.js', 'test.js']) { + const source = await readFile(join(packageRoot, 'dist', entry), 'utf8'); + expect(source, entry).not.toMatch(staticRuntimeImport); + } +}); 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..cba58b6aa --- /dev/null +++ b/packages/agent-bundle/tests/route-invocation-dev-server.test.ts @@ -0,0 +1,485 @@ +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import type { RouteInvocationResponse } from '../src/dev/routes/route-invocation-result.ts'; +import type { RouteInvocationListResponse } from '../src/dev/routes/route-invocation.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 { createProjectFixture } from './helpers/project-fixture.ts'; +import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; +import { replaceWatchedSourceAndAwaitRebuild } from './support/watched-files.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': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n', + 'src/cli/greet.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + "export const config = { description: 'Greets one name.', positionals: ['name'] };", + "export const inputSchema = z.object({ name: z.string().min(1) }).strict();", + "export const resultSchema = z.object({ message: z.string() }).strict();", + '', + 'export default async function Greet({ input }) {', + ' const message = `Hello, ${input.name}.`;', + ' return createElement(Agent.Result, { value: { message } }, createElement(Agent.Text, null, message));', + '}', + '', + ].join('\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'), + 'src/scripts/summary.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + 'export default async function Summary({ argv }) {', + " return createElement(Agent.Result, { value: { arguments: argv } }, createElement(Agent.Text, null, 'Summary ready.'));", + '}', + '', + ].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 { + server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: assetsRoot }), + open: false, + port: 0, + root: project.root, + }); + 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, + }; + try { + await expect.poll( + async () => fetch(`${server!.url}/api/routes/manifest`, { headers }).then((response) => response.status), + { timeout: 10_000 }, + ).toBe(200); + } catch (error) { + throw new Error(`Route manifest did not become ready: ${JSON.stringify(server.status())}`, { cause: error }); + } + + 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 cliResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ input: { name: 'Ada' }, routeId: 'cli:greet' }), + headers, + method: 'POST', + }); + expect(cliResponse.status).toBe(200); + const cli = await cliResponse.json() as RouteInvocationResponse; + expect(cli.invocation).toMatchObject({ + kind: 'cli', + projection: { + cli: { + exitCode: 0, + text: expect.stringContaining('Hello, Ada.'), + }, + }, + result: { message: 'Hello, Ada.' }, + status: 'succeeded', + }); + + const scriptResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'script:summary' }), + headers, + method: 'POST', + }); + expect(scriptResponse.status).toBe(200); + const script = await scriptResponse.json() as RouteInvocationResponse; + expect(script.invocation).toMatchObject({ + kind: 'script', + projection: { + cli: { + exitCode: 0, + text: expect.stringContaining('Summary ready.'), + }, + }, + status: 'succeeded', + }); + + const listedResponse = await fetch(`${server.url}/api/routes/invocations?limit=4`, { headers }); + const listed = await listedResponse.json() as RouteInvocationListResponse; + expect(listed.invocations.map((invocation) => invocation.id)).toEqual([ + script.invocation.id, + cli.invocation.id, + 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 reportRoutePath = join(project.root, 'src/mcp/status/tools/report.tsx'); + const failedAttempt = await replaceWatchedSourceAndAwaitRebuild( + server, + project.root, + reportRoutePath, + [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "import './missing.js';", + '', + "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 }) {', + ' const service = `rebuilt-${input.service}`;', + ' return createElement(Agent.Result, { value: { service } }, createElement(Agent.Text, null, `Service ${service}`));', + '}', + '', + ].join('\n'), + { timeoutMs: 10_000 }, + ); + expect(failedAttempt.outcome).toBe('failed'); + expect(server.status().build.state).toBe('failed'); + + const staleManifestResponse = await fetch(`${server.url}/api/routes/manifest`, { headers }); + const staleManifest = await staleManifestResponse.json() as RouteManifestResponse; + const staleRouteIds = staleManifest.manifest.servers.flatMap((manifestServer) => + manifestServer.routes.map((route) => route.id)); + expect(staleRouteIds).toContain('tool:status/report'); + + const staleInvocationResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ input: { service: 'published' }, routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(staleInvocationResponse.status).toBe(409); + await expect(staleInvocationResponse.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8232', + message: 'The source is newer than the published build. Rebuild before invoking routes.', + }, + }); + + const repairedAttempt = await replaceWatchedSourceAndAwaitRebuild( + server, + project.root, + reportRoutePath, + [ + "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 }) {', + ' const service = `rebuilt-${input.service}`;', + ' return createElement(Agent.Result, { value: { service } }, createElement(Agent.Text, null, `Service ${service}`));', + '}', + '', + ].join('\n'), + { timeoutMs: 10_000 }, + ); + expect(repairedAttempt.outcome).toBe('succeeded'); + const repairedInvocationResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ input: { service: 'published' }, routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(repairedInvocationResponse.status).toBe(200); + const repairedInvocation = await repairedInvocationResponse.json() as RouteInvocationResponse; + expect(repairedInvocation.invocation).toMatchObject({ + result: { service: 'rebuilt-published' }, + status: 'succeeded', + }); + + 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 }); + } +}); + +it('publishes invocation routes only after a successful initial or recovered build', { timeout: 90_000 }, async () => { + const project = await createProjectFixture({ + config: [ + 'export default {', + " plugin: { name: 'route-invocation-publication-gate', version: '1.0.0' },", + " targets: ['claude'],", + '};', + '', + ].join('\n'), + files: { + 'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n', + 'src/mcp/status/tools/report.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "import './missing.js';", + '', + "export const inputSchema = z.object({}).strict();", + 'export const resultSchema = z.object({ version: z.string() }).strict();', + '', + 'export default async function Report() {', + " return createElement(Agent.Result, { value: { version: 'published' } }, createElement(Agent.Text, null, 'Published route.'));", + '}', + '', + ].join('\n'), + }, + prefix: 'agent-bundle-route-invocation-publication-gate-', + }); + const assetsRoot = join(project.root, 'workbench'); + const reportRoutePath = join(project.root, 'src/mcp/status/tools/report.tsx'); + 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 publication gate'), + ]); + try { + server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: assetsRoot }), + open: false, + port: 0, + root: project.root, + }); + let bootstrap = await fetch(`${server.url}/api/project/session`, { + headers: { 'sec-fetch-site': 'same-origin' }, + }); + let session = await bootstrap.json() as { readonly token: string }; + let headers = { + 'content-type': 'application/json', + origin: server.url, + 'x-agent-bundle-session': session.token, + }; + + await expect.poll( + () => server!.status().build.state, + { timeout: 10_000 }, + ).toBe('failed'); + const unavailableManifest = await fetch(`${server.url}/api/routes/manifest`, { headers }); + expect(unavailableManifest.status).toBe(409); + const unavailableInvocation = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(unavailableInvocation.status).toBe(409); + await expect(unavailableInvocation.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8232', + message: 'No published build and route manifest are available.', + }, + }); + + const repairedAttempt = await replaceWatchedSourceAndAwaitRebuild( + server, + project.root, + reportRoutePath, + [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + '', + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ version: z.string() }).strict();', + '', + 'export default async function Report() {', + " return createElement(Agent.Result, { value: { version: 'published' } }, createElement(Agent.Text, null, 'Published route.'));", + '}', + '', + ].join('\n'), + { timeoutMs: 10_000 }, + ); + expect(repairedAttempt.outcome, JSON.stringify(repairedAttempt.diagnostics)).toBe('succeeded'); + const publishedInvocationResponse = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(publishedInvocationResponse.status).toBe(200); + const publishedInvocation = await publishedInvocationResponse.json() as RouteInvocationResponse; + expect(publishedInvocation.invocation).toMatchObject({ + result: { version: 'published' }, + status: 'succeeded', + }); + + await server.close(); + server = undefined; + await writeFile(reportRoutePath, [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "import { z } from 'zod';", + "import './missing.js';", + '', + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ version: z.string() }).strict();', + '', + 'export default async function Report() {', + " return createElement(Agent.Result, { value: { version: 'unpublished' } }, createElement(Agent.Text, null, 'Unpublished route.'));", + '}', + '', + ].join('\n')); + server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: assetsRoot }), + open: false, + port: 0, + root: project.root, + }); + bootstrap = await fetch(`${server.url}/api/project/session`, { + headers: { 'sec-fetch-site': 'same-origin' }, + }); + session = await bootstrap.json() as { readonly token: string }; + headers = { + 'content-type': 'application/json', + origin: server.url, + 'x-agent-bundle-session': session.token, + }; + + expect(server.status().artifact.state).toBe('stale'); + const recoveredManifestResponse = await fetch(`${server.url}/api/routes/manifest`, { headers }); + expect(recoveredManifestResponse.status).toBe(409); + await expect(recoveredManifestResponse.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8121', + message: 'Route manifest is not available.', + }, + }); + const recoveredInvocation = await fetch(`${server.url}/api/routes/invocations`, { + body: JSON.stringify({ routeId: 'tool:status/report' }), + headers, + method: 'POST', + }); + expect(recoveredInvocation.status).toBe(409); + await expect(recoveredInvocation.json()).resolves.toEqual({ + diagnostic: { + code: 'AB8232', + message: 'No published build and route manifest are available.', + }, + }); + } 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..3885d269e --- /dev/null +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -0,0 +1,308 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import type { RouteInvocation } from '../src/dev/routes/route-invocation-result.ts'; +import { + InvocationRingBuffer, + RouteInvocationService, + RouteInvocationRequestError, + invocationSummary, + parseRouteInvocationRequest, +} from '../src/dev/routes/route-invocation-service.ts'; +import type { RouteManifest } from '../src/dev/routes/route-manifest.ts'; +import type { CompiledRouteGraph } from '../src/routes/types.ts'; +import { testManifestFromRouteGraph } from '../src/test/manifest.ts'; +import { isProcessGone } from './support/bin-process.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'); +}); + +it('aborts and drains a running render when the service closes', async () => { + const route = { + config: [], + id: 'tool:fixture/echo', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:fixture', + source: 'src/mcp/fixture/tools/echo.tsx', + } as const; + const service = new RouteInvocationService({ + manifest: { + manifest: () => ({ + diagnostics: [], + digest: 'digest', + events: [], + providers: [], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [route] }], + sourceRevision: 'revision', + }), + }, + prepared: () => ({ + manifest: { projectRoot: '/project' } as never, + targets: ['claude'], + }), + renderChild: (_request, signal) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + }); + + const pending = service.invoke({ input: {}, routeId: route.id }); + await Promise.resolve(); + await service.close(); + + await expect(pending).resolves.toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'AB8236' })], + status: 'failed', + }); +}); + +interface LeakingRouteProject { + readonly pids: () => Promise | undefined>; + readonly root: string; + readonly service: (options?: Readonly<{ timeoutMs?: number }>) => RouteInvocationService; +} + +/** A tool route that holds an interval and a forked descendant, and writes both pids. */ +const leakingRouteProject = async (behaviour: 'hang' | 'reply'): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-invocation-child-')); + const relativePath = 'src/mcp/fixture/tools/leak.tsx'; + const source = join(root, relativePath); + const pidsPath = join(root, 'pids.json'); + await mkdir(dirname(source), { recursive: true }); + await writeFile(source, [ + "import { spawn } from 'node:child_process';", + "import { writeFileSync } from 'node:fs';", + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + '', + 'export default async function Leak() {', + ' setInterval(() => {}, 60_000);', + " const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 60_000)'], { stdio: 'ignore' });", + ` writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify({ child: process.pid, descendant: descendant.pid }));`, + ...(behaviour === 'hang' ? [' await new Promise(() => {});'] : []), + " return createElement(Agent.Result, null, createElement(Agent.Text, null, 'leaked'));", + '}', + '', + ].join('\n')); + const compiled = { + config: {}, + id: 'tool:fixture/leak', + kind: 'tool', + provenance: { kind: 'conventional', relativePath }, + serverId: 'mcp:fixture', + source, + } as const; + const graph = { + diagnostics: [], + digest: 'digest', + events: [], + providers: [], + scripts: [], + servers: [{ id: 'mcp:fixture', mode: 'generated', name: 'fixture', routes: [compiled] }], + } satisfies CompiledRouteGraph; + const manifest: RouteManifest = { + diagnostics: [], + digest: 'digest', + events: [], + providers: [], + scripts: [], + servers: [{ + id: 'mcp:fixture', + mode: 'generated', + name: 'fixture', + routes: [{ + config: [], + id: compiled.id, + kind: compiled.kind, + provenance: { kind: 'conventional' }, + serverId: compiled.serverId, + source: relativePath, + }], + }], + sourceRevision: 'revision', + }; + const prepared = Object.freeze({ + manifest: testManifestFromRouteGraph({ graph, projectRoot: root }), + targets: ['claude' as const], + }); + return { + pids: async () => { + if (!existsSync(pidsPath)) return undefined; + return JSON.parse(await readFile(pidsPath, 'utf8')) as Readonly<{ child: number; descendant: number }>; + }, + root, + service: (options = {}) => new RouteInvocationService({ + manifest: { manifest: () => manifest }, + prepared: () => prepared, + timeoutMs: options.timeoutMs, + }), + }; +}; + +/** A zombie has exited; only a process still scheduled counts as alive. */ +const alive = (pid: number): boolean => { + if (isProcessGone(pid)) return false; + if (process.platform !== 'linux') return true; + try { + return !/\) Z /u.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); + } catch { + return false; + } +}; + +const recordedPids = async (project: LeakingRouteProject): Promise> => { + await expect.poll(() => project.pids(), { interval: 50, timeout: 20_000 }).toBeDefined(); + const pids = await project.pids(); + if (pids === undefined) throw new Error('The leaking route did not record process ids.'); + return pids; +}; + +it('reaps the render child and its descendants after a successful reply', { timeout: 30_000 }, async () => { + const project = await leakingRouteProject('reply'); + try { + const invocation = await project.service().invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const pids = await project.pids(); + + expect(invocation.status).toBe('succeeded'); + if (pids === undefined) throw new Error('The leaking route did not record process ids.'); + expect(alive(pids.child)).toBe(false); + expect(alive(pids.descendant)).toBe(false); + } finally { + await rm(project.root, { force: true, recursive: true }); + } +}); + +it('reaps the render child and its descendants when the invocation times out', { timeout: 30_000 }, async () => { + const project = await leakingRouteProject('hang'); + try { + const service = project.service({ timeoutMs: 8_000 }); + const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const pids = await recordedPids(project); + expect(alive(pids.child)).toBe(true); + expect(alive(pids.descendant)).toBe(true); + + await expect(pending).resolves.toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'AB8236', message: 'Route invocation child timed out.' })], + status: 'failed', + }); + expect(alive(pids.child)).toBe(false); + expect(alive(pids.descendant)).toBe(false); + } finally { + await rm(project.root, { force: true, recursive: true }); + } +}); + +it('reaps the render child and its descendants when the service closes mid-render', { timeout: 30_000 }, async () => { + const project = await leakingRouteProject('hang'); + try { + const service = project.service(); + const pending = service.invoke({ input: {}, routeId: 'tool:fixture/leak' }); + const pids = await recordedPids(project); + expect(alive(pids.child)).toBe(true); + expect(alive(pids.descendant)).toBe(true); + + await service.close(); + expect(alive(pids.child)).toBe(false); + expect(alive(pids.descendant)).toBe(false); + await expect(pending).resolves.toMatchObject({ + diagnostics: [expect.objectContaining({ message: 'Route invocation child stopped because the service closed.' })], + status: 'failed', + }); + } finally { + await rm(project.root, { force: true, recursive: true }); + } +}); 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/rstest-pool-configs.test.ts b/packages/agent-bundle/tests/rstest-pool-configs.test.ts index 961c2a522..6602b1230 100644 --- a/packages/agent-bundle/tests/rstest-pool-configs.test.ts +++ b/packages/agent-bundle/tests/rstest-pool-configs.test.ts @@ -41,7 +41,6 @@ const poolConfigs = [ 'rstest.projection.config.ts', 'rstest.integration.config.ts', 'rstest.packed.config.ts', - 'rstest.evidence.config.ts', 'rstest.mcp-conformance.config.ts', 'rstest.native-host.config.ts', ] as const; @@ -87,7 +86,6 @@ interface PoolExpectation { const expectations: Readonly> = { 'rstest.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, - 'rstest.evidence.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, 'rstest.integration.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, 'rstest.mcp-conformance.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 180_000 }, 'rstest.native-host.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 60_000 }, @@ -169,8 +167,7 @@ describe('process pools', () => { expect(resolved[name].env?.['AGENT_BUNDLE_TEST_TIME_SCALE']).toBe(String(processPoolTimeScale(workers))); }); - it('runs the evidence, conformance and native-host journeys on one worker', () => { - expect(resolved['rstest.evidence.config.ts'].maxWorkers).toBe(1); + it('runs the conformance and native-host journeys on one worker', () => { expect(resolved['rstest.mcp-conformance.config.ts'].maxWorkers).toBe(1); expect(resolved['rstest.native-host.config.ts'].maxWorkers).toBe(1); }); 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..c39befb0d 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'; @@ -27,7 +27,7 @@ it('matches the route manifest and lifecycle inventory a real dev server serves' '', ].join('\n'), files: { - 'package.json': '{"type":"module"}\n', + 'package.json': '{"dependencies":{"@agent-bundle/runtime":"workspace:*","react":"19.2.8","zod":"4.5.4"},"type":"module"}\n', 'src/cli/greet.ts': [ "import { z } from 'zod';", '', @@ -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..059674cad 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,65 @@ 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(['mcp', 'events', 'scripts', 'skills']); + expect(applicationGroup(surface, 'mcp')).toMatchObject({ + label: 'MCP', + servers: [expect.objectContaining({ label: 'status', mode: 'stdio', subgroups: [] })], + }); + 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 +269,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 +305,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 +327,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 +336,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 +358,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 +417,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 }); } diff --git a/packages/workbench/src/advanced/advanced-page.tsx b/packages/workbench/src/advanced/advanced-page.tsx new file mode 100644 index 000000000..5df32facc --- /dev/null +++ b/packages/workbench/src/advanced/advanced-page.tsx @@ -0,0 +1,159 @@ +/** + * Advanced (#600): the secondary destinations behind one sub-nav — Evals + * (Runs · Compare) · Artifact · Protocol · Host diagnostics · Raw logs — each + * mounting the existing page component. The sub-nav is URL-addressable + * (`/advanced/
`); the section components own their own state. + */ +import React, { useEffect, useState } from 'react'; + +import type { ProjectStatus } from '../../../agent-bundle/src/contracts/project.ts'; +import { ArtifactClient } from '../artifacts/artifact-client.ts'; +import { ArtifactsPage } from '../artifacts/artifacts-page.tsx'; +import { downloadBlob } from '../client-helpers.ts'; +import type { ComparisonClient } from '../evals/comparison-client.ts'; +import type { DiscoveryClient } from '../discovery/discovery-client.ts'; +import { DiscoveryPage } from '../discovery/discovery-page.tsx'; +import type { EvalClient } from '../evals/eval-client.ts'; +import { EvalsPage } from '../evals/evals-page.tsx'; +import type { LogClient } from '../logs/log-client.ts'; +import { LogsPage } from '../logs/logs-page.tsx'; +import type { McpAppClient } from '../mcp/mcp-app-client.ts'; +import type { McpInspectorLaunchController } from '../mcp/mcp-inspector-launch-controller.ts'; +import { + McpPage, + mcpPageEmptyServerCatalogFor, + mcpPageServerCatalogFor, + type McpPageController, + type McpPageServerCatalog, +} from '../mcp/mcp-page.tsx'; +import type { McpDownload } from '../mcp/mcp-protocol-trace.ts'; +import { activeEpochFor } from '../shell/build-status-model.ts'; +import { advancedSections, formatWorkbenchLocation, type AdvancedSection, type WorkbenchLocation } from '../shell/workbench-location.ts'; + +export interface AdvancedClients { + readonly appClient: McpAppClient; + readonly artifactClient: ArtifactClient; + readonly comparisonClient: ComparisonClient; + readonly discoveryClient: DiscoveryClient; + readonly evalClient: EvalClient; + readonly logClient: LogClient; +} + +/** The raw MCP session the Protocol section drives; the shell owns its lifetime so it survives navigation. */ +export interface AdvancedProtocolSession { + readonly controller: McpPageController; + readonly inspectorLaunch: McpInspectorLaunchController; + /** Replaces a terminal controller with a fresh idle one. */ + readonly onResetSession: () => void; +} + +export interface AdvancedPageProps { + readonly clients: AdvancedClients; + /** The compiled route manifest's source revision, which Host diagnostics compares installed plugins against. */ + readonly manifestSourceRevision?: string; + readonly onNavigate: (location: WorkbenchLocation) => void; + readonly protocol: AdvancedProtocolSession; + readonly section: AdvancedSection; + readonly status: ProjectStatus; +} + +export const advancedSectionLabels: Readonly> = Object.freeze({ + artifact: 'Artifact', + evals: 'Evals', + hosts: 'Host diagnostics', + logs: 'Raw logs', + protocol: 'Protocol', +}); + +const mcpTargets = ['portable', 'claude', 'codex'] as const; + +const downloadMcpFile = ({ blob, filename }: McpDownload): void => downloadBlob(blob, filename); + +/** + * The raw MCP protocol inspector: the artifact-bound `McpPage` with the + * published epoch's servers as advisory defaults. Unmounting closes any App + * preview the page opened; the session controller itself outlives the section. + */ +const ProtocolSection = ({ appClient, artifactClient, protocol, status }: { + readonly appClient: McpAppClient; + readonly artifactClient: Pick; + readonly protocol: AdvancedProtocolSession; + readonly status: ProjectStatus; +}) => { + const activeEpoch = activeEpochFor(status); + const [serverCatalog, setServerCatalog] = useState(); + const targetOptions = mcpTargets.filter((target) => activeEpoch !== undefined && target in activeEpoch.targetDigests); + const serverCatalogState = activeEpoch !== undefined && (serverCatalog === undefined || serverCatalog.epochId !== activeEpoch.id) + ? 'loading' as const + : 'ready' as const; + const serverOptions = activeEpoch !== undefined && serverCatalogState === 'ready' ? serverCatalog?.options ?? [] : []; + useEffect(() => { + const epochId = activeEpoch?.id; + const request = new AbortController(); + setServerCatalog(undefined); + if (epochId === undefined) return () => request.abort(); + void artifactClient.inspect(epochId, request.signal).then((inspection) => { + const catalog = mcpPageServerCatalogFor(epochId, inspection, request.signal); + if (catalog !== undefined) setServerCatalog(catalog); + }).catch(() => { + const catalog = mcpPageEmptyServerCatalogFor(epochId, request.signal); + if (catalog !== undefined) setServerCatalog(catalog); + }); + return () => request.abort(); + }, [activeEpoch?.id, artifactClient]); + return
+ +
; +}; + +const AdvancedSectionContent = ({ clients, manifestSourceRevision, protocol, section, status }: Omit) => { + switch (section) { + case 'evals': + return ; + case 'artifact': + return ; + case 'protocol': + return ; + case 'hosts': + return ; + case 'logs': + return ; + default: { + const exhaustive: never = section; + return exhaustive; + } + } +}; + +export const AdvancedPage = (props: AdvancedPageProps) => { + const { onNavigate, section } = props; + return
+ +
+ +
+
; +}; diff --git a/packages/workbench/src/application/app-route-workspace.tsx b/packages/workbench/src/application/app-route-workspace.tsx new file mode 100644 index 000000000..a24dc57a9 --- /dev/null +++ b/packages/workbench/src/application/app-route-workspace.tsx @@ -0,0 +1,239 @@ +/** + * The MCP App leaf workspace (#600): the bound App preview iframe is the + * center. An App is a browser surface registered as a resource on the + * generated server and bound to tools through `_meta.ui.resourceUri`, so the + * workspace opens one MCP session against the published build, lists the + * server's tools, defaults to the one bound to this App, and calls it with + * the tool input editor beside the preview. The call result feeds the same + * preview machinery the protocol inspector uses (`src/mcp/**`, imported, not + * copied). Nothing here executes the App outside its sandboxed frame. + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import type { ProjectStatus } from '../../../agent-bundle/src/contracts/project.ts'; +import type { JsonObject } from '../../../agent-bundle/src/contracts/strict-json.ts'; +import { errorMessage, isRecord } from '../client-helpers.ts'; +import { workbenchMcpAppHostContext, type McpAppJsonValue, type McpAppPreviewProfile } from '../mcp/mcp-app-client.ts'; +import { McpAppPreview } from '../mcp/mcp-app-preview.tsx'; +import { McpJsonInput } from '../mcp/mcp-json-input.tsx'; +import { supportedMcpAppPreviewProfiles } from '../mcp/mcp-page.tsx'; +import { createMcpSessionController, type McpSessionController } from '../mcp/mcp-session-controller.ts'; +import type { McpBrowserSessionModel } from '../mcp/mcp-session-model.ts'; +import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { ApplicationLeaf } from './application-tree-model.ts'; +import { WorkspaceHeader } from './executable-route-workspace.tsx'; +import { displayAgentDocumentValue } from './rendered-document.tsx'; +import { publishedEpochFor, type WorkspaceClients } from './workspace-contracts.ts'; +import './workspace.css'; + +export interface AppRouteWorkspaceProps { + readonly clients: Pick; + readonly leaf: ApplicationLeaf; + readonly onNavigate: (location: WorkbenchLocation) => void; + readonly status: ProjectStatus; +} + +export interface McpCatalogTool { + readonly description?: string; + readonly inputSchema?: unknown; + readonly name: string; + readonly resourceUri?: string; +} + +const text = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; + +/** The `ui://` resource this App leaf serves, from its static `config.resourceUri`. */ +export const appResourceUriFor = (leaf: ApplicationLeaf): string | undefined => + leaf.config.find((entry) => entry.key === 'resourceUri' && entry.kind === 'string')?.value; + +/** The server's live tool catalog with each tool's `_meta.ui.resourceUri` binding surfaced. */ +export const catalogToolsFor = (tools: readonly unknown[]): readonly McpCatalogTool[] => Object.freeze(tools.flatMap((entry) => { + if (!isRecord(entry)) return []; + const name = text(entry.name); + if (name === undefined) return []; + const meta = isRecord(entry._meta) ? entry._meta : undefined; + const ui = meta !== undefined && isRecord(meta.ui) ? meta.ui : undefined; + const resourceUri = ui === undefined ? undefined : text(ui.resourceUri); + return [Object.freeze({ + ...(text(entry.description) === undefined ? {} : { description: text(entry.description) }), + ...(entry.inputSchema === undefined ? {} : { inputSchema: entry.inputSchema }), + name, + ...(resourceUri === undefined ? {} : { resourceUri }), + })]; +})); + +/** Tools bound to this App first, then the rest of the server's tools. */ +export const orderedToolsForApp = (tools: readonly McpCatalogTool[], resourceUri: string | undefined): readonly McpCatalogTool[] => Object.freeze([ + ...tools.filter((tool) => resourceUri !== undefined && tool.resourceUri === resourceUri), + ...tools.filter((tool) => resourceUri === undefined || tool.resourceUri !== resourceUri), +]); + +interface ToolCall { + readonly input: JsonObject; + readonly result: McpAppJsonValue; + readonly sessionId: string; + readonly toolName: string; +} + +const preferredTarget = (targets: readonly string[]): string | undefined => + targets.find((target) => target === 'portable') ?? targets[0]; + +/** The App preview as the center, one MCP session per workspace, the bound tool's input beside it. */ +export const AppRouteWorkspace = ({ clients, leaf, onNavigate, status }: AppRouteWorkspaceProps): React.ReactNode => { + const epoch = publishedEpochFor(status); + const targets = useMemo(() => Object.keys(epoch?.targetDigests ?? {}).sort((left, right) => left.localeCompare(right)), [epoch]); + const serverName = leaf.ref.kind === 'app' ? leaf.ref.server : undefined; + const resourceUri = appResourceUriFor(leaf); + const [target, setTarget] = useState(() => preferredTarget(targets)); + const [profile, setProfile] = useState('portable'); + const [model, setModel] = useState(); + const [sessionError, setSessionError] = useState(); + const [selectedTool, setSelectedTool] = useState(); + const [toolInput, setToolInput] = useState(Object.freeze({})); + const [calling, setCalling] = useState(false); + const [callError, setCallError] = useState(); + const [call, setCall] = useState(); + const controllerRef = useRef(undefined); + const host = useMemo(workbenchMcpAppHostContext, []); + + useEffect(() => { + setTarget((previous) => previous !== undefined && targets.includes(previous) ? previous : preferredTarget(targets)); + }, [targets]); + + // One session per (epoch, server, target); closed when any of them changes. + useEffect(() => { + if (epoch === undefined || serverName === undefined || target === undefined) { + setModel(undefined); + return; + } + const controller = createMcpSessionController({ routes: clients.mcpRoutes }); + controllerRef.current = controller; + setSessionError(undefined); + setCall(undefined); + const unsubscribe = controller.subscribe(setModel); + void controller.open({ epochId: epoch.id, serverName, target }).catch((reason: unknown) => { + if (controllerRef.current === controller) setSessionError(errorMessage(reason, 'The MCP session could not be opened.')); + }); + return () => { + unsubscribe(); + if (controllerRef.current === controller) controllerRef.current = undefined; + void controller.close().catch(() => undefined); + }; + }, [clients.mcpRoutes, epoch, serverName, target]); + + const tools = useMemo(() => orderedToolsForApp(catalogToolsFor(model?.catalogs.tools ?? []), resourceUri), [model, resourceUri]); + const boundTools = tools.filter((tool) => resourceUri !== undefined && tool.resourceUri === resourceUri); + const tool = tools.find((candidate) => candidate.name === selectedTool) ?? tools[0]; + + useEffect(() => { + if (tool !== undefined && selectedTool !== tool.name) { + setSelectedTool(tool.name); + setToolInput(Object.freeze({})); + } + }, [selectedTool, tool]); + + const callTool = (input: JsonObject): void => { + const controller = controllerRef.current; + if (controller === undefined || tool === undefined || model?.phase !== 'ready' || calling) return; + setCalling(true); + setCallError(undefined); + const sessionId = model.sessionId; + void controller.invoke({ + id: `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + operation: 'callTool', + request: { arguments: input, name: tool.name }, + }).then( + (result) => { setCall(Object.freeze({ input, result: result as McpAppJsonValue, sessionId, toolName: tool.name })); }, + (reason: unknown) => { setCallError(errorMessage(reason, 'The tool call failed.')); }, + ).finally(() => setCalling(false)); + }; + + const phase = model?.phase ?? 'idle'; + + return
+
+ +
+
+
Server
{serverName ?? '—'}
+
Resource
{resourceUri === undefined ? No static config.resourceUri : {resourceUri}}
+
Build
{epoch === undefined ? 'No published build' : epoch.id}
+
Session
{phase}
+
+
+ + + +
+ {sessionError === undefined ? undefined :

{sessionError}

} + {model?.diagnostics.length ?
    {model.diagnostics.map((diagnostic, index) =>
  • {diagnostic.code} {diagnostic.message}
  • )}
: undefined} +
+
+
+ {epoch === undefined + ?

Publish a build to preview this App: the preview runs against the generated server.

+ : phase !== 'ready' + ?

{phase === 'error' || phase === 'closed' ? 'The MCP session is not available.' : 'Opening the MCP session…'}

+ : tools.length === 0 + ?

The server lists no tools; an App renders from a tool call result.

+ : <> + + {boundTools.length === 0 + ?

No tool declares _meta.ui.resourceUri for this App; call any tool to exercise the preview.

+ : tool !== undefined && !boundTools.includes(tool) + ?

This tool is not bound to the App; its result renders as a plain tool result.

+ : undefined} + {tool === undefined ? undefined : } + {callError === undefined ? undefined :

{callError}

} + } +
+
+ {call === undefined + ?

Call the bound tool to render the App with its result.

+ : } + {call === undefined ? undefined :
+ Tool result +
{displayAgentDocumentValue(call.result)}
+
} +
+
+
+
; +}; 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..62eea99c0 --- /dev/null +++ b/packages/workbench/src/application/application-tree-model.ts @@ -0,0 +1,89 @@ +import type { ArtifactInspection } from '../../../agent-bundle/src/contracts/artifacts.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 { RouteCatalogState } from '../routes/routes-model.ts'; + +export interface ApplicationTreeSources { + readonly inspection?: ArtifactInspection; + readonly manifest?: RouteManifest; + readonly message?: string; + readonly skillTree?: SkillDocumentTree; + readonly state: RouteCatalogState; +} + +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..6ad17ea6c --- /dev/null +++ b/packages/workbench/src/application/application-tree.tsx @@ -0,0 +1,201 @@ +import React, { useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'; + +import { + type ApplicationNodeRef, + sameApplicationNodeRef, +} from '../../../agent-bundle/src/contracts/workbench-shell.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 = (label: string, leaves: readonly ApplicationLeaf[]) => ( +
+ {leaves.map((leaf) => ( + + ))} +
+ ); + + const renderSubgroup = (subgroup: ApplicationSubgroup) => { + const expanded = !collapsed.has(subgroup.key); + return
+ toggle(subgroup.key)} + /> + {expanded ? renderLeaves(subgroup.label, 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.label, 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/src/application/dev-server-backend.ts b/packages/workbench/src/application/dev-server-backend.ts new file mode 100644 index 000000000..6e6a2fb70 --- /dev/null +++ b/packages/workbench/src/application/dev-server-backend.ts @@ -0,0 +1,38 @@ +import type { + 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; + }>; +} + +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) => { + if (event.type === 'route.invocation') listener(event.payload.invocation); + }), +}); diff --git a/packages/workbench/src/application/event-route-workspace.tsx b/packages/workbench/src/application/event-route-workspace.tsx new file mode 100644 index 000000000..b1828a761 --- /dev/null +++ b/packages/workbench/src/application/event-route-workspace.tsx @@ -0,0 +1,285 @@ +/** + * The event route workspace (#600): the shared executable body with a host + * selector in front of it. `Canonical` submits the canonical event payload the + * route's schema describes; `Claude | Codex | Cursor` submit that host's + * native hook payload — seeded from the served lifecycle fixture — as + * `event: { host, fixtureId }` so the service canonicalizes it exactly as the + * emitted wrapper would. The plugin-visible decision (the rendered document) + * stays the default result; the codec panes the old Hooks page led with are + * secondary tabs: canonical → host mapping, native in / out, canonical + * result, and Replay (an observed receipt pasted and run as that host). + */ +import React, { useEffect, useMemo, useState } from 'react'; + +import type { RouteInvocation, RouteInvocationEventHost } from '../../../agent-bundle/src/contracts/invocations.ts'; +import type { JsonObject } from '../../../agent-bundle/src/contracts/strict-json.ts'; +import { errorMessage } from '../client-helpers.ts'; +import type { Lifecycle, LifecycleClient, LifecycleTarget } from '../lifecycles/lifecycle-client.ts'; +import type { WorkbenchLocation } from '../shell/workbench-location.ts'; +import type { ApplicationLeaf } from './application-tree-model.ts'; +import { ExecutableRouteWorkspace } from './executable-route-workspace.tsx'; +import { displayAgentDocumentValue } from './rendered-document.tsx'; +import type { ResultTabDefinition } from './result-tabs.tsx'; +import { requestContextRows } from './route-inspector.tsx'; +import { + invocationOf, + type RouteInputFixture, + type RouteInvocationController, + type RouteInvocationDraft, + type WorkspaceClients, +} from './workspace-contracts.ts'; +import './workspace.css'; + +export type EventHostSelection = 'canonical' | RouteInvocationEventHost; + +export const eventHosts: readonly RouteInvocationEventHost[] = Object.freeze(['claude', 'codex', 'cursor']); + +const hostLabels: Readonly> = Object.freeze({ + canonical: 'Canonical', + claude: 'Claude', + codex: 'Codex', + cursor: 'Cursor', +}); + +const isEventHost = (value: string): value is RouteInvocationEventHost => (eventHosts as readonly string[]).includes(value); + +type LifecycleState = + | Readonly<{ readonly state: 'loading' }> + | Readonly<{ readonly lifecycle?: Lifecycle; readonly state: 'ready' }> + | Readonly<{ readonly message: string; readonly state: 'unavailable' }>; + +/** The served lifecycle entry for this leaf's compiled route, when the catalog lists one. */ +export const lifecycleForLeaf = (lifecycles: readonly Lifecycle[], leaf: ApplicationLeaf): Lifecycle | undefined => + lifecycles.find((lifecycle) => lifecycle.routeId === leaf.routeId) ?? + lifecycles.find((lifecycle) => lifecycle.event === leaf.event); + +export const eventHostTarget = (lifecycle: Lifecycle | undefined, host: RouteInvocationEventHost): LifecycleTarget | undefined => + lifecycle?.targets.find((target) => target.target === host); + +/** One native payload fixture per host the lifecycle catalog serves for this route. */ +export const eventFixturesFor = (lifecycle: Lifecycle | undefined): readonly RouteInputFixture[] => Object.freeze( + (lifecycle?.targets ?? []) + .filter((target) => isEventHost(target.target) && target.fixture !== undefined) + .map((target) => Object.freeze({ + host: target.target as RouteInvocationEventHost, + id: `${target.target}:${target.fixture!.label}`, + input: target.fixture!.native as JsonObject, + label: `${target.fixture!.label} · ${hostLabels[target.target as RouteInvocationEventHost]}`, + })), +); + +export const eventRequestFor = ( + host: EventHostSelection, + draft: RouteInvocationDraft, +): RouteInvocationDraft => { + if (host === 'canonical') return draft; + return Object.freeze({ ...draft, event: Object.freeze({ host }) }); +}; + +const Rows = ({ rows }: { readonly rows: readonly { readonly label: string; readonly value: string }[] }): React.ReactNode =>
+ {rows.map((entry) =>
{entry.label}
{entry.value}
)} +
; + +const Empty = ({ children }: { readonly children: React.ReactNode }): React.ReactNode =>

{children}

; + +const MappingTab = ({ invocation, leaf, lifecycle }: { readonly invocation?: RouteInvocation; readonly leaf: ApplicationLeaf; readonly lifecycle?: Lifecycle }): React.ReactNode => { + const host = invocation?.event?.host; + const target = host === undefined ? undefined : eventHostTarget(lifecycle, host); + return
+ entry.target).join(', ') || 'none' }]), + ]} /> + {invocation === undefined ? Run the route as a host to see how its native payload maps onto the canonical event. : <> +

Canonical payload the route received

+
{displayAgentDocumentValue(invocation.event?.canonical ?? invocation.input)}
+

Request context

+ + } +
; +}; + +const NativeTab = ({ invocation }: { readonly invocation?: RouteInvocation }): React.ReactNode => { + if (invocation === undefined) return Run the route as a host to see the native payload in and the native response out.; + const host = invocation.event?.host; + const projections = invocation.projection.hosts ?? []; + return
+
+

Native in{host === undefined ? '' : ` · ${hostLabels[host]}`}

+ {invocation.event?.native === undefined + ? This invocation was a canonical submission; no native payload was received. + :
{displayAgentDocumentValue(invocation.event.native)}
} +
+
+

Native out

+ {projections.length === 0 + ? No host projection was produced. + : projections.map((projection) =>
+

{hostLabels[projection.host]}

+ {projection.diagnostics.length === 0 ? undefined :
    {projection.diagnostics.map((diagnostic, index) =>
  • {diagnostic.code} {diagnostic.message}
  • )}
} + {projection.native === undefined ? The projection failed. :
{displayAgentDocumentValue(projection.native)}
} +
)} +
+
; +}; + +const CanonicalResultTab = ({ invocation }: { readonly invocation?: RouteInvocation }): React.ReactNode => { + if (invocation === undefined) return Run the route to see the canonical result its document lowers to.; + const value = invocation.result ?? invocation.document?.value; + return
+ + {value === undefined + ? The document carries no value; the decision is expressed by its nodes (see Rendered). + :
{displayAgentDocumentValue(value)}
} +
; +}; + +const ReplayTab = ({ controller, defaultHost, lifecycle }: { + readonly controller: RouteInvocationController; + readonly defaultHost: RouteInvocationEventHost; + readonly lifecycle?: Lifecycle; +}): React.ReactNode => { + const [host, setHost] = useState(defaultHost); + const [receipt, setReceipt] = useState(''); + const [error, setError] = useState(); + const observed = controller.history.filter((summary) => summary.event?.host !== undefined); + const replay = (): void => { + let parsed: unknown; + try { + parsed = JSON.parse(receipt); + } catch { + setError('Paste the native hook payload as a JSON object.'); + return; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + setError('The receipt must be a JSON object.'); + return; + } + setError(undefined); + controller.run(Object.freeze({ event: Object.freeze({ host }), input: parsed as JsonObject })); + }; + return
+

Replay a receipt a real host produced: paste its native payload and run it through this route exactly as the emitted wrapper would.

+ +