feat(inspector): expose Cordis trees through CDP DOM

This commit is contained in:
imccyu
2026-08-27 16:15:18 +08:00
parent 7ecd7004eb
commit 28cc3e930b
28 changed files with 3126 additions and 0 deletions
@@ -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;',
+2
View File
@@ -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',