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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-only-host-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Read-only host discovery: `/api/discovery` dev route over the install doctor and the Workbench Hosts page.
1 change: 1 addition & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ gate a build, a validation, or a dev rebuild.
| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. |
| `AB7xxx` | Project preparation and development rebuilds. |
| `AB7300`–`AB7316` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health, and durable-state inventory. |
| `AB8215`–`AB8218` | Workbench read-only host discovery route. |
| `AB8xxx` | Development server configuration. |
| `AB9xxx` | Eval selection, harnesses, and persisted runs. |

Expand Down
4 changes: 2 additions & 2 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,8 @@ static assets.

Exact-pin `effect` + `@effect/atom-react` (synchronized with the repo's effect
pin, currently `4.0.0-rc.112`) are allowed there, but only in dedicated
browser-state modules (`src/runtime/agent-document-atoms.ts` and
`src/routes/route-editor-atoms.ts`).
browser-state modules (`src/runtime/agent-document-atoms.ts`,
`src/routes/route-editor-atoms.ts`, and `src/discovery/discovery-atoms.ts`).
Atoms live in `effect/unstable/reactivity`; React bindings come from
`@effect/atom-react`.

Expand Down
102 changes: 102 additions & 0 deletions packages/agent-bundle/src/contracts/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
export type DiscoveryHost = 'claude' | 'codex' | 'cursor';
export type DiscoveryProbeStatus = 'available' | 'failed' | 'unavailable';
export type DiscoveryInventoryStatus = 'known' | 'skipped' | 'unknown';
export type DiscoveryFindingState =
| 'conflicted'
| 'corrupt'
| 'drifted'
| 'failed'
| 'installed'
| 'interrupted-install'
| 'live'
| 'missing'
| 'registered'
| 'skipped'
| 'stale-lock'
| 'stale-socket'
| 'unknown'
| 'unregistered';

export interface DiscoveryDiagnostic {
readonly code: string;
readonly message: string;
readonly recovery: string;
readonly severity: 'error' | 'info' | 'warning';
readonly target?: string;
}

export interface DiscoveryDurableStateStore {
readonly bytes: number;
readonly file: string;
readonly mtime: string;
readonly path: string;
}

export interface DiscoveryDurableState {
readonly diagnostics: readonly DiscoveryDiagnostic[];
readonly directory: string;
readonly findings: readonly DiscoveryDurableStateStore[];
readonly status: 'known' | 'warnings';
readonly summary: Readonly<{
readonly bytes: number;
readonly stores: number;
}>;
}

export interface DiscoveryFinding {
readonly durableState?: DiscoveryDurableState;
readonly entry?: string;
readonly manifest?: string;
readonly name?: string;
readonly path?: string;
readonly state: DiscoveryFindingState;
readonly version?: string;
}

export interface DiscoveryBundleFinding extends DiscoveryFinding {
readonly bundleRoot?: string;
readonly marketplace?: string;
}

export interface DiscoveryProbe {
readonly evidence?: 'directory';
readonly status: DiscoveryProbeStatus;
readonly version?: string;
}

export interface DiscoveryHostReport {
readonly bundle?: DiscoveryBundleFinding;
readonly diagnostics: readonly DiscoveryDiagnostic[];
readonly host: DiscoveryHost;
readonly inventory: Readonly<{
readonly findings: readonly DiscoveryFinding[];
readonly status: DiscoveryInventoryStatus;
}>;
readonly probe: DiscoveryProbe;
}

export interface DiscoveryEndpointReport {
readonly diagnostics: readonly DiscoveryDiagnostic[];
readonly directory: string;
readonly findings: readonly DiscoveryFinding[];
readonly status: 'failed' | 'healthy' | 'skipped' | 'warnings';
readonly summary: Readonly<{
readonly live: number;
readonly staleLocks: number;
readonly staleSockets: number;
}>;
}

