diff --git a/packages/experimental/inspector/src/client/inspection/cordis.ts b/packages/experimental/inspector/src/client/inspection/cordis.ts new file mode 100644 index 0000000000..4492aa2e1b --- /dev/null +++ b/packages/experimental/inspector/src/client/inspection/cordis.ts @@ -0,0 +1,25 @@ +/** Browser adapter that publishes shared Cordis snapshots over the Client bridge. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { CordisTreeLimits } from '../../shared/cordis/collector.ts' +import { observeCordisTree } from '../../shared/cordis/observer.ts' +import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' + +/** + * Observe the Client Cordis runtime and retain its latest bridge snapshot. + * @param ctx - Client plugin context whose root is inspected. + * @param publisher - Active Client bridge publisher. + * @param limits - Snapshot node and encoded-byte limits. + * @returns A disposer that stops observation and releases retained objects. + */ +export function publishCordisTree( + ctx: Context, + publisher: InspectorStatePublisher, + limits: CordisTreeLimits, +): () => void { + return observeCordisTree(ctx, (snapshot) => { + publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue) + }, limits) +} diff --git a/packages/experimental/inspector/src/host/inspection/cordis.ts b/packages/experimental/inspector/src/host/inspection/cordis.ts new file mode 100644 index 0000000000..871b234d5e --- /dev/null +++ b/packages/experimental/inspector/src/host/inspection/cordis.ts @@ -0,0 +1,25 @@ +/** Host adapter that publishes shared Cordis snapshots over the Host bridge. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { CordisTreeLimits } from '../../shared/cordis/collector.ts' +import { observeCordisTree } from '../../shared/cordis/observer.ts' +import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' + +/** + * Observe the Host Cordis runtime and retain its latest bridge snapshot. + * @param ctx - Host plugin context whose root is inspected. + * @param publisher - Active Host bridge publisher. + * @param limits - Snapshot node and encoded-byte limits. + * @returns A disposer that stops observation and releases retained objects. + */ +export function publishCordisTree( + ctx: Context, + publisher: InspectorStatePublisher, + limits: CordisTreeLimits, +): () => void { + return observeCordisTree(ctx, (snapshot) => { + publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue) + }, limits) +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/cordis.ts b/packages/experimental/inspector/src/shared/bridge/messages/cordis.ts new file mode 100644 index 0000000000..7b51cf5b70 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/cordis.ts @@ -0,0 +1,4 @@ +/** Bridge message metadata for Cordis runtime-tree snapshots. */ + +/** Observation topic carrying the latest complete Cordis tree. */ +export const CORDIS_TREE_TOPIC = 'cordis/tree' diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts new file mode 100644 index 0000000000..967753bf60 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts @@ -0,0 +1,138 @@ +/** Exact decoders for non-CDP Inspector query frames. */ + +import { parseCordisRuntimeTree } from '../../../cordis/model.ts' +import { isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts' +import type { + InspectorQueryRequestFrame, + InspectorQueryRequestId, + InspectorQueryResponseFrame, +} from './frames.ts' +import type { InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts' + +/** Correlation fields recoverable before a query body is accepted. */ +export interface InspectorQueryFrameIdentity { + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly requestId: InspectorQueryRequestId +} + +/** + * Test whether a decoded carrier value belongs to the query request protocol. + * @param value - Decoded carrier value. + * @returns Whether the query request decoder owns the value. + */ +export function isInspectorQueryRequestEnvelope(value: unknown): boolean { + return isPlainObject(value) && value.t === 'query/request' +} + +/** + * Test whether a decoded carrier value belongs to the query response protocol. + * @param value - Decoded carrier value. + * @returns Whether the query response decoder owns the value. + */ +export function isInspectorQueryResponseEnvelope(value: unknown): boolean { + return isPlainObject(value) && value.t === 'query/response' +} + +/** + * Decode one source-to-Worker query request. + * @param value - Untrusted decoded carrier value. + * @returns The detached, validated request frame. + */ +export function parseInspectorQueryRequestFrame(value: unknown): InspectorQueryRequestFrame { + const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'query'], 'query request') + if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/request') { + throw new Error('inspector protocol: invalid query request envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/request', + sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'), + requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'), + query: parseQuery(record.query), + } +} + +/** + * Decode correlation fields used to reject a malformed request without timing out its caller. + * @param value - Candidate query request frame. + * @returns Validated source and request identities. + */ +export function parseInspectorQueryFrameIdentity(value: unknown): InspectorQueryFrameIdentity { + if (!isPlainObject(value) || value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'query/request') { + throw new Error('inspector protocol: invalid query request envelope') + } + return { + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + requestId: wireId<'InspectorQueryRequestId'>(value.requestId, 'requestId'), + } +} + +/** + * Decode one Worker-to-source query response. + * @param value - Untrusted decoded carrier value. + * @returns The detached, validated response frame. + */ +export function parseInspectorQueryResponseFrame(value: unknown): InspectorQueryResponseFrame { + const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'outcome'], 'query response') + if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/response') { + throw new Error('inspector protocol: invalid query response envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/response', + sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'), + requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'), + outcome: parseOutcome(record.outcome), + } +} + +function parseQuery(value: unknown): InspectorQuery { + const record = exactObject(value, ['op'], 'Inspector query') + if (record.op !== 'cordis-tree/get') { + throw new Error(`inspector protocol: unknown query operation ${JSON.stringify(record.op)}`) + } + return { op: 'cordis-tree/get' } +} + +function parseResult(value: unknown): InspectorQueryResult { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: query result must have an op') + } + switch (value.op) { + case 'cordis-tree/get': + exactKeys(value, ['op', 'tree'], 'Cordis tree query result') + return { op: 'cordis-tree/get', tree: parseCordisRuntimeTree(value.tree) } + default: + throw new Error(`inspector protocol: unknown query result ${JSON.stringify(value.op)}`) + } +} + +function parseOutcome(value: unknown): InspectorQueryResponseFrame['outcome'] { + if (!isPlainObject(value) || typeof value.ok !== 'boolean') { + throw new Error('inspector protocol: invalid query outcome') + } + if (value.ok) { + exactKeys(value, ['ok', 'result'], 'successful query outcome') + return { ok: true, result: parseResult(value.result) } + } + exactKeys(value, ['ok', 'error'], 'failed query outcome') + const error = exactObject(value.error, ['code', 'message'], 'query error') + if (!QUERY_ERROR_CODES.has(error.code as InspectorQueryError['code']) || typeof error.message !== 'string') { + throw new Error('inspector protocol: invalid query error') + } + return { + ok: false, + error: { code: error.code as InspectorQueryError['code'], message: error.message }, + } +} + +const QUERY_ERROR_CODES = new Set([ + 'invalid-request', 'stale-source', 'result-too-large', 'internal-error', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts new file mode 100644 index 0000000000..f2f626cd2d --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts @@ -0,0 +1,40 @@ +/** Closed non-CDP Inspector query and result model. */ + +import type { CordisRuntimeTree } from '../../../cordis/model.ts' + +/** Read the latest committed Cordis runtime tree. */ +export interface CordisTreeGetQuery { + readonly op: 'cordis-tree/get' +} + +/** Query operations accepted by the Inspector Worker. */ +export type InspectorQuery = CordisTreeGetQuery + +/** Result of reading the latest committed Cordis runtime tree. */ +export interface CordisTreeGetResult { + readonly op: 'cordis-tree/get' + readonly tree: CordisRuntimeTree +} + +/** Results correlated to {@link InspectorQuery} by `op`. */ +export type InspectorQueryResult = CordisTreeGetResult + +/** Result member corresponding to one query member. */ +export type InspectorQueryResultFor = Extract + +/** Stable Worker-side query failure. */ +export interface InspectorQueryError { + readonly code: 'invalid-request' | 'stale-source' | 'result-too-large' | 'internal-error' + readonly message: string +} + +/** Host/Client interface implemented by the shared correlated-query owner. */ +export interface InspectorQueryRequester { + /** + * Execute one query against the current connected source generation. + * @param query - Closed typed query command. + * @returns The result with the same operation discriminant. + * @throws When transport or Worker processing cannot settle the request successfully. + */ + request(query: Query): Promise> +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts new file mode 100644 index 0000000000..7a864878f8 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts @@ -0,0 +1,30 @@ +/** Versioned frames for source-to-Worker non-CDP queries. */ + +import type { InspectorId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts' + +/** Identity of one in-flight Inspector query. */ +export type InspectorQueryRequestId = InspectorId<'InspectorQueryRequestId'> + +/** Source request for one Worker-owned query operation. */ +export interface InspectorQueryRequestFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'query/request' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly requestId: InspectorQueryRequestId + readonly query: InspectorQuery +} + +/** Worker response correlated to one source query request. */ +export interface InspectorQueryResponseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'query/response' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly requestId: InspectorQueryRequestId + readonly outcome: + | { readonly ok: true; readonly result: InspectorQueryResult } + | { readonly ok: false; readonly error: InspectorQueryError } +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/index.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/index.ts new file mode 100644 index 0000000000..e8bd0c0311 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/index.ts @@ -0,0 +1,5 @@ +/** Public exports for the non-CDP Inspector query protocol. */ + +export * from './codec.ts' +export * from './commands.ts' +export * from './frames.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/query-reader.ts b/packages/experimental/inspector/src/shared/bridge/query-reader.ts new file mode 100644 index 0000000000..464d088756 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/query-reader.ts @@ -0,0 +1,18 @@ +/** Query-backed adapter for the transport-independent Cordis tree reader. */ + +import type { CordisRuntimeTreeReader } from '../cordis/reader.ts' +import type { InspectorQueryRequester } from './messages/query/commands.ts' + +/** + * Create a reader that obtains the tree through the typed Inspector query protocol. + * @param requester - Active Host or Client query connection. + * @returns A non-CDP Cordis tree reader. + */ +export function createQueryCordisRuntimeTreeReader(requester: InspectorQueryRequester): CordisRuntimeTreeReader { + return { + async getTree() { + const result = await requester.request({ op: 'cordis-tree/get' }) + return result.tree + }, + } +} diff --git a/packages/experimental/inspector/src/shared/cordis/collector.ts b/packages/experimental/inspector/src/shared/cordis/collector.ts new file mode 100644 index 0000000000..622e4d6158 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/collector.ts @@ -0,0 +1,195 @@ +/** Shared Host/Client projection from live Cordis objects to a bounded semantic tree. */ + +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { jsonByteLength, type InspectorJsonValue } from '../json.ts' +import { + CORDIS_TREE_SCHEMA_VERSION, + type CordisContextTreeNode, + type CordisFiberTreeNode, + type CordisTreeSnapshot, +} from './snapshot.ts' +import type { InspectorObjectHandle } from './ids.ts' +import { RealmObjectRegistry } from './object-registry.ts' + +const SHADOW = Symbol.for('cordis.shadow') + +/** Bounds applied before one snapshot enters a source frame. */ +export interface CordisTreeLimits { + readonly maxNodes: number + readonly maxBytes: number +} + +interface ContextInfo { + readonly value: Context + readonly children: ContextInfo[] + readonly fiber: Fiber | undefined +} + +interface MutableContextNode extends Omit { + readonly children: MutableTreeNode[] +} + +interface MutableFiberNode extends Omit { + readonly children: [MutableContextNode] +} + +type MutableTreeNode = MutableContextNode | MutableFiberNode + +/** Realm-local collector with a current live-object table. */ +export class CordisTreeCollector { + /** Live-object table replaced atomically with each emitted snapshot. */ + readonly objects = new RealmObjectRegistry() + private revision = 0 + + constructor(private readonly root: Context, private readonly limits: CordisTreeLimits) {} + + /** + * Capture the current reachable Context/Fiber tree. + * @returns A detached JSON snapshot whose retained objects replace the prior generation atomically. + */ + snapshot(): CordisTreeSnapshot { + const tree = collectContexts(this.root) + const objects = this.objects.begin() + let nodeCount = 0 + let truncated = false + + const contextNode = (info: ContextInfo): MutableContextNode | undefined => { + if (nodeCount >= this.limits.maxNodes) { + truncated = true + return undefined + } + nodeCount++ + const node: MutableContextNode = { + kind: 'context', + objectHandle: objects.retain(info.value).handle, + children: [], + } + for (const child of info.children) { + if (child.fiber !== undefined && child.fiber.ctx === child.value) { + const projected = fiberNode(child.fiber, child) + if (projected !== undefined) node.children.push(projected) + } else { + const projected = contextNode(child) + if (projected !== undefined) node.children.push(projected) + } + } + return node + } + const fiberNode = (fiber: Fiber, owned: ContextInfo): MutableFiberNode | undefined => { + if (fiber.uid === null) return undefined + if (nodeCount + 2 > this.limits.maxNodes) { + truncated = true + return undefined + } + nodeCount++ + const context = contextNode(owned) + if (context === undefined) throw new Error('inspector: reserved Fiber Context was not collected') + return { + kind: 'fiber', + objectHandle: objects.retain(fiber).handle, + uid: fiber.uid, + children: [context], + } + } + + const root = contextNode(tree) + if (root === undefined) throw new Error('inspector: maxNodes cannot retain the root Context') + let snapshot: CordisTreeSnapshot = { + schemaVersion: CORDIS_TREE_SCHEMA_VERSION, + revision: ++this.revision, + objectRegistryId: this.objects.id, + root, + truncated, + } + while (jsonByteLength(snapshot as unknown as InspectorJsonValue) > this.limits.maxBytes) { + const removed = pruneLast(root) + if (removed.length === 0) break + for (const handle of removed) objects.release(handle) + snapshot = { ...snapshot, truncated: true } + } + if (jsonByteLength(snapshot as unknown as InspectorJsonValue) > this.limits.maxBytes) { + throw new Error('inspector: Cordis root exceeds the source-frame byte limit') + } + objects.commit() + return snapshot + } + + /** Release the realm-global resolver and every retained object. */ + close(): void { + this.objects.close() + } +} + +function collectContexts(root: Context): ContextInfo { + const contexts = new Map() + const ensure = (candidate: unknown, depth = 0): ContextInfo | undefined => { + if (depth > 100) return undefined + const value = unwrapContext(candidate) + if (!Context.is(value)) return undefined + const existing = contexts.get(value) + if (existing !== undefined) return existing + if (value === root) { + const info = describeContext(value) + contexts.set(value, info) + return info + } + const prototype = unwrapContext(Object.getPrototypeOf(value) as unknown) + const parent = ensure(prototype, depth + 1) + if (parent === undefined) return undefined + const info = describeContext(value) + contexts.set(value, info) + parent.children.push(info) + return info + } + + const rootInfo = ensure(root) + if (rootInfo === undefined) throw new Error('inspector: Cordis root context is not reachable') + for (const runtime of root.registry.values()) { + for (const fiber of runtime.fibers) { + if (fiber.uid === null) continue + ensure(fiber.parent) + ensure(fiber.ctx) + } + } + for (const key of Reflect.ownKeys(root.events._hooks)) { + for (const hook of root.events._hooks[key] ?? []) ensure(hook.ctx) + } + const order = (info: ContextInfo): number => info.fiber?.uid ?? Number.MAX_SAFE_INTEGER + for (const info of contexts.values()) { + info.children.sort((left, right) => order(left) - order(right)) + } + return rootInfo +} + +function describeContext(value: Context): ContextInfo { + const fiber = ownValue(value, 'fiber') as Fiber | undefined + return { value, children: [], fiber } +} + +function ownValue(value: object, key: PropertyKey): unknown { + return Reflect.getOwnPropertyDescriptor(value, key)?.value +} + +function unwrapContext(value: unknown): unknown { + let current = value + while (typeof current === 'object' && current !== null && Object.hasOwn(current, SHADOW)) { + current = Object.getPrototypeOf(current) + } + return current +} + +function pruneLast(context: MutableContextNode): InspectorObjectHandle[] { + const child = context.children.at(-1) + if (child === undefined) return [] + if (child.kind === 'context') { + const nested = pruneLast(child) + if (nested.length > 0) return nested + context.children.pop() + return [child.objectHandle] + } + const owned = child.children[0] + const nested = pruneLast(owned) + if (nested.length > 0) return nested + context.children.pop() + return [child.objectHandle, owned.objectHandle] +} diff --git a/packages/experimental/inspector/src/shared/cordis/ids.ts b/packages/experimental/inspector/src/shared/cordis/ids.ts new file mode 100644 index 0000000000..6cfcd27e39 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/ids.ts @@ -0,0 +1,9 @@ +/** Opaque identifiers owned by a realm-local Cordis object registry. */ + +import type { InspectorId } from '../identity.ts' + +/** Identity of one realm-local table that retains objects named in a snapshot. */ +export type InspectorObjectRegistryId = InspectorId<'InspectorObjectRegistryId'> + +/** Opaque reference to one object retained by a realm-local registry. */ +export type InspectorObjectHandle = InspectorId<'InspectorObjectHandle'> diff --git a/packages/experimental/inspector/src/shared/cordis/model.ts b/packages/experimental/inspector/src/shared/cordis/model.ts new file mode 100644 index 0000000000..86750e508d --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/model.ts @@ -0,0 +1,161 @@ +/** Consumer-neutral Cordis runtime tree shared by non-CDP readers. */ + +import { CORDIS_TREE_MAX_DEPTH } from './snapshot.ts' +import { inspectorId, type InspectorId } from '../identity.ts' +import { isPlainObject } from '../json.ts' +import { exactKeys, exactObject, wireId } from '../validation.ts' + +/** Current consumer-neutral Cordis tree version. */ +export const CORDIS_RUNTIME_TREE_SCHEMA_VERSION = 0 as const + +/** Consumer-visible identity of one inspected Cordis runtime. */ +export type CordisRuntimeSourceId = InspectorId<'CordisRuntimeSourceId'> + +/** Execution environment represented by one consumer-visible Cordis runtime. */ +export type CordisRuntimeSourceKind = 'host' | 'client' + +/** Availability of the realm represented by a retained tree. */ +export type CordisRuntimeConnection = + | { readonly state: 'connected' } + | { readonly state: 'disconnected'; readonly reason: string } + +/** Consumer-visible identity of one Cordis realm. */ +export interface CordisRuntimeSource { + readonly sourceId: CordisRuntimeSourceId + readonly kind: CordisRuntimeSourceKind + readonly label: string +} + +/** One Context in a consumer-neutral Cordis tree. */ +export interface CordisRuntimeContext { + readonly kind: 'context' + readonly children: readonly CordisRuntimeNode[] +} + +/** One Fiber and its owned Context in a consumer-neutral Cordis tree. */ +export interface CordisRuntimeFiber { + readonly kind: 'fiber' + readonly uid: number + readonly children: readonly [CordisRuntimeContext] +} + +/** One semantic Cordis runtime node. */ +export type CordisRuntimeNode = CordisRuntimeContext | CordisRuntimeFiber + +/** Latest retained topology and availability of one Cordis realm. */ +export interface CordisRuntimeRealm { + readonly source: CordisRuntimeSource + readonly connection: CordisRuntimeConnection + readonly revision: number + readonly truncated: boolean + readonly root: CordisRuntimeContext +} + +/** Latest Host and Client Cordis topology without routing or CDP identifiers. */ +export interface CordisRuntimeTree { + readonly schemaVersion: typeof CORDIS_RUNTIME_TREE_SCHEMA_VERSION + readonly host: CordisRuntimeRealm | null + readonly clients: readonly CordisRuntimeRealm[] +} + +/** + * Decode a consumer-neutral tree received across an Inspector transport. + * @param value - Untrusted query result value. + * @returns A detached tree containing only public semantic fields. + */ +export function parseCordisRuntimeTree(value: unknown): CordisRuntimeTree { + const record = exactObject(value, ['schemaVersion', 'host', 'clients'], 'Cordis runtime tree') + if (record.schemaVersion !== CORDIS_RUNTIME_TREE_SCHEMA_VERSION || !Array.isArray(record.clients)) { + throw new Error('inspector protocol: invalid Cordis runtime tree') + } + const host = record.host === null ? null : parseRealm(record.host, 'host') + const clients = record.clients.map(client => parseRealm(client, 'client')) + const sourceIds = new Set() + for (const realm of host === null ? clients : [host, ...clients]) { + if (sourceIds.has(realm.source.sourceId)) { + throw new Error('inspector protocol: Cordis runtime tree repeats a sourceId') + } + sourceIds.add(realm.source.sourceId) + } + return { + schemaVersion: CORDIS_RUNTIME_TREE_SCHEMA_VERSION, + host, + clients, + } +} + +function parseRealm(value: unknown, kind: CordisRuntimeSourceKind): CordisRuntimeRealm { + const record = exactObject(value, ['source', 'connection', 'revision', 'truncated', 'root'], 'Cordis runtime realm') + const source = exactObject(record.source, ['sourceId', 'kind', 'label'], 'Cordis runtime source') + if (source.kind !== kind || typeof source.label !== 'string' || source.label.length === 0 || source.label.length > 256) { + throw new Error(`inspector protocol: invalid ${kind} Cordis runtime source`) + } + if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 1 || typeof record.truncated !== 'boolean') { + throw new Error('inspector protocol: invalid Cordis runtime realm header') + } + const root = parseNode(record.root, { fiberUids: new Set() }, 0) + if (root.kind !== 'context') throw new Error('inspector protocol: Cordis runtime root must be a Context') + return { + source: { + sourceId: wireId<'CordisRuntimeSourceId'>(source.sourceId, 'sourceId'), + kind, + label: source.label, + }, + connection: parseConnection(record.connection), + revision: record.revision as number, + truncated: record.truncated, + root, + } +} + +/** + * Project an inspected source id into the consumer-visible Cordis identity namespace. + * @param value - Stable source id carried by the current runtime observation. + * @returns The corresponding Cordis runtime source id. + */ +export function cordisRuntimeSourceId(value: string): CordisRuntimeSourceId { + return inspectorId<'CordisRuntimeSourceId'>(value, 'sourceId') +} + +function parseConnection(value: unknown): CordisRuntimeConnection { + if (!isPlainObject(value)) throw new Error('inspector protocol: Cordis runtime connection must be an object') + if (value.state === 'connected') { + exactKeys(value, ['state'], 'connected Cordis runtime connection') + return { state: 'connected' } + } + if (value.state === 'disconnected' && typeof value.reason === 'string') { + exactKeys(value, ['state', 'reason'], 'disconnected Cordis runtime connection') + return { state: 'disconnected', reason: value.reason } + } + throw new Error('inspector protocol: invalid Cordis runtime connection') +} + +interface ParseState { + readonly fiberUids: Set +} + +function parseNode(value: unknown, state: ParseState, depth: number): CordisRuntimeNode { + if (depth > CORDIS_TREE_MAX_DEPTH) throw new Error('inspector protocol: Cordis runtime tree exceeds the depth limit') + if (!isPlainObject(value) || (value.kind !== 'context' && value.kind !== 'fiber')) { + throw new Error('inspector protocol: Cordis runtime node must have a known kind') + } + const record = exactObject(value, value.kind === 'fiber' + ? ['kind', 'uid', 'children'] + : ['kind', 'children'], 'Cordis runtime node') + if (!Array.isArray(record.children)) throw new Error('inspector protocol: Cordis runtime node children must be an array') + if (record.kind === 'context') { + return { kind: 'context', children: record.children.map(child => parseNode(child, state, depth + 1)) } + } + if (record.kind !== 'fiber' + || !Number.isSafeInteger(record.uid) + || (record.uid as number) < 1 + || record.children.length !== 1) { + throw new Error('inspector protocol: invalid Cordis runtime Fiber') + } + const uid = record.uid as number + if (state.fiberUids.has(uid)) throw new Error('inspector protocol: Cordis runtime tree repeats a Fiber uid') + state.fiberUids.add(uid) + const context = parseNode(record.children[0], state, depth + 1) + if (context.kind !== 'context') throw new Error('inspector protocol: Cordis runtime Fiber child must be a Context') + return { kind: 'fiber', uid, children: [context] } +} diff --git a/packages/experimental/inspector/src/shared/cordis/object-reference.ts b/packages/experimental/inspector/src/shared/cordis/object-reference.ts new file mode 100644 index 0000000000..756414252f --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/object-reference.ts @@ -0,0 +1,23 @@ +/** Opaque references to live objects retained inside an observation source realm. */ + +import type { InspectorObjectHandle, InspectorObjectRegistryId } from './ids.ts' +import { exactObject, wireId } from '../validation.ts' + +/** Wire-safe identity of one live object; the source generation supplies the realm identity. */ +export interface InspectorObjectReference { + readonly registryId: InspectorObjectRegistryId + readonly handle: InspectorObjectHandle +} + +/** + * Decode one source-local live-object reference. + * @param value - Untrusted wire value. + * @returns The validated opaque reference. + */ +export function parseInspectorObjectReference(value: unknown): InspectorObjectReference { + const record = exactObject(value, ['registryId', 'handle'], 'object reference') + return { + registryId: wireId<'InspectorObjectRegistryId'>(record.registryId, 'registryId'), + handle: wireId<'InspectorObjectHandle'>(record.handle, 'handle'), + } +} diff --git a/packages/experimental/inspector/src/shared/cordis/object-registry.ts b/packages/experimental/inspector/src/shared/cordis/object-registry.ts new file mode 100644 index 0000000000..c36c171c1e --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/object-registry.ts @@ -0,0 +1,176 @@ +/** Realm-local retention and identity for live objects referenced by Inspector snapshots. */ + +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { inspectorId } from '../identity.ts' +import { + type InspectorObjectHandle, + type InspectorObjectRegistryId, +} from './ids.ts' +import type { InspectorObjectReference } from './object-reference.ts' + +const REGISTRIES_SYMBOL = 'dsh.inspector.realm-object-registries' +const MAX_FIBER_WRAPPER_DEPTH = 8 + +/** Self-contained function sent through CDP to identify its `this` object in the inspected realm. */ +export const IDENTIFY_REALM_OBJECT_FUNCTION = `function () { + const table = globalThis[Symbol.for(${JSON.stringify(REGISTRIES_SYMBOL)})] + if (!(table instanceof Map)) return undefined + for (const registry of table.values()) { + const reference = registry.identify(this) + if (reference !== undefined) return reference + } + return undefined +}` + +/** One realm's bounded table of objects retained by its latest semantic snapshot. */ +export class RealmObjectRegistry { + /** Realm-unique id carried by every reference from this registry. */ + readonly id = inspectorId<'InspectorObjectRegistryId'>(randomUUID(), 'registryId') + private readonly known = new WeakMap() + private retained = new Map() + private nextHandle = 1 + private disposed = false + + constructor() { + registries().set(this.id, this) + } + + /** + * Start one replacement generation. + * @returns A collector that atomically installs exactly the retained objects on commit. + */ + begin(): RealmObjectGeneration { + if (this.disposed) throw new Error('inspector: realm object registry is disposed') + return new RealmObjectGeneration(this) + } + + /** + * Resolve one current opaque handle. + * @param handle - Handle from the latest committed snapshot. + * @returns The live object, when it remains retained. + */ + resolve(handle: InspectorObjectHandle): object | undefined { + return this.retained.get(handle) + } + + /** + * Identify one object retained by the latest snapshot. Cordis plugin calls may return nested thenable facades; + * only objects whose prototype path consists exclusively of those `then` wrappers resolve to the retained Fiber. + * @param value - Candidate live value. + * @returns Its wire reference, when present in this registry. + */ + identify(value: unknown): InspectorObjectReference | undefined { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined + let candidate: object | null = value + for (let depth = 0; candidate !== null && depth <= MAX_FIBER_WRAPPER_DEPTH; depth++) { + const handle = this.known.get(candidate) + if (handle !== undefined && this.retained.get(handle) === candidate) return { registryId: this.id, handle } + try { + const keys = Reflect.ownKeys(candidate) + if (keys.length !== 1 || keys[0] !== 'then') return undefined + candidate = Object.getPrototypeOf(candidate) as object | null + } catch { + // A hostile proxy cannot prevent later registries from checking the original value. + return undefined + } + } + return undefined + } + + /** Remove this registry from the realm and release all strong references. */ + close(): void { + if (this.disposed) return + this.disposed = true + registries().delete(this.id) + this.retained.clear() + } + + /** + * Assign a stable handle and retain a value in one pending generation. + * @param value - Object represented by the pending snapshot. + * @param next - Pending generation's strong-reference table. + * @returns The registry id and stable object handle. + */ + retain(value: object, next: Map): InspectorObjectReference { + let handle = this.known.get(value) + if (handle === undefined) { + handle = inspectorId<'InspectorObjectHandle'>(`object-${String(this.nextHandle++)}`, 'objectHandle') + this.known.set(value, handle) + } + next.set(handle, value) + return { registryId: this.id, handle } + } + + /** + * Replace the current strong-reference set with one completed generation. + * @param next - Complete object table for the committed snapshot. + */ + commit(next: Map): void { + this.retained = next + } +} + +/** Mutable object set assembled before one snapshot becomes visible. */ +export class RealmObjectGeneration { + private readonly retained = new Map() + private committed = false + + constructor(private readonly owner: RealmObjectRegistry) {} + + /** + * Retain one object and obtain its stable opaque reference. + * @param value - Context or Fiber represented in the snapshot. + * @returns Source-local wire reference. + */ + retain(value: object): InspectorObjectReference { + if (this.committed) throw new Error('inspector: realm object generation is already committed') + return this.owner.retain(value, this.retained) + } + + /** + * Stop retaining an object omitted while bounding the pending snapshot. + * @param handle - Opaque handle removed from this pending generation. + */ + release(handle: InspectorObjectHandle): void { + if (this.committed) throw new Error('inspector: realm object generation is already committed') + this.retained.delete(handle) + } + + /** Atomically replace the registry's retained set. */ + commit(): void { + if (this.committed) return + this.committed = true + this.owner.commit(this.retained) + } +} + +/** + * Build an expression that resolves one reference inside its owning realm. + * @param reference - Validated source-local object reference. + * @returns Side-effect-free JavaScript expression for Runtime evaluation. + */ +export function realmObjectExpression(reference: InspectorObjectReference): string { + return `globalThis[Symbol.for(${JSON.stringify(REGISTRIES_SYMBOL)})]?.get(${JSON.stringify(reference.registryId)})?.resolve(${JSON.stringify(reference.handle)})` +} + +/** + * Identify a retained object across all Inspector collectors in this realm. + * @param value - Runtime value returned to a debugger. + * @returns Its source-local reference, when the value is a visible entity. + */ +export function identifyRealmObject(value: unknown): InspectorObjectReference | undefined { + for (const registry of registries().values()) { + const reference = registry.identify(value) + if (reference !== undefined) return reference + } + return undefined +} + +function registries(): Map { + const key = Symbol.for(REGISTRIES_SYMBOL) + const existing = Reflect.get(globalThis, key) as unknown + if (existing instanceof Map) return existing as Map + const value = new Map() + Reflect.set(globalThis, key, value) + return value +} diff --git a/packages/experimental/inspector/src/shared/cordis/observer.ts b/packages/experimental/inspector/src/shared/cordis/observer.ts new file mode 100644 index 0000000000..00793a3739 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/observer.ts @@ -0,0 +1,46 @@ +/** Lifecycle-driven Cordis tree publication shared by Host and Client plugin faces. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { CordisTreeSnapshot } from './snapshot.ts' +import { CordisTreeCollector, type CordisTreeLimits } from './collector.ts' + +/** Receives one complete semantic snapshot after a coalesced Cordis mutation. */ +export type CordisTreeSnapshotListener = (snapshot: CordisTreeSnapshot) => void + +/** + * Observe one Cordis realm and publish immutable tree replacements. + * @param ctx - Plugin context whose root is inspected and whose effects own listeners. + * @param listener - Consumer of complete snapshots in the inspected realm. + * @param limits - Snapshot node and encoded-byte limits. + * @returns A disposer that unregisters listeners and releases retained objects. + */ +export function observeCordisTree( + ctx: Context, + listener: CordisTreeSnapshotListener, + limits: CordisTreeLimits, +): () => void { + const collector = new CordisTreeCollector(ctx.root, limits) + let scheduled = false + let closed = false + const publish = (): void => { + scheduled = false + if (closed) return + listener(collector.snapshot()) + } + const schedule = (): void => { + if (scheduled || closed) return + scheduled = true + queueMicrotask(publish) + } + const disposers = [ + ctx.on('internal/plugin', schedule, { global: true }), + ctx.on('internal/status', schedule, { global: true }), + ] + publish() + return () => { + if (closed) return + closed = true + for (const dispose of disposers) dispose() + collector.close() + } +} diff --git a/packages/experimental/inspector/src/shared/cordis/projector.ts b/packages/experimental/inspector/src/shared/cordis/projector.ts new file mode 100644 index 0000000000..d0d02efe77 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/projector.ts @@ -0,0 +1,88 @@ +/** Pure projection from routed Cordis snapshots to the consumer-neutral tree. */ + +import type { CordisTreeNode, CordisTreeSnapshot } from './snapshot.ts' +import { + CORDIS_RUNTIME_TREE_SCHEMA_VERSION, + cordisRuntimeSourceId, + type CordisRuntimeContext, + type CordisRuntimeNode, + type CordisRuntimeSourceKind, + type CordisRuntimeTree, +} from './model.ts' + +/** Whether a retained routed snapshot still has a live source generation. */ +export type CordisTreeSourceConnection = + | { readonly state: 'connected' } + | { readonly state: 'disconnected'; readonly reason: string } + +/** One source generation and its latest routed Cordis snapshot. */ +export interface CordisTreeSource { + readonly sourceId: string + readonly kind: CordisRuntimeSourceKind + readonly label: string +} + +/** One source generation and its latest routed Cordis snapshot. */ +export interface CordisTreeSourceSnapshot { + readonly source: Source + readonly snapshot: CordisTreeSnapshot + readonly connection: CordisTreeSourceConnection +} + +/** Routed Host and Client snapshots before consumer-neutral projection. */ +export interface CordisInspectionTree { + readonly host: CordisTreeSourceSnapshot | null + readonly clients: readonly CordisTreeSourceSnapshot[] +} + +/** + * Strip transport and live-object routing fields from retained Cordis snapshots. + * @param tree - Worker-owned routed snapshots. + * @returns A detached semantic tree safe for non-CDP consumers. + */ +export function projectCordisRuntimeTree(tree: CordisInspectionTree): CordisRuntimeTree { + return { + schemaVersion: CORDIS_RUNTIME_TREE_SCHEMA_VERSION, + host: tree.host === null ? null : projectRealm(tree.host), + clients: tree.clients.map(projectRealm), + } +} + +function projectRealm(realm: CordisTreeSourceSnapshot): CordisRuntimeTree['clients'][number] { + return { + source: { + sourceId: cordisRuntimeSourceId(realm.source.sourceId), + kind: realm.source.kind, + label: realm.source.label, + }, + connection: realm.connection.state === 'connected' + ? { state: 'connected' } + : { state: 'disconnected', reason: realm.connection.reason }, + revision: realm.snapshot.revision, + truncated: realm.snapshot.truncated, + root: projectContext(realm.snapshot.root), + } +} + +function projectContext(node: Extract): CordisRuntimeContext { + return { kind: 'context', children: node.children.map(projectNode) } +} + +function projectNode(node: CordisTreeNode): CordisRuntimeNode { + switch (node.kind) { + case 'context': + return projectContext(node) + case 'fiber': + return { + kind: 'fiber', + uid: node.uid, + children: [projectContext(node.children[0])], + } + default: + return assertNever(node) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Cordis tree node: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/shared/cordis/reader.ts b/packages/experimental/inspector/src/shared/cordis/reader.ts new file mode 100644 index 0000000000..f335cb484d --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/reader.ts @@ -0,0 +1,24 @@ +/** Environment-independent Cordis runtime tree reader. */ + +import type { CordisRuntimeTree } from './model.ts' + +/** Read-only access to the latest committed consumer-neutral Cordis tree. */ +export interface CordisRuntimeTreeReader { + /** + * Read the latest Worker snapshot without activating CDP domains. + * @returns A detached Host and Client Cordis tree. + * @throws When the source transport is unavailable, closes, times out, or rejects the query. + */ + getTree(): Promise +} + +/** + * Create a reader around a local committed-tree projection. + * @param read - Synchronous or asynchronous latest-tree read. + * @returns A reader suitable for query and CDP adapters. + */ +export function createCordisRuntimeTreeReader( + read: () => CordisRuntimeTree | Promise, +): CordisRuntimeTreeReader { + return { getTree: async () => await read() } +} diff --git a/packages/experimental/inspector/src/shared/cordis/snapshot.ts b/packages/experimental/inspector/src/shared/cordis/snapshot.ts new file mode 100644 index 0000000000..ba0dcfa8e8 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/snapshot.ts @@ -0,0 +1,111 @@ +/** CDP-independent snapshot model for a Cordis Context and Fiber tree. */ + +import { + type InspectorObjectHandle, + type InspectorObjectRegistryId, +} from './ids.ts' +import { isPlainObject } from '../json.ts' +import { exactKeys, exactObject, wireId } from '../validation.ts' + +/** Current serialized Cordis tree model version. */ +export const CORDIS_TREE_SCHEMA_VERSION = 0 as const + +/** Maximum nesting accepted from one realm snapshot. */ +export const CORDIS_TREE_MAX_DEPTH = 256 + +interface CordisTreeNodeBase { + readonly objectHandle: InspectorObjectHandle +} + +/** One Context entity in a Cordis tree snapshot. */ +export interface CordisContextTreeNode extends CordisTreeNodeBase { + readonly kind: 'context' + readonly children: readonly CordisTreeNode[] +} + +/** One Fiber entity in a Cordis tree snapshot. */ +export interface CordisFiberTreeNode extends CordisTreeNodeBase { + readonly kind: 'fiber' + readonly uid: number + readonly children: readonly [CordisContextTreeNode] +} + +/** One semantic entity node in preorder. */ +export type CordisTreeNode = CordisContextTreeNode | CordisFiberTreeNode + +/** Immutable, serializable state of one realm's reachable Cordis tree. */ +export interface CordisTreeSnapshot { + readonly schemaVersion: typeof CORDIS_TREE_SCHEMA_VERSION + readonly revision: number + readonly objectRegistryId: InspectorObjectRegistryId + readonly root: CordisContextTreeNode + readonly truncated: boolean +} + +/** + * Decode and validate one complete Cordis tree replacement. + * @param value - Untrusted observation payload. + * @param maxNodes - Maximum nodes admitted from one source. + * @returns A detached, validated snapshot. + */ +export function parseCordisTreeSnapshot(value: unknown, maxNodes: number): CordisTreeSnapshot { + const record = exactObject(value, [ + 'schemaVersion', 'revision', 'objectRegistryId', 'root', 'truncated', + ], 'Cordis tree') + if (record.schemaVersion !== CORDIS_TREE_SCHEMA_VERSION + || !Number.isSafeInteger(record.revision) || (record.revision as number) < 1 + || typeof record.truncated !== 'boolean') { + throw new Error('inspector protocol: invalid Cordis tree header') + } + const state: ParseState = { count: 0, handles: new Set(), fiberUids: new Set() } + const root = parseNode(record.root, state, maxNodes, 0) + if (root.kind !== 'context') throw new Error('inspector protocol: Cordis tree root must be a Context') + return { + schemaVersion: CORDIS_TREE_SCHEMA_VERSION, + revision: record.revision as number, + objectRegistryId: wireId<'InspectorObjectRegistryId'>(record.objectRegistryId, 'objectRegistryId'), + root, + truncated: record.truncated, + } +} + +interface ParseState { + count: number + readonly handles: Set + readonly fiberUids: Set +} + +function parseNode(value: unknown, state: ParseState, maxNodes: number, depth: number): CordisTreeNode { + if (depth > CORDIS_TREE_MAX_DEPTH) throw new Error('inspector protocol: Cordis tree exceeds the depth limit') + if (++state.count > maxNodes) throw new Error(`inspector protocol: Cordis tree exceeds ${String(maxNodes)} nodes`) + if (!isPlainObject(value) || (value.kind !== 'context' && value.kind !== 'fiber')) { + throw new Error('inspector protocol: Cordis tree node must have a known kind') + } + const objectHandle = wireId<'InspectorObjectHandle'>(value.objectHandle, 'objectHandle') + if (state.handles.has(objectHandle)) throw new Error('inspector protocol: Cordis tree repeats an object handle') + state.handles.add(objectHandle) + if (!Array.isArray(value.children)) throw new Error('inspector protocol: Cordis tree node children must be an array') + if (value.kind === 'context') { + exactKeys(value, ['kind', 'objectHandle', 'children'], 'Context tree node') + return { + kind: 'context', + objectHandle, + children: value.children.map(child => parseNode(child, state, maxNodes, depth + 1)), + } + } + exactKeys(value, ['kind', 'objectHandle', 'uid', 'children'], 'Fiber tree node') + if (!Number.isSafeInteger(value.uid) || (value.uid as number) < 1) { + throw new Error('inspector protocol: Cordis Fiber uid must be a positive safe integer') + } + if (state.fiberUids.has(value.uid as number)) throw new Error('inspector protocol: Cordis tree repeats a Fiber uid') + state.fiberUids.add(value.uid as number) + if (value.children.length !== 1) throw new Error('inspector protocol: Cordis Fiber must own exactly one Context') + const context = parseNode(value.children[0], state, maxNodes, depth + 1) + if (context.kind !== 'context') throw new Error('inspector protocol: Cordis Fiber child must be a Context') + return { + kind: 'fiber', + objectHandle, + uid: value.uid as number, + children: [context], + } +} diff --git a/packages/experimental/inspector/src/shared/service.ts b/packages/experimental/inspector/src/shared/service.ts new file mode 100644 index 0000000000..0cd8bbae83 --- /dev/null +++ b/packages/experimental/inspector/src/shared/service.ts @@ -0,0 +1,32 @@ +/** Cordis service API shared by the Host and Client plugin faces. */ + +import type { CordisRuntimeTreeReader } from './cordis/reader.ts' +import { createQueryCordisRuntimeTreeReader } from './bridge/query-reader.ts' +import type { InspectorJsonValue } from './json.ts' +import type { InspectorConnection } from './bridge/publisher.ts' + +/** Shared Host/Client service façade over the realm's source publisher. */ +export interface InspectorService { + /** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void + + /** Read-only Cordis topology queries independent of CDP sessions. */ + readonly cordis: CordisRuntimeTreeReader +} + +/** + * Create the shared service façade without exposing the carrier implementation. + * @param connection - Realm-local observation and query transport. + * @returns The Cordis service value. + */ +export function createInspectorService(connection: InspectorConnection): InspectorService { + return { + publish: (topic, payload, monotonicMs) => { connection.publish(topic, payload, monotonicMs) }, + cordis: createQueryCordisRuntimeTreeReader(connection), + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts new file mode 100644 index 0000000000..d7288f784a --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts @@ -0,0 +1,4 @@ +/** Cordis semantic DOM domain exports. */ + +export { CordisDomBackend, type CordisDomChange } from './model.ts' +export { CordisDomSession } from './session.ts' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts new file mode 100644 index 0000000000..ad70430dab --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts @@ -0,0 +1,213 @@ +/** Worker projection from Cordis snapshots to a connection-neutral semantic DOM. */ + +import type { CordisTreeNode } from '../../../../shared/cordis/snapshot.ts' +import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts' +import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts' +import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts' +import { cdpNumericId, type CdpBackendNodeId } from '../../ids.ts' +import type { + CordisTreeObjectRoute, + CordisTreeSourceSnapshot, + CordisTreeStore, + CordisTreeStoreEvent, +} from '../../../inspection/cordis-store.ts' + +/** One Worker-global backend node independent of any DevTools connection. */ +export interface CordisDomNode { + readonly backendNodeId: CdpBackendNodeId + readonly key: string + readonly name: string + readonly attributes: readonly (readonly [string, string])[] + readonly description: string + readonly object?: CordisTreeObjectRoute + readonly children: readonly CordisDomNode[] +} + +/** Immutable document revision shared by all current DevTools sessions. */ +export interface CordisDomDocument { + readonly revision: number + readonly root: CordisDomNode + readonly byBackendId: ReadonlyMap + readonly parentByBackendId: ReadonlyMap +} + +/** A full tree replacement or an in-place source availability change. */ +export type CordisDomChange = + | { readonly type: 'document-updated' } + | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor } + +/** Assigns durable backend ids and projects the latest source snapshots. */ +export class CordisDomBackend { + private readonly backendIdByKey = new Map() + private readonly listeners = new Set<(event: CordisDomChange) => void>() + private documentValue: CordisDomDocument + private nextBackendNodeId = 1 + private nextRevision = 1 + private readonly unsubscribe: () => void + private readonly nodeByObject = new Map() + + constructor(private readonly trees: CordisTreeStore) { + this.documentValue = this.build() + this.unsubscribe = trees.subscribe((event) => { + const previous = this.documentValue + this.documentValue = this.build() + const change = this.change(event, previous) + for (const listener of [...this.listeners]) { + try { + listener(change) + } catch { + // One closed CDP connection cannot prevent sibling sessions from receiving the new document. + } + } + }) + } + + /** + * Read the latest connection-neutral semantic document. + * @returns The current immutable document revision. + */ + document(): CordisDomDocument { + return this.documentValue + } + + /** + * Subscribe to full document replacements and in-place realm state changes. + * @param listener - Called after a new backend revision is installed. + * @returns A disposer removing the listener. + */ + subscribe(listener: (event: CordisDomChange) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Release repository subscriptions at Worker shutdown. */ + close(): void { + this.unsubscribe() + this.listeners.clear() + } + + /** + * Resolve one source-local object reference to its current projected node. + * @param source - Connected source generation that owns the reference. + * @param reference - Realm-local registry and object handle. + * @returns The current projected node, when present. + */ + nodeForObject(source: InspectorSourceDescriptor, reference: InspectorObjectReference): CordisDomNode | undefined { + return this.nodeByObject.get(objectKey(source, reference)) + } + + /** + * Resolve a reference when a Runtime route identifies only Host or Client ownership. + * @param kind - Host or Client ownership inferred by the Runtime adapter. + * @param reference - Realm-local registry and object handle. + * @returns The current projected node, when present. + */ + nodeForObjectKind(kind: InspectorSourceDescriptor['kind'], reference: InspectorObjectReference): CordisDomNode | undefined { + const route = this.trees.resolveObjectInKind(kind, reference) + return route === undefined ? undefined : this.nodeForObject(route.source, reference) + } + + /** + * Resolve one realm-neutral Runtime reference to its current projected node. + * @param realm - Realm that exposed the Runtime object. + * @param reference - Realm-local registry and object handle. + * @returns The current projected node, when present. + */ + nodeForRealm(realm: InspectorRealmDescriptor, reference: InspectorObjectReference): CordisDomNode | undefined { + if (realm.kind === 'host') return this.nodeForObjectKind('host', reference) + const route = this.trees.resolveObjectIdentity(realm.sourceId, realm.generation, reference) + return route === undefined ? undefined : this.nodeForObject(route.source, reference) + } + + private build(): CordisDomDocument { + const byBackendId = new Map() + const parentByBackendId = new Map() + this.nodeByObject.clear() + const tree = this.trees.tree() + const root = this.node('document', '#document', [], '#document') + const host = this.node('host', 'host', [], '') + if (tree.host !== null) host.children.push(this.entity(tree.host, tree.host.snapshot.root)) + const clients = this.node('clients', 'clients', [], '') + for (const clientTree of tree.clients) { + const client = this.node(`client:${clientTree.source.sourceId}`, 'client', [], '') + client.children.push(this.entity(clientTree, clientTree.snapshot.root)) + clients.children.push(client) + } + root.children.push(host, clients) + const retainedKeys = new Set() + const freeze = (node: MutableDomNode, parent?: MutableDomNode): CordisDomNode => { + const value: CordisDomNode = { ...node, children: node.children.map(child => freeze(child, node)) } + retainedKeys.add(value.key) + byBackendId.set(value.backendNodeId, value) + if (parent !== undefined) parentByBackendId.set(value.backendNodeId, parent.backendNodeId) + if (value.object?.connection.state === 'connected') this.nodeByObject.set(objectKey(value.object.source, { + registryId: value.object.snapshot.objectRegistryId, + handle: value.object.node.objectHandle, + }), value) + return value + } + const frozenRoot = freeze(root) + for (const key of this.backendIdByKey.keys()) { + if (!retainedKeys.has(key)) this.backendIdByKey.delete(key) + } + return { revision: this.nextRevision++, root: frozenRoot, byBackendId, parentByBackendId } + } + + private entity( + tree: CordisTreeSourceSnapshot, + node: CordisTreeNode, + ): MutableDomNode { + const { source, snapshot } = tree + const key = `entity:${objectKey(source, { registryId: snapshot.objectRegistryId, handle: node.objectHandle })}` + const object = { ...tree, node } + const attributes: readonly (readonly [string, string])[] = node.kind === 'fiber' + ? [['uid', String(node.uid)]] + : [] + const projected = this.node(key, node.kind, attributes, elementDescription(node.kind, attributes), object) + projected.children.push(...node.children.map(child => this.entity(tree, child))) + return projected + } + + private node( + key: string, + name: string, + attributes: readonly (readonly [string, string])[], + description: string, + object?: CordisTreeObjectRoute, + ): MutableDomNode { + let backendNodeId = this.backendIdByKey.get(key) + if (backendNodeId === undefined) { + backendNodeId = cdpNumericId<'CdpBackendNodeId'>(this.nextBackendNodeId++, 'backendNodeId') + this.backendIdByKey.set(key, backendNodeId) + } + return { backendNodeId, key, name, attributes, description, ...(object === undefined ? {} : { object }), children: [] } + } + + private change(event: CordisTreeStoreEvent, previous: CordisDomDocument): CordisDomChange { + if (event.type === 'source-disconnected' && sameNodeSet(previous, this.documentValue)) { + return { type: 'source-disconnected', source: event.source } + } + return { type: 'document-updated' } + } +} + +interface MutableDomNode extends Omit { + readonly children: MutableDomNode[] +} + +function elementDescription(name: string, attributes: readonly (readonly [string, string])[]): string { + const rendered = attributes.map(([key, value]) => value === '' ? key : `${key}=${JSON.stringify(value)}`).join(' ') + return `<${name}${rendered === '' ? '' : ` ${rendered}`}>` +} + +function objectKey(source: InspectorSourceDescriptor, reference: InspectorObjectReference): string { + return `${source.sourceId}\0${source.generation}\0${reference.registryId}\0${reference.handle}` +} + +function sameNodeSet(left: CordisDomDocument, right: CordisDomDocument): boolean { + if (left.byBackendId.size !== right.byBackendId.size) return false + for (const backendNodeId of left.byBackendId.keys()) { + if (!right.byBackendId.has(backendNodeId)) return false + } + return true +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts new file mode 100644 index 0000000000..4f2e53181c --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts @@ -0,0 +1,361 @@ +/** Per-DevTools-session read-only DOM projection over Cordis tree snapshots. */ + +import { realmObjectExpression } from '../../../../shared/cordis/object-registry.ts' +import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts' +import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts' +import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts' +import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts' +import type { RuntimeDomainSession } from '../runtime/index.ts' +import type { RuntimeObjectPresentation } from '../runtime/object-table.ts' +import type { CordisDomBackend, CordisDomChange, CordisDomNode } from './model.ts' +import { + cdpNumericId, + cdpStringId, + type CdpBackendNodeId, + type CdpNodeId, + type CdpRemoteObjectId, +} from '../../ids.ts' + +const READ_ONLY_METHODS = new Set([ + 'DOM.setAttributeValue', 'DOM.setAttributesAsText', 'DOM.setNodeName', 'DOM.setNodeValue', + 'DOM.setOuterHTML', 'DOM.removeNode', 'DOM.moveTo', 'DOM.copyTo', +]) + +interface BoundDomObject { + readonly backendNodeId: CdpBackendNodeId + readonly sourceId: string + readonly generation: string +} + +/** Connection-local NodeId, search, and RemoteObject mapping owner. */ +export class CordisDomSession { + private readonly nodeIdByBackend = new Map() + private readonly backendByNodeId = new Map() + private readonly backendByObjectId = new Map() + private readonly objectIdsByGroup = new Map>() + private readonly searches = new Map() + private readonly unsubscribe: () => void + private nextNodeId = 1 + private nextSearchId = 1 + private enabled = false + + constructor( + private readonly transport: CdpTransport, + private readonly backend: CordisDomBackend, + private readonly runtime: RuntimeDomainSession, + ) { + this.unsubscribe = backend.subscribe((event) => { this.updateDocument(event) }) + } + + /** + * Handle one DOM command. + * @param request - Parsed CDP request. + * @returns Whether this adapter owns the method. + */ + handle(request: CdpRequest): boolean { + if (!request.method.startsWith('DOM.')) return false + this.respond(request, async () => this.execute(request.method, request.params)) + return true + } + + /** + * Forget a Runtime object mapping before its owner releases the object. + * @param objectId - Connection-local Runtime object id. + */ + releaseObject(objectId: unknown): void { + if (typeof objectId !== 'string') return + const id = cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId') + this.backendByObjectId.delete(id) + for (const ids of this.objectIdsByGroup.values()) ids.delete(id) + } + + /** + * Recognize a Runtime object from any realm as one current Cordis node. + * @param objectId - Connection-local CDP object id. + * @param realm - Realm that exposed the object. + * @param reference - Realm-local semantic object identity. + * @param group - Runtime object group retaining the id. + * @returns Node presentation fields, when the object remains in the current tree. + */ + bindObject( + objectId: CdpRemoteObjectId, + realm: InspectorRealmDescriptor, + reference: InspectorObjectReference, + group: string | undefined, + ): RuntimeObjectPresentation | undefined { + const node = this.backend.nodeForRealm(realm, reference) + if (node === undefined) return undefined + this.bindObjectId(objectId, node, group) + return presentation(node) + } + + /** + * Forget every DOM mapping retained under one Runtime object group. + * @param group - Runtime object-group name. + */ + releaseObjectGroup(group: unknown): void { + if (typeof group !== 'string') return + for (const objectId of this.objectIdsByGroup.get(group) ?? []) this.backendByObjectId.delete(objectId) + this.objectIdsByGroup.delete(group) + } + + /** Release connection-owned ids and subscriptions. */ + close(): void { + this.unsubscribe() + this.resetDocument() + this.searches.clear() + } + + private async execute(method: string, params: Readonly>): Promise { + if (READ_ONLY_METHODS.has(method)) throw new Error('Cordis DOM projection is read-only') + switch (method) { + case 'DOM.enable': + this.enabled = true + return {} + case 'DOM.disable': + this.enabled = false + this.resetDocument() + return {} + case 'DOM.getDocument': + this.enabled = true + return { root: this.serialize(this.backend.document().root, 0, true) } + case 'DOM.requestChildNodes': { + const node = this.fromNodeId(params.nodeId) + this.transport.send({ + method: 'DOM.setChildNodes', + params: { + parentId: numberParam(params.nodeId, 'nodeId'), + nodes: node.children.map(child => this.serialize(child, this.nodeId(node), true)), + }, + }) + return {} + } + case 'DOM.describeNode': { + const node = this.selectNode(params) + return { node: this.serialize(node, this.parentNodeId(node), true) } + } + case 'DOM.getAttributes': + return { attributes: this.fromNodeId(params.nodeId).attributes.flat() } + case 'DOM.getOuterHTML': + return { outerHTML: outerHtml(this.selectNode(params)) } + case 'DOM.pushNodesByBackendIdsToFrontend': { + if (!Array.isArray(params.backendNodeIds)) throw new Error('backendNodeIds must be an array') + return { + nodeIds: params.backendNodeIds.map((value) => { + if (!Number.isSafeInteger(value) || (value as number) < 1) return 0 + const node = this.backend.document().byBackendId.get(cdpBackendNodeId(value, 'backendNodeId')) + return node === undefined ? 0 : this.nodeId(node) + }), + } + } + case 'DOM.resolveNode': + return { object: await this.resolveNode(this.selectNode(params), optionalString(params.objectGroup)) } + case 'DOM.requestNode': { + const objectId = cdpStringId<'CdpRemoteObjectId'>(stringParam(params.objectId, 'objectId'), 'objectId') + const binding = this.backendByObjectId.get(objectId) + if (binding === undefined) throw new Error('RemoteObject is not a current Cordis node') + const node = this.backend.document().byBackendId.get(binding.backendNodeId) + if (node === undefined) throw new Error('Cordis node is no longer available') + return { nodeId: this.nodeId(node) } + } + case 'DOM.performSearch': { + const query = stringParam(params.query, 'query').toLowerCase() + const nodes = [...this.backend.document().byBackendId.values()] + .filter(node => node.name !== '#document' && searchable(node).includes(query)) + .map(node => this.nodeId(node)) + const searchId = `cordis-search-${String(this.nextSearchId++)}` + this.searches.set(searchId, nodes) + return { searchId, resultCount: nodes.length } + } + case 'DOM.getSearchResults': { + const ids = this.searches.get(stringParam(params.searchId, 'searchId')) ?? [] + return { + nodeIds: ids.slice(nonNegativeInteger(params.fromIndex, 'fromIndex'), nonNegativeInteger(params.toIndex, 'toIndex')), + } + } + case 'DOM.discardSearchResults': + this.searches.delete(stringParam(params.searchId, 'searchId')) + return {} + case 'DOM.setInspectedNode': + this.fromNodeId(params.nodeId) + return {} + case 'DOM.getBoxModel': + case 'DOM.getNodeForLocation': + throw new Error('Cordis semantic nodes do not have browser layout geometry') + default: + throw new Error(`Method not found: ${method}`) + } + } + + private async resolveNode(node: CordisDomNode, objectGroup: string | undefined): Promise>> { + const route = node.object + if (route === undefined) throw new Error('Structural Cordis node has no live Runtime object') + if (route.connection.state === 'disconnected') throw new Error('Cordis realm is disconnected') + const expression = realmObjectExpression({ + registryId: route.snapshot.objectRegistryId, + handle: route.node.objectHandle, + }) + const remote = await this.runtime.resolveObject(route.source, expression, objectGroup) + const rawObjectId = remote.objectId + if (typeof rawObjectId !== 'string') throw new Error('Cordis object lookup returned no RemoteObjectId') + const objectId = cdpStringId<'CdpRemoteObjectId'>(rawObjectId, 'objectId') + this.bindObjectId(objectId, node, objectGroup) + return { + ...remote, + ...presentation(node), + } + } + + private bindObjectId(objectId: CdpRemoteObjectId, node: CordisDomNode, group: string | undefined): void { + const source = node.object?.source + if (source === undefined) throw new Error('Structural Cordis node cannot bind a Runtime object') + this.backendByObjectId.set(objectId, { + backendNodeId: node.backendNodeId, + sourceId: source.sourceId, + generation: source.generation, + }) + if (group === undefined) return + let ids = this.objectIdsByGroup.get(group) + if (ids === undefined) this.objectIdsByGroup.set(group, ids = new Set()) + ids.add(objectId) + } + + private selectNode(params: Readonly>): CordisDomNode { + if (params.nodeId !== undefined) return this.fromNodeId(params.nodeId) + if (params.backendNodeId !== undefined) { + const id = cdpBackendNodeId(params.backendNodeId, 'backendNodeId') + const node = this.backend.document().byBackendId.get(id) + if (node !== undefined) return node + } + if (typeof params.objectId === 'string') { + const binding = this.backendByObjectId.get(cdpStringId<'CdpRemoteObjectId'>(params.objectId, 'objectId')) + const node = binding === undefined + ? undefined + : this.backend.document().byBackendId.get(binding.backendNodeId) + if (node !== undefined) return node + } + throw new Error('Cordis node is not available') + } + + private fromNodeId(value: unknown): CordisDomNode { + const backendId = this.backendByNodeId.get(cdpNodeId(value, 'nodeId')) + const node = backendId === undefined ? undefined : this.backend.document().byBackendId.get(backendId) + if (node === undefined) throw new Error('Cordis NodeId is not available in this document') + return node + } + + private serialize(node: CordisDomNode, parentId: CdpNodeId | 0, children: boolean): object { + const nodeId = this.nodeId(node) + const document = node.name === '#document' + return { + nodeId, + backendNodeId: node.backendNodeId, + nodeType: document ? 9 : 1, + nodeName: document ? '#document' : node.name.toUpperCase(), + localName: document ? '' : node.name, + nodeValue: '', + ...(parentId === 0 ? {} : { parentId }), + ...(document ? { documentURL: 'dsh://cordis', baseURL: 'dsh://cordis' } : {}), + childNodeCount: node.children.length, + ...(children ? { children: node.children.map(child => this.serialize(child, nodeId, true)) } : {}), + attributes: node.attributes.flat(), + } + } + + private nodeId(node: CordisDomNode): CdpNodeId { + let nodeId = this.nodeIdByBackend.get(node.backendNodeId) + if (nodeId === undefined) { + nodeId = cdpNumericId<'CdpNodeId'>(this.nextNodeId++, 'nodeId') + this.nodeIdByBackend.set(node.backendNodeId, nodeId) + this.backendByNodeId.set(nodeId, node.backendNodeId) + } + return nodeId + } + + private parentNodeId(node: CordisDomNode): CdpNodeId | 0 { + const parent = this.backend.document().parentByBackendId.get(node.backendNodeId) + if (parent === undefined) return 0 + const nodeValue = this.backend.document().byBackendId.get(parent) + return nodeValue === undefined ? 0 : this.nodeId(nodeValue) + } + + private resetDocument(): void { + this.nodeIdByBackend.clear() + this.backendByNodeId.clear() + this.backendByObjectId.clear() + this.objectIdsByGroup.clear() + this.searches.clear() + } + + private updateDocument(event: CordisDomChange): void { + if (event.type === 'source-disconnected') { + this.releaseSourceObjects(event.source) + return + } + this.resetDocument() + if (this.enabled) this.transport.send({ method: 'DOM.documentUpdated', params: {} }) + } + + private releaseSourceObjects(source: InspectorSourceDescriptor): void { + for (const [objectId, binding] of this.backendByObjectId) { + if (binding.sourceId !== source.sourceId || binding.generation !== source.generation) continue + this.backendByObjectId.delete(objectId) + for (const [group, objectIds] of this.objectIdsByGroup) { + objectIds.delete(objectId) + if (objectIds.size === 0) this.objectIdsByGroup.delete(group) + } + } + } + + private respond(request: CdpRequest, operation: () => Promise): void { + respondToCdpRequest(this.transport, request, operation) + } +} + +function outerHtml(node: CordisDomNode, indent = ''): string { + const attributes = node.attributes.map(([name, value]) => ` ${name}=${JSON.stringify(value)}`).join('') + if (node.children.length === 0) return `${indent}<${node.name}${attributes} />` + const children = node.children.map(child => outerHtml(child, `${indent} `)).join('\n') + return `${indent}<${node.name}${attributes}>\n${children}\n${indent}` +} + +function searchable(node: CordisDomNode): string { + return `${node.name} ${node.description} ${node.attributes.flat().join(' ')}`.toLowerCase() +} + +function numberParam(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative integer`) + return value as number +} + +function cdpNodeId(value: unknown, name: string): CdpNodeId { + if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`) + return cdpNumericId<'CdpNodeId'>(value as number, name) +} + +function cdpBackendNodeId(value: unknown, name: string): CdpBackendNodeId { + if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`) + return cdpNumericId<'CdpBackendNodeId'>(value as number, name) +} + +function nonNegativeInteger(value: unknown, name: string): number { + return numberParam(value, name) +} + +function stringParam(value: unknown, name: string): string { + if (typeof value !== 'string') throw new Error(`${name} must be a string`) + return value +} + +function optionalString(value: unknown): string | undefined { + if (value === undefined) return undefined + return stringParam(value, 'objectGroup') +} + +function presentation(node: CordisDomNode): RuntimeObjectPresentation { + return { + subtype: 'node', + className: node.object?.node.kind === 'fiber' ? 'Fiber' : 'Context', + description: node.description, + } +} diff --git a/packages/experimental/inspector/src/worker/inspection/cordis-query.ts b/packages/experimental/inspector/src/worker/inspection/cordis-query.ts new file mode 100644 index 0000000000..73794a3815 --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/cordis-query.ts @@ -0,0 +1,17 @@ +/** Cordis tree query execution independent of its source carrier. */ + +import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts' +import type { InspectorQuery, InspectorQueryResult } from '../../shared/bridge/messages/query/commands.ts' + +/** + * Execute one closed Inspector query against the shared semantic reader. + * @param reader - Latest committed Cordis tree reader. + * @param query - Validated query command. + * @returns The result corresponding to the query operation. + */ +export async function executeInspectorQuery( + reader: CordisRuntimeTreeReader, + query: InspectorQuery, +): Promise { + return { op: query.op, tree: await reader.getTree() } +} diff --git a/packages/experimental/inspector/src/worker/inspection/cordis-store.ts b/packages/experimental/inspector/src/worker/inspection/cordis-store.ts new file mode 100644 index 0000000000..bb78b1336e --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/cordis-store.ts @@ -0,0 +1,248 @@ +/** Worker-owned repository of CDP-independent Cordis tree snapshots. */ + +import { + parseCordisTreeSnapshot, + type CordisTreeNode, + type CordisTreeSnapshot, +} from '../../shared/cordis/snapshot.ts' +import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts' +import type { InspectorObjectReference } from '../../shared/cordis/object-reference.ts' +import { + projectCordisRuntimeTree, + type CordisInspectionTree as SharedCordisInspectionTree, + type CordisTreeSourceSnapshot as SharedCordisTreeSourceSnapshot, +} from '../../shared/cordis/projector.ts' +import type { CordisRuntimeTree } from '../../shared/cordis/model.ts' +import type { IngestedInspectorRecord, InspectorRecordConsumer } from '../bridge/hub.ts' + +/** Routed Worker snapshot retaining its complete source-generation descriptor. */ +export type CordisTreeSourceSnapshot = SharedCordisTreeSourceSnapshot + +/** Routed Host and Client snapshots retained by the Worker. */ +export type CordisInspectionTree = SharedCordisInspectionTree + +export type { CordisTreeSourceConnection } from '../../shared/cordis/projector.ts' + +/** One object-backed tree node with its owning source generation. */ +export interface CordisTreeObjectRoute extends CordisTreeSourceSnapshot { + readonly node: CordisTreeNode +} + +/** Store mutation consumed by presentation adapters. */ +export type CordisTreeStoreEvent = + | { readonly type: 'snapshot-changed'; readonly source: InspectorSourceDescriptor } + | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor } + +/** Independent bounds for live tree size and retained disconnected snapshots. */ +export interface CordisTreeStoreOptions { + readonly maxNodes: number + readonly maxDisconnectedTrees: number +} + +interface StoredTree extends CordisTreeSourceSnapshot { + readonly nodesByObject: ReadonlyMap +} + +/** Validated latest-value store consumed independently by CDP and future query adapters. */ +export class CordisTreeStore implements InspectorRecordConsumer { + readonly topics = new Set([CORDIS_TREE_TOPIC]) + private readonly trees = new Map() + private readonly disconnected = new Set() + private readonly listeners = new Set<(event: CordisTreeStoreEvent) => void>() + + constructor(private readonly options: CordisTreeStoreOptions) {} + + /** Replace all retained state for one source generation. */ + replace(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void { + const next = this.latest(source, records) + const changed = next === undefined + ? this.remove(source.sourceId) + : this.install(source, next) + if (changed) this.emit({ type: 'snapshot-changed', source }) + } + + /** Apply later state replacements, ignoring unrelated observation topics. */ + append(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void { + const next = this.latest(source, records) + if (next !== undefined && this.install(source, next)) this.emit({ type: 'snapshot-changed', source }) + } + + /** Freeze a closed source generation's last tree and invalidate its object routes. */ + close(source: InspectorSourceDescriptor, reason: string): void { + const current = this.trees.get(source.sourceId) + if (current?.source.generation !== source.generation || current.connection.state === 'disconnected') return + this.trees.set(source.sourceId, { + ...current, + connection: { state: 'disconnected', reason }, + }) + this.disconnected.delete(source.sourceId) + this.disconnected.add(source.sourceId) + while (this.disconnected.size > this.options.maxDisconnectedTrees) { + const oldest = this.disconnected.values().next().value + if (oldest === undefined) break + this.remove(oldest) + } + this.emit({ type: 'source-disconnected', source }) + } + + /** + * Read all current realm snapshots without CDP identifiers. + * @returns Snapshots in source admission order. + */ + snapshots(): CordisTreeSourceSnapshot[] { + return [...this.trees.values()].map(({ source, snapshot, connection }) => ({ source, snapshot, connection })) + } + + /** + * Compose the common realm model into Host and Client slots. + * @returns A detached view whose Host and Client entries share one type. + */ + tree(): CordisInspectionTree { + const snapshots = this.snapshots() + return { + host: snapshots.find(tree => tree.source.kind === 'host') ?? null, + clients: snapshots.filter(tree => tree.source.kind === 'client'), + } + } + + /** + * Read a detached semantic tree without object-routing or CDP identifiers. + * @returns The latest retained Host and Client topology. + */ + readTree(): CordisRuntimeTree { + return projectCordisRuntimeTree(this.tree()) + } + + /** + * Resolve a source-local object reference to its semantic tree node. + * @param source - Active source generation. + * @param reference - Realm-local registry and object handle. + * @returns The matching node while its source remains connected. + */ + resolveObject(source: InspectorSourceDescriptor, reference: InspectorObjectReference): CordisTreeObjectRoute | undefined { + const tree = this.trees.get(source.sourceId) + if (tree === undefined + || tree.source.generation !== source.generation + || tree.connection.state === 'disconnected') return undefined + const node = tree.nodesByObject.get(objectKey(reference)) + return node === undefined ? undefined : this.route(tree, node) + } + + /** + * Resolve a source-local object without requiring the source's presentation fields. + * @param sourceId - Logical source identity. + * @param generation - Active source generation. + * @param reference - Realm-local object reference. + * @returns The matching live tree node. + */ + resolveObjectIdentity( + sourceId: InspectorSourceId, + generation: InspectorSourceGeneration, + reference: InspectorObjectReference, + ): CordisTreeObjectRoute | undefined { + const tree = this.trees.get(sourceId) + if (tree === undefined || tree.source.generation !== generation || tree.connection.state === 'disconnected') { + return undefined + } + const node = tree.nodesByObject.get(objectKey(reference)) + return node === undefined ? undefined : this.route(tree, node) + } + + /** + * Resolve a live reference when only its source realm kind is known. + * @param kind - Host or Client ownership inferred by the Runtime adapter. + * @param reference - Realm-local registry and object handle. + * @returns The matching connected node, when present. + */ + resolveObjectInKind(kind: InspectorSourceDescriptor['kind'], reference: InspectorObjectReference): CordisTreeObjectRoute | undefined { + for (const tree of this.trees.values()) { + if (tree.source.kind !== kind || tree.connection.state === 'disconnected') continue + const node = tree.nodesByObject.get(objectKey(reference)) + if (node !== undefined) return this.route(tree, node) + } + return undefined + } + + /** + * Subscribe to accepted tree replacements and source availability changes. + * @param listener - Repository observer. + * @returns A disposer removing the observer. + */ + subscribe(listener: (event: CordisTreeStoreEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + private latest( + source: InspectorSourceDescriptor, + records: readonly IngestedInspectorRecord[], + ): CordisTreeSnapshot | undefined { + let snapshot: CordisTreeSnapshot | undefined + for (const record of records) { + if (record.topic !== CORDIS_TREE_TOPIC) continue + const candidate = parseCordisTreeSnapshot(record.payload, this.options.maxNodes) + if (snapshot === undefined || candidate.revision > snapshot.revision) snapshot = candidate + } + if (snapshot === undefined) return undefined + const current = this.trees.get(source.sourceId) + if (current?.source.generation === source.generation && current.snapshot.revision >= snapshot.revision) { + return current.snapshot + } + return snapshot + } + + private install(source: InspectorSourceDescriptor, snapshot: CordisTreeSnapshot): boolean { + const current = this.trees.get(source.sourceId) + if (current?.source.generation === source.generation + && current.snapshot === snapshot + && current.connection.state === 'connected') return false + this.disconnected.delete(source.sourceId) + this.trees.set(source.sourceId, { + source, + snapshot, + connection: { state: 'connected' }, + nodesByObject: new Map(treeNodes(snapshot.root).map(node => [objectKey({ + registryId: snapshot.objectRegistryId, + handle: node.objectHandle, + }), node])), + }) + return true + } + + private remove(sourceId: string): boolean { + this.disconnected.delete(sourceId) + return this.trees.delete(sourceId) + } + + private route(tree: StoredTree, node: CordisTreeNode): CordisTreeObjectRoute { + return { source: tree.source, snapshot: tree.snapshot, connection: tree.connection, node } + } + + private emit(event: CordisTreeStoreEvent): void { + for (const listener of [...this.listeners]) { + try { + listener(event) + } catch { + // One query adapter cannot prevent later repository observers from updating. + } + } + } +} + +function objectKey(reference: InspectorObjectReference): string { + return `${reference.registryId}\0${reference.handle}` +} + +function treeNodes(root: CordisTreeNode): CordisTreeNode[] { + const nodes: CordisTreeNode[] = [] + const pending: CordisTreeNode[] = [root] + while (pending.length > 0) { + const node = pending.pop() + if (node === undefined) break + nodes.push(node) + pending.push(...node.children.toReversed()) + } + return nodes +} diff --git a/packages/experimental/inspector/src/worker/inspection/query-router.ts b/packages/experimental/inspector/src/worker/inspection/query-router.ts new file mode 100644 index 0000000000..ef58e9e122 --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/query-router.ts @@ -0,0 +1,255 @@ +/** Worker-side admission, execution, and bounded settlement of non-CDP queries. */ + +import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts' +import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts' +import { jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorQueryError } from '../../shared/bridge/messages/query/commands.ts' +import { + isInspectorQueryRequestEnvelope, + parseInspectorQueryFrameIdentity, + parseInspectorQueryRequestFrame, +} from '../../shared/bridge/messages/query/codec.ts' +import type { + InspectorQueryRequestFrame, + InspectorQueryRequestId, + InspectorQueryResponseFrame, +} from '../../shared/bridge/messages/query/frames.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts' +import { executeInspectorQuery } from './cordis-query.ts' + +/** Carrier operations owned by one Worker query peer. */ +export interface InspectorQueryPeerTransport { + /** Send one bounded Worker response. */ + send(frame: InspectorQueryResponseFrame): void + /** Reject a malformed peer whose request cannot be correlated safely. */ + close(code: number, reason: string): void +} + +interface AcceptedGeneration { + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration +} + +/** Creates isolated query peers over one shared semantic reader. */ +export class InspectorQueryRouter { + private readonly peers = new Set() + private readonly activeBySource = new Map() + + constructor( + private readonly reader: CordisRuntimeTreeReader, + private readonly maxFrameBytes: number, + ) {} + + /** + * Create query state for one Host MessagePort or Client WebSocket. + * @param transport - Carrier response and rejection operations. + * @returns The peer that receives frames from this carrier only. + */ + open(transport: InspectorQueryPeerTransport): InspectorQueryPeer { + const peer: InspectorQueryPeer = new InspectorQueryPeer( + this.reader, + this.maxFrameBytes, + transport, + (accepted) => { + for (const [sourceId, active] of this.activeBySource) { + if (active.peer === peer) this.activeBySource.delete(sourceId) + } + this.activeBySource.set(accepted.sourceId, { ...accepted, peer }) + }, + (accepted): boolean => this.activeBySource.get(accepted.sourceId)?.peer === peer + && this.activeBySource.get(accepted.sourceId)?.generation === accepted.generation, + () => { + this.peers.delete(peer) + for (const [sourceId, active] of this.activeBySource) { + if (active.peer === peer) this.activeBySource.delete(sourceId) + } + }, + ) + this.peers.add(peer) + return peer + } + + /** + * Revoke query access when the source registry closes one generation. + * @param source - Closed source generation. + */ + disconnect(source: InspectorSourceDescriptor): void { + const active = this.activeBySource.get(source.sourceId) + if (active?.generation !== source.generation) return + this.activeBySource.delete(source.sourceId) + active.peer.revoke(source.sourceId, source.generation) + } + + /** Revoke every peer during Worker shutdown. */ + close(): void { + for (const peer of [...this.peers]) peer.close() + this.activeBySource.clear() + } +} + +/** Query protocol state associated with exactly one source carrier. */ +export class InspectorQueryPeer { + private accepted: AcceptedGeneration | undefined + private readonly inFlight = new Map() + private closed = false + + constructor( + private readonly reader: CordisRuntimeTreeReader, + private readonly maxFrameBytes: number, + private readonly transport: InspectorQueryPeerTransport, + private readonly register: (accepted: AcceptedGeneration) => void, + private readonly isRegistered: (accepted: AcceptedGeneration) => boolean, + private readonly unregister: () => void, + ) {} + + /** + * Admit the source generation after the source registry accepts it. + * @param sourceId - Stable source identity. + * @param generation - Active carrier generation. + */ + accept(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): void { + if (this.closed) return + this.accepted = { sourceId, generation } + this.inFlight.clear() + this.register(this.accepted) + } + + /** + * Revoke one generation while leaving its carrier available for a later source/open. + * @param sourceId - Stable source identity. + * @param generation - Generation being removed by the source registry. + */ + revoke(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): void { + if (this.accepted?.sourceId !== sourceId || this.accepted.generation !== generation) return + this.accepted = undefined + this.inFlight.clear() + } + + /** + * Consume a decoded carrier value when it belongs to the query protocol. + * @param value - Untrusted source-to-Worker value. + * @returns Whether this peer owned the value. + */ + receive(value: unknown): boolean { + if (!isInspectorQueryRequestEnvelope(value)) return false + let frame: InspectorQueryRequestFrame + try { + frame = parseInspectorQueryRequestFrame(value) + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) { + throw new Error(`inspector protocol: query request exceeds ${String(this.maxFrameBytes)} bytes`) + } + } catch (error) { + this.rejectMalformed(value, renderError(error)) + return true + } + const accepted = this.accepted + if (this.closed || accepted === undefined || !this.isRegistered(accepted) + || accepted.sourceId !== frame.sourceId + || accepted.generation !== frame.generation) { + this.sendFailure(frame, 'stale-source', 'Inspector query does not belong to the accepted source generation') + return true + } + if (this.inFlight.has(frame.requestId)) { + this.sendFailure(frame, 'invalid-request', 'Inspector query requestId is already in flight') + return true + } + this.inFlight.set(frame.requestId, accepted) + void this.execute(frame, accepted) + return true + } + + /** Stop this peer and suppress completion from in-flight readers. */ + close(): void { + if (this.closed) return + this.closed = true + this.accepted = undefined + this.inFlight.clear() + this.unregister() + } + + private async execute(frame: InspectorQueryRequestFrame, accepted: AcceptedGeneration): Promise { + try { + const result = await executeInspectorQuery(this.reader, frame.query) + if (!this.canReply(frame, accepted)) return + const response: InspectorQueryResponseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/response', + sourceId: frame.sourceId, + generation: frame.generation, + requestId: frame.requestId, + outcome: { ok: true, result }, + } + if (jsonByteLength(response as unknown as InspectorJsonValue) > this.maxFrameBytes) { + this.sendFailure(frame, 'result-too-large', `Inspector query result exceeds ${String(this.maxFrameBytes)} bytes`) + return + } + this.deliver(response) + } catch (error) { + if (this.canReply(frame, accepted)) this.sendFailure(frame, 'internal-error', renderError(error).message) + } finally { + if (this.inFlight.get(frame.requestId) === accepted) this.inFlight.delete(frame.requestId) + } + } + + private rejectMalformed(value: unknown, error: Error): void { + try { + const identity = parseInspectorQueryFrameIdentity(value) + this.sendFailure(identity, 'invalid-request', error.message) + } catch { + this.rejectTransport(1008, error.message) + } + } + + private sendFailure( + frame: Pick, + code: InspectorQueryError['code'], + message: string, + ): void { + if (this.closed) return + const response: InspectorQueryResponseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/response', + sourceId: frame.sourceId, + generation: frame.generation, + requestId: frame.requestId, + outcome: { ok: false, error: { code, message } }, + } + if (jsonByteLength(response as unknown as InspectorJsonValue) > this.maxFrameBytes) { + this.rejectTransport(1009, 'Inspector query error exceeds the frame limit') + return + } + this.deliver(response) + } + + private canReply(frame: InspectorQueryRequestFrame, accepted: AcceptedGeneration): boolean { + return !this.closed + && this.accepted === accepted + && this.isRegistered(accepted) + && this.inFlight.get(frame.requestId) === accepted + } + + private deliver(frame: InspectorQueryResponseFrame): void { + try { + this.transport.send(frame) + } catch (error) { + this.rejectTransport(1011, renderError(error).message) + } + } + + private rejectTransport(code: number, reason: string): void { + this.close() + try { + this.transport.close(code, reason.slice(0, 123)) + } catch { + // The carrier is already unusable; query state has reached quiescence. + } + } +} + +function renderError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/experimental/inspector/tests/cordis-query.host.spec.ts b/packages/experimental/inspector/tests/cordis-query.host.spec.ts new file mode 100644 index 0000000000..be258cff62 --- /dev/null +++ b/packages/experimental/inspector/tests/cordis-query.host.spec.ts @@ -0,0 +1,349 @@ +/** Host-driven Cordis query integration. */ + +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCordisRuntimeTreeReader } from '../src/shared/cordis/reader.ts' +import { + cordisRuntimeSourceId, + type CordisRuntimeContext, + type CordisRuntimeTree, +} from '../src/shared/cordis/model.ts' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import type { InspectorJsonValue } from '../src/shared/json.ts' +import { InspectorQueryConnection } from '../src/shared/bridge/rpc.ts' +import { parseInspectorQueryRequestFrame, parseInspectorQueryResponseFrame } from '../src/shared/bridge/messages/query/codec.ts' +import type { InspectorQueryRequestFrame, InspectorQueryResponseFrame } from '../src/shared/bridge/messages/query/frames.ts' +import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' +import { createInspectorService } from '../src/shared/service.ts' +import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts' +import { InspectorQueryRouter } from '../src/worker/inspection/query-router.ts' +import { InspectorClientFixture } from './fixtures/client-source.host.ts' + +describe('consumer-neutral Cordis tree', () => { + it('projects a detached recursive tree without routing identifiers', () => { + const store = new CordisTreeStore({ maxNodes: 10, maxDisconnectedTrees: 1 }) + const source = sourceDescriptor('host-1', 'generation-1', 'host') + store.replace(source, [{ + sequence: 1, + monotonicMs: 1, + topic: 'cordis/tree', + payload: asJson({ + schemaVersion: 0, + revision: 3, + objectRegistryId: 'registry-1', + truncated: false, + root: { + kind: 'context', + objectHandle: 'context-1', + children: [{ + kind: 'fiber', + uid: 12, + objectHandle: 'fiber-1', + children: [{ kind: 'context', objectHandle: 'context-2', children: [] }], + }], + }, + }), + }]) + + const tree = store.readTree() + expect(tree).toEqual({ + schemaVersion: 0, + host: { + source: { sourceId: 'host-1', kind: 'host', label: 'host-1' }, + connection: { state: 'connected' }, + revision: 3, + truncated: false, + root: { + kind: 'context', + children: [{ kind: 'fiber', uid: 12, children: [{ kind: 'context', children: [] }] }], + }, + }, + clients: [], + }) + expect(tree.host?.root).not.toBe(store.tree().host?.snapshot.root) + expect(forbiddenKeys(tree)).toEqual([]) + + store.close(source, 'transport closed') + expect(store.readTree().host?.connection).toEqual({ state: 'disconnected', reason: 'transport closed' }) + + const reconnected = sourceDescriptor('host-1', 'generation-2', 'host') + store.replace(reconnected, [{ + sequence: 1, + monotonicMs: 2, + topic: 'cordis/tree', + payload: asJson({ + schemaVersion: 0, + revision: 4, + objectRegistryId: 'registry-2', + truncated: false, + root: { kind: 'context', objectHandle: 'context-3', children: [] }, + }), + }]) + expect(store.readTree().host).toMatchObject({ + connection: { state: 'connected' }, + revision: 4, + root: { kind: 'context', children: [] }, + }) + expect(forbiddenKeys(store.readTree())).toEqual([]) + }) +}) + +describe('Inspector query protocol', () => { + afterEach(() => { vi.useRealTimers() }) + + it('uses exact request and response codecs', () => { + const hiddenTree = runtimeTree() + if (hiddenTree.host === null) throw new Error('test tree requires a Host realm') + expect(parseInspectorQueryRequestFrame({ + v: 0, + t: 'query/request', + sourceId: 'host-1', + generation: 'generation-1', + requestId: 'query-1', + query: { op: 'cordis-tree/get' }, + })).toMatchObject({ query: { op: 'cordis-tree/get' } }) + expect(() => parseInspectorQueryRequestFrame({ + v: 0, + t: 'query/request', + sourceId: 'host-1', + generation: 'generation-1', + requestId: 'query-1', + query: { op: 'cordis-tree/get', extension: true }, + })).toThrow('unknown field') + expect(() => parseInspectorQueryResponseFrame({ + ...successResponse('query-1', runtimeTree()), + outcome: { + ok: true, + result: { + op: 'cordis-tree/get', + tree: { + ...hiddenTree, + host: { + ...hiddenTree.host, + root: { kind: 'context', objectHandle: 'private', children: [] }, + }, + }, + }, + }, + })).toThrow('unknown field') + }) + + it('correlates results and clears stale, malformed, timed-out, and closed requests', async () => { + const sent: InspectorQueryRequestFrame[] = [] + const connection = new InspectorQueryConnection({ timeoutMs: 20, maxFrameBytes: 16_384 }) + connection.connect(sourceId('host-1'), generation('generation-1'), { + send: (frame) => { sent.push(frame) }, + }) + + const first = connection.request({ op: 'cordis-tree/get' }) + const firstFrame = sent.at(-1)! + expect(connection.receive(successResponse(firstFrame.requestId, runtimeTree()))).toBe(true) + await expect(first).resolves.toEqual({ op: 'cordis-tree/get', tree: runtimeTree() }) + + const stale = connection.request({ op: 'cordis-tree/get' }) + const staleFrame = sent.at(-1)! + expect(connection.receive({ + ...successResponse(staleFrame.requestId, runtimeTree()), + generation: generation('generation-old'), + })).toBe(true) + await expect(stale).rejects.toThrow('source generation does not match') + + const malformed = connection.request({ op: 'cordis-tree/get' }) + const malformedFrame = sent.at(-1)! + const malformedRejection = expect(malformed).rejects.toThrow('Invalid Inspector query response') + expect(() => connection.receive({ + ...successResponse(malformedFrame.requestId, runtimeTree()), + extension: true, + })).toThrow('unknown field') + await malformedRejection + + connection.connect(sourceId('host-1'), generation('generation-2'), { + send: (frame) => { sent.push(frame) }, + }) + vi.useFakeTimers() + const timedOut = connection.request({ op: 'cordis-tree/get' }) + const timeoutRejection = expect(timedOut).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(21) + await timeoutRejection + vi.useRealTimers() + + const closed = connection.request({ op: 'cordis-tree/get' }) + connection.close() + await expect(closed).rejects.toThrow('closed') + }) + + it('rejects malformed, stale, and oversized Worker requests with bounded outcomes', async () => { + const responses: InspectorQueryResponseFrame[] = [] + const close = vi.fn() + const largeTree = runtimeTree({ + kind: 'context', + children: Array.from({ length: 100 }, () => ({ kind: 'context', children: [] } as const)), + }) + const router = new InspectorQueryRouter(createCordisRuntimeTreeReader(() => largeTree), 512) + const peer = router.open({ send: (frame) => { responses.push(frame) }, close }) + peer.accept(sourceId('host-1'), generation('generation-1')) + + expect(peer.receive(requestFrame('query-stale', 'generation-old'))).toBe(true) + expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'stale-source' } }) + + expect(peer.receive({ ...requestFrame('query-malformed'), extension: true })).toBe(true) + expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'invalid-request' } }) + + expect(peer.receive(requestFrame('query-large'))).toBe(true) + await vi.waitFor(() => { + expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'result-too-large' } }) + }) + expect(close).not.toHaveBeenCalled() + + const requester = new InspectorQueryConnection({ timeoutMs: 100, maxFrameBytes: 512 }) + const pairedPeer = router.open({ + send: (frame) => { requester.receive(frame) }, + close: vi.fn(), + }) + pairedPeer.accept(sourceId('client-2'), generation('generation-1')) + requester.connect(sourceId('client-2'), generation('generation-1'), { + send: (frame) => { pairedPeer.receive(frame) }, + }) + await expect(requester.request({ op: 'cordis-tree/get' })).rejects.toMatchObject({ code: 'result-too-large' }) + requester.close() + }) + + it('revokes an older carrier when the same source opens a new generation', () => { + const firstResponses: InspectorQueryResponseFrame[] = [] + const router = new InspectorQueryRouter(createCordisRuntimeTreeReader(() => runtimeTree()), 16_384) + const first = router.open({ send: (frame) => { firstResponses.push(frame) }, close: vi.fn() }) + const second = router.open({ send: vi.fn(), close: vi.fn() }) + first.accept(sourceId('client-1'), generation('generation-1')) + second.accept(sourceId('client-1'), generation('generation-2')) + + expect(first.receive({ + ...requestFrame('query-old', 'generation-1'), + sourceId: sourceId('client-1'), + })).toBe(true) + expect(firstResponses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'stale-source' } }) + }) +}) + +describe('Cordis query service integration', () => { + let inspector: InspectorHandle | undefined + let clientSource: InspectorClientFixture | undefined + const observers: Array<() => void> = [] + + afterEach(async () => { + for (const dispose of observers.splice(0).reverse()) dispose() + await clientSource?.close() + clientSource = undefined + await inspector?.close() + inspector = undefined + }) + + it('returns the same Worker snapshot to Host and Client services without a CDP connection', async () => { + inspector = await startInspector({ + port: 0, + captureFetch: false, + queryTimeoutMs: 1_000, + maxCordisNodes: 100, + }) + const hostContext = new Context() + observers.push(publishHostCordisTree(hostContext, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 })) + const hostService = createInspectorService(inspector.source) + + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Query Client' }) + + await vi.waitFor(async () => { + const [hostTree, clientTree] = await Promise.all([ + hostService.cordis.getTree(), + clientSource!.getCordisTree(), + ]) + expect(hostTree).toEqual(clientTree) + expect(hostTree.host?.source.kind).toBe('host') + expect(hostTree.clients).toHaveLength(1) + expect(forbiddenKeys(hostTree)).toEqual([]) + }) + + await clientSource.close() + clientSource = undefined + await vi.waitFor(async () => { + const tree = await hostService.cordis.getTree() + expect(tree.clients[0]?.connection.state).toBe('disconnected') + }) + }) +}) + +function sourceDescriptor( + id: string, + sourceGeneration: string, + kind: InspectorSourceDescriptor['kind'], +): InspectorSourceDescriptor { + return { + sourceId: sourceId(id), + generation: generation(sourceGeneration), + kind, + label: id, + timeOriginMs: 0, + capabilities: [], + } +} + +function sourceId(value: string): InspectorSourceDescriptor['sourceId'] { + return inspectorId<'InspectorSourceId'>(value, 'sourceId') +} + +function generation(value: string): InspectorSourceDescriptor['generation'] { + return inspectorId<'InspectorSourceGeneration'>(value, 'generation') +} + +function runtimeTree(root: CordisRuntimeContext = { kind: 'context', children: [] }): CordisRuntimeTree { + return { + schemaVersion: 0, + host: { + source: { sourceId: cordisRuntimeSourceId('host-1'), kind: 'host', label: 'Host' }, + connection: { state: 'connected' }, + revision: 1, + truncated: false, + root, + }, + clients: [], + } +} + +function requestFrame(requestId: string, sourceGeneration = 'generation-1'): InspectorQueryRequestFrame { + return { + v: 0, + t: 'query/request', + sourceId: sourceId('host-1'), + generation: generation(sourceGeneration), + requestId: inspectorId<'InspectorQueryRequestId'>(requestId, 'requestId'), + query: { op: 'cordis-tree/get' }, + } +} + +function successResponse(requestId: string, tree: CordisRuntimeTree): InspectorQueryResponseFrame { + return { + v: 0, + t: 'query/response', + sourceId: sourceId('host-1'), + generation: generation('generation-1'), + requestId: inspectorId<'InspectorQueryRequestId'>(requestId, 'requestId'), + outcome: { ok: true, result: { op: 'cordis-tree/get', tree } }, + } +} + +function forbiddenKeys(value: unknown): string[] { + if (value === null || typeof value !== 'object') return [] + if (Array.isArray(value)) return value.flatMap(forbiddenKeys) + const forbidden = new Set([ + 'objectHandle', 'objectRegistryId', 'registryId', 'generation', 'executionContextId', + 'scriptId', 'nodeId', 'backendNodeId', 'objectId', 'remoteObjectId', + ]) + return Reflect.ownKeys(value).flatMap((key) => { + if (typeof key !== 'string') return [] + return [...(forbidden.has(key) ? [key] : []), ...forbiddenKeys(Reflect.get(value, key))] + }) +} + +function asJson(value: object): InspectorJsonValue { + return value as unknown as InspectorJsonValue +} diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts new file mode 100644 index 0000000000..1f014d4311 --- /dev/null +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -0,0 +1,454 @@ +/** Host-driven Cordis tree integration. */ + +import { Context } from '@deepseek-ai/cordis' +import WebSocket, { type RawData } from 'ws' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CordisTreeCollector } from '../src/shared/cordis/collector.ts' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts' +import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import type { InspectorJsonValue } from '../src/shared/json.ts' +import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' +import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts' +import { InspectorClientFixture } from './fixtures/client-source.host.ts' + +interface CdpMessage { + readonly id?: number + readonly method?: string + readonly params?: Record + readonly result?: Record + readonly error?: { message: string } +} + +interface CdpNode { + readonly nodeId: number + readonly backendNodeId: number + readonly localName: string + readonly attributes?: string[] + readonly children?: CdpNode[] +} + +class CdpClient { + private nextId = 0 + private readonly pending = new Map void>() + readonly events: CdpMessage[] = [] + + private constructor(private readonly socket: WebSocket) { + socket.on('message', (data) => { + const message = JSON.parse(rawText(data)) as CdpMessage + if (message.id !== undefined) this.pending.get(message.id)?.(message) + else this.events.push(message) + }) + } + + static async connect(url: string): Promise { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new CdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + const id = ++this.nextId + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { reject(new Error(`CDP call timed out: ${method}`)) }, 5_000) + this.pending.set(id, (message) => { + clearTimeout(timer) + this.pending.delete(id) + resolve(message) + }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((resolve) => { this.socket.once('close', () => { resolve() }) }) + this.socket.close() + await closed + } +} + +describe('Cordis tree inspection', () => { + let inspector: InspectorHandle | undefined + let cdp: CdpClient | undefined + let secondCdp: CdpClient | undefined + let clientSource: InspectorClientFixture | undefined + const observers: Array<() => void> = [] + const fibers: Array<{ dispose(): Promise }> = [] + + afterEach(async () => { + for (const dispose of observers.splice(0).reverse()) dispose() + for (const fiber of fibers.splice(0).reverse()) await fiber.dispose() + await clientSource?.close() + clientSource = undefined + await cdp?.close() + cdp = undefined + await secondCdp?.close() + secondCdp = undefined + await inspector?.close() + inspector = undefined + Reflect.deleteProperty(globalThis, '__cordisHostProbe') + }) + + it('preserves separate Fiber and Context identities in one shared snapshot model', async () => { + const root = new Context() + const parent = root.isolate('probe') + const fiber = parent.plugin({ name: 'child', apply() {} }) + await fiber.await() + const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 }) + + const snapshot = collector.snapshot() + expect(parseCordisTreeSnapshot(snapshot, 100)).toEqual(snapshot) + const nodes = treeNodes(snapshot.root) + const fiberNode = nodes.find(node => node.kind === 'fiber' && node.uid === fiber.uid) + if (fiberNode === undefined) throw new Error('expected child Fiber node') + expect(nodes.every(node => !('id' in node) && !('parentId' in node))).toBe(true) + expect(() => parseCordisTreeSnapshot({ + ...snapshot, + root: { ...snapshot.root, children: [{ ...fiberNode, children: [] }] }, + }, 100)).toThrow('exactly one Context') + const contextNode = fiberNode.children[0] + const isolateNode = nodes.find(node => node.kind === 'context' + && collector.objects.resolve(node.objectHandle) === parent) + + expect(snapshot.root.kind).toBe('context') + expect(nodes.some(node => node.kind === 'fiber' && node.uid === 0)).toBe(false) + expect(isolateNode?.children).toContain(fiberNode) + const retainedFiber = collector.objects.resolve(fiberNode.objectHandle) + expect(Reflect.get(retainedFiber ?? {}, 'uid')).toBe(fiber.uid) + expect(Reflect.get(retainedFiber ?? {}, 'ctx') === fiber.ctx).toBe(true) + expect(collector.objects.resolve(contextNode.objectHandle) === fiber.ctx).toBe(true) + const identifiedFiber = collector.objects.identify(fiber) + expect(identifiedFiber).toEqual({ + registryId: snapshot.objectRegistryId, + handle: fiberNode.objectHandle, + }) + expect(collector.objects.identify(Object.create(parent) as object)).toBeUndefined() + + collector.close() + await fiber.dispose() + }) + + it('freezes a disconnected snapshot and replaces it with the reconnect generation', () => { + const root = new Context() + const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 }) + const snapshot = collector.snapshot() + const store = new CordisTreeStore({ maxNodes: 100, maxDisconnectedTrees: 1 }) + const first = source('client-a', 'generation-1') + store.replace(first, [{ sequence: 1, monotonicMs: 1, topic: 'cordis/tree', payload: asJson(snapshot) }]) + + const object = snapshot.root + expect(store.resolveObject(first, { + registryId: snapshot.objectRegistryId, + handle: object.objectHandle, + })).toBeDefined() + store.close(first, 'transport closed') + expect(store.snapshots()[0]?.connection).toEqual({ state: 'disconnected', reason: 'transport closed' }) + expect(store.resolveObject(first, { + registryId: snapshot.objectRegistryId, + handle: object.objectHandle, + })).toBeUndefined() + + const reconnected = source('client-a', 'generation-2') + store.replace(reconnected, [{ + sequence: 1, + monotonicMs: 2, + topic: 'cordis/tree', + payload: asJson({ ...snapshot, revision: snapshot.revision + 1 }), + }]) + expect(store.snapshots()).toEqual([ + expect.objectContaining({ source: reconnected, connection: { state: 'connected' } }), + ]) + + store.close(reconnected, 'transport closed again') + const other = source('client-b', 'generation-1') + store.replace(other, [{ sequence: 1, monotonicMs: 3, topic: 'cordis/tree', payload: asJson(snapshot) }]) + store.close(other, 'other transport closed') + const retained = store.snapshots() + expect(retained).toHaveLength(1) + expect(retained[0]?.source).toEqual(other) + expect(retained[0]?.connection.state).toBe('disconnected') + collector.close() + }) + + it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + const host = new Context() + const hostFiber = host.plugin({ name: 'host-child', apply() {} }) + fibers.push(hostFiber) + await hostFiber.await() + Reflect.set(globalThis, '__cordisHostProbe', host) + observers.push(publishHostCordisTree(host, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 })) + + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Tree Client' }) + cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + + let document: CdpNode | undefined + await vi.waitFor(async () => { + const response = await cdp!.call('DOM.getDocument') + expect(response.error).toBeUndefined() + document = response.result?.root as CdpNode + expect(hostContainer(document)).toBeDefined() + expect(clientContainers(document)).toHaveLength(1) + }) + if (document === undefined) throw new Error('DOM.getDocument returned no root') + expect(document.children?.map(node => node.localName)).toEqual(['host', 'clients']) + expect(document.children?.every(node => (node.attributes ?? []).length === 0)).toBe(true) + + const stored = await cdp.call('DSHInspector.getCordisTree') + const model = stored.result?.tree as { + host: { root: Record } | null + clients: Array<{ root: Record }> + } + expect(model.host?.root).toMatchObject({ kind: 'context' }) + expect(model.clients).toHaveLength(1) + expect(model.clients[0]?.root).toMatchObject({ kind: 'context' }) + expect(model.host?.root).not.toHaveProperty('nodeId') + expect(model.host?.root).not.toHaveProperty('backendNodeId') + + const realms = [ + ['host', hostContainer(document)], + ['client', clientContainers(document)[0]], + ] as const + for (const [realmKind, realm] of realms) { + expect(realm?.attributes ?? []).toEqual([]) + const rootContext = realm?.children?.[0] + expect(rootContext?.localName).toBe('context') + expect(rootContext?.children?.[0]?.localName).toBe('fiber') + expect(rootContext?.children?.[0]?.children?.[0]?.localName).toBe('context') + for (const entityKind of ['context', 'fiber']) { + const node = realm === undefined ? undefined : walk(realm).find(item => item.localName === entityKind) + if (node === undefined) throw new Error(`missing ${realmKind} ${entityKind} node`) + expect(node.attributes ?? []).toEqual(entityKind === 'fiber' + ? ['uid', expect.stringMatching(/^\d+$/u)] + : []) + expect(node.nodeId).toBeGreaterThan(0) + expect(node.backendNodeId).toBeGreaterThan(0) + const objectGroup = `tree-${realmKind}-${entityKind}` + const resolved = await cdp.call('DOM.resolveNode', { nodeId: node.nodeId, objectGroup }) + expect(resolved.error).toBeUndefined() + const remote = resolved.result?.object as Record + expect(remote).toMatchObject({ + type: 'object', + subtype: 'node', + className: entityKind === 'fiber' ? 'Fiber' : 'Context', + }) + expect(typeof remote.objectId).toBe('string') + const properties = await cdp.call('Runtime.getProperties', { objectId: remote.objectId, ownProperties: true }) + expect(properties.error).toBeUndefined() + await expect(cdp.call('DOM.requestNode', { objectId: remote.objectId })).resolves.toMatchObject({ + result: { nodeId: node.nodeId }, + }) + await cdp.call('Runtime.releaseObjectGroup', { objectGroup }) + } + } + + const hostNode = walk(hostContainer(document)!).find(item => item.localName === 'context')! + const hostEvaluated = await cdp.call('Runtime.evaluate', { expression: 'globalThis.__cordisHostProbe' }) + expect(hostEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { + objectId: (hostEvaluated.result?.result as Record).objectId, + })).resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } }) + const hostThrown = await cdp.call('Runtime.evaluate', { expression: 'throw globalThis.__cordisHostProbe' }) + const hostException = hostThrown.result?.exceptionDetails as Record + const hostExceptionObject = hostException.exception as Record + expect(hostExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { objectId: hostExceptionObject.objectId })) + .resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } }) + + let clientContextId: number | undefined + await vi.waitFor(() => { + const event = cdp!.events.find(item => item.method === 'Runtime.executionContextCreated' + && String((item.params?.context as { name?: string } | undefined)?.name).startsWith('Client')) + clientContextId = (event?.params?.context as { id?: number } | undefined)?.id + expect(clientContextId).toBeTypeOf('number') + }) + const clientNode = walk(clientContainers(document)[0]!).find(item => item.localName === 'context')! + const clientEvaluated = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.__cordisClientProbe', + contextId: clientContextId, + }) + expect(clientEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { + objectId: (clientEvaluated.result?.result as Record).objectId, + })).resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } }) + const clientThrown = await cdp.call('Runtime.evaluate', { + expression: 'throw globalThis.__cordisClientProbe', + contextId: clientContextId, + }) + const clientException = clientThrown.result?.exceptionDetails as Record + const clientExceptionObject = clientException.exception as Record + expect(clientExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { objectId: clientExceptionObject.objectId })) + .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } }) + + const consoleOffset = cdp.events.length + await clientSource.logCordis('cordis-client-console') + let consoleObject: Record | undefined + let consoleFiber: Record | undefined + await vi.waitFor(() => { + const event = cdp!.events.slice(consoleOffset).find((candidate) => { + const params = candidate.params + if (params === undefined + || candidate.method !== 'Runtime.consoleAPICalled' + || params.executionContextId !== clientContextId + || !Array.isArray(params.args)) return false + return params.args.some(argument => (argument as { value?: unknown }).value === 'cordis-client-console') + }) + const args = event?.params?.args + consoleObject = Array.isArray(args) ? args[0] as Record | undefined : undefined + consoleFiber = Array.isArray(args) ? args[1] as Record | undefined : undefined + expect(consoleObject).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' }) + expect(consoleFiber).toMatchObject({ type: 'object', subtype: 'node', className: 'Fiber' }) + }) + await expect(cdp.call('DOM.requestNode', { objectId: consoleObject!.objectId })) + .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } }) + const requestedFiber = await cdp.call('DOM.requestNode', { objectId: consoleFiber!.objectId }) + const requestedFiberId = (requestedFiber.result as { nodeId?: number } | undefined)?.nodeId + const clientFiberNode = walk(clientContainers(document)[0]!).find(node => node.nodeId === requestedFiberId) + expect(clientFiberNode).toMatchObject({ + localName: 'fiber', + attributes: ['uid', String(clientSource.fiberUid)], + }) + + const firstResolved = await cdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId }) + const firstObjectId = (firstResolved.result?.object as Record).objectId + secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode + const secondNode = walk(secondDocument).find(node => node.backendNodeId === clientNode.backendNodeId) + expect(secondNode).toBeDefined() + const secondResolved = await secondCdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId }) + const secondObjectId = (secondResolved.result?.object as Record).objectId + expect(secondObjectId).not.toBe(firstObjectId) + expect((await secondCdp.call('DOM.requestNode', { objectId: firstObjectId })).error).toBeDefined() + + const eventOffset = cdp.events.length + await clientSource.close() + clientSource = undefined + await vi.waitFor(() => { + const events = cdp!.events.slice(eventOffset) + expect(events.some(event => event.method === 'Runtime.executionContextDestroyed' + && event.params?.executionContextId === clientContextId)).toBe(true) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + + const disconnectedDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode + const disconnectedClient = clientContainers(disconnectedDocument)[0] + expect(disconnectedClient).toBeDefined() + expect(walk(disconnectedClient!).find(node => node.backendNodeId === clientNode.backendNodeId)?.nodeId) + .toBe(clientNode.nodeId) + expect((await cdp.call('DOM.resolveNode', { nodeId: clientNode.nodeId })).error?.message) + .toContain('Cordis realm is disconnected') + expect((await cdp.call('DOM.requestNode', { + objectId: (clientEvaluated.result?.result as Record).objectId, + })).error).toBeDefined() + const disconnectedTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ connection: { state: string } }> + } + expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected') + }) + + it('restores a disconnected Client tree from a new transport generation', async () => { + inspector = await startInspector({ + port: 0, + captureFetch: false, + maxCordisNodes: 100, + clientReconnectBaseMs: 10, + clientReconnectMaxMs: 20, + }) + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Reconnect Client' }) + cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + + let document: CdpNode | undefined + let contextId: number | undefined + await vi.waitFor(async () => { + document = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode + expect(clientContainers(document)).toHaveLength(1) + const created = cdp!.events.find(event => event.method === 'Runtime.executionContextCreated' + && String((event.params?.context as { name?: string } | undefined)?.name).startsWith('Client')) + contextId = (created?.params?.context as { id?: number } | undefined)?.id + expect(contextId).toBeTypeOf('number') + }) + const initialTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ source: { sourceId: string } }> + } + const sourceId = initialTree.clients[0]?.source.sourceId + const eventOffset = cdp.events.length + await clientSource.disconnect() + + await vi.waitFor(() => { + const events = cdp!.events.slice(eventOffset) + const destroyed = events.findIndex(event => event.method === 'Runtime.executionContextDestroyed' + && event.params?.executionContextId === contextId) + const created = events.findIndex((event) => { + if (event.method !== 'Runtime.executionContextCreated') return false + const context = event.params?.context as { id?: number } | undefined + return typeof context?.id === 'number' && context.id !== contextId + }) + const refreshed = events.findIndex(event => event.method === 'DOM.documentUpdated') + expect(destroyed).toBeGreaterThanOrEqual(0) + expect(created).toBeGreaterThan(destroyed) + expect(refreshed).toBeGreaterThan(created) + expect(events.slice(0, created).some(event => event.method?.startsWith('DOM.'))).toBe(false) + }) + + await vi.waitFor(async () => { + const current = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode + expect(clientContainers(current)).toHaveLength(1) + expect(clientContainers(current)[0]?.children?.[0]?.localName).toBe('context') + const tree = (await cdp!.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ + source: { sourceId: string } + connection: { state: string } + }> + } + expect(tree.clients).toHaveLength(1) + expect(tree.clients[0]?.source.sourceId).toBe(sourceId) + expect(tree.clients[0]?.connection.state).toBe('connected') + }) + }) +}) + +function source(sourceId: string, generation: string): InspectorSourceDescriptor { + return { + sourceId: inspectorId<'InspectorSourceId'>(sourceId, 'sourceId'), + generation: inspectorId<'InspectorSourceGeneration'>(generation, 'generation'), + kind: 'client', + label: sourceId, + timeOriginMs: 0, + capabilities: [], + } +} + +function hostContainer(root: CdpNode | undefined): CdpNode | undefined { + return root?.children?.find(node => node.localName === 'host') +} + +function clientContainers(root: CdpNode | undefined): CdpNode[] { + return root?.children?.find(node => node.localName === 'clients')?.children + ?.filter(node => node.localName === 'client') ?? [] +} + +function walk(root: CdpNode): CdpNode[] { + return [root, ...(root.children ?? []).flatMap(walk)] +} + +function treeNodes(root: CordisTreeNode): CordisTreeNode[] { + return [root, ...root.children.flatMap(treeNodes)] +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +function asJson(value: object): InspectorJsonValue { + return value as unknown as InspectorJsonValue +} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 99743551e4..00f2992f94 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1015,6 +1015,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'inspector', + summary: 'Shared Host/Client service façade over the realm\'s source publisher.', + description: 'Shared Host/Client service façade over the realm\'s source publisher.', + methods: [ + { + signature: 'publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void', + description: 'Publish one JSON observation without waiting for Worker delivery.', + parameters: [{ name: 'topic', description: 'Domain-owned topic name.' }, { name: 'payload', description: 'JSON value validated before it reaches the carrier.' }, { name: 'monotonicMs', description: 'Source-clock timestamp; defaults to `performance.now()`.' }], + }, + { + signature: 'readonly cordis: CordisRuntimeTreeReader', + description: 'Read-only Cordis topology queries independent of CDP sessions.', + parameters: [], + }, + ], + }, { key: 'invariants', summary: 'Package-owned invariant registry with global and regex-based selection.', @@ -3678,6 +3695,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CordisInspectRequestId', declaration: 'export type CordisInspectRequestId = Branded<\'CordisInspectRequestId\'>;', }, + { + name: 'CordisRuntimeConnection', + declaration: 'export type CordisRuntimeConnection = {\n readonly state: \'connected\';\n} | {\n readonly state: \'disconnected\';\n readonly reason: string;\n};', + }, + { + name: 'CordisRuntimeContext', + declaration: 'export interface CordisRuntimeContext {\n readonly kind: \'context\';\n readonly children: readonly CordisRuntimeNode[];\n}', + }, + { + name: 'CordisRuntimeFiber', + declaration: 'export interface CordisRuntimeFiber {\n readonly kind: \'fiber\';\n readonly uid: number;\n readonly children: readonly [\n CordisRuntimeContext\n ];\n}', + }, + { + name: 'CordisRuntimeNode', + declaration: 'export type CordisRuntimeNode = CordisRuntimeContext | CordisRuntimeFiber;', + }, + { + name: 'CordisRuntimeRealm', + declaration: 'export interface CordisRuntimeRealm {\n readonly source: CordisRuntimeSource;\n readonly connection: CordisRuntimeConnection;\n readonly revision: number;\n readonly truncated: boolean;\n readonly root: CordisRuntimeContext;\n}', + }, + { + name: 'CordisRuntimeSource', + declaration: 'export interface CordisRuntimeSource {\n readonly sourceId: CordisRuntimeSourceId;\n readonly kind: CordisRuntimeSourceKind;\n readonly label: string;\n}', + }, + { + name: 'CordisRuntimeSourceId', + declaration: 'export type CordisRuntimeSourceId = InspectorId<\'CordisRuntimeSourceId\'>;', + }, + { + name: 'CordisRuntimeSourceKind', + declaration: 'export type CordisRuntimeSourceKind = \'host\' | \'client\';', + }, + { + name: 'CordisRuntimeTree', + declaration: 'export interface CordisRuntimeTree {\n readonly schemaVersion: typeof CORDIS_RUNTIME_TREE_SCHEMA_VERSION;\n readonly host: CordisRuntimeRealm | null;\n readonly clients: readonly CordisRuntimeRealm[];\n}', + }, + { + name: 'CordisRuntimeTreeReader', + declaration: 'export interface CordisRuntimeTreeReader {\n getTree(): Promise;\n}', + }, { name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', @@ -4006,6 +4063,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'IndexInjectionPlacement', declaration: 'export type IndexInjectionPlacement = \'head\' | \'body\';', }, + { + name: 'InspectorId', + declaration: 'export type InspectorId = Branded;', + }, + { + name: 'InspectorJsonObject', + declaration: 'export interface InspectorJsonObject {\n readonly [key: string]: InspectorJsonValue;\n}', + }, + { + name: 'InspectorJsonPrimitive', + declaration: 'export type InspectorJsonPrimitive = null | boolean | number | string;', + }, + { + name: 'InspectorJsonValue', + declaration: 'export type InspectorJsonValue = InspectorJsonPrimitive | readonly InspectorJsonValue[] | InspectorJsonObject;', + }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 2392962be1..d9870dabc7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -79,6 +79,7 @@ export const SERVICE_PAGE: Record = { fileReferences: 'session-reference.md', fs: 'filesystem.md', goals: 'goal.md', + inspector: 'extensions.md', webServer: 'web-server.md', invariants: 'invariants.md', llm: 'llm-streaming.md', @@ -249,6 +250,7 @@ export const LINK_MAP: Readonly> = { GenerateOptions: 'llm-streaming.md', InboxItem: 'core.md', InboxPlacement: 'core.md', + InspectorJsonValue: 'extensions.md', MessageId: 'llm-streaming.md', ResumeAgentOptions: 'core.md', SettleReason: 'core.md',