mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
feat(inspector): expose Cordis trees through CDP DOM
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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<InspectorQueryError['code']>([
|
||||
'invalid-request', 'stale-source', 'result-too-large', 'internal-error',
|
||||
])
|
||||
@@ -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<Query extends InspectorQuery> = Extract<InspectorQueryResult, { op: Query['op'] }>
|
||||
|
||||
/** 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 extends InspectorQuery>(query: Query): Promise<InspectorQueryResultFor<Query>>
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<CordisContextTreeNode, 'children'> {
|
||||
readonly children: MutableTreeNode[]
|
||||
}
|
||||
|
||||
interface MutableFiberNode extends Omit<CordisFiberTreeNode, 'children'> {
|
||||
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<Context, ContextInfo>()
|
||||
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]
|
||||
}
|
||||
@@ -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'>
|
||||
@@ -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<CordisRuntimeSourceId>()
|
||||
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<number>
|
||||
}
|
||||
|
||||
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] }
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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<object, InspectorObjectHandle>()
|
||||
private retained = new Map<InspectorObjectHandle, object>()
|
||||
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<InspectorObjectHandle, object>): 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<InspectorObjectHandle, object>): void {
|
||||
this.retained = next
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable object set assembled before one snapshot becomes visible. */
|
||||
export class RealmObjectGeneration {
|
||||
private readonly retained = new Map<InspectorObjectHandle, object>()
|
||||
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<InspectorObjectRegistryId, RealmObjectRegistry> {
|
||||
const key = Symbol.for(REGISTRIES_SYMBOL)
|
||||
const existing = Reflect.get(globalThis, key) as unknown
|
||||
if (existing instanceof Map) return existing as Map<InspectorObjectRegistryId, RealmObjectRegistry>
|
||||
const value = new Map<InspectorObjectRegistryId, RealmObjectRegistry>()
|
||||
Reflect.set(globalThis, key, value)
|
||||
return value
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Source extends CordisTreeSource = CordisTreeSource> {
|
||||
readonly source: Source
|
||||
readonly snapshot: CordisTreeSnapshot
|
||||
readonly connection: CordisTreeSourceConnection
|
||||
}
|
||||
|
||||
/** Routed Host and Client snapshots before consumer-neutral projection. */
|
||||
export interface CordisInspectionTree<Source extends CordisTreeSource = CordisTreeSource> {
|
||||
readonly host: CordisTreeSourceSnapshot<Source> | null
|
||||
readonly clients: readonly CordisTreeSourceSnapshot<Source>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Source extends CordisTreeSource>(tree: CordisInspectionTree<Source>): 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<CordisTreeNode, { kind: 'context' }>): 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)}`)
|
||||
}
|
||||
@@ -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<CordisRuntimeTree>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CordisRuntimeTree>,
|
||||
): CordisRuntimeTreeReader {
|
||||
return { getTree: async () => await read() }
|
||||
}
|
||||
@@ -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<InspectorObjectHandle>
|
||||
readonly fiberUids: Set<number>
|
||||
}
|
||||
|
||||
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],
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Cordis semantic DOM domain exports. */
|
||||
|
||||
export { CordisDomBackend, type CordisDomChange } from './model.ts'
|
||||
export { CordisDomSession } from './session.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<CdpBackendNodeId, CordisDomNode>
|
||||
readonly parentByBackendId: ReadonlyMap<CdpBackendNodeId, CdpBackendNodeId>
|
||||
}
|
||||
|
||||
/** 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<string, CdpBackendNodeId>()
|
||||
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<string, CordisDomNode>()
|
||||
|
||||
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<CdpBackendNodeId, CordisDomNode>()
|
||||
const parentByBackendId = new Map<CdpBackendNodeId, CdpBackendNodeId>()
|
||||
this.nodeByObject.clear()
|
||||
const tree = this.trees.tree()
|
||||
const root = this.node('document', '#document', [], '#document')
|
||||
const host = this.node('host', 'host', [], '<host>')
|
||||
if (tree.host !== null) host.children.push(this.entity(tree.host, tree.host.snapshot.root))
|
||||
const clients = this.node('clients', 'clients', [], '<clients>')
|
||||
for (const clientTree of tree.clients) {
|
||||
const client = this.node(`client:${clientTree.source.sourceId}`, 'client', [], '<client>')
|
||||
client.children.push(this.entity(clientTree, clientTree.snapshot.root))
|
||||
clients.children.push(client)
|
||||
}
|
||||
root.children.push(host, clients)
|
||||
const retainedKeys = new Set<string>()
|
||||
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<CordisDomNode, 'children'> {
|
||||
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
|
||||
}
|
||||
@@ -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<CdpBackendNodeId, CdpNodeId>()
|
||||
private readonly backendByNodeId = new Map<CdpNodeId, CdpBackendNodeId>()
|
||||
private readonly backendByObjectId = new Map<CdpRemoteObjectId, BoundDomObject>()
|
||||
private readonly objectIdsByGroup = new Map<string, Set<CdpRemoteObjectId>>()
|
||||
private readonly searches = new Map<string, CdpNodeId[]>()
|
||||
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<Record<string, unknown>>): Promise<object> {
|
||||
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<Readonly<Record<string, unknown>>> {
|
||||
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<Record<string, unknown>>): 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<object>): 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}</${node.name}>`
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<InspectorQueryResult> {
|
||||
return { op: query.op, tree: await reader.getTree() }
|
||||
}
|
||||
@@ -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<InspectorSourceDescriptor>
|
||||
|
||||
/** Routed Host and Client snapshots retained by the Worker. */
|
||||
export type CordisInspectionTree = SharedCordisInspectionTree<InspectorSourceDescriptor>
|
||||
|
||||
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<string, CordisTreeNode>
|
||||
}
|
||||
|
||||
/** 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<string, StoredTree>()
|
||||
private readonly disconnected = new Set<string>()
|
||||
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
|
||||
}
|
||||
@@ -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<InspectorQueryPeer>()
|
||||
private readonly activeBySource = new Map<InspectorSourceId, {
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly peer: InspectorQueryPeer
|
||||
}>()
|
||||
|
||||
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<InspectorQueryRequestId, AcceptedGeneration>()
|
||||
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<void> {
|
||||
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<InspectorQueryRequestFrame, 'sourceId' | 'generation' | 'requestId'>,
|
||||
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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
readonly result?: Record<string, unknown>
|
||||
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<number, (message: CdpMessage) => 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<CdpClient> {
|
||||
const socket = new WebSocket(url)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('open', () => { resolve() })
|
||||
socket.once('error', reject)
|
||||
})
|
||||
return new CdpClient(socket)
|
||||
}
|
||||
|
||||
call(method: string, params: Record<string, unknown> = {}): Promise<CdpMessage> {
|
||||
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<void> {
|
||||
if (this.socket.readyState === WebSocket.CLOSED) return
|
||||
const closed = new Promise<void>((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<void> }> = []
|
||||
|
||||
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<string, unknown> } | null
|
||||
clients: Array<{ root: Record<string, unknown> }>
|
||||
}
|
||||
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<string, unknown>
|
||||
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<string, unknown>).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<string, unknown>
|
||||
const hostExceptionObject = hostException.exception as Record<string, unknown>
|
||||
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<string, unknown>).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<string, unknown>
|
||||
const clientExceptionObject = clientException.exception as Record<string, unknown>
|
||||
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<string, unknown> | undefined
|
||||
let consoleFiber: Record<string, unknown> | 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<string, unknown> | undefined : undefined
|
||||
consoleFiber = Array.isArray(args) ? args[1] as Record<string, unknown> | 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<string, unknown>).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<string, unknown>).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<string, unknown>).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
|
||||
}
|
||||
@@ -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<CordisRuntimeTree>;\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<Role extends string> = Branded<Role>;',
|
||||
},
|
||||
{
|
||||
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;',
|
||||
|
||||
@@ -79,6 +79,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
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<Record<string, string>> = {
|
||||
GenerateOptions: 'llm-streaming.md',
|
||||
InboxItem: 'core.md',
|
||||
InboxPlacement: 'core.md',
|
||||
InspectorJsonValue: 'extensions.md',
|
||||
MessageId: 'llm-streaming.md',
|
||||
ResumeAgentOptions: 'core.md',
|
||||
SettleReason: 'core.md',
|
||||
|
||||
Reference in New Issue
Block a user