export interface HostDiscoveryReport {
readonly bundleSource?: string;
readonly diagnostics: readonly DiscoveryDiagnostic[];
readonly endpoints: DiscoveryEndpointReport;
readonly generatedAt: string;
readonly hosts: readonly DiscoveryHostReport[];
readonly manifestDigest?: string;
readonly summary: Readonly<{
readonly errors: number;
readonly infos: number;
readonly warnings: number;
}>;
}
10 changes: 10 additions & 0 deletions packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { EvalRoutes, type EvalRouteService } from './eval/eval-routes.ts';
import type { ProjectEventHub, ProjectEventSubscription } from './events.ts';
import { InspectorRoutes, type InspectorRouteService } from './inspector-routes.ts';
import { HookPlaygroundRoutes, type HookPlaygroundRouteService } from './playground/hook-playground-routes.ts';
import { HostDiscoveryRoutes, type HostDiscoveryRouteService } from './playground/host-discovery-routes.ts';
import { LifecycleReplayRoutes, type LifecycleReplayRouteService } from './playground/lifecycle-replay-routes.ts';
import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts';
import { McpSessionRoutes } from './mcp-session/mcp-session-routes.ts';
Expand Down Expand Up @@ -132,6 +133,8 @@ export interface ForegroundServerOptions {
readonly mcpAppPreviews?: McpAppRoutePreviewService;
/** Epoch-bound hook playground service; the browser never selects a wrapper or artifact path. */
readonly hookPlayground?: HookPlaygroundRouteService;
/** Read-only host probes, install inventory, bundle drift, and runtime endpoint health. */
readonly hostDiscovery?: HostDiscoveryRouteService;
/** Read-only semantic lifecycle replay over the latest valid prepared graph. */
readonly lifecycleReplay?: LifecycleReplayRouteService;
/** Opt-in standalone MCP Inspector child; never auto-started. */
Expand Down Expand Up @@ -435,6 +438,7 @@ export class ForegroundServer {
readonly #evalRoutes: EvalRoutes;
readonly #eventHub: ProjectEventHub;
readonly #hookPlaygroundRoutes: HookPlaygroundRoutes;
readonly #hostDiscoveryRoutes: HostDiscoveryRoutes;
readonly #host: string;
readonly #inspectorRoutes: InspectorRoutes;
readonly #lifecycleReplayRoutes: LifecycleReplayRoutes;
Expand Down Expand Up @@ -517,6 +521,10 @@ export class ForegroundServer {
authorize: (request) => this.#assertMutationSession(request),
...(options.hookPlayground === undefined ? {} : { service: options.hookPlayground }),
});
this.#hostDiscoveryRoutes = new HostDiscoveryRoutes({
authorize: (request) => this.#assertMutationSession(request),
...(options.hostDiscovery === undefined ? {} : { service: options.hostDiscovery }),
});
this.#lifecycleReplayRoutes = new LifecycleReplayRoutes({
authorize: (request) => this.#assertMutationSession(request),
...(options.lifecycleReplay === undefined ? {} : { service: options.lifecycleReplay }),
Expand Down Expand Up @@ -673,6 +681,7 @@ export class ForegroundServer {
// abort callbacks may synchronously re-enter foreground shutdown, and
// must observe the already-published close outcome.
const releaseHookPlayground = this.#hookPlaygroundRoutes.close();
this.#hostDiscoveryRoutes.close();
// App tombstone publication below deliberately yields once before joining
// resource drains. Observe this promise now so a fast drain failure cannot
// become an unhandled rejection during that handoff; allSettled below still
Expand Down Expand Up @@ -765,6 +774,7 @@ export class ForegroundServer {
if (await this.#runtimeMcpRoutes.handle(request, response)) return;
if (await this.#runtimeRoutes.handle(request, response)) return;
if (await this.#hookPlaygroundRoutes.handle(request, response)) return;
if (await this.#hostDiscoveryRoutes.handle(request, response)) return;
if (await this.#lifecycleReplayRoutes.handle(request, response)) return;
if (await this.#playgroundRoutes.handle(request, response)) return;
if (await this.#inspectorRoutes.handle(request, response)) return;
Expand Down
78 changes: 78 additions & 0 deletions packages/agent-bundle/src/dev/playground/host-discovery-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Buffer } from 'node:buffer';
import type { IncomingMessage, ServerResponse } from 'node:http';

import type { HostDiscoveryReport } from '../../contracts/discovery.ts';
import {
diagnostic,
rawPathname,
requestError,
responseDiagnostic,
responseJson as writeJsonResponse,
} from '../http.ts';

export const hostDiscoveryResponseLimit = 16 * 1024 * 1024;

export interface HostDiscoveryRouteService {
discover(): Promise<HostDiscoveryReport>;
}

export interface HostDiscoveryRoutesOptions {
readonly authorize: (request: IncomingMessage) => void;
readonly responseByteLimit?: number;
readonly service?: HostDiscoveryRouteService;
}

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

const matchesDiscoveryRoute = (requestTarget: string | undefined): boolean => {
const pathname = rawPathname(requestTarget);
if (pathname === '/api/discovery') return true;
if (pathname.startsWith('/api/discovery/')) {
throw requestError(diagnostic('AB8215', 'Host discovery route path is not valid.', 400));
}
return false;
};

const noQuery = (requestTarget: string | undefined): void => {
if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) {
throw requestError(diagnostic('AB8216', 'Host discovery request is not valid.', 400));
}
};

export class HostDiscoveryRoutes {
readonly #authorize: (request: IncomingMessage) => void;
readonly #responseByteLimit: number;
readonly #service: HostDiscoveryRouteService | undefined;
#closed = false;

constructor(options: HostDiscoveryRoutesOptions) {
this.#authorize = options.authorize;
this.#responseByteLimit = options.responseByteLimit ?? hostDiscoveryResponseLimit;
this.#service = options.service;
}

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

async handle(request: IncomingMessage, response: ServerResponse): Promise<boolean> {
if (!matchesDiscoveryRoute(request.url)) return false;
this.#authorize(request);
if (this.#closed || this.#service === undefined) {
throw requestError(diagnostic('AB8218', 'Host discovery is not available.', 503));
}
noQuery(request.url);
const method = request.method ?? 'GET';
if (method !== 'GET') {
responseDiagnostic(response, diagnostic('AB8216', 'Host discovery request is not valid.', 405));
return true;
}
const report = await this.#service.discover();
if (Buffer.byteLength(JSON.stringify(report), 'utf8') > this.#responseByteLimit) {
throw requestError(diagnostic('AB8217', 'Host discovery exceeds the 16 MiB response limit.', 413));
}
responseJson(response, report);
return true;
}
}
Loading
Loading