Merge remote-tracking branch 'origin/master' into feat/plugin-owned-settings-surface
# Conflicts: # packages/client/ui-input-trigger/README.i18n.yaml # packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx # packages/client/ui-settings-plugins/src/client/index.ts # packages/client/ui-settings-plugins/src/client/tab-store.ts # packages/client/ui-settings-plugins/tests/apply.client.spec.ts # packages/client/ui-settings-plugins/tests/section.client.spec.tsx # packages/client/ui-settings-plugins/tests/stores.client.spec.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts
This commit is contained in:
@@ -0,0 +1,971 @@
|
||||
/**
|
||||
* Generated by scripts/gen-cordis-api.ts — do not edit by hand; run
|
||||
* `pnpm run gen-cordis-api` to regenerate (freshness-gated by
|
||||
* `pnpm run verify-cordis-api` in doc-sync).
|
||||
*
|
||||
* The machine-readable cordis API catalog `cordis_inspect` serves to the
|
||||
* model: harness services (summary + structured public method contracts),
|
||||
* harness events (mode + structured listener contracts), and the inherited `ctx` API. Produced by
|
||||
* the same AST walk as docs/cordis-catalog, so this data and the rendered
|
||||
* docs cannot diverge.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-cordis-client-runner/client/api-catalog
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
/** One named parameter in a Service method or Event listener. */
|
||||
export interface ApiParameter {
|
||||
/** Parameter name from the exact signature. */
|
||||
name: string
|
||||
/** Source-owned parameter contract. */
|
||||
description: string
|
||||
}
|
||||
|
||||
/** One public service member and its source-owned contract. */
|
||||
export interface ServiceApiMethod {
|
||||
/** Public method signature with its body stripped. */
|
||||
signature: string
|
||||
/** Method purpose and behavior. */
|
||||
description: string
|
||||
/** Named parameters in signature order. */
|
||||
parameters: readonly ApiParameter[]
|
||||
/** Non-void result contract when documented. */
|
||||
returns?: string
|
||||
/** Documented failure conditions. */
|
||||
throws?: readonly string[]
|
||||
}
|
||||
|
||||
/** One harness `ctx.<key>` service and its public methods. */
|
||||
export interface ServiceApiEntry {
|
||||
/** The `ctx.<key>` name, e.g. `tools`. */
|
||||
key: string
|
||||
/** First sentence of the service class JSDoc. */
|
||||
summary: string
|
||||
/** Complete service description. */
|
||||
description: string
|
||||
/** Public methods, bodies stripped, in source order. */
|
||||
methods: readonly ServiceApiMethod[]
|
||||
}
|
||||
|
||||
/** One harness event: its dispatch mode, exact signature, and listener contract. */
|
||||
export interface EventApiEntry {
|
||||
/** The scoped event name, e.g. `agent/status`. */
|
||||
name: string
|
||||
/** The dispatch mode from the declaration's `@mode` tag. */
|
||||
mode: string
|
||||
/** The exact listener signature, whitespace-normalized. */
|
||||
signature: string
|
||||
/** First sentence of the event JSDoc. */
|
||||
summary: string
|
||||
/** Complete event description. */
|
||||
description: string
|
||||
/** Named listener parameters in signature order. */
|
||||
parameters: readonly ApiParameter[]
|
||||
}
|
||||
|
||||
/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */
|
||||
export interface InheritedApiEntry {
|
||||
/** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */
|
||||
name: string
|
||||
/** One-line summary of what the member does. */
|
||||
summary: string
|
||||
}
|
||||
|
||||
/** One named type declaration referenced by a Service or Event signature. */
|
||||
export interface TypeApiEntry {
|
||||
/** The exported type/interface name, e.g. `ShellRunResult`. */
|
||||
name: string
|
||||
/** The full declaration text, comments stripped. */
|
||||
declaration: string
|
||||
}
|
||||
|
||||
/** Every harness `ctx.<key>` service, sorted by key. */
|
||||
export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'layout',
|
||||
summary: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply.',
|
||||
description: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply. The attachPanels wiring hook stays on the concrete class (root-entry assembly only).',
|
||||
methods: [
|
||||
{
|
||||
signature: 'toggleSidebar(): void',
|
||||
description: 'Toggle the sidebar panel (closed ⟷ contract default width).',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'openDetails(): void',
|
||||
description: 'Open the details panel (no-op when already open).',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'closeDetails(): void',
|
||||
description: 'Close the details panel.',
|
||||
parameters: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'locale',
|
||||
summary: 'Dictionary registry plus locale preference.',
|
||||
description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s zh fallback -> the shared common namespace (active, then zh) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).',
|
||||
methods: [
|
||||
{
|
||||
signature: 'getLocale(): LocaleSnapshot',
|
||||
description: 'Read the current immutable locale snapshot.',
|
||||
parameters: [],
|
||||
returns: 'the current snapshot (stable reference until the next change).',
|
||||
},
|
||||
{
|
||||
signature: 'getSnapshot(): LocaleSnapshot',
|
||||
description: 'LocaleFace getSnapshot: the current snapshot (carries `revision`; stable reference between changes, uSES-safe).',
|
||||
parameters: [],
|
||||
returns: 'the current snapshot.',
|
||||
},
|
||||
{
|
||||
signature: 'subscribe(fn: () => void): () => void',
|
||||
description: 'LocaleFace subscribe: notified on every snapshot change (locale switch or dictionary registration — registrations bump the revision so already rendered outlets pick up late-arriving dictionaries).',
|
||||
parameters: [{ name: 'fn', description: 'change callback.' }],
|
||||
returns: 'unsubscribe.',
|
||||
},
|
||||
{
|
||||
signature: 'setLocale(id: string): void',
|
||||
description: 'Switch the active locale — the only user preference write entry.',
|
||||
parameters: [{ name: 'id', description: 'a registered locale id; unknown ids throw.' }],
|
||||
},
|
||||
{
|
||||
signature: 'register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void',
|
||||
description: 'Register a declared namespace\'s dictionaries, all locales in one call — the typed form: each dictionary is checked against the namespace\'s LocaleNamespaceMap key union (a missing or extra key is a compile error), and every shipped locale is required (bilingual balance enforced at registration). Duplicate (ns, locale) throws (single occupant; a namespace\'s texts have one owner). Registration bumps the revision so mounted outlets pick up late-arriving dictionaries.',
|
||||
parameters: [{ name: 'ns', description: 'a namespace merged into LocaleNamespaceMap.' }, { name: 'dicts', description: 'complete dictionaries keyed by locale id.' }],
|
||||
returns: 'disposer removing every locale registered by this call (idempotent).',
|
||||
},
|
||||
{
|
||||
signature: 'register(ns: string, locale: string, dict: LocaleDict): () => void',
|
||||
description: 'Single-locale untyped form for namespaces outside the merge table (dynamic composition, tests).',
|
||||
parameters: [{ name: 'ns', description: 'namespace.' }, { name: 'locale', description: 'locale tag.' }, { name: 'dict', description: 'dictionary.' }],
|
||||
returns: 'disposer (idempotent).',
|
||||
},
|
||||
{
|
||||
signature: 'bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>',
|
||||
description: 'Bind a declared namespace to a translate function typed to its dictionary key union (plus the shared common vocabulary) — the same key domain the framework-injected `t` seat carries. The returned reference is stable per namespace (repeat binds return the same function), so it can ride inject surfaces without breaking memoization.',
|
||||
parameters: [{ name: 'ns', description: 'a namespace merged into LocaleNamespaceMap.' }],
|
||||
returns: 'the typed translate function (reads the active locale at call time).',
|
||||
},
|
||||
{
|
||||
signature: 'bind(ns: string): Translate',
|
||||
description: 'Untyped form for namespaces outside the merge table (dynamic composition, tests).',
|
||||
parameters: [{ name: 'ns', description: 'namespace.' }],
|
||||
returns: 'the translate function.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
summary: 'The sessions-service face injected as `ctx.sessions`.',
|
||||
description: 'The sessions-service face injected as `ctx.sessions`.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'open(id: SessionId): void',
|
||||
description: 'Select a session as current.',
|
||||
parameters: [{ name: 'id', description: 'session id (must exist in the list; unknown ids fail loud).' }],
|
||||
},
|
||||
{
|
||||
signature: 'openSubagent(address: SubagentAddress): void',
|
||||
description: 'Open a healthy catalog child through its exact direct-parent address.',
|
||||
parameters: [{ name: 'address', description: 'catalog-derived parent and child ids.' }],
|
||||
},
|
||||
{
|
||||
signature: 'setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void',
|
||||
description: 'Mark whether a catalog menu is consuming live membership updates.',
|
||||
parameters: [{ name: 'parentSessionId', description: 'catalog owner.' }, { name: 'open', description: 'current menu state.' }],
|
||||
},
|
||||
{
|
||||
signature: 'refreshSubagents(parentSessionId: SessionId): Promise<void>',
|
||||
description: 'Refresh one direct-child catalog.',
|
||||
parameters: [{ name: 'parentSessionId', description: 'catalog owner.' }],
|
||||
returns: 'completion of the current or newly started refresh.',
|
||||
},
|
||||
{
|
||||
signature: 'search( query: string, signal: AbortSignal, ): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>',
|
||||
description: 'Search the Host\'s visible message-content index. Results stay request-local; the list snapshot remains the metadata authority.',
|
||||
parameters: [{ name: 'query', description: 'non-blank literal phrase.' }, { name: 'signal', description: 'cancellation for a superseded search.' }],
|
||||
returns: 'bounded results, or a business/transport error.',
|
||||
},
|
||||
{
|
||||
signature: 'fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>',
|
||||
description: 'Fork a session from a completed-turn prefix of the source; on resolution the child is in the list store and `open()` can target it.',
|
||||
parameters: [{ name: 'opts', description: 'source session id, the optional event seq anchoring the cut (the boundary is the first turn/end at or after it; an in-log anchor in an open turn is unavailable rather than clipped backward), and whether to increment an inherited durable title before resolving.' }],
|
||||
returns: 'the child session id.',
|
||||
throws: ['when the fork fails, or when a requested child-title rename fails after creation.'],
|
||||
},
|
||||
{
|
||||
signature: 'scope(id: SessionId): AgentContext | undefined',
|
||||
description: 'Resolve an Agent-scoped context view (use-and-discard).',
|
||||
parameters: [{ name: 'id', description: 'session id.' }],
|
||||
returns: 'scoped ctx, or undefined for a session neither listed nor already scoped.',
|
||||
},
|
||||
{
|
||||
signature: 'binding(id: SessionId): SessionBinding | undefined',
|
||||
description: 'Resolve the stable session binding (scope-addressed assembly feed).',
|
||||
parameters: [{ name: 'id', description: 'session id.' }],
|
||||
returns: 'binding, or undefined for a session neither listed nor already scoped.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'slots',
|
||||
summary: 'cordis Service layer of the slot system; see the module doc for the split with SlotCore.',
|
||||
description: 'cordis Service layer of the slot system; see the module doc for the split with SlotCore.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'declare readonly register: SlotCore[\'register\']',
|
||||
description: 'The single registration API. The typed face IS the core\'s register (both overloads reused verbatim — one authority, no structural copy; see SlotCore.register for children declaration, store seat, inject face, load-time validation, and the unload cascade). This layer adds: disposal through the caller\'s ctx.effect (fiber unload = cascade), exclusive-factory minting (`store: createXxxStore` becomes a per-entry handle), the registrant diagnostics stamp, and store-instance lifecycle on the entry axis.\n\nDeclared here, implemented by prototype assignment below the class: it MUST stay a prototype method (never an instance arrow) — the cordis service proxy binds `this.ctx` to the CALLER\'s context at call time, which is what routes the effect (and the unload cascade) into the caller\'s fiber. An arrow property would freeze `this` to the service\'s own root ctx and silently break per-plugin disposal.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void',
|
||||
description: 'Install an effect for each declaration lifetime of a slot. The callback runs synchronously when the declaration already exists; otherwise it runs inside the declaring `register()` call after the declaration is committed. Collapse disposes the effect and a later declaration runs it again. Callback effects are synchronous disposers; iterable effects install transactionally and dispose in reverse order. The controller belongs to the caller\'s fiber, so plugin unload cancels a pending wait and removes any active contribution.',
|
||||
parameters: [{ name: 'key', description: 'declared SlotMap key to depend on.' }, { name: 'callback', description: 'creates one disposer or an iterable of disposers.' }],
|
||||
returns: 'idempotent disposer for the wait and active effect.',
|
||||
throws: ['callback setup failures synchronously when the slot is already declared.'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
summary: 'Theme registry and preference owner.',
|
||||
description: 'Theme registry and preference owner. `light`/`dark` are built in (the base stylesheets carry both palettes); third-party themes register alias-layer overrides. Reads go through getTheme; preference writes only through setTheme; continuous sync only through the `theme/change` event. overrideTokens stacks partial token layers over the active theme without touching the registry. The service holds the `prefers-color-scheme` media query (environment sensing, not presentation) and re-emits when the OS scheme flips while the preference is `system`.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'getTheme(): ThemeSnapshot',
|
||||
description: 'Read the current immutable theme snapshot.',
|
||||
parameters: [],
|
||||
returns: 'the current snapshot (stable reference until the next change).',
|
||||
},
|
||||
{
|
||||
signature: 'setTheme(id: string): void',
|
||||
description: 'Switch the theme preference — the only user preference write entry. Built-in preferences are written through the settings scope and every accepted value emits `theme/change`.',
|
||||
parameters: [{ name: 'id', description: 'a registered theme id or `system`; unknown ids throw.' }],
|
||||
},
|
||||
{
|
||||
signature: 'register(definition: ThemeDefinition): () => void',
|
||||
description: 'Register a theme. Duplicate id throws (single occupant per id; the built-in pair counts; `system` is a preference, not a registrable id).',
|
||||
parameters: [{ name: 'definition', description: 'theme id, colorScheme, and alias-token overrides.' }],
|
||||
returns: 'disposer. Disposing the theme backing the active preference resets the preference to the default so the UI never keeps tokens of an unregistered theme.',
|
||||
},
|
||||
{
|
||||
signature: 'overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void',
|
||||
description: 'Stack a token override layer on top of the active theme — the token-level analogue of slot shading: the base theme stays untouched, layers compose in seq order with later layers winning per-token, and removing a layer restores whatever it covered. Calling again with the same source replaces that source\'s whole layer and restacks it on top (effect re-registration semantics). Emits `theme/change` with the recomposed snapshot.',
|
||||
parameters: [{ name: 'source', description: 'layer identity; one layer per source (dynamic packages pass their package id — the façade pins it, so it also names the layer\'s origin for inspection).' }, { name: 'tokens', description: 'token-name → `{ light, dark }` value pairs. Validated at runtime (model-authored callers reach this boundary with untyped JS); a bare string value throws a teaching error.' }],
|
||||
returns: 'disposer removing exactly the layer this call created; a no-op once the source has re-overridden (the newer layer is not torn down).',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'timer',
|
||||
summary: 'Disposable timer helpers mixed into Cordis contexts.',
|
||||
description: 'Disposable timer helpers mixed into Cordis contexts.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'timeout(callback: () => void, delay: number): () => void',
|
||||
description: 'Run a callback once and return its disposer.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'timeout(delay: number): Promise<void>',
|
||||
description: 'Resolve after a delay; disposal rejects the pending promise.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'interval(callback: () => void, delay: number): () => void',
|
||||
description: 'Run a callback repeatedly and return its disposer.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>',
|
||||
description: 'Return an async iterator of timer ticks.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
|
||||
description: 'Return a throttled function whose timer is disposed with the current fiber.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
|
||||
description: 'Return a debounced function whose timer is disposed with the current fiber.',
|
||||
parameters: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'workspaces',
|
||||
summary: 'The workspaces-service face injected as `ctx.workspaces`.',
|
||||
description: 'The workspaces-service face injected as `ctx.workspaces`.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>',
|
||||
description: 'Connect a Workspace to its reusable or freshly created blank session.',
|
||||
parameters: [{ name: 'workspaceId', description: 'target workspace.' }],
|
||||
returns: 'the connected session id.',
|
||||
},
|
||||
{
|
||||
signature: 'startSession(workspaceId?: WorkspaceId): void',
|
||||
description: 'The New Session flow: connect the explicit, current-Session, or recent Workspace and open the resulting session; failures surface on the session list state.',
|
||||
parameters: [{ name: 'workspaceId', description: 'explicit target; omitted inherits the current Session\'s Workspace before falling back to the recency projection.' }],
|
||||
},
|
||||
{
|
||||
signature: 'create(input: { path: string }): Promise<WorkspaceView>',
|
||||
description: 'Register an existing path as a Workspace.',
|
||||
parameters: [{ name: 'input', description: 'the Host create payload.' }],
|
||||
returns: 'the created or idempotently resolved Workspace.',
|
||||
},
|
||||
{
|
||||
signature: 'pickDirectory(): Promise<string | null>',
|
||||
description: 'Open the Host\'s native directory picker.',
|
||||
parameters: [],
|
||||
returns: 'the selected path, or null when the user cancelled.',
|
||||
},
|
||||
{
|
||||
signature: 'listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>',
|
||||
description: 'List one directory level through the Host\'s `browse` capability.',
|
||||
parameters: [{ name: 'path', description: 'absolute directory to list; absent lists the Host home directory.' }, { name: 'signal', description: 'aborts the wire request (and the Host\'s scan) when the caller supersedes it.' }],
|
||||
returns: 'the level\'s listing with breadcrumb ancestry.',
|
||||
},
|
||||
{
|
||||
signature: 'createDirectory(path: string, name: string): Promise<string>',
|
||||
description: 'Create one child directory through the Host\'s `browse` capability.',
|
||||
parameters: [{ name: 'path', description: 'absolute existing parent directory.' }, { name: 'name', description: 'single non-blank path segment.' }],
|
||||
returns: 'the created directory\'s absolute path.',
|
||||
},
|
||||
{
|
||||
signature: 'openPath(path: string): Promise<void>',
|
||||
description: 'Open a filesystem path with the Host operating system\'s default application.',
|
||||
parameters: [{ name: 'path', description: 'absolute or host-resolvable path.' }],
|
||||
},
|
||||
{
|
||||
signature: 'rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>',
|
||||
description: 'Rename a Workspace.',
|
||||
parameters: [{ name: 'workspaceId', description: 'target workspace.' }, { name: 'title', description: 'the new display title.' }],
|
||||
returns: 'the updated Workspace view.',
|
||||
},
|
||||
{
|
||||
signature: 'delete(workspaceId: WorkspaceId): Promise<void>',
|
||||
description: 'Delete a Workspace (its sessions fall back to the unaccounted group).',
|
||||
parameters: [{ name: 'workspaceId', description: 'target workspace.' }],
|
||||
},
|
||||
{
|
||||
signature: 'insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>',
|
||||
description: 'Move an accounted session within/into a Workspace\'s ordered list.',
|
||||
parameters: [{ name: 'workspaceId', description: 'target workspace.' }, { name: 'sessionId', description: 'accounted session to move.' }, { name: 'beforeSessionId', description: 'accounted anchor to insert before; omitted appends.' }],
|
||||
returns: 'the updated Workspace view.',
|
||||
},
|
||||
{
|
||||
signature: 'archiveSession(sessionId: SessionId): Promise<void>',
|
||||
description: 'Archive a session into the registry-global set (hidden from grouping surfaces; session log and accounting slot remain). Archiving the current session clears the selection into the New Session view state.',
|
||||
parameters: [{ name: 'sessionId', description: 'session to archive.' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Every harness event, sorted by name. */
|
||||
export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'connection/reset',
|
||||
mode: 'emit',
|
||||
signature: '\'connection/reset\'(): void',
|
||||
summary: 'A connection generation was (re-)established.',
|
||||
description: 'A connection generation was (re-)established. Wire-derived caches must treat their state as stale and repull (commands directory; the queue mirrors reset themselves through the session resync path).',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
name: 'locale/change',
|
||||
mode: 'emit',
|
||||
signature: '\'locale/change\'(snapshot: LocaleSnapshot): void',
|
||||
summary: 'The active locale switched.',
|
||||
description: 'The active locale switched. Dictionary registrations do NOT emit this event (listeners may re-register slots in response, and boot registers one namespace per package); continuous render refresh rides the LocaleFace revision instead.',
|
||||
parameters: [{ name: 'snapshot', description: 'Current immutable locale snapshot.' }],
|
||||
},
|
||||
{
|
||||
name: 'slots/changed',
|
||||
mode: 'emit',
|
||||
signature: '\'slots/changed\'(key: string): void',
|
||||
summary: 'A slot\'s definition or registration set changed.',
|
||||
description: 'A slot\'s definition or registration set changed.',
|
||||
parameters: [{ name: 'key', description: 'the mutated SlotMap key.' }],
|
||||
},
|
||||
{
|
||||
name: 'theme/change',
|
||||
mode: 'emit',
|
||||
signature: '\'theme/change\'(snapshot: ThemeSnapshot): void',
|
||||
summary: 'Theme state changed (preference switched, registry updated, or the OS color scheme changed while the preference is `system`).',
|
||||
description: 'Theme state changed (preference switched, registry updated, or the OS color scheme changed while the preference is `system`).',
|
||||
parameters: [{ name: 'snapshot', description: 'Current immutable theme snapshot.' }],
|
||||
},
|
||||
]
|
||||
|
||||
/** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */
|
||||
export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
{
|
||||
name: 'ActionsDecl',
|
||||
declaration: 'export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>;',
|
||||
},
|
||||
{
|
||||
name: 'AgentContext',
|
||||
declaration: 'export type AgentContext = Omit<Context, \'remote\'> & {\n readonly remote: TypertClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};',
|
||||
},
|
||||
{
|
||||
name: 'AssistantBlock',
|
||||
declaration: 'export type AssistantBlock = {\n kind: \'text\';\n text: string;\n} | {\n kind: \'reasoning\';\n text: string;\n} | {\n kind: \'image\';\n attachment: ImageAttachmentRef;\n} | {\n kind: \'tool-call\';\n callId: string;\n name: string;\n argsRaw: string;\n} | {\n kind: \'other\';\n block: unknown;\n};',
|
||||
},
|
||||
{
|
||||
name: 'AssistantMessageNode',
|
||||
declaration: 'export interface AssistantMessageNode {\n kind: \'assistant\';\n seq: number;\n messageId?: MessageId;\n time: number;\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n usage?: unknown;\n provenance?: AssistantProvenanceView;\n requestConfig?: AssistantRequestConfig;\n timing?: AssistantTiming;\n interrupted?: true;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssistantProvenanceView',
|
||||
declaration: 'export interface AssistantProvenanceView {\n provider: string;\n model: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssistantRequestConfig',
|
||||
declaration: 'export interface AssistantRequestConfig {\n provider: string;\n model: string;\n purpose?: string;\n thinking?: string;\n reasoningEffort?: string;\n temperature?: number;\n maxTokens?: number;\n stop?: readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'AssistantTiming',
|
||||
declaration: 'export interface AssistantTiming {\n stepStartTime: number | null;\n firstTokenTime: number | null;\n completedTime: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BakedActions',
|
||||
declaration: 'export type BakedActions<T, A extends ActionsDecl<T>> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};',
|
||||
},
|
||||
{
|
||||
name: 'BoundActions',
|
||||
declaration: 'export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never;',
|
||||
},
|
||||
{
|
||||
name: 'ChainKeysOf',
|
||||
declaration: 'export type ChainKeysOf<S extends keyof SlotMap & string> = S extends unknown ? (SlotMap[S][\'kind\'] extends \'chain\' ? S : never) : never;',
|
||||
},
|
||||
{
|
||||
name: 'ChainRenderOpts',
|
||||
declaration: 'export interface ChainRenderOpts {\n fallback?: ReactNode;\n overlay?: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ChatConversationViewNode',
|
||||
declaration: 'export interface ChatConversationViewNode extends ConversationViewNode {\n readonly target: \'chat\';\n readonly anchorSeq: number;\n readonly location: ConversationLocation;\n readonly visibility: \'visible\' | \'hidden\';\n}',
|
||||
},
|
||||
{
|
||||
name: 'ChatLocationNodeIndex',
|
||||
declaration: 'export interface ChatLocationNodeIndex {\n getTurn(turn: number): readonly string[];\n getStep(turn: number, step: number): readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ChatNodeStore',
|
||||
declaration: 'export interface ChatNodeStore {\n get(key: string): ChatConversationViewNode | undefined;\n values(): readonly ChatConversationViewNode[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ChatSnapshot',
|
||||
declaration: 'export interface ChatSnapshot {\n readonly order: readonly string[];\n readonly nodes: ChatNodeStore;\n readonly locations: ChatLocationNodeIndex;\n readonly timeline: ConversationTimelineSnapshot;\n readonly legacy: LegacyConversationSlice;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ChildrenDecl',
|
||||
declaration: 'export type ChildrenDecl = {\n [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]>;\n};',
|
||||
},
|
||||
{
|
||||
name: 'CommandNode',
|
||||
declaration: 'export interface CommandNode {\n kind: \'command\';\n seq: number;\n time: number;\n commandId: CommandId;\n name: string | null;\n args: string | null;\n outcome: {\n kind: \'success\' | \'error\';\n text?: string;\n sourceEventSeq?: number;\n } | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CommonKeyOf',
|
||||
declaration: 'export type CommonKeyOf = LocaleNamespaceMap extends {\n common: infer C;\n} ? C & string : never;',
|
||||
},
|
||||
{
|
||||
name: 'CompactionSummaryNode',
|
||||
declaration: 'export interface CompactionSummaryNode {\n kind: \'compaction\';\n seq: number;\n time: number;\n summary: string | null;\n summaryEventSeq: number | null;\n shadowedItemCount: number | null;\n shadowedTokenCount: number | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ComposedProps',
|
||||
declaration: 'export type ComposedProps<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>, S extends keyof SlotMap & string, H, I extends object, M = never, N = undefined> = PropsRuntime<K, EntryKey> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>;',
|
||||
},
|
||||
{
|
||||
name: 'ComposerPhase',
|
||||
declaration: 'export type ComposerPhase = \'blank\' | \'engaging\' | \'active\';',
|
||||
},
|
||||
{
|
||||
name: 'ContextMessageNode',
|
||||
declaration: 'export interface ContextMessageNode {\n kind: \'context\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n provenance: ContextProvenanceView;\n form: KnownContextForm | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ContextProvenanceView',
|
||||
declaration: 'export interface ContextProvenanceView {\n role: ContextRole;\n label: string | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ContextRole',
|
||||
declaration: 'export type ContextRole = \'inject\' | \'recall\';',
|
||||
},
|
||||
{
|
||||
name: 'ConversationLocation',
|
||||
declaration: 'export type ConversationLocation = {\n readonly kind: \'session\';\n} | {\n readonly kind: \'turn\';\n readonly turn: TurnLocation;\n} | {\n readonly kind: \'step\';\n readonly turn: TurnLocation;\n readonly step: StepLocation;\n} | {\n readonly kind: \'unresolved\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'ConversationLocationDataStore',
|
||||
declaration: 'export interface ConversationLocationDataStore<DataMap extends object> {\n get<Key extends keyof DataMap & string>(key: Key): Readonly<DataMap[Key]> | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConversationNode',
|
||||
declaration: 'export type ConversationNode = UserMessageNode | AssistantMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | TurnErrorNode | TurnMaxTokensNode | ToolResultNode | CommandNode | CompactionSummaryNode | UnknownSurfaceNode;',
|
||||
},
|
||||
{
|
||||
name: 'ConversationSnapshot',
|
||||
declaration: 'export interface ConversationSnapshot {\n sessionId: SessionId;\n views: ConversationViewSnapshotStore;\n chat: ChatSnapshot;\n nodes: readonly ConversationNode[];\n turnTimings: ReadonlyMap<number, {\n readonly startTime: number;\n readonly endTime?: number;\n }>;\n turnEnds: ReadonlyMap<number, number>;\n partial: PartialAssistant | null;\n runningCalls: readonly RunningToolCall[];\n pending: readonly PendingInteraction[];\n queue: readonly QueuedMessage[];\n running: boolean;\n subagent: {\n address: SubagentAddress;\n parentAvailable: boolean;\n } | null;\n composerPhase: ComposerPhase;\n removed: boolean;\n openState: OpenState;\n openError: RpcError | null;\n hasMore: boolean;\n loadingOlder: boolean;\n promptError: PromptError | null;\n blank: boolean;\n lastAgentError: string | null;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConversationStepDataMap',
|
||||
declaration: 'export interface ConversationStepDataMap {\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConversationTimelineSnapshot',
|
||||
declaration: 'export interface ConversationTimelineSnapshot {\n readonly turnOrder: readonly number[];\n readonly turns: ReadonlyMap<number, TurnLocation>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConversationTurnDataMap',
|
||||
declaration: 'export interface ConversationTurnDataMap {\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConversationViewNode',
|
||||
declaration: 'export interface ConversationViewNode {\n readonly key: string;\n readonly kind: string;\n readonly id: string;\n readonly target: string;\n readonly data: unknown;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConversationViewSnapshotMap',
|
||||
declaration: 'export interface ConversationViewSnapshotMap {\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConversationViewSnapshotStore',
|
||||
declaration: 'export interface ConversationViewSnapshotStore {\n get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(target: Target): ConversationViewSnapshotMap[Target] | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'EntryKeyOf',
|
||||
declaration: 'export type EntryKeyOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;',
|
||||
},
|
||||
{
|
||||
name: 'GlobalStandardProps',
|
||||
declaration: 'export interface GlobalStandardProps {\n}',
|
||||
},
|
||||
{
|
||||
name: 'HandleOf',
|
||||
declaration: 'export type HandleOf<H> = H extends () => infer R ? R : H;',
|
||||
},
|
||||
{
|
||||
name: 'HooksSources',
|
||||
declaration: 'export type HooksSources = Record<string, HostObservable<unknown>>;',
|
||||
},
|
||||
{
|
||||
name: 'HostObservable',
|
||||
declaration: 'export interface HostObservable<T> {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InjectFace',
|
||||
declaration: 'export type InjectFace<I extends object> = I extends {\n hooks: infer HS extends HooksSources;\n} ? Omit<I, \'hooks\'> & PropsHooks<HS> : I;',
|
||||
},
|
||||
{
|
||||
name: 'InjectParams',
|
||||
declaration: 'export type InjectParams<K extends keyof SlotMap & string, H> = ScopeOf<K> extends \'session\' ? ([\n H\n] extends [\n StoreDecl\n] ? [\n sessionId: SessionIdOf,\n actions: BoundActions<HandleOf<H>>\n] : [\n sessionId: SessionIdOf\n]) : ScopeOf<K> extends \'session-maybe\' ? ([\n H\n] extends [\n StoreDecl\n] ? [\n sessionId: SessionIdOf | undefined,\n actions: BoundActions<HandleOf<H>> | undefined\n] : [\n sessionId: SessionIdOf | undefined\n]) : ([\n H\n] extends [\n StoreDecl\n] ? [\n actions: BoundActions<HandleOf<H>>\n] : [\n]);',
|
||||
},
|
||||
{
|
||||
name: 'ISession',
|
||||
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\'): Promise<RpcResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<RpcResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{\n accepted: true;\n }>>;\n cancel(): Promise<RpcResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<RpcResult<{\n title: string;\n seq: number;\n }>>;\n loadOlder(): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'KeyPropsOf',
|
||||
declaration: 'export type KeyPropsOf<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? EntryKey extends keyof P ? P[EntryKey] extends object ? P[EntryKey] : never : never : object;',
|
||||
},
|
||||
{
|
||||
name: 'KnownContextForm',
|
||||
declaration: 'export type KnownContextForm = typeof KNOWN_FORMS[number];',
|
||||
},
|
||||
{
|
||||
name: 'LegacyConversationSlice',
|
||||
declaration: 'export interface LegacyConversationSlice {\n readonly nodes: readonly ConversationNode[];\n readonly turnTimings: ReadonlyMap<number, {\n readonly startTime: number;\n readonly endTime?: number;\n }>;\n readonly turnEnds: ReadonlyMap<number, number>;\n readonly partial: PartialAssistant | null;\n readonly runningCalls: readonly RunningToolCall[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'LocaleDefinition',
|
||||
declaration: 'export interface LocaleDefinition {\n id: LocaleId;\n label: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'LocaleDict',
|
||||
declaration: 'export type LocaleDict = Record<string, string>;',
|
||||
},
|
||||
{
|
||||
name: 'LocaleDictOf',
|
||||
declaration: 'export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> = Record<LocaleNamespaceMap[N] & string, string>;',
|
||||
},
|
||||
{
|
||||
name: 'LocaleId',
|
||||
declaration: 'export type LocaleId = typeof LOCALE_IDS[number];',
|
||||
},
|
||||
{
|
||||
name: 'LocaleKeysOf',
|
||||
declaration: 'export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> = (LocaleNamespaceMap[N] & string) | CommonKeyOf;',
|
||||
},
|
||||
{
|
||||
name: 'LocaleNamespaceMap',
|
||||
declaration: 'export interface LocaleNamespaceMap {\n}',
|
||||
},
|
||||
{
|
||||
name: 'LocaleSnapshot',
|
||||
declaration: 'export interface LocaleSnapshot {\n active: LocaleId;\n locales: readonly LocaleDefinition[];\n revision: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'MatchedShare',
|
||||
declaration: 'export type MatchedShare<E extends SlotEntryDef, M> = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;',
|
||||
},
|
||||
{
|
||||
name: 'ModelRetryNode',
|
||||
declaration: 'export type ModelRetryNode = LlmRetryEventData & {\n kind: \'model-retry\';\n seq: number;\n time: number;\n retryState: \'scheduled\' | \'started\' | \'cancelled\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'ObservableSnapshot',
|
||||
declaration: 'export interface ObservableSnapshot<T> {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'OpenState',
|
||||
declaration: 'export type OpenState = \'cold\' | \'loading\' | \'open\' | \'error\';',
|
||||
},
|
||||
{
|
||||
name: 'OwnerOf',
|
||||
declaration: 'export type OwnerOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n owner: infer O extends object;\n} ? O : object;',
|
||||
},
|
||||
{
|
||||
name: 'PartialAssistant',
|
||||
declaration: 'export interface PartialAssistant {\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'PendingInteraction',
|
||||
declaration: 'export type PendingInteraction = {\n [K in PendingKind]: PendingWait<K>;\n}[PendingKind];',
|
||||
},
|
||||
{
|
||||
name: 'PendingKind',
|
||||
declaration: 'export type PendingKind = keyof PendingPayloads;',
|
||||
},
|
||||
{
|
||||
name: 'PendingPayloads',
|
||||
declaration: 'export interface PendingPayloads {\n approval: Omit<Extract<MuxFrame, {\n type: \'approval/requested\';\n }>, \'type\' | \'sessionId\'>;\n question: Omit<Extract<MuxFrame, {\n type: \'question/requested\';\n }>, \'type\' | \'sessionId\'>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PendingWait',
|
||||
declaration: 'export class PendingWait<K extends PendingKind = PendingKind> {\n readonly kind: K;\n readonly key: string;\n readonly sessionId: SessionId;\n readonly payload: PendingPayloads[K];\n constructor(kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K], respond: (message: ClientResponse) => Promise<RpcReceipt>);\n respond(result: ClientResponse[\'result\']): Promise<RpcReceipt>;\n markSettled(): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionsFace',
|
||||
declaration: 'export interface ProjectionsFace {\n faceOf(key: string): ObservableSnapshot<unknown>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptError',
|
||||
declaration: 'export interface PromptError {\n op: \'send\' | \'stop\';\n error: RpcError;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PropsHooks',
|
||||
declaration: 'export type PropsHooks<HS extends HooksSources> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>;\n};',
|
||||
},
|
||||
{
|
||||
name: 'PropsLocale',
|
||||
declaration: 'export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string ? {\n t: TranslateNS<N>;\n} : object;',
|
||||
},
|
||||
{
|
||||
name: 'PropsRenderSlots',
|
||||
declaration: 'export type PropsRenderSlots<S extends keyof SlotMap & string> = {\n renderSlot: RenderSlotFn<Exclude<S, ChainKeysOf<S>>>;\n readonly __renders?: ((key: S) => void) | undefined;\n} & ([\n ChainKeysOf<S>\n] extends [\n never\n] ? object : {\n renderSlotChain: <K extends ChainKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: ChainRenderOpts) => ReactNode;\n}) & (\'session\' extends ScopeOf<S> ? {\n SessionProvider: SessionProviderComponent;\n} : object);',
|
||||
},
|
||||
{
|
||||
name: 'PropsRuntime',
|
||||
declaration: 'export type PropsRuntime<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>> = OwnerOf<K> & KeyPropsOf<K, EntryKey> & SlotInjectFace<SlotInjectOf<K>> & (ScopeOf<K> extends \'session\' ? SessionStandardProps : ScopeOf<K> extends \'session-maybe\' ? SessionMaybeStandardProps : object) & GlobalStandardProps;',
|
||||
},
|
||||
{
|
||||
name: 'PropsSlotHooks',
|
||||
declaration: 'export type PropsSlotHooks<HS extends object> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: BoundHookOf<HS[N]>;\n};',
|
||||
},
|
||||
{
|
||||
name: 'PropsStore',
|
||||
declaration: 'export type PropsStore<H> = H extends StoreHandle<infer T, infer A> ? {\n useStore: SnapshotSelectorHook<T>;\n actions: BakedActions<T, A>;\n} : object;',
|
||||
},
|
||||
{
|
||||
name: 'QueueAction',
|
||||
declaration: 'export type QueueAction = Parameters<SessionFace[\'updateQueue\']>[1];',
|
||||
},
|
||||
{
|
||||
name: 'RunningToolCall',
|
||||
declaration: 'export interface RunningToolCall {\n callId: string;\n name: string;\n argsRaw: string;\n turn: number;\n step: number;\n time: number;\n callView: ToolCallView | null;\n subCalls: readonly ToolCallBlock[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ScopeOf',
|
||||
declaration: 'export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K][\'scope\'];',
|
||||
},
|
||||
{
|
||||
name: 'SessionAreaProps',
|
||||
declaration: 'export interface SessionAreaProps {\n empty?: (() => ReactNode) | undefined;\n children: (sessionId: SessionIdOf) => ReactNode;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionBinding',
|
||||
declaration: 'export interface SessionBinding {\n readonly sessionId: SessionId;\n readonly session: SessionFace;\n readonly ctx: AgentContext;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionFace',
|
||||
declaration: 'export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>;',
|
||||
},
|
||||
{
|
||||
name: 'SessionIdOf',
|
||||
declaration: 'export type SessionIdOf = SessionStandardProps extends {\n sessionId: infer S;\n} ? S : string;',
|
||||
},
|
||||
{
|
||||
name: 'SessionMaybeStandardProps',
|
||||
declaration: 'export interface SessionMaybeStandardProps {\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionProviderComponent',
|
||||
declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;',
|
||||
},
|
||||
{
|
||||
name: 'SessionSearchResultItem',
|
||||
declaration: 'export interface SessionSearchResultItem {\n sessionId: SessionId;\n snippet: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionStandardProps',
|
||||
declaration: 'export interface SessionStandardProps {\n}',
|
||||
},
|
||||
{
|
||||
name: 'SlotComponent',
|
||||
declaration: 'export type SlotComponent<P> = (props: P) => ReactNode;',
|
||||
},
|
||||
{
|
||||
name: 'SlotCore',
|
||||
declaration: 'export class SlotCore {\n constructor();\n register<K extends keyof SlotMap & string, const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>, const D extends ChildrenDecl = Record<never, never>, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent<never> = SlotComponent<never>>(options: BaseOptions<K, EntryKey, D, H, M, N> & {\n inject?: undefined;\n }, component: C & SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>> & RendersCheck<C, D>): () => void;\n register<K extends keyof SlotMap & string, I extends object, const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>, const D extends ChildrenDecl = Record<never, never>, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent<never> = SlotComponent<never>>(options: BaseOptions<K, EntryKey, D, H, M, N> & {\n inject: (...args: InjectParams<K, H>) => I;\n }, component: C & SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>> & RendersCheck<C, D>): () => void;\n register(options: ErasedOptions, component: unknown): () => void;\n isLive(entry: StoredEntry): boolean;\n entries(key: string): readonly StoredEntry[];\n entriesOfSlot(key /* …truncated — full shape in source */',
|
||||
},
|
||||
{
|
||||
name: 'SlotEntryDef',
|
||||
declaration: 'export interface SlotEntryDef {\n kind: SlotKind;\n scope: SlotScope;\n owner?: object;\n keyProps?: Record<string, object>;\n hookContext?: unknown;\n inject?: object;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SlotInjectFace',
|
||||
declaration: 'export type SlotInjectFace<I extends object> = I extends {\n hooks: infer HS extends object;\n} ? Omit<I, \'hooks\'> & PropsSlotHooks<HS> : I;',
|
||||
},
|
||||
{
|
||||
name: 'SlotInjectOf',
|
||||
declaration: 'export type SlotInjectOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n inject: infer Injected extends object;\n} ? Injected : object;',
|
||||
},
|
||||
{
|
||||
name: 'SlotKind',
|
||||
declaration: 'export type SlotKind = \'single\' | \'list\' | \'keyed\' | \'chain\';',
|
||||
},
|
||||
{
|
||||
name: 'SlotLabel',
|
||||
declaration: 'export type SlotLabel = string | (() => string);',
|
||||
},
|
||||
{
|
||||
name: 'SlotMap',
|
||||
declaration: 'export interface SlotMap {\n}',
|
||||
},
|
||||
{
|
||||
name: 'SlotScope',
|
||||
declaration: 'export type SlotScope = \'root\' | \'session-maybe\' | \'session\';',
|
||||
},
|
||||
{
|
||||
name: 'SlotSpec',
|
||||
declaration: 'export type SlotSpec<E extends SlotEntryDef> = {\n kind: E[\'kind\'];\n scope: E[\'scope\'];\n} & (\'inject\' extends keyof E ? E extends {\n inject: infer Injected extends object;\n} ? {\n inject: Injected;\n} : {\n inject?: object;\n} : {\n inject?: never;\n});',
|
||||
},
|
||||
{
|
||||
name: 'SnapshotSelectorHook',
|
||||
declaration: 'export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S;',
|
||||
},
|
||||
{
|
||||
name: 'SteeringMessageNode',
|
||||
declaration: 'export interface SteeringMessageNode {\n kind: \'steering\';\n messageId: MessageId;\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StepLocation',
|
||||
declaration: 'export interface StepLocation {\n readonly turn: number;\n readonly step: number;\n readonly start: SessionEvent<\'step/start\'> | undefined;\n readonly end: SessionEvent<\'step/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly data: ConversationLocationDataStore<ConversationStepDataMap>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StoreDecl',
|
||||
declaration: 'export type StoreDecl = StoreHandle<any, any> | StoreFactory;',
|
||||
},
|
||||
{
|
||||
name: 'StoredEntry',
|
||||
declaration: 'export interface StoredEntry {\n component: unknown;\n options: {\n key?: string;\n id?: string;\n order?: number;\n label?: SlotLabel;\n priority?: number;\n };\n select?: ((owner: never) => unknown) | undefined;\n inject?: ((...args: never[]) => Record<string, unknown>) | undefined;\n children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined;\n store?: StoreDecl | undefined;\n locale?: string | undefined;\n registrant?: string | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StoreFactory',
|
||||
declaration: 'export type StoreFactory = () => StoreHandle<any, any>;',
|
||||
},
|
||||
{
|
||||
name: 'StoreHandle',
|
||||
declaration: 'export interface StoreHandle<T, A extends ActionsDecl<T>> {\n readonly spec: StoreSpec<T, A>;\n create(scopeKey?: string): StoreInstance<T, A>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StoreInstance',
|
||||
declaration: 'export interface StoreInstance<T, A extends ActionsDecl<T>> {\n readonly actions: BakedActions<T, A>;\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n clearPersisted(): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StoreSpec',
|
||||
declaration: 'export interface StoreSpec<T, A extends ActionsDecl<T>> {\n init: () => T;\n persist?: string;\n actions: A;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ThemeDefinition',
|
||||
declaration: 'export interface ThemeDefinition {\n id: string;\n colorScheme: \'light\' | \'dark\';\n tokens: ThemeTokens;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ThemePreference',
|
||||
declaration: 'export type ThemePreference = typeof THEME_PREFERENCES[number];',
|
||||
},
|
||||
{
|
||||
name: 'ThemeSnapshot',
|
||||
declaration: 'export interface ThemeSnapshot {\n preference: ThemePreference;\n active: ThemeDefinition;\n themes: readonly ThemeDefinition[];\n revision: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ThemeTokenModes',
|
||||
declaration: 'export interface ThemeTokenModes {\n light: string;\n dark: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ThemeTokenOverrides',
|
||||
declaration: 'export type ThemeTokenOverrides = Record<string, ThemeTokenModes>;',
|
||||
},
|
||||
{
|
||||
name: 'ThemeTokens',
|
||||
declaration: 'export type ThemeTokens = Record<string, string>;',
|
||||
},
|
||||
{
|
||||
name: 'ToolCallBlock',
|
||||
declaration: 'export type ToolCallBlock = RunningToolCall | ToolResultNode;',
|
||||
},
|
||||
{
|
||||
name: 'ToolResultNode',
|
||||
declaration: 'export interface ToolResultNode {\n kind: \'tool-result\';\n seq: number;\n time: number;\n callId: string;\n call: {\n name: string;\n argsRaw: string;\n } | null;\n callTime: number | null;\n content: readonly ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n callView: ToolCallView | null;\n resultView: ToolResultView | null;\n subCalls: readonly ToolCallBlock[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'Translate',
|
||||
declaration: 'export type Translate<K extends string = string> = (key: K, params?: Record<string, unknown>) => string;',
|
||||
},
|
||||
{
|
||||
name: 'TranslateNS',
|
||||
declaration: 'export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>;',
|
||||
},
|
||||
{
|
||||
name: 'TurnErrorNode',
|
||||
declaration: 'export interface TurnErrorNode {\n kind: \'turn-error\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n message: string;\n code?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TurnLocation',
|
||||
declaration: 'export interface TurnLocation {\n readonly turn: number;\n readonly start: SessionEvent<\'turn/start\'> | undefined;\n readonly end: SessionEvent<\'turn/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly steps: readonly StepLocation[];\n readonly data: ConversationLocationDataStore<ConversationTurnDataMap>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TurnMaxTokensNode',
|
||||
declaration: 'export interface TurnMaxTokensNode {\n kind: \'turn-max-tokens\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'UnknownSurfaceNode',
|
||||
declaration: 'export interface UnknownSurfaceNode {\n kind: \'unknown\';\n seq: number;\n time: number;\n type: string;\n data: unknown;\n}',
|
||||
},
|
||||
{
|
||||
name: 'UserMessageNode',
|
||||
declaration: 'export interface UserMessageNode {\n kind: \'user\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}',
|
||||
},
|
||||
]
|
||||
|
||||
/** The inherited `ctx` API (cordis core + loader/hmr/timer), in curated order. */
|
||||
export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / short-circuit chain).' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.' },
|
||||
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.' },
|
||||
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).' },
|
||||
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.' },
|
||||
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the four supported helpers are mixed onto ctx directly (declared via Pick).' },
|
||||
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).' },
|
||||
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).' },
|
||||
]
|
||||
|
||||
function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] {
|
||||
const included = new Set<string>()
|
||||
let frontier = [...seeds]
|
||||
while (frontier.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const entry of TYPE_API) {
|
||||
if (included.has(entry.name)) continue
|
||||
const pattern = new RegExp(`\b${entry.name}\b`)
|
||||
if (!frontier.some(text => pattern.test(text))) continue
|
||||
included.add(entry.name)
|
||||
next.push(entry.declaration)
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return TYPE_API.filter(entry => included.has(entry.name))
|
||||
}
|
||||
|
||||
function contextProperty(key: string): string {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(key) ? `ctx.${key}` : `ctx[${JSON.stringify(key)}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the Service Catalog as a compact directory or one exact coding contract.
|
||||
* @param key - exact Service key; omit it to list all Services and method signatures.
|
||||
* @param services - platform-specific visible Service entries.
|
||||
* @returns compact navigation data or one detailed Service with its referenced type closure.
|
||||
*/
|
||||
export function queryServiceApi(key?: string, services: readonly ServiceApiEntry[] = SERVICE_API): object {
|
||||
if (key === undefined) {
|
||||
return {
|
||||
mode: 'catalog',
|
||||
services: services.map(service => ({
|
||||
key: service.key,
|
||||
description: service.summary,
|
||||
methods: service.methods.map(method => ({ signature: method.signature })),
|
||||
})),
|
||||
}
|
||||
}
|
||||
const service = services.find(candidate => candidate.key === key)
|
||||
if (service === undefined) throw new Error(`no catalogued Service named "${key}"`)
|
||||
return {
|
||||
mode: 'service',
|
||||
service: {
|
||||
key: service.key,
|
||||
description: service.description,
|
||||
access: {
|
||||
optional: { expression: `ctx.get(${JSON.stringify(service.key)})`, requiresUndefinedCheck: true },
|
||||
hardDependency: { inject: [service.key], expression: contextProperty(service.key) },
|
||||
},
|
||||
methods: service.methods,
|
||||
},
|
||||
referencedTypes: referencedTypeClosure(service.methods.map(method => method.signature)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the Event Catalog as a compact directory or one exact listener contract.
|
||||
* @param name - exact Event name; omit it to list all Events and listener signatures.
|
||||
* @param events - platform-specific visible Event entries.
|
||||
* @returns compact navigation data or one detailed Event with its referenced type closure.
|
||||
*/
|
||||
export function queryEventApi(name?: string, events: readonly EventApiEntry[] = EVENT_API): object {
|
||||
if (name === undefined) {
|
||||
return {
|
||||
mode: 'catalog',
|
||||
events: events.map(event => ({
|
||||
name: event.name,
|
||||
description: event.summary,
|
||||
mode: event.mode,
|
||||
signature: event.signature,
|
||||
})),
|
||||
}
|
||||
}
|
||||
const event = events.find(candidate => candidate.name === name)
|
||||
if (event === undefined) throw new Error(`no catalogued Event named "${name}"`)
|
||||
return {
|
||||
mode: 'event',
|
||||
event: {
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
mode: event.mode,
|
||||
signature: event.signature,
|
||||
parameters: event.parameters,
|
||||
},
|
||||
referencedTypes: referencedTypeClosure([event.signature]),
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
222
packages/extensions/cordis-client-runner/src/client/evaluator.ts
Normal file
222
packages/extensions/cordis-client-runner/src/client/evaluator.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Browser-half closure evaluation: the package source runs as the body of an
|
||||
* async function whose parameters ARE the symbol surface. Shadowing parameters
|
||||
* (setTimeout/fetch/require/…) turn the ambient browser globals into teaching
|
||||
* redirects without touching the page. The host syntax-prechecked the source at
|
||||
* define time; SyntaxError handling here is the engine-divergence fallback and
|
||||
* reaches the model through the load report.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
/** A mountable plugin as the closure must return it (FUNCTION or OBJECT form). */
|
||||
export interface DynamicCordisEvaluatedPlugin {
|
||||
/** Optional plugin name; the runner overwrites it with the module id. */
|
||||
name?: string
|
||||
/** Services the browser half declares; the runner overwrites it from the dispatched row. */
|
||||
inject?: string[]
|
||||
/** Plugin body receiving the guard facade. */
|
||||
apply: (ctx: unknown, config?: unknown) => unknown
|
||||
}
|
||||
|
||||
/** What the evaluator needs from the runner to build one package's closure. */
|
||||
export interface DynamicCordisClosureEnv {
|
||||
/** Route `host.call` to this package's host half over the wire. */
|
||||
invoke(method: string, args: unknown): Promise<unknown>
|
||||
/** Mirror one runtime error text into the load report (console.error copies). */
|
||||
noteError(message: string): void
|
||||
}
|
||||
|
||||
const TIMER_REDIRECT
|
||||
= 'browser timer globals are unavailable in dynamic packages. Declare inject: [\'timer\'] on the returned plugin, '
|
||||
+ 'query Client Service.listService for the exact API, and close over that plugin ctx. In React, create timers '
|
||||
+ 'from an event handler or React.useEffect and return callback-form disposers from the effect cleanup.'
|
||||
|
||||
/**
|
||||
* Where each withheld browser global sends the author instead. One home for two
|
||||
* consumers: the closure traps below throw these, and a render crash whose
|
||||
* message names one of them gets the same redirect appended — a package that
|
||||
* reached the global some other way (`window.setInterval`) crashes with the
|
||||
* engine's own bare text, and the author needs the redirect either way.
|
||||
*/
|
||||
export const DYNAMIC_CLIENT_REDIRECTS: Readonly<Record<string, string>> = {
|
||||
setTimeout: TIMER_REDIRECT,
|
||||
setInterval: TIMER_REDIRECT,
|
||||
clearTimeout: TIMER_REDIRECT,
|
||||
clearInterval: TIMER_REDIRECT,
|
||||
fetch:
|
||||
'network belongs to the HOST half: register a handler there with harness.handle(method, fn) and call it here via host.call(method, args).',
|
||||
require:
|
||||
'modules cannot be imported here. React arrives as the `React` closure symbol; everything else goes through ctx services or host.call.',
|
||||
}
|
||||
|
||||
/** Callable teaching traps shadowing the ambient globals the closure must not reach. */
|
||||
function closureTraps(): Record<string, () => never> {
|
||||
const traps: Record<string, () => never> = {}
|
||||
for (const [name, redirect] of Object.entries(DYNAMIC_CLIENT_REDIRECTS)) {
|
||||
traps[name] = (): never => {
|
||||
throw new Error(`${name} is not available in a dynamic client half — ${redirect}`)
|
||||
}
|
||||
}
|
||||
return traps
|
||||
}
|
||||
|
||||
/** The `harness` seat exists only host-side; any touch teaches the split. */
|
||||
function harnessTrap(): unknown {
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
throw new Error(
|
||||
`harness.${String(prop)} belongs to the HOST half (\`code\`): register handlers there with harness.handle(method, fn); `
|
||||
+ 'the browser half calls them via host.call(method, args).',
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Per-package style-tag bookkeeping behind the `styles.insert` symbol. */
|
||||
export class DynamicCordisStyles {
|
||||
private readonly tags = new Set<HTMLStyleElement>()
|
||||
|
||||
/** @param pluginId - owning Plugin ID, stamped as `data-dyn` on every tag. */
|
||||
constructor(private readonly pluginId: CordisDynamicPluginId) {}
|
||||
|
||||
/**
|
||||
* Inject one stylesheet, removed automatically on package unload.
|
||||
* @param css - raw CSS text.
|
||||
* @returns disposer removing this one tag early.
|
||||
*/
|
||||
insert(css: string): () => void {
|
||||
if (typeof css !== 'string') throw new Error('styles.insert(css) needs a CSS string')
|
||||
const tag = document.createElement('style')
|
||||
tag.dataset.dyn = this.pluginId
|
||||
tag.textContent = css
|
||||
document.head.append(tag)
|
||||
this.tags.add(tag)
|
||||
return () => {
|
||||
this.tags.delete(tag)
|
||||
tag.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/** Live tag count (load-report contribution summary). */
|
||||
get count(): number {
|
||||
return this.tags.size
|
||||
}
|
||||
|
||||
/** Remove every tag this package still owns (unload path). */
|
||||
dispose(): void {
|
||||
for (const tag of this.tags) tag.remove()
|
||||
this.tags.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Stringify one console argument for the error mirror. */
|
||||
function errorText(arg: unknown): string {
|
||||
if (arg instanceof Error) return arg.message
|
||||
if (typeof arg === 'string') return arg
|
||||
if (arg === undefined) return 'undefined'
|
||||
try {
|
||||
return JSON.stringify(arg)
|
||||
} catch {
|
||||
// A circular or otherwise non-serializable console argument: the mirror
|
||||
// carries the message, and nothing else here can fail.
|
||||
return '[unserializable console argument]'
|
||||
}
|
||||
}
|
||||
|
||||
/** Tagged write-through console; error lines additionally copy into the load report. */
|
||||
function taggedConsole(pluginId: CordisDynamicPluginId, noteError: (message: string) => void): Console {
|
||||
const tag = `[cordis:${pluginId}]`
|
||||
const forward = (level: 'log' | 'info' | 'warn' | 'error' | 'debug') => (...args: unknown[]): void => {
|
||||
console[level](tag, ...args)
|
||||
if (level !== 'error') return
|
||||
noteError(args.map(errorText).join(' ').slice(0, 500))
|
||||
}
|
||||
return {
|
||||
...console,
|
||||
log: forward('log'),
|
||||
info: forward('info'),
|
||||
warn: forward('warn'),
|
||||
error: forward('error'),
|
||||
debug: forward('debug'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a closure return value to a mountable plugin (host guard mirror).
|
||||
* @param value - whatever the closure returned.
|
||||
* @returns whether the value is mountable.
|
||||
*/
|
||||
export function isDynamicCordisPlugin(value: unknown): value is DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown) {
|
||||
if (typeof value === 'function') return true
|
||||
return typeof value === 'object' && value !== null
|
||||
&& typeof (value as { apply?: unknown }).apply === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one package's browser half and return the (un-guarded) plugin.
|
||||
* @param pluginId - stable Plugin ID (console tag and style ownership).
|
||||
* @param clientCode - the browser half's source: an async function body returning a plugin.
|
||||
* @param env - runner wiring for `host.call` and error mirroring.
|
||||
* @param styles - the package's style bookkeeping (owned by the caller so unload can dispose it).
|
||||
* @returns the plugin the closure returned.
|
||||
* @throws teaching errors for syntax failures and non-plugin returns.
|
||||
*/
|
||||
export async function evaluateClientHalf(
|
||||
pluginId: CordisDynamicPluginId,
|
||||
clientCode: string,
|
||||
env: DynamicCordisClosureEnv,
|
||||
styles: DynamicCordisStyles,
|
||||
): Promise<DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)> {
|
||||
const traps = closureTraps()
|
||||
const parameters = ['React', 'console', 'styles', 'host', 'harness', ...Object.keys(traps), 'process', 'Buffer']
|
||||
let closure: (...args: unknown[]) => Promise<unknown>
|
||||
try {
|
||||
// The wrapper mirrors the host precheck exactly, so line offsets match.
|
||||
// Evaluating a definition's browser half IS this package's product: the
|
||||
// source arrived from a host process that accepted and prechecked it.
|
||||
// oxlint-disable-next-line typescript/no-implied-eval -- see above
|
||||
const factory = new Function(...parameters, `return (async () => {\n${clientCode}\n})()`)
|
||||
closure = factory as (...args: unknown[]) => Promise<unknown>
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error
|
||||
// Engine-divergence fallback: the host precheck already carried the
|
||||
// line/caret teaching; browsers give only the message.
|
||||
throw new Error(
|
||||
`client half failed to parse in this browser: ${error.message}\n`
|
||||
+ 'The browser half is plain JavaScript (no JSX, no TypeScript); build elements with React.createElement.',
|
||||
)
|
||||
}
|
||||
const host = {
|
||||
/**
|
||||
* Call a host-half handler of THIS package (harness.handle pairing). A call
|
||||
* with nothing to pass omits the argument: it arrives at the handler as
|
||||
* `null`, because the wire carries JSON and `undefined` is not JSON —
|
||||
* requiring `host.call('m', {})` would be a ritual, and defaulting to `{}`
|
||||
* would invent an empty argument the caller never wrote.
|
||||
*/
|
||||
call: (method: string, args: unknown = null): Promise<unknown> => env.invoke(method, args),
|
||||
}
|
||||
const returned = await closure(
|
||||
React,
|
||||
taggedConsole(pluginId, (message) => { env.noteError(message) }),
|
||||
styles,
|
||||
host,
|
||||
harnessTrap(),
|
||||
...Object.values(traps),
|
||||
undefined, // process: undefined keeps `typeof process` probes safe
|
||||
undefined, // Buffer
|
||||
)
|
||||
if (!isDynamicCordisPlugin(returned)) {
|
||||
if (returned === undefined) {
|
||||
throw new Error(
|
||||
'client half returned `undefined` — did you forget `return`?\n'
|
||||
+ ' ✓ return (ctx) => { … }\n'
|
||||
+ ' ✓ return { name: \'…\', inject: [\'slots\'], apply(ctx) { … } }',
|
||||
)
|
||||
}
|
||||
throw new Error('client half must `return` a plugin: a function, or an object with an `apply(ctx)` method')
|
||||
}
|
||||
return returned
|
||||
}
|
||||
239
packages/extensions/cordis-client-runner/src/client/guard.ts
Normal file
239
packages/extensions/cordis-client-runner/src/client/guard.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The browser twin of the tool-cordis context facade: a whitelist of
|
||||
* lifecycle-safe verbs plus optional `ctx.get()` lookup and declared-service
|
||||
* property access, with
|
||||
* framework internals withheld and Context-valued returns denied. Two seats
|
||||
* carry extra machinery: `slots`, where the register proxy assigns the
|
||||
* shadowing priority and ledgers the registration — invoking the service with
|
||||
* the traced receiver so the effect lands on the CALLING plugin's fiber
|
||||
* (SlotRegistry.register must stay a prototype method for exactly that
|
||||
* reason) — and `theme`, whose override source is pinned to the package id.
|
||||
*
|
||||
* This is API discipline, not a security boundary: a dynamic package's code is
|
||||
* as trusted as the host process that accepted its definition.
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { DynamicCordisPackage } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
/** Facade verbs beyond declared services (host CTX_VERBS twin). */
|
||||
const CTX_VERBS = new Set([
|
||||
'effect', 'on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce',
|
||||
])
|
||||
const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
|
||||
|
||||
/** One package's slot-registration ledger row (contribution projection source). */
|
||||
export interface DynamicCordisSlotLedgerRow {
|
||||
/** Target slot name. */
|
||||
slot: string
|
||||
/** The assigned shadowing priority (globally unique — how winners are matched back to packages). */
|
||||
priority: number | undefined
|
||||
}
|
||||
|
||||
/** What the facade needs beyond the real ctx to govern one package. */
|
||||
export interface DynamicCordisGuardEnv {
|
||||
/** The dispatched Package row. */
|
||||
pkg: DynamicCordisPackage
|
||||
/** Ledger sink: every slot registration this package makes. */
|
||||
ledger: DynamicCordisSlotLedgerRow[]
|
||||
/**
|
||||
* Ownership index sink: the component object seated in a slot, so a later
|
||||
* render crash reported against the stored entry can be attributed back to
|
||||
* this package. Identity is the key — the registry stores the component
|
||||
* verbatim — which is why nothing else has to be remembered about the entry.
|
||||
* @param component - whatever the package passed as its component.
|
||||
*/
|
||||
claim(component: unknown): void
|
||||
/** Allocate one page-local shadowing rank; later registrations sort first. */
|
||||
allocatePriority(): number
|
||||
/** Report one post-activation guard rejection to the owning Agent. */
|
||||
reportFailure(error: Error): void
|
||||
}
|
||||
|
||||
/** Reject any service return that is a cordis Context (host guard twin). */
|
||||
function denyContext(value: unknown, service: string, env: DynamicCordisGuardEnv): unknown {
|
||||
if (value instanceof Context) {
|
||||
return rejectGuard(env,
|
||||
`service "${service}" returned a cordis Context, which the dynamic facade does not expose. `
|
||||
+ 'Operate through your own plugin ctx and the services you declared — never another context.',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward service methods with the traced service as receiver — `this.ctx`
|
||||
* inside prototype methods (slots.register) must stay the CALLER's ctx so
|
||||
* effects land on the calling plugin's fiber — while denying Context returns.
|
||||
*/
|
||||
function guardedService(service: object, name: string, env: DynamicCordisGuardEnv): unknown {
|
||||
return new Proxy(service, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop, target) as unknown
|
||||
if (typeof value !== 'function') return denyContext(value, name, env)
|
||||
return (...args: unknown[]): unknown => {
|
||||
const result = Reflect.apply(value, target, args) as unknown
|
||||
if (result instanceof Promise) return result.then(resolved => denyContext(resolved, name, env))
|
||||
return denyContext(result, name, env)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Erased register options as this facade reads and rewrites them. */
|
||||
interface ErasedSlotOptions {
|
||||
name?: string
|
||||
priority?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The slots seat: automatic shadowing priority and ledger recording around the
|
||||
* traced service's own register.
|
||||
*/
|
||||
function guardedSlots(slots: SlotRegistry, env: DynamicCordisGuardEnv): unknown {
|
||||
return new Proxy(slots, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop, target) as unknown
|
||||
if (prop !== 'register') {
|
||||
if (typeof value !== 'function') return denyContext(value, 'slots', env)
|
||||
return (...args: unknown[]): unknown => denyContext(Reflect.apply(value, target, args), 'slots', env)
|
||||
}
|
||||
return (rawOptions: unknown, component: unknown): unknown => {
|
||||
if (typeof rawOptions !== 'object' || rawOptions === null) {
|
||||
return rejectGuard(env, 'slots.register(options, component) needs an options object with a `name`')
|
||||
}
|
||||
const options = { ...rawOptions as ErasedSlotOptions }
|
||||
const slot = options.name
|
||||
if (typeof slot !== 'string' || slot.length === 0) {
|
||||
return rejectGuard(env, 'slots.register options need a string `name` (the target slot key)')
|
||||
}
|
||||
if (slot === 'tool.view.cordis') {
|
||||
if (options.key !== 'self') {
|
||||
return rejectGuard(env, 'tool.view.cordis only accepts key "self"; the runtime binds it to this Package')
|
||||
}
|
||||
options.key = `${env.pkg.pluginId}.${env.pkg.packageId}`
|
||||
}
|
||||
// Shadowing kinds get a page-local rank. Later registrations sort first;
|
||||
// chain slots keep their own election (select order) untouched.
|
||||
const spec = (slots.spec as (key: string) => { kind?: string } | undefined)(slot)
|
||||
let priority = options.priority
|
||||
if (spec === undefined || spec.kind !== 'chain') {
|
||||
priority = env.allocatePriority()
|
||||
options.priority = priority
|
||||
}
|
||||
const register = Reflect.get(target, 'register', target) as unknown as (opts: object, comp: unknown) => () => void
|
||||
const dispose = register.call(target, options, component)
|
||||
env.ledger.push({ slot, priority })
|
||||
// After the registry accepted it: a rejected registration seats no entry,
|
||||
// so claiming one would index a component no crash can ever name.
|
||||
env.claim(component)
|
||||
return dispose
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme seat: `overrideTokens`' source is FORCED to the package id — a
|
||||
* dynamic package can never impersonate (or evict) another source's layer, and
|
||||
* its own layers converge under one identity unload can reason about. The
|
||||
* layer's disposer is additionally hung on the calling fiber, because the
|
||||
* documented contract is "unload restores" and model code cannot be trusted to
|
||||
* keep the returned handle (slots parity — register hangs its own cleanup).
|
||||
* Everything else forwards through the generic guard.
|
||||
*/
|
||||
function guardedTheme(theme: ThemeRuntime, env: DynamicCordisGuardEnv, ctx: Context): unknown {
|
||||
return new Proxy(theme, {
|
||||
get(target, prop) {
|
||||
if (prop !== 'overrideTokens') {
|
||||
const value = Reflect.get(target, prop, target) as unknown
|
||||
if (typeof value !== 'function') return denyContext(value, 'theme', env)
|
||||
return (...args: unknown[]): unknown => {
|
||||
const result = Reflect.apply(value, target, args) as unknown
|
||||
if (result instanceof Promise) return result.then(resolved => denyContext(resolved, 'theme', env))
|
||||
return denyContext(result, 'theme', env)
|
||||
}
|
||||
}
|
||||
return (source: unknown, tokens: unknown): unknown => {
|
||||
// Two-argument shape preserved so the facade matches the documented
|
||||
// service signature; the source VALUE is replaced, never trusted.
|
||||
if (tokens === undefined && typeof source === 'object' && source !== null) {
|
||||
return rejectGuard(env,
|
||||
'theme.overrideTokens(source, tokens) takes two arguments; source is replaced with your package id, '
|
||||
+ 'so pass any string first and the token map second: overrideTokens(\'mine\', { \'--dsw-alias-…\': { light: \'…\', dark: \'…\' } })',
|
||||
)
|
||||
}
|
||||
const method = Reflect.get(target, 'overrideTokens', target)
|
||||
const dispose = Reflect.apply(method, target, [`${env.pkg.pluginId}.${env.pkg.packageId}`, tokens]) as () => void
|
||||
// Fiber-owned lifetime; the returned handle stays valid for early
|
||||
// removal (the service disposer is idempotent per layer identity).
|
||||
ctx.effect(() => dispose, 'cordis-client-runner: dynamic theme override layer')
|
||||
return dispose
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the facade one dynamic plugin's `apply` receives (host sandboxContext
|
||||
* twin, browser seats). `ctx.get(name)` performs optional lookup; direct
|
||||
* `ctx.serviceName` access is gated by the fiber's `inject` declaration.
|
||||
* @param ctx - the plugin's real fiber ctx (loader-created).
|
||||
* @param env - package row + ledger sink.
|
||||
* @returns the whitelisting proxy standing in for ctx.
|
||||
*/
|
||||
export function dynamicCordisContext(ctx: Context, env: DynamicCordisGuardEnv): Context {
|
||||
const declared = new Set(Object.keys(ctx.fiber.inject))
|
||||
const denyRead = (prop: string): never => {
|
||||
if (ctx.get(prop) !== undefined) {
|
||||
return rejectGuard(env,
|
||||
`service "${prop}" is not declared by your plugin. Declare it on the plugin you return: `
|
||||
+ `{ inject: ['${prop}', …], apply(ctx) { … } } — a plain \`function\` has no declaration site, `
|
||||
+ 'so use the object form. The runtime then parks the package if the provider unloads.',
|
||||
)
|
||||
}
|
||||
return rejectGuard(env,
|
||||
`dynamic ctx does not expose "${prop}". Available: ctx.on / ctx.provide / timer helpers after injecting timer, and any service your `
|
||||
+ 'returned plugin declared in inject (slots and theme are the usual UI seats). Framework internals are withheld '
|
||||
+ 'by design.',
|
||||
)
|
||||
}
|
||||
const readService = (name: string, requireDeclaration: boolean): unknown => {
|
||||
if (requireDeclaration && !declared.has(name)) return denyRead(name)
|
||||
const service = denyContext(ctx.get(name), name, env)
|
||||
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
|
||||
if (name === 'slots') return guardedSlots(service as SlotRegistry, env)
|
||||
if (name === 'theme') return guardedTheme(service as ThemeRuntime, env, ctx)
|
||||
return guardedService(service, name, env)
|
||||
}
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'get') return (name: string): unknown => readService(name, false)
|
||||
if (typeof prop !== 'string') return undefined
|
||||
// Lazy verb forwarder (host twin): resolve ctx[verb] only when called.
|
||||
if (CTX_VERBS.has(prop)) {
|
||||
return (...args: unknown[]): unknown => {
|
||||
if (TIMER_VERBS.has(prop) && !declared.has('timer')) return denyRead('timer')
|
||||
const method = ctx[prop as keyof Context]
|
||||
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
|
||||
}
|
||||
}
|
||||
return readService(prop, true)
|
||||
},
|
||||
set(_target, prop) {
|
||||
return rejectGuard(env, `dynamic ctx is read-only; cannot assign "${String(prop)}"`)
|
||||
},
|
||||
has: (_target, prop) => prop === 'get'
|
||||
|| (typeof prop === 'string'
|
||||
&& ((CTX_VERBS.has(prop) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))),
|
||||
}) as unknown as Context
|
||||
}
|
||||
|
||||
function rejectGuard(env: DynamicCordisGuardEnv, message: string): never {
|
||||
const error = new Error(message)
|
||||
env.reportFailure(error)
|
||||
throw error
|
||||
}
|
||||
308
packages/extensions/cordis-client-runner/src/client/index.ts
Normal file
308
packages/extensions/cordis-client-runner/src/client/index.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Dynamic-package runner, browser half: the load engine that turns one browser
|
||||
* half's source into a live cordis plugin (closure → guard → module table →
|
||||
* loader entry, ./runtime.ts), plus the retract announcement that unloads it.
|
||||
*
|
||||
* Nothing loads on activation: this page holds no dynamic package until a
|
||||
* dispatch arrives, and a dispatch only follows a model `cordis_run` or a user
|
||||
* pressing a card's start control. A refresh therefore starts clean by design —
|
||||
* host process memory still holds the definition, the page simply does not run
|
||||
* it until asked again.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPluginId, DynamicCordisInvokeResult, JsonValue,
|
||||
DynamicCordisInventoryRow,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// The Client Remote assembly is the one place the two planes meet: it mounts the
|
||||
// `dynamicCordisRunner` namespace and re-exports its payload vocabulary, so this
|
||||
// package names what it sends without importing a Host package.
|
||||
import type { DynamicCordisLivePackage } from './runtime.ts'
|
||||
import { DynamicCordisPackageRunner } from './runtime.ts'
|
||||
import { CordisRunOrchestrator } from './orchestrator.ts'
|
||||
import { ClientCordisInspectRegistry, provideClientCordisInspect } from './inspect-registry.ts'
|
||||
import { clientInspectProviders } from './providers.ts'
|
||||
import { provideClientTimer } from './timer.ts'
|
||||
import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest } from './orchestrator.ts'
|
||||
import type { CordisObservable, DynamicCordisRenderFailure } from './runtime.ts'
|
||||
|
||||
export { CordisRunOrchestrator } from './orchestrator.ts'
|
||||
export { ClientCordisInspectRegistry } from './inspect-registry.ts'
|
||||
export type {
|
||||
ClientCordisInspectHost, ClientCordisInspectProviderRegistration, ClientCordisInspectQueryContext,
|
||||
} from './inspect-registry.ts'
|
||||
export type {
|
||||
CordisRunActivity, CordisRunFailure, CordisRunHostSeam,
|
||||
CordisRunOrchestratorEnv, CordisRunRequest, CordisUserRunRequest,
|
||||
} from './orchestrator.ts'
|
||||
export { DynamicCordisPackageRunner } from './runtime.ts'
|
||||
export type {
|
||||
CordisObservable, DynamicCordisClientHalf, DynamicCordisLivePackage, DynamicCordisLoadErrorCause,
|
||||
DynamicCordisLoadResult, DynamicCordisRenderFailure, DynamicCordisRunnerEnv,
|
||||
} from './runtime.ts'
|
||||
|
||||
export { DynamicCordisStyles, evaluateClientHalf, isDynamicCordisPlugin } from './evaluator.ts'
|
||||
export type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from './evaluator.ts'
|
||||
export { dynamicCordisContext } from './guard.ts'
|
||||
export type { DynamicCordisGuardEnv, DynamicCordisSlotLedgerRow } from './guard.ts'
|
||||
export { ClientTimerService } from './timer.ts'
|
||||
// Re-exported so consumers of the service face and the two events can name
|
||||
// their subjects without reaching into the wire contract themselves.
|
||||
export type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
DynamicCordisPackage,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
|
||||
/**
|
||||
* What a run surface reads and calls. The activity map is the single home of
|
||||
* "a run is in flight", so an affordance never keeps its own copy — that is what
|
||||
* makes it survive a remount.
|
||||
*/
|
||||
export interface CordisRunnerFace {
|
||||
/** Each definition's in-flight run activity. */
|
||||
readonly activeRuns: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>>
|
||||
/** The last failure of this page's own run attempt, per definition. */
|
||||
readonly lastRunError: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>>
|
||||
/**
|
||||
* This page's last render crash per definition: a browser half that loaded
|
||||
* cleanly and then broke while React rendered it. Page-local and current by
|
||||
* construction — cleared when the package stops, is retracted, or loads again —
|
||||
* which is what makes it safe for a row to render directly. The host keeps its
|
||||
* own last-across-pages copy for the model; the two have different owners and
|
||||
* lifetimes and neither is derived from the other.
|
||||
*/
|
||||
readonly renderFailures: CordisObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>>
|
||||
/**
|
||||
* Restore pending approvals after a page reconnect or missed event.
|
||||
* @param rows - current dynamic Plugin inventory.
|
||||
*/
|
||||
reconcileApprovals(rows: readonly DynamicCordisInventoryRow[]): void
|
||||
/**
|
||||
* Answer one run request with "run it" and drive both halves.
|
||||
* @param requestId - the request being answered; unknown or settled ids are a no-op.
|
||||
* @param approveFutureVersions - whether this decision covers later Packages of the same Plugin.
|
||||
* @returns after the orchestration settled.
|
||||
*/
|
||||
approve(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void>
|
||||
/**
|
||||
* Answer one run request with "do not run it".
|
||||
* @param requestId - the request being answered; unknown or settled ids are a no-op.
|
||||
* @returns after the refusal reached the host.
|
||||
*/
|
||||
decline(requestId: ApprovalRequestId): Promise<void>
|
||||
/**
|
||||
* Run a definition here at the user's own request (the gesture authorizes it).
|
||||
* A definition with a browser half also loads onto this page; a host-only one
|
||||
* only comes up in the host process.
|
||||
* @param request - the definition to run, its session, and whether it has a browser half.
|
||||
* @returns after the orchestration settled.
|
||||
*/
|
||||
startUserRun(request: CordisUserRunRequest): Promise<void>
|
||||
/**
|
||||
* Observe what this page has loaded.
|
||||
* @param fn - notified after every converged load or unload.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
/**
|
||||
* Read what this page currently has loaded.
|
||||
* @returns immutable rows for live Client halves.
|
||||
*/
|
||||
getSnapshot(): readonly DynamicCordisLivePackage[]
|
||||
/**
|
||||
* Whether this page loaded a definition's browser half — page-local truth,
|
||||
* never the host's "it is running".
|
||||
* @param pluginId - stable Plugin identity.
|
||||
* @returns true while a load is live here.
|
||||
*/
|
||||
isLoaded(pluginId: CordisDynamicPluginId): boolean
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Run orchestration and page-local load state: what run surfaces read and call. */
|
||||
dynamicCordisRunner: CordisRunnerFace
|
||||
}
|
||||
}
|
||||
|
||||
/** Teaching text for a routing failure the infrastructure itself reports. */
|
||||
function invokeFailure(pluginId: CordisDynamicPluginId, method: string, result: Extract<DynamicCordisInvokeResult, { ok: false }>): string {
|
||||
const where = `host.call("${method}") on ${pluginId}`
|
||||
if (result.code === 'plugin-not-running') {
|
||||
return `${where} found no active Host half — the Plugin is stopped or was removed.`
|
||||
}
|
||||
if (result.code === 'stale-run') {
|
||||
return `${where} belongs to an activation that has already been replaced.`
|
||||
}
|
||||
if (result.code === 'method-not-found') {
|
||||
return `${where} is not registered: the host half must declare it with harness.handle("${method}", fn).`
|
||||
}
|
||||
return `${where} failed inside the host handler: ${result.message}`
|
||||
}
|
||||
|
||||
/** Preserve a Host handler's stack while adding the Client call site diagnosis. */
|
||||
function invokeError(
|
||||
pluginId: CordisDynamicPluginId,
|
||||
method: string,
|
||||
result: Extract<DynamicCordisInvokeResult, { ok: false }>,
|
||||
): Error {
|
||||
const error = new Error(invokeFailure(pluginId, method, result))
|
||||
if (result.stack !== undefined) error.stack = `${error.stack ?? error.message}\nHost stack:\n${result.stack}`
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Teaching text for a `host.call` the wire itself refused: the generated codec
|
||||
* rejected the argument before sending, or the result on the way back, or the
|
||||
* transport broke. The infrastructure's message names the field it refused but
|
||||
* not the call it belonged to, and the model authored both halves — so this adds
|
||||
* the call and the contract it has to satisfy.
|
||||
*/
|
||||
function wireFailure(id: CordisDynamicPluginId, method: string, error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `host.call("${method}") on ${id} did not complete: ${message}\n`
|
||||
+ 'Both directions carry JSON only: pass plain JSON data as the argument — or omit it, and the handler receives '
|
||||
+ `null — and answer from harness.handle("${method}", fn) with JSON (\`return null\` when there is nothing to report).`
|
||||
}
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'cordis-client-runner'
|
||||
|
||||
/**
|
||||
* Required services: the loader/module chain for entries, the slot registry for
|
||||
* contributions, and the `dynamicCordisRunner` Remote namespace. Declaring the
|
||||
* namespace parks this plugin until the host side exists, so a page never loads
|
||||
* a browser half whose host half it could not reach.
|
||||
*/
|
||||
export const inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCordisRunner']
|
||||
|
||||
/**
|
||||
* Client plugin body: build the runner and subscribe the dispatch family.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
provideClientTimer(ctx)
|
||||
const inspect = new ClientCordisInspectRegistry({
|
||||
sync: async (providers) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.syncInspectManifest(providers)
|
||||
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
|
||||
},
|
||||
resolve: async (agentId, requestId, resolution) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.resolveInspectQuery(agentId, requestId, resolution)
|
||||
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
|
||||
},
|
||||
})
|
||||
provideClientCordisInspect(ctx, inspect)
|
||||
for (const provider of clientInspectProviders(ctx)) {
|
||||
ctx.effect(() => inspect.register(provider), `cordis-client-runner: inspect ${provider.manifest.id}`)
|
||||
}
|
||||
ctx.on('connection/reset', () => { inspect.publish() })
|
||||
|
||||
const runner = new DynamicCordisPackageRunner({
|
||||
ctx,
|
||||
loader: ctx.loader,
|
||||
modules: ctx.get('modules') as ClientModuleSystem,
|
||||
slots: ctx.get('slots') as SlotRegistry,
|
||||
invoke: async (pluginId, pluginRunId, method, args) => {
|
||||
// Model-authored arguments reach this boundary untyped; the namespace's
|
||||
// generated codec is what validates them as JSON, and its rejection is a
|
||||
// bare field name — this is the only place that still knows which call it
|
||||
// belonged to, so the teaching has to be added here.
|
||||
const answered = await ctx.remote.dynamicCordisRunner.invoke(pluginId, pluginRunId, method, args as JsonValue)
|
||||
.catch((error: unknown) => { throw new Error(wireFailure(pluginId, method, error)) })
|
||||
// Two failure layers, and they teach different things: the carrier's error
|
||||
// branch means the call never reached the host half, while the namespace's
|
||||
// own `ok: false` is that half answering with a refusal.
|
||||
if (!answered.ok) throw new Error(wireFailure(pluginId, method, `${answered.error.code}: ${answered.error.message}`))
|
||||
const result = answered.value
|
||||
if (result.ok) return result.value
|
||||
throw invokeError(pluginId, method, result)
|
||||
},
|
||||
// Post-settle diagnosis, deliberately fire-and-forget: the run this package
|
||||
// belongs to was answered before it ever rendered, so nothing waits on this
|
||||
// and a failed report must not turn one crash into two.
|
||||
reportRenderFailure: (agentId, pluginId, pluginRunId, failure) => {
|
||||
void ctx.remote.dynamicCordisRunner.reportRenderFailure(agentId, pluginId, pluginRunId, failure).then((result) => {
|
||||
if (!result.ok) {
|
||||
console.error(`[cordis-client-runner] reporting a render failure of ${pluginId} failed:`, result.error)
|
||||
}
|
||||
}, (error: unknown) => {
|
||||
console.error(`[cordis-client-runner] reporting a render failure of ${pluginId} failed:`, error)
|
||||
})
|
||||
},
|
||||
reportGuardFailure: (agentId, pluginId, pluginRunId, failure) => {
|
||||
void ctx.remote.dynamicCordisRunner.reportClientGuardFailure(agentId, pluginId, pluginRunId, failure).then((result) => {
|
||||
if (!result.ok) {
|
||||
console.error(`[cordis-client-runner] reporting a guard failure of ${pluginId} failed:`, result.error)
|
||||
}
|
||||
}, (error: unknown) => {
|
||||
console.error(`[cordis-client-runner] reporting a guard failure of ${pluginId} failed:`, error)
|
||||
})
|
||||
},
|
||||
})
|
||||
const orchestrator = new CordisRunOrchestrator({
|
||||
runner,
|
||||
host: {
|
||||
// The seam names business payloads only, so a carrier failure is folded
|
||||
// here into whatever each verb already does with one: the short-circuit
|
||||
// message for a start, a throw where the caller has a catch of its own.
|
||||
runHostHalf: async (agentId, pluginId, packageId, mode, requestId, approveFutureVersions) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.runHostHalf(
|
||||
agentId, pluginId, packageId, mode, requestId, approveFutureVersions,
|
||||
)
|
||||
return answered.ok ? answered.value : { ok: false, message: `${answered.error.code}: ${answered.error.message}` }
|
||||
},
|
||||
getClientCode: async (agentId, pluginId, pluginRunId) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.getClientCode(agentId, pluginId, pluginRunId)
|
||||
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
|
||||
return answered.value
|
||||
},
|
||||
resolveRequestRun: async (requestId, resolution) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.resolveRequestRun(requestId, resolution)
|
||||
// Thrown rather than returned: `answer` logs and drops a failed answer,
|
||||
// and the host settles the request on its own either way.
|
||||
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
|
||||
return answered.value
|
||||
},
|
||||
settleUserRun: async (agentId, pluginId, resolution) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.settleUserRun(agentId, pluginId, resolution)
|
||||
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
|
||||
return answered.value
|
||||
},
|
||||
},
|
||||
})
|
||||
const face: CordisRunnerFace = {
|
||||
activeRuns: orchestrator.activeRuns,
|
||||
lastRunError: orchestrator.lastRunError,
|
||||
renderFailures: runner.renderFailures,
|
||||
reconcileApprovals: (rows) => { orchestrator.reconcileApprovals(rows) },
|
||||
approve: (requestId, approveFutureVersions) => orchestrator.approve(requestId, approveFutureVersions),
|
||||
decline: requestId => orchestrator.decline(requestId),
|
||||
startUserRun: request => orchestrator.startUserRun(request),
|
||||
subscribe: fn => runner.subscribe(fn),
|
||||
getSnapshot: () => runner.getSnapshot(),
|
||||
isLoaded: id => runner.isLoaded(id),
|
||||
}
|
||||
ctx.provide('dynamicCordisRunner', face)
|
||||
ctx.effect(() => () => { void runner.dispose() }, 'cordis-client-runner: dynamic package runner')
|
||||
|
||||
// Forwarded Host events: `$on` hands the listener the Host's own argument list,
|
||||
// so these read the request itself rather than a transport envelope.
|
||||
ctx.remote.$on('cordis/request-run', (request) => {
|
||||
orchestrator.open(request)
|
||||
})
|
||||
ctx.remote.$on('cordis/request-run-resolved', (resolved) => { orchestrator.close(resolved.requestId) })
|
||||
ctx.remote.$on('cordis/dynamic-retract', (retracted) => {
|
||||
runner.retract(retracted.pluginId, retracted.pluginRunId)
|
||||
})
|
||||
ctx.remote.$on('cordis/inspect-query', (request) => {
|
||||
void inspect.query(request).catch((error: unknown) => {
|
||||
console.error(`[cordis-client-runner] inspect query ${request.provider}.${request.method} failed:`, error)
|
||||
})
|
||||
})
|
||||
ctx.remote.$on('cordis/inspect-query-resolved', (resolved) => { inspect.close(resolved.requestId) })
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/** Browser registry for read-only Cordis capability providers. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
CordisInspectProviderManifest, CordisInspectQueryRequest, CordisInspectQueryResolution,
|
||||
CordisInspectRequestId, JsonValue,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Context supplied to a Client inspect provider query. */
|
||||
export interface ClientCordisInspectQueryContext {
|
||||
/** Cancellation broadcast by the Host. */
|
||||
signal: AbortSignal
|
||||
/** Session whose model requested the query. */
|
||||
sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Client provider registration retained beside its serializable manifest. */
|
||||
export interface ClientCordisInspectProviderRegistration {
|
||||
/** Provider and explicit query directory. */
|
||||
manifest: CordisInspectProviderManifest
|
||||
/** Execute one declared read-only method. */
|
||||
query(method: string, input: JsonValue | undefined, context: ClientCordisInspectQueryContext): Promise<JsonValue>
|
||||
}
|
||||
|
||||
/** Remote operations needed by the Client registry. */
|
||||
export interface ClientCordisInspectHost {
|
||||
/** Replace the Host's mirrored Client manifest. */
|
||||
sync(providers: readonly CordisInspectProviderManifest[]): Promise<void>
|
||||
/** Submit one query result; the first accepted page wins. */
|
||||
resolve(
|
||||
sessionId: SessionId,
|
||||
requestId: CordisInspectRequestId,
|
||||
resolution: CordisInspectQueryResolution,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
/** Client provider registry, manifest publisher, and live query dispatcher. */
|
||||
export class ClientCordisInspectRegistry {
|
||||
private readonly providers = new Map<string, ClientCordisInspectProviderRegistration>()
|
||||
private readonly active = new Map<CordisInspectRequestId, AbortController>()
|
||||
private publishQueued = false
|
||||
private syncChain = Promise.resolve()
|
||||
|
||||
/** @param host - folded manifest and query result transport. */
|
||||
constructor(private readonly host: ClientCordisInspectHost) {}
|
||||
|
||||
/**
|
||||
* Register one Client provider and publish a new complete manifest.
|
||||
* @param registration - provider manifest and local handler.
|
||||
* @returns idempotent disposer.
|
||||
*/
|
||||
register(registration: ClientCordisInspectProviderRegistration): () => void {
|
||||
const { manifest } = registration
|
||||
if (manifest.id.trim() === '') throw new Error('Client Cordis inspect provider id must not be empty')
|
||||
if (this.providers.has(manifest.id)) throw new Error(`Client Cordis inspect provider "${manifest.id}" is already registered`)
|
||||
const names = new Set<string>()
|
||||
for (const method of manifest.methods) {
|
||||
if (names.has(method.name)) throw new Error(`Client Cordis inspect provider "${manifest.id}" repeats method "${method.name}"`)
|
||||
names.add(method.name)
|
||||
}
|
||||
this.providers.set(manifest.id, registration)
|
||||
this.publish()
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
if (this.providers.get(manifest.id) === registration) {
|
||||
this.providers.delete(manifest.id)
|
||||
this.publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the current complete manifest, including after reconnect. */
|
||||
publish(): void {
|
||||
if (this.publishQueued) return
|
||||
this.publishQueued = true
|
||||
queueMicrotask(() => {
|
||||
this.publishQueued = false
|
||||
const manifests = [...this.providers.values()].map(provider => provider.manifest)
|
||||
this.syncChain = this.syncChain.then(async () => {
|
||||
await this.host.sync(manifests)
|
||||
}).catch((error: unknown) => {
|
||||
console.error('[cordis-client-runner] syncing inspect providers failed:', error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute and answer one Host-broadcast query.
|
||||
* @param request - exact provider query and Session correlation received from Host.
|
||||
* @returns after the first local result has been sent back to Host.
|
||||
*/
|
||||
async query(request: CordisInspectQueryRequest): Promise<void> {
|
||||
if (this.active.has(request.requestId)) return
|
||||
const controller = new AbortController()
|
||||
this.active.set(request.requestId, controller)
|
||||
let resolution: CordisInspectQueryResolution
|
||||
try {
|
||||
const provider = this.providers.get(request.provider)
|
||||
if (provider === undefined) {
|
||||
resolution = { ok: false, reason: 'provider-missing', message: `Client inspect provider "${request.provider}" is unavailable` }
|
||||
} else if (!provider.manifest.methods.some(method => method.name === request.method)) {
|
||||
resolution = { ok: false, reason: 'method-missing', message: `Client inspect provider "${request.provider}" has no method "${request.method}"` }
|
||||
} else {
|
||||
const data = await provider.query(request.method, request.input, {
|
||||
signal: controller.signal,
|
||||
sessionId: request.agentId,
|
||||
})
|
||||
resolution = controller.signal.aborted
|
||||
? { ok: false, reason: 'cancelled', message: 'Client inspect query was cancelled' }
|
||||
: { ok: true, data }
|
||||
}
|
||||
} catch (error) {
|
||||
resolution = controller.signal.aborted
|
||||
? { ok: false, reason: 'cancelled', message: 'Client inspect query was cancelled' }
|
||||
: { ok: false, reason: 'provider-error', message: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
this.active.delete(request.requestId)
|
||||
}
|
||||
if (controller.signal.aborted) return
|
||||
await this.host.resolve(request.agentId, request.requestId, resolution)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel local work after another page answered or the Tool call ended.
|
||||
* @param requestId - query correlation that is no longer answerable.
|
||||
*/
|
||||
close(requestId: CordisInspectRequestId): void {
|
||||
this.active.get(requestId)?.abort()
|
||||
this.active.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Browser registry for pre-definition Cordis capability discovery. */
|
||||
cordisInspect: ClientCordisInspectRegistry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the registry as a normal Client service.
|
||||
* @param ctx - Client Cordis context receiving the service.
|
||||
* @param registry - page-local inspect registry to publish.
|
||||
*/
|
||||
export function provideClientCordisInspect(ctx: Context, registry: ClientCordisInspectRegistry): void {
|
||||
ctx.provide('cordisInspect', registry)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Page-side run orchestration for model approvals and direct panel gestures.
|
||||
* Host activation always precedes Client loading. The same Plugin-keyed state
|
||||
* drives every surface, so remounting a panel never loses an open approval or
|
||||
* an in-flight transition.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApprovalRequestId,
|
||||
CordisDynamicPackageId,
|
||||
CordisDynamicPluginId,
|
||||
CordisDynamicPluginRunId,
|
||||
CordisDynamicRunMode,
|
||||
DynamicCordisClientSource,
|
||||
DynamicCordisHostHalfResult,
|
||||
DynamicCordisInventoryRow,
|
||||
DynamicCordisResolveAck,
|
||||
DynamicCordisRunResolution,
|
||||
DynamicCordisRunResponse,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { errorDetails } from './runtime.ts'
|
||||
import type { CordisErrorDetails, CordisObservable, DynamicCordisPackageRunner } from './runtime.ts'
|
||||
|
||||
/** One Plugin's in-flight approval or activation. */
|
||||
export type CordisRunActivity =
|
||||
| {
|
||||
phase: 'awaiting-approval'
|
||||
requestId: ApprovalRequestId
|
||||
agentId: SessionId
|
||||
packageId: CordisDynamicPackageId
|
||||
mode: CordisDynamicRunMode
|
||||
name: string
|
||||
purpose: string
|
||||
}
|
||||
| {
|
||||
phase: 'orchestrating'
|
||||
agentId: SessionId
|
||||
packageId: CordisDynamicPackageId
|
||||
mode: CordisDynamicRunMode
|
||||
}
|
||||
|
||||
/** Why this page's latest activation attempt failed. */
|
||||
export interface CordisRunFailure {
|
||||
/** Package the attempt targeted. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Which half or settlement stage failed. */
|
||||
reason: 'host-half-failed' | 'client-half-failed'
|
||||
/** Actionable failure text. */
|
||||
message: string
|
||||
/** Original failure stack when available. */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
/** Host operations consumed by the orchestrator after transport folding. */
|
||||
export interface CordisRunHostSeam {
|
||||
/** Start a new Host activation or attach this page to an existing one. */
|
||||
runHostHalf(
|
||||
agentId: SessionId,
|
||||
pluginId: CordisDynamicPluginId,
|
||||
packageId: CordisDynamicPackageId,
|
||||
mode: CordisDynamicRunMode,
|
||||
requestId: ApprovalRequestId | null,
|
||||
approveFutureVersions: boolean,
|
||||
): Promise<DynamicCordisHostHalfResult>
|
||||
/** Fetch Client source for one exact active run. */
|
||||
getClientCode(
|
||||
agentId: SessionId,
|
||||
pluginId: CordisDynamicPluginId,
|
||||
pluginRunId: CordisDynamicPluginRunId,
|
||||
): Promise<DynamicCordisClientSource>
|
||||
/** Settle a model-driven approval. */
|
||||
resolveRequestRun(
|
||||
requestId: ApprovalRequestId,
|
||||
resolution: DynamicCordisRunResolution,
|
||||
): Promise<DynamicCordisResolveAck>
|
||||
/** Settle a direct panel activation after this page handles its Client half. */
|
||||
settleUserRun(
|
||||
agentId: SessionId,
|
||||
pluginId: CordisDynamicPluginId,
|
||||
resolution: DynamicCordisRunResolution,
|
||||
): Promise<DynamicCordisRunResponse>
|
||||
}
|
||||
|
||||
/** Dependencies of one page's orchestrator. */
|
||||
export interface CordisRunOrchestratorEnv {
|
||||
/** Page-local Client loader. */
|
||||
runner: DynamicCordisPackageRunner
|
||||
/** Folded Host RPC operations. */
|
||||
host: CordisRunHostSeam
|
||||
}
|
||||
|
||||
/** Forwarded approval request fields used by this page. */
|
||||
export interface CordisRunRequest {
|
||||
requestId: ApprovalRequestId
|
||||
agentId: SessionId
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
mode: CordisDynamicRunMode
|
||||
name: string
|
||||
purpose: string
|
||||
requiresApproval: boolean
|
||||
}
|
||||
|
||||
/** Direct panel activation request. */
|
||||
export interface CordisUserRunRequest {
|
||||
agentId: SessionId
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
mode: CordisDynamicRunMode
|
||||
/** Host-only Packages finish without a Client load or settlement call. */
|
||||
hasClientHalf: boolean
|
||||
}
|
||||
|
||||
interface RunPlan extends CordisUserRunRequest {
|
||||
requestId?: ApprovalRequestId
|
||||
approveFutureVersions?: boolean
|
||||
}
|
||||
|
||||
/** Drives Host → Client activation and publishes Plugin-keyed activity. */
|
||||
export class CordisRunOrchestrator {
|
||||
private readonly requests = new Map<ApprovalRequestId, CordisRunRequest>()
|
||||
private readonly activity = new Map<CordisDynamicPluginId, CordisRunActivity>()
|
||||
private readonly failures = new Map<CordisDynamicPluginId, CordisRunFailure>()
|
||||
private readonly inFlight = new Map<CordisDynamicPluginId, Promise<void>>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private activityCache: ReadonlyMap<CordisDynamicPluginId, CordisRunActivity> | undefined
|
||||
private failureCache: ReadonlyMap<CordisDynamicPluginId, CordisRunFailure> | undefined
|
||||
|
||||
/** @param env - Client loader and folded Host operations. */
|
||||
constructor(private readonly env: CordisRunOrchestratorEnv) {}
|
||||
|
||||
/** Open approvals and current activation attempts, keyed by stable Plugin ID. */
|
||||
readonly activeRuns: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>> = {
|
||||
getSnapshot: () => this.activityCache ??= new Map(this.activity),
|
||||
subscribe: fn => this.observe(fn),
|
||||
}
|
||||
|
||||
/** Latest page-side activation failure for each Plugin. */
|
||||
readonly lastRunError: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>> = {
|
||||
getSnapshot: () => this.failureCache ??= new Map(this.failures),
|
||||
subscribe: fn => this.observe(fn),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a Client activation request, starting it immediately when the Plugin is already authorized.
|
||||
* @param request - forwarded approval and activation metadata.
|
||||
*/
|
||||
open(request: CordisRunRequest): void {
|
||||
this.requests.set(request.requestId, request)
|
||||
if (!request.requiresApproval) {
|
||||
void this.orchestrate({
|
||||
agentId: request.agentId,
|
||||
pluginId: request.pluginId,
|
||||
packageId: request.packageId,
|
||||
mode: request.mode,
|
||||
requestId: request.requestId,
|
||||
hasClientHalf: true,
|
||||
}).catch((error: unknown) => {
|
||||
console.error(`[cordis-client-runner] automatic activation ${request.requestId} failed:`, error)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.activity.get(request.pluginId)?.phase !== 'orchestrating') {
|
||||
this.activity.set(request.pluginId, {
|
||||
phase: 'awaiting-approval',
|
||||
requestId: request.requestId,
|
||||
agentId: request.agentId,
|
||||
packageId: request.packageId,
|
||||
mode: request.mode,
|
||||
name: request.name,
|
||||
purpose: request.purpose,
|
||||
})
|
||||
}
|
||||
this.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild pending approvals and automatic Client activations from an authoritative Host inventory read.
|
||||
* @param rows - complete process-wide Plugin inventory.
|
||||
*/
|
||||
reconcileApprovals(rows: readonly DynamicCordisInventoryRow[]): void {
|
||||
const expected = new Map<ApprovalRequestId, CordisRunRequest>()
|
||||
for (const row of rows) {
|
||||
const attempt = row.latestRun
|
||||
if (attempt?.approvalRequestId === undefined
|
||||
|| (attempt.status !== 'awaiting-approval'
|
||||
&& attempt.status !== 'starting-host'
|
||||
&& attempt.status !== 'client-pending')) continue
|
||||
const pkg = row.packages.find(candidate => candidate.packageId === attempt.packageId)
|
||||
if (pkg === undefined) continue
|
||||
expected.set(attempt.approvalRequestId, {
|
||||
requestId: attempt.approvalRequestId,
|
||||
agentId: row.agentId,
|
||||
pluginId: row.pluginId,
|
||||
packageId: attempt.packageId,
|
||||
mode: attempt.mode,
|
||||
name: pkg.name,
|
||||
purpose: pkg.purpose,
|
||||
requiresApproval: attempt.requiresApproval ?? attempt.status === 'awaiting-approval',
|
||||
})
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [requestId, request] of [...this.requests]) {
|
||||
if (expected.has(requestId)) continue
|
||||
this.requests.delete(requestId)
|
||||
const current = this.activity.get(request.pluginId)
|
||||
if (current?.phase === 'awaiting-approval' && current.requestId === requestId) {
|
||||
this.activity.delete(request.pluginId)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
for (const [requestId, request] of expected) {
|
||||
const previous = this.requests.get(requestId)
|
||||
const current = this.activity.get(request.pluginId)
|
||||
if (!request.requiresApproval && current?.phase === 'orchestrating') continue
|
||||
if (request.requiresApproval
|
||||
&& sameRequest(previous, request)
|
||||
&& current?.phase === 'awaiting-approval'
|
||||
&& current.requestId === requestId) continue
|
||||
if (!request.requiresApproval) {
|
||||
this.open(request)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
this.requests.set(requestId, request)
|
||||
if (current?.phase !== 'orchestrating') {
|
||||
this.activity.set(request.pluginId, {
|
||||
phase: 'awaiting-approval',
|
||||
requestId,
|
||||
agentId: request.agentId,
|
||||
packageId: request.packageId,
|
||||
mode: request.mode,
|
||||
name: request.name,
|
||||
purpose: request.purpose,
|
||||
})
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an approval settled by another page or by cancellation.
|
||||
* @param requestId - approval request that can no longer be answered here.
|
||||
*/
|
||||
close(requestId: ApprovalRequestId): void {
|
||||
const request = this.requests.get(requestId)
|
||||
if (request === undefined) return
|
||||
this.requests.delete(requestId)
|
||||
const current = this.activity.get(request.pluginId)
|
||||
if (current?.phase === 'awaiting-approval' && current.requestId === requestId) {
|
||||
this.activity.delete(request.pluginId)
|
||||
}
|
||||
this.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve and execute one still-open model request.
|
||||
* @param requestId - approval request to execute.
|
||||
* @param approveFutureVersions - whether this approval covers later Packages for the same Plugin.
|
||||
*/
|
||||
approve(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void> {
|
||||
const request = this.requests.get(requestId)
|
||||
if (request === undefined || !request.requiresApproval) return Promise.resolve()
|
||||
return this.orchestrate({
|
||||
agentId: request.agentId,
|
||||
pluginId: request.pluginId,
|
||||
packageId: request.packageId,
|
||||
mode: request.mode,
|
||||
requestId,
|
||||
approveFutureVersions,
|
||||
hasClientHalf: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject one still-open model request without executing either half.
|
||||
* @param requestId - approval request to reject.
|
||||
*/
|
||||
async decline(requestId: ApprovalRequestId): Promise<void> {
|
||||
const request = this.requests.get(requestId)
|
||||
if (request === undefined || !request.requiresApproval) return
|
||||
const current = this.activity.get(request.pluginId)
|
||||
if (current?.phase !== 'awaiting-approval' || current.requestId !== requestId) return
|
||||
this.requests.delete(requestId)
|
||||
this.activity.delete(request.pluginId)
|
||||
this.commit()
|
||||
await this.answer(requestId, { ok: false, reason: 'rejected' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a direct panel run; the user gesture itself authorizes it.
|
||||
* @param request - exact Package activation selected by the user.
|
||||
*/
|
||||
startUserRun(request: CordisUserRunRequest): Promise<void> {
|
||||
return this.orchestrate(request)
|
||||
}
|
||||
|
||||
private observe(fn: () => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => { this.listeners.delete(fn) }
|
||||
}
|
||||
|
||||
private commit(): void {
|
||||
this.activityCache = undefined
|
||||
this.failureCache = undefined
|
||||
for (const fn of [...this.listeners]) fn()
|
||||
}
|
||||
|
||||
private orchestrate(plan: RunPlan): Promise<void> {
|
||||
const running = this.inFlight.get(plan.pluginId)
|
||||
if (running !== undefined) return running
|
||||
this.activity.set(plan.pluginId, {
|
||||
phase: 'orchestrating',
|
||||
agentId: plan.agentId,
|
||||
packageId: plan.packageId,
|
||||
mode: plan.mode,
|
||||
})
|
||||
this.failures.delete(plan.pluginId)
|
||||
if (plan.requestId !== undefined) this.requests.delete(plan.requestId)
|
||||
this.commit()
|
||||
const attempt = this.drive(plan).finally(() => {
|
||||
this.inFlight.delete(plan.pluginId)
|
||||
this.activity.delete(plan.pluginId)
|
||||
this.commit()
|
||||
})
|
||||
this.inFlight.set(plan.pluginId, attempt)
|
||||
return attempt
|
||||
}
|
||||
|
||||
private async drive(plan: RunPlan): Promise<void> {
|
||||
const started = await this.startHost(plan)
|
||||
if (!started.ok) {
|
||||
this.fail(plan, 'host-half-failed', started)
|
||||
if (plan.requestId !== undefined) {
|
||||
await this.answer(plan.requestId, { ...started, reason: 'host-half-failed' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!plan.hasClientHalf) return
|
||||
|
||||
let source: DynamicCordisClientSource
|
||||
try {
|
||||
source = await this.env.host.getClientCode(plan.agentId, plan.pluginId, started.pluginRunId)
|
||||
} catch (error) {
|
||||
await this.finishClientFailure(plan, started.pluginRunId, started.startedHere, errorDetails(error), error)
|
||||
return
|
||||
}
|
||||
const loaded = await this.env.runner.load({
|
||||
pluginId: source.pluginId,
|
||||
packageId: source.packageId,
|
||||
pluginRunId: source.pluginRunId,
|
||||
agentId: plan.agentId,
|
||||
name: source.name,
|
||||
code: source.code,
|
||||
}).catch((error: unknown) => ({ ok: false, cause: 'evaluate', ...errorDetails(error), error }) as const)
|
||||
if (!loaded.ok) {
|
||||
await this.finishClientFailure(
|
||||
plan,
|
||||
started.pluginRunId,
|
||||
started.startedHere,
|
||||
{
|
||||
message: `${loaded.cause}: ${loaded.message}`,
|
||||
...loaded.stack === undefined ? {} : { stack: loaded.stack },
|
||||
},
|
||||
loaded.error,
|
||||
)
|
||||
return
|
||||
}
|
||||
const resolution: DynamicCordisRunResolution = {
|
||||
ok: true,
|
||||
pluginRunId: loaded.pluginRunId,
|
||||
...loaded.waitingFor === undefined ? {} : { waitingFor: loaded.waitingFor },
|
||||
}
|
||||
if (plan.requestId !== undefined) {
|
||||
await this.answer(plan.requestId, resolution)
|
||||
return
|
||||
}
|
||||
await this.settleDirect(plan, resolution)
|
||||
}
|
||||
|
||||
private async startHost(plan: RunPlan): Promise<DynamicCordisHostHalfResult> {
|
||||
try {
|
||||
return await this.env.host.runHostHalf(
|
||||
plan.agentId,
|
||||
plan.pluginId,
|
||||
plan.packageId,
|
||||
plan.mode,
|
||||
plan.requestId ?? null,
|
||||
plan.approveFutureVersions ?? false,
|
||||
)
|
||||
} catch (error) {
|
||||
return { ok: false, ...errorDetails(error) }
|
||||
}
|
||||
}
|
||||
|
||||
private async finishClientFailure(
|
||||
plan: RunPlan,
|
||||
pluginRunId: CordisDynamicPluginRunId,
|
||||
startedHere: boolean,
|
||||
failure: CordisErrorDetails,
|
||||
originalError?: unknown,
|
||||
): Promise<void> {
|
||||
console.error(
|
||||
`[cordis-client-runner] Client activation ${plan.pluginId}/${plan.packageId} (${pluginRunId}) failed:`,
|
||||
originalError ?? failure,
|
||||
)
|
||||
this.fail(plan, 'client-half-failed', failure)
|
||||
const resolution: DynamicCordisRunResolution = {
|
||||
ok: false,
|
||||
reason: 'client-half-failed',
|
||||
pluginRunId,
|
||||
startedHere,
|
||||
...failure,
|
||||
}
|
||||
if (plan.requestId !== undefined) await this.answer(plan.requestId, resolution)
|
||||
else await this.settleDirect(plan, resolution)
|
||||
}
|
||||
|
||||
private async settleDirect(plan: RunPlan, resolution: DynamicCordisRunResolution): Promise<void> {
|
||||
try {
|
||||
const response = await this.env.host.settleUserRun(plan.agentId, plan.pluginId, resolution)
|
||||
if (!response.ok) this.fail(plan, 'client-half-failed', response)
|
||||
} catch (error) {
|
||||
this.fail(plan, 'client-half-failed', errorDetails(error))
|
||||
}
|
||||
}
|
||||
|
||||
private async answer(requestId: ApprovalRequestId, resolution: DynamicCordisRunResolution): Promise<void> {
|
||||
try {
|
||||
await this.env.host.resolveRequestRun(requestId, resolution)
|
||||
} catch (error) {
|
||||
console.error(`[cordis-client-runner] answering run request ${requestId} failed:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
private fail(
|
||||
plan: Pick<RunPlan, 'pluginId' | 'packageId'>,
|
||||
reason: CordisRunFailure['reason'],
|
||||
failure: CordisErrorDetails,
|
||||
): void {
|
||||
this.failures.set(plan.pluginId, { packageId: plan.packageId, reason, ...failure })
|
||||
this.commit()
|
||||
}
|
||||
}
|
||||
|
||||
function sameRequest(left: CordisRunRequest | undefined, right: CordisRunRequest): boolean {
|
||||
return left?.requestId === right.requestId
|
||||
&& left.agentId === right.agentId
|
||||
&& left.pluginId === right.pluginId
|
||||
&& left.packageId === right.packageId
|
||||
&& left.mode === right.mode
|
||||
&& left.name === right.name
|
||||
&& left.purpose === right.purpose
|
||||
&& left.requiresApproval === right.requiresApproval
|
||||
}
|
||||
245
packages/extensions/cordis-client-runner/src/client/providers.ts
Normal file
245
packages/extensions/cordis-client-runner/src/client/providers.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/** Built-in Client inspect providers over live Client-owned services. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { queryEventApi, queryServiceApi } from './api-catalog.ts'
|
||||
import type { ClientCordisInspectProviderRegistration } from './inspect-registry.ts'
|
||||
import { CLIENT_SLOT_API } from './slot-catalog.ts'
|
||||
import type { ClientSlotEntry } from './slot-catalog.ts'
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
const EMPTY_INPUT = { type: 'object', properties: {}, additionalProperties: false } as const
|
||||
const ANY_OUTPUT = { description: 'JSON data owned by this inspect provider.' } as const
|
||||
const SERVICE_INPUT = exactInput('service', 'Exact Service key. Omit it for the compact Service and method-signature directory.')
|
||||
const EVENT_INPUT = exactInput('event', 'Exact Event name. Omit it for the compact Event and listener-signature directory.')
|
||||
const SERVICE_OUTPUT = {
|
||||
description: 'Compact Service directory, or one exact Service contract with only its referenced type declarations.',
|
||||
} as const
|
||||
const EVENT_OUTPUT = {
|
||||
description: 'Compact Event directory, or one exact Event contract with only its referenced type declarations.',
|
||||
} as const
|
||||
/* jscpd:ignore-end */
|
||||
const SUBTREE_OUTPUT = {
|
||||
description: 'Compact purpose/topology trees. With root, selected also contains that Slot\'s full contract and live occupants.',
|
||||
} as const
|
||||
const SUBTREE_INPUT = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
root: {
|
||||
type: 'string',
|
||||
description: 'Exact live Slot key. When supplied, selected contains the full contract for this Slot.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const
|
||||
|
||||
/** Exact Client closure symbols exposed by the evaluator and guard. */
|
||||
export const CLIENT_BUILTIN_INSPECTION: readonly JsonValue[] = [
|
||||
{
|
||||
name: 'ctx',
|
||||
description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject only for hard dependencies.',
|
||||
signatures: [
|
||||
'ctx.get(name: string): unknown | undefined',
|
||||
'ctx.on(name: string, listener: Function): () => void',
|
||||
'ctx.provide(name: string, value: unknown): () => void',
|
||||
'ctx.effect(callback: Function, label?: string): () => void',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'React',
|
||||
description: 'React runtime exposed without JSX transformation.',
|
||||
signatures: ['React.createElement(type, props, ...children): ReactElement', 'React.useState(initial)', 'React.useEffect(effect, deps)'],
|
||||
},
|
||||
{
|
||||
name: 'host',
|
||||
description: 'Package-private JSON RPC from Client to this Package\'s Host half.',
|
||||
signatures: ['host.call(method: string, args?: JsonValue): Promise<JsonValue>'],
|
||||
},
|
||||
{
|
||||
name: 'styles',
|
||||
description: 'Package-owned stylesheet insertion cleaned up with the Client run.',
|
||||
signatures: ['styles.insert(css: string): () => void'],
|
||||
},
|
||||
{
|
||||
name: 'console',
|
||||
description: 'Package-tagged browser logging.',
|
||||
signatures: ['console.log(...values): void', 'console.error(...values): void'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Construct the first-party Client provider registrations.
|
||||
* @param ctx - Client context used for live Service-backed queries.
|
||||
* @returns registrations for static catalogs and live Client capabilities.
|
||||
*/
|
||||
export function clientInspectProviders(ctx: Context): ClientCordisInspectProviderRegistration[] {
|
||||
return [
|
||||
registration(
|
||||
'Service',
|
||||
'Progressive Client Service discovery: compact capability/signature directory, then one exact coding contract.',
|
||||
'listService',
|
||||
input => queryServiceApi(readExact(input, 'service')) as unknown as JsonValue,
|
||||
SERVICE_INPUT,
|
||||
SERVICE_OUTPUT,
|
||||
),
|
||||
registration(
|
||||
'Event',
|
||||
'Progressive Client Event discovery: compact listener directory, then one exact event contract.',
|
||||
'listEvents',
|
||||
input => queryEventApi(readExact(input, 'event')) as unknown as JsonValue,
|
||||
EVENT_INPUT,
|
||||
EVENT_OUTPUT,
|
||||
),
|
||||
registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Client half.', 'listBuiltins', () => ({
|
||||
builtins: [...CLIENT_BUILTIN_INSPECTION],
|
||||
referencedTypes: [],
|
||||
})),
|
||||
{
|
||||
manifest: {
|
||||
id: 'Slots',
|
||||
description: 'Progressive live Slot inspection: compact purpose/topology trees plus one exact Slot contract.',
|
||||
methods: [{
|
||||
name: 'listSubTree',
|
||||
description: 'Return compact live Slot trees for navigation. With root, also return the selected Slot\'s full contract and occupants.',
|
||||
inputSchema: SUBTREE_INPUT,
|
||||
outputSchema: SUBTREE_OUTPUT,
|
||||
}],
|
||||
},
|
||||
query(method, input) {
|
||||
if (method !== 'listSubTree') throw new Error(`unknown Slots inspect method "${method}"`)
|
||||
const slots = ctx.get('slots')
|
||||
if (slots === undefined) throw new Error('Client Slots service is not running')
|
||||
const root = typeof input === 'object' && input !== null && !Array.isArray(input)
|
||||
&& typeof input.root === 'string' ? input.root : undefined
|
||||
const trees = slots.snapshot(root)
|
||||
const selected = trees[0]
|
||||
return Promise.resolve({
|
||||
...root === undefined ? {} : { requestedRoot: { name: root, available: trees.length > 0 } },
|
||||
trees: trees.map(compactSlotTree),
|
||||
...root === undefined || selected === undefined ? {} : { selected: inspectLiveSlot(selected) },
|
||||
referencedTypes: [],
|
||||
})
|
||||
},
|
||||
},
|
||||
registration('Theme', 'Current theme token names and light/dark override requirements.', 'listTokens', () => {
|
||||
const theme = ctx.get('theme')
|
||||
if (theme === undefined) throw new Error('Client Theme service is not running')
|
||||
return { tokens: theme.exportInspectTokens(), referencedTypes: [] } as unknown as JsonValue
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
function registration(
|
||||
id: string,
|
||||
description: string,
|
||||
method: string,
|
||||
query: (input: JsonValue | undefined) => JsonValue | Promise<JsonValue>,
|
||||
inputSchema: JsonValue = EMPTY_INPUT,
|
||||
outputSchema: JsonValue = ANY_OUTPUT,
|
||||
): ClientCordisInspectProviderRegistration {
|
||||
return {
|
||||
manifest: {
|
||||
id,
|
||||
description,
|
||||
methods: [{
|
||||
name: method,
|
||||
description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
}],
|
||||
},
|
||||
async query(requested, input) {
|
||||
if (requested !== method) throw new Error(`unknown ${id} inspect method "${requested}"`)
|
||||
return await query(input)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function exactInput(field: string, description: string): JsonValue {
|
||||
return { type: 'object', properties: { [field]: { type: 'string', description } }, additionalProperties: false }
|
||||
}
|
||||
|
||||
function readExact(input: JsonValue | undefined, field: string): string | undefined {
|
||||
if (input === undefined || input === null || Array.isArray(input) || typeof input !== 'object') return undefined
|
||||
const value = input[field]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
type LiveSlotNode = ReturnType<SlotRegistry['snapshot']>[number]
|
||||
|
||||
const SLOT_CATALOG = new Map(CLIENT_SLOT_API.map(entry => [entry.key, entry]))
|
||||
const GUARDED_SLOT_KEYS = new Map<string, {
|
||||
description: string
|
||||
values: readonly { value: string; description: string }[]
|
||||
}>([
|
||||
['tool.view.cordis', {
|
||||
description: 'fixed by the dynamic Client Guard',
|
||||
values: [{
|
||||
value: 'self',
|
||||
description: 'The only accepted key. The Guard binds it to this Package\'s pluginId and packageId.',
|
||||
}],
|
||||
}],
|
||||
])
|
||||
|
||||
function compactSlotTree(node: LiveSlotNode): JsonValue {
|
||||
const catalog = SLOT_CATALOG.get(node.name)
|
||||
const guardedKeys = catalog === undefined ? undefined : GUARDED_SLOT_KEYS.get(catalog.key)
|
||||
return {
|
||||
name: node.name,
|
||||
kind: node.kind,
|
||||
scope: node.scope,
|
||||
...catalog === undefined ? {} : {
|
||||
purpose: catalog.summary,
|
||||
replaceRisk: catalog.replaceRisk,
|
||||
...catalog.registerOptions.length === 0 ? {} : {
|
||||
registration: catalog.registerOptions.map(option => ({
|
||||
name: option.name,
|
||||
type: option.type,
|
||||
required: option.requirement === 'required',
|
||||
})),
|
||||
},
|
||||
...catalog.keyDomain === '' ? {} : {
|
||||
keyDomain: guardedKeys?.description ?? catalog.keyDomain,
|
||||
...guardedKeys === undefined ? {} : { allowedKeys: guardedKeys.values.map(value => ({ ...value })) },
|
||||
},
|
||||
},
|
||||
children: node.children.map(compactSlotTree),
|
||||
}
|
||||
}
|
||||
|
||||
function inspectLiveSlot(node: LiveSlotNode): JsonValue {
|
||||
const catalog = SLOT_CATALOG.get(node.name)
|
||||
return {
|
||||
name: node.name,
|
||||
kind: node.kind,
|
||||
scope: node.scope,
|
||||
...node.declaredBy === undefined ? {} : { declaredBy: node.declaredBy },
|
||||
occupants: node.occupants.map(occupant => ({ ...occupant })),
|
||||
...catalog === undefined ? {} : { catalog: inspectSlotCatalog(catalog) },
|
||||
}
|
||||
}
|
||||
|
||||
function inspectSlotCatalog(entry: ClientSlotEntry): JsonValue {
|
||||
const guardedKeys = GUARDED_SLOT_KEYS.get(entry.key)
|
||||
return {
|
||||
description: entry.doc,
|
||||
registration: entry.registerOptions.map(option => ({
|
||||
name: option.name,
|
||||
type: option.type,
|
||||
required: option.requirement === 'required',
|
||||
description: option.doc,
|
||||
})),
|
||||
ownerProps: [...entry.ownerProps],
|
||||
ownerPropsReferences: [...entry.ownerPropsReferences],
|
||||
standardProps: [...entry.standardProps],
|
||||
keyDomain: guardedKeys?.description ?? entry.keyDomain,
|
||||
...guardedKeys === undefined ? {} : { allowedKeys: guardedKeys.values.map(value => ({ ...value })) },
|
||||
hookContext: entry.hookContext,
|
||||
slotInject: entry.slotInject,
|
||||
replaceRisk: entry.replaceRisk,
|
||||
}
|
||||
}
|
||||
507
packages/extensions/cordis-client-runner/src/client/runtime.ts
Normal file
507
packages/extensions/cordis-client-runner/src/client/runtime.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Per-package browser lifecycle: evaluate the closure, wrap `apply` in the guard
|
||||
* facade, seat a ready-made factory in the module table, and create a loader
|
||||
* entry — so dynamic packages ride the exact machinery static plugins do
|
||||
* (activation gating on inject, fiber-effect cleanup, status projection). Unload
|
||||
* = loader entry removal (fiber disposal cascades slot entries and facade
|
||||
* effects) + factory invalidation + style removal.
|
||||
*
|
||||
* The engine answers its caller: `load` resolves with what this page ended up
|
||||
* with, which is what the run orchestration reports back to the host. Loads
|
||||
* converge by Plugin Run ID against live state, not history: loading the exact
|
||||
* activation this page already runs is a no-op that still answers, another run
|
||||
* replaces it, and the same Package after a retract loads afresh. Per-Plugin
|
||||
* serialization keeps a second request from interleaving with one in flight.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, DynamicCordisPackage,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DynamicCordisStyles, evaluateClientHalf, DYNAMIC_CLIENT_REDIRECTS } from './evaluator.ts'
|
||||
import type { DynamicCordisEvaluatedPlugin } from './evaluator.ts'
|
||||
import { dynamicCordisContext } from './guard.ts'
|
||||
import type { DynamicCordisSlotLedgerRow } from './guard.ts'
|
||||
|
||||
/**
|
||||
* Snapshot source a surface can subscribe to (the render seam's observable
|
||||
* shape). Lives here because both this engine and the run orchestration publish
|
||||
* through it, and the orchestration already depends on this module.
|
||||
*/
|
||||
export interface CordisObservable<T> {
|
||||
/** Current value; the reference is stable between mutations. */
|
||||
getSnapshot(): T
|
||||
/**
|
||||
* Observe mutations.
|
||||
* @param fn - notified after each committed change.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/** Which stage of a load failed, as the page classified it. */
|
||||
export type DynamicCordisLoadErrorCause = 'evaluate' | 'module-import' | 'activate'
|
||||
|
||||
/** Error fields retained by the page runner and Host transport. */
|
||||
export interface CordisErrorDetails {
|
||||
/** Original error message. */
|
||||
message: string
|
||||
/** Original stack when the thrown value supplied one. */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
/** One package's browser half as the host handed it over. */
|
||||
export interface DynamicCordisClientHalf {
|
||||
/** Stable Plugin instance. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Immutable Package source version. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Exact activation. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
/** Session the run is carried out for; a later render failure is reported under it. */
|
||||
agentId: SessionId
|
||||
/** Label from the define call; also the plugin name. */
|
||||
name: string
|
||||
/** Browser-half source: an async function body returning a plugin. */
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One render-time crash of a dynamic package's slot entry, as this page reports
|
||||
* it. Post-settle diagnosis only: the run it belongs to was answered long before
|
||||
* (a package that crashes while rendering loaded successfully), so this never
|
||||
* reaches a run resolution.
|
||||
*/
|
||||
export interface DynamicCordisRenderFailure {
|
||||
/** Slot key the crashed entry rendered under. */
|
||||
slot: string
|
||||
/** What the author has to read to fix it: the crash text, plus a redirect when it names a withheld global. */
|
||||
message: string
|
||||
/** Original render failure stack when available. */
|
||||
stack?: string
|
||||
/** Whether the crash retired the entry from its cell — the package's UI is gone, not merely broken. */
|
||||
abdicated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* What this page ended up with. A parked package is a success — the browser half
|
||||
* settled and waits on declared services this page has not got.
|
||||
*/
|
||||
export type DynamicCordisLoadResult =
|
||||
| { ok: true; pluginRunId: CordisDynamicPluginRunId; waitingFor?: string[] }
|
||||
| ({ ok: false; cause: DynamicCordisLoadErrorCause; error?: unknown } & CordisErrorDetails)
|
||||
|
||||
/** The `window.__ModuleLoader__` registration sink (client-modules contract C6). */
|
||||
interface ModuleLoaderSink {
|
||||
__ModuleLoader__?: {
|
||||
load(handoff: { id: string; factory: (require: (spec: string) => unknown) => unknown }): void
|
||||
}
|
||||
}
|
||||
|
||||
/** One live package's bookkeeping. */
|
||||
interface LivePackage {
|
||||
pkg: DynamicCordisPackage
|
||||
entryId: string
|
||||
styles: DynamicCordisStyles
|
||||
ledger: DynamicCordisSlotLedgerRow[]
|
||||
/** Services the browser half declared and this page has not got (parked, still a success). */
|
||||
waitingFor: string[]
|
||||
}
|
||||
|
||||
/** Runner dependencies, resolved by the plugin entry at activation. */
|
||||
export interface DynamicCordisRunnerEnv {
|
||||
/** The client root context (service reads and the guard's fiber owner). */
|
||||
ctx: Context
|
||||
/** Client cordis Loader: dynamic packages become entries under it. */
|
||||
loader: Loader
|
||||
/** Module table, for factory invalidation before every (re-)registration. */
|
||||
modules: ClientModuleSystem
|
||||
/** Slot registry, for the entry-crash supervision seam. */
|
||||
slots: SlotRegistry
|
||||
/** Route one `host.call` to the package's host half through the Remote namespace. */
|
||||
invoke(
|
||||
pluginId: CordisDynamicPluginId,
|
||||
pluginRunId: CordisDynamicPluginRunId,
|
||||
method: string,
|
||||
args: unknown,
|
||||
): Promise<unknown>
|
||||
/**
|
||||
* Send one render-time crash back to the session that authored the package.
|
||||
* Fire-and-forget by contract: the crash already happened, and a failed report
|
||||
* must not become a second failure.
|
||||
* @param agentId - session the crashed package was run for.
|
||||
* @param id - the crashed package.
|
||||
* @param failure - slot, teaching text, and whether the entry was retired.
|
||||
*/
|
||||
reportRenderFailure(
|
||||
agentId: SessionId,
|
||||
pluginId: CordisDynamicPluginId,
|
||||
pluginRunId: CordisDynamicPluginRunId,
|
||||
failure: DynamicCordisRenderFailure,
|
||||
): void
|
||||
/** Send one post-activation Client guard rejection to the owning Agent. */
|
||||
reportGuardFailure(
|
||||
agentId: SessionId,
|
||||
pluginId: CordisDynamicPluginId,
|
||||
pluginRunId: CordisDynamicPluginRunId,
|
||||
failure: CordisErrorDetails,
|
||||
): void
|
||||
}
|
||||
|
||||
/** Module-table id of one package (also its loader entry name and fiber name). */
|
||||
function moduleIdOf(id: CordisDynamicPluginId): string {
|
||||
return `dyn/${id}`
|
||||
}
|
||||
|
||||
/** One live package's contribution summary in this page. */
|
||||
export interface DynamicCordisLivePackage {
|
||||
/** Stable Plugin instance. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Immutable Package source version. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Exact activation loaded in this page. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
/** Label from the define call. */
|
||||
name: string
|
||||
/** Slot names this package registered into here. */
|
||||
slots: string[]
|
||||
/** Live injected-style tag count. */
|
||||
styleCount: number
|
||||
}
|
||||
|
||||
/** The browser-side load engine for dynamic packages. */
|
||||
export class DynamicCordisPackageRunner {
|
||||
private readonly live = new Map<CordisDynamicPluginId, LivePackage>()
|
||||
/** Serializes load/unload per package id (a second request can outrun a slow load). */
|
||||
private readonly queues = new Map<CordisDynamicPluginId, Promise<unknown>>()
|
||||
private readonly changeListeners = new Set<() => void>()
|
||||
/** Page-local shadowing rank. A later registration receives a lower priority. */
|
||||
private nextPriority = 0
|
||||
/**
|
||||
* Which package seated which component, and for whom. Component identity is the
|
||||
* only attribution key that holds:
|
||||
* - the registry stores the component verbatim, so a crashed entry carries its
|
||||
* own way back — no parallel entry ledger to keep in step;
|
||||
* - `entry.registrant` is `options.registrant ?? fiber.name` and the facade does
|
||||
* not strip a package-supplied one, so a package could name itself something
|
||||
* else — attributing by it would let a package impersonate another;
|
||||
* - the assigned shadowing priority is unique but absent on chain entries (their
|
||||
* election is deliberately left alone), so it would miss chain crashes;
|
||||
* - a package torn down between the crash and the report is still attributable,
|
||||
* because this index does not depend on the live record.
|
||||
*
|
||||
* Two packages cannot collide here: each browser half is evaluated in its own
|
||||
* closure, so no component object reaches two of them. A collision is only
|
||||
* possible inside ONE package (the same component seated twice), where both
|
||||
* entries map to the same id and the value is identical.
|
||||
*/
|
||||
private readonly owners = new WeakMap<object, {
|
||||
pluginId: CordisDynamicPluginId
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
agentId: SessionId
|
||||
}>()
|
||||
/** This page's last render crash per package: what a run surface shows on the row. */
|
||||
private readonly failures = new Map<CordisDynamicPluginId, DynamicCordisRenderFailure>()
|
||||
private readonly unwatch: () => void
|
||||
private snapshotCache: readonly DynamicCordisLivePackage[] | undefined
|
||||
private failureCache: ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure> | undefined
|
||||
|
||||
/** @param env - loader/module/slot wiring plus the two host verbs this engine uses. */
|
||||
constructor(private readonly env: DynamicCordisRunnerEnv) {
|
||||
// The supervision seam fires for EVERY entry crash on the page, factory UI
|
||||
// included; only the ones this runner seated are ours to report.
|
||||
this.unwatch = env.slots.onEntryError((slot, entry, error, info) => {
|
||||
const component: unknown = (entry as { component?: unknown }).component
|
||||
const owner = indexable(component) ? this.owners.get(component) : undefined
|
||||
if (owner === undefined) return
|
||||
const details = errorDetails(error)
|
||||
const failure: DynamicCordisRenderFailure = {
|
||||
slot,
|
||||
message: renderFailureMessage(slot, details.message),
|
||||
...details.stack === undefined ? {} : { stack: details.stack },
|
||||
abdicated: info.abdicated,
|
||||
}
|
||||
// One observation, two outlets with different owners and lifetimes: the host
|
||||
// keeps the last crash ACROSS pages for the model, this map is what THIS page
|
||||
// currently shows. Neither is derived from the other.
|
||||
env.reportRenderFailure(owner.agentId, owner.pluginId, owner.pluginRunId, failure)
|
||||
this.failures.set(owner.pluginId, failure)
|
||||
this.notify()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe live-set changes (the run-state surface's re-render seam).
|
||||
* @param fn - notified after every converged mutation.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.changeListeners.add(fn)
|
||||
return () => { this.changeListeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* This page's last render crash per package, on the same notification channel as
|
||||
* the live set — a surface that already subscribed learns about a crash without
|
||||
* a second mechanism to wire.
|
||||
*/
|
||||
readonly renderFailures: CordisObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>> = {
|
||||
getSnapshot: () => this.failureCache ??= new Map(this.failures),
|
||||
subscribe: fn => this.subscribe(fn),
|
||||
}
|
||||
|
||||
/**
|
||||
* What this page currently has loaded (stable reference between mutations, so
|
||||
* it can back a snapshot selector).
|
||||
* @returns one row per live package.
|
||||
*/
|
||||
getSnapshot(): readonly DynamicCordisLivePackage[] {
|
||||
return this.snapshotCache ??= [...this.live.values()].map(({ pkg, ledger, styles }) => ({
|
||||
pluginId: pkg.pluginId,
|
||||
packageId: pkg.packageId,
|
||||
pluginRunId: pkg.pluginRunId,
|
||||
name: pkg.name,
|
||||
slots: [...new Set(ledger.map(row => row.slot))],
|
||||
styleCount: styles.count,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this page has the browser half loaded — page-local truth, never the
|
||||
* host's "it is running".
|
||||
* @param pluginId - stable Plugin identity.
|
||||
* @returns true while one activation of the Plugin is live here.
|
||||
*/
|
||||
isLoaded(pluginId: CordisDynamicPluginId): boolean {
|
||||
return this.live.has(pluginId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one browser half into this page and answer what happened.
|
||||
* @param half - source for one exact Host activation.
|
||||
* @returns the outcome the run orchestration reports to the host.
|
||||
*/
|
||||
load(half: DynamicCordisClientHalf): Promise<DynamicCordisLoadResult> {
|
||||
return this.enqueue(half.pluginId, async () => {
|
||||
const current = this.live.get(half.pluginId)
|
||||
if (current !== undefined) {
|
||||
// Already running this activation here: nothing to load, but the caller
|
||||
// still needs an answer (a replayed run must not look unacknowledged).
|
||||
if (current.pkg.pluginRunId === half.pluginRunId) return settled(current)
|
||||
await this.teardown(current.pkg.pluginId, current.entryId, current.styles)
|
||||
}
|
||||
const result = await this.mount(half)
|
||||
this.notify()
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload one package (`cordis/dynamic-retract`: a stop, or an undefine
|
||||
* that stops first).
|
||||
* @param pluginId - stable Plugin identity.
|
||||
* @param pluginRunId - exact activation being retracted; a newer run survives.
|
||||
*/
|
||||
retract(pluginId: CordisDynamicPluginId, pluginRunId: CordisDynamicPluginRunId): void {
|
||||
void this.enqueue(pluginId, async () => {
|
||||
const current = this.live.get(pluginId)
|
||||
if (current === undefined || current.pkg.pluginRunId !== pluginRunId) return
|
||||
await this.teardown(pluginId, current.entryId, current.styles)
|
||||
this.notify()
|
||||
})
|
||||
}
|
||||
|
||||
/** Unload everything (plugin disposal path). */
|
||||
async dispose(): Promise<void> {
|
||||
this.unwatch()
|
||||
for (const current of [...this.live.values()]) {
|
||||
await this.teardown(current.pkg.pluginId, current.entryId, current.styles)
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.snapshotCache = undefined
|
||||
this.failureCache = undefined
|
||||
for (const fn of [...this.changeListeners]) fn()
|
||||
}
|
||||
|
||||
/** Queue one package operation behind that package's previous ones. */
|
||||
private enqueue<T>(id: CordisDynamicPluginId, op: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(id) ?? Promise.resolve()
|
||||
const next = previous.then(op)
|
||||
// The queue tail must survive this operation's failure, or one rejection
|
||||
// would wedge every later operation on the same package.
|
||||
this.queues.set(id, next.then(() => {}, () => {}))
|
||||
return next
|
||||
}
|
||||
|
||||
private async mount(half: DynamicCordisClientHalf): Promise<DynamicCordisLoadResult> {
|
||||
const styles = new DynamicCordisStyles(half.pluginId)
|
||||
const ledger: DynamicCordisSlotLedgerRow[] = []
|
||||
let plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
|
||||
try {
|
||||
plugin = await evaluateClientHalf(half.pluginId, half.code, {
|
||||
invoke: (method, args) => this.env.invoke(half.pluginId, half.pluginRunId, method, args),
|
||||
noteError: (message) => {
|
||||
// A loaded package's own console.error: a page-local diagnostic with
|
||||
// no wire carrier (the run round trip settled long before).
|
||||
console.error(`[cordis-client-runner] ${half.pluginId} logged an error:`, message)
|
||||
},
|
||||
}, styles)
|
||||
} catch (error) {
|
||||
styles.dispose()
|
||||
return { ok: false, cause: 'evaluate', ...errorDetails(error), error }
|
||||
}
|
||||
|
||||
const pkg: DynamicCordisPackage = {
|
||||
pluginId: half.pluginId,
|
||||
packageId: half.packageId,
|
||||
pluginRunId: half.pluginRunId,
|
||||
name: half.name,
|
||||
}
|
||||
const surface = this.guardedSurface(pkg, half.agentId, plugin, ledger)
|
||||
const moduleId = moduleIdOf(half.pluginId)
|
||||
// Invalidate-then-register keeps re-loading legal: the module table throws
|
||||
// loudly on a duplicate factory registration.
|
||||
this.env.modules.invalidate(moduleId)
|
||||
const sink = (globalThis as ModuleLoaderSink).__ModuleLoader__
|
||||
if (sink === undefined) {
|
||||
throw new Error('cordis-client-runner: window.__ModuleLoader__ is missing (booted outside the web shell?)')
|
||||
}
|
||||
sink.load({ id: moduleId, factory: () => surface })
|
||||
|
||||
const entryId = await this.env.loader.create({ name: moduleId })
|
||||
const fiber = this.env.loader.resolve(entryId).fiber
|
||||
if (fiber === undefined) {
|
||||
await this.teardown(half.pluginId, entryId, styles)
|
||||
return { ok: false, cause: 'module-import', message: 'module import failed (see the browser console)' }
|
||||
}
|
||||
try {
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
await this.teardown(half.pluginId, entryId, styles)
|
||||
return { ok: false, cause: 'activate', ...errorDetails(error), error }
|
||||
}
|
||||
// Settled but not active = legal pending on an unsatisfied declaration. The
|
||||
// record is seated only now, so an error mirrored during `apply` cannot
|
||||
// claim the package is already live.
|
||||
const waitingFor = Object.keys(fiber.inject).filter(name => this.env.ctx.get(name) === undefined)
|
||||
const record: LivePackage = { pkg, entryId, styles, ledger, waitingFor }
|
||||
this.live.set(half.pluginId, record)
|
||||
// A fresh load answers for itself: whatever this page last showed as crashed
|
||||
// is no longer true of what is mounted now.
|
||||
this.failures.delete(half.pluginId)
|
||||
return settled(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the evaluated plugin so `apply` sees the guard facade; the surface
|
||||
* doubles as the module-table module. The plugin's OWN `inject` survives (the
|
||||
* object form's declaration is the facade's service gate, mirroring the host
|
||||
* sandbox reading `ctx.fiber.inject`); the function form has no declaration
|
||||
* site and therefore reaches no service.
|
||||
*/
|
||||
private guardedSurface(
|
||||
pkg: DynamicCordisPackage,
|
||||
agentId: SessionId,
|
||||
plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown),
|
||||
ledger: DynamicCordisSlotLedgerRow[],
|
||||
): DynamicCordisEvaluatedPlugin {
|
||||
const claim = (component: unknown): void => {
|
||||
if (indexable(component)) {
|
||||
this.owners.set(component, { pluginId: pkg.pluginId, pluginRunId: pkg.pluginRunId, agentId })
|
||||
}
|
||||
}
|
||||
const guarded = (ctx: unknown): Context => dynamicCordisContext(ctx as Context, {
|
||||
pkg,
|
||||
ledger,
|
||||
claim,
|
||||
allocatePriority: () => --this.nextPriority,
|
||||
reportFailure: (error) => {
|
||||
this.env.reportGuardFailure(agentId, pkg.pluginId, pkg.pluginRunId, errorDetails(error))
|
||||
},
|
||||
})
|
||||
if (typeof plugin === 'function') {
|
||||
return { name: moduleIdOf(pkg.pluginId), apply: (ctx: unknown) => plugin(guarded(ctx)) }
|
||||
}
|
||||
return {
|
||||
...plugin,
|
||||
name: moduleIdOf(pkg.pluginId),
|
||||
apply: (ctx: unknown, config?: unknown) => plugin.apply(guarded(ctx), config),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload one package's contributions. Takes the pieces rather than the record
|
||||
* because a load can fail before any record is seated.
|
||||
*/
|
||||
private async teardown(
|
||||
id: CordisDynamicPluginId,
|
||||
entryId: string,
|
||||
styles: DynamicCordisStyles,
|
||||
): Promise<void> {
|
||||
this.live.delete(id)
|
||||
// Nothing of this package renders here any more, so a crash row would outlive
|
||||
// the thing it described.
|
||||
this.failures.delete(id)
|
||||
// Entry removal disposes the fiber (slot entries and facade effects
|
||||
// cascade); the factory invalidation makes a later re-load legal.
|
||||
await this.env.loader.remove(entryId)
|
||||
this.env.modules.invalidate(moduleIdOf(id))
|
||||
styles.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** The success answer for a package that is live here, parked or active. */
|
||||
function settled(record: { pkg: DynamicCordisPackage; waitingFor: string[] }): DynamicCordisLoadResult {
|
||||
return {
|
||||
ok: true,
|
||||
pluginRunId: record.pkg.pluginRunId,
|
||||
...record.waitingFor.length > 0 ? { waitingFor: record.waitingFor } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a component can key the ownership index. Identity is the key, so only
|
||||
* objects and functions qualify — a package may register anything, and what it
|
||||
* registered is what a crash report carries back.
|
||||
* @param component - whatever a package passed as its component.
|
||||
* @returns true when the value can be indexed by identity.
|
||||
*/
|
||||
function indexable(component: unknown): component is object {
|
||||
return typeof component === 'object' && component !== null || typeof component === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve error fields for a load result without fabricating a stack.
|
||||
* @param error - original thrown value.
|
||||
* @returns its message and original string stack, when present.
|
||||
*/
|
||||
/* jscpd:ignore-start */
|
||||
export function errorDetails(error: unknown): CordisErrorDetails {
|
||||
if (typeof error !== 'object' || error === null) return { message: String(error) }
|
||||
const message = 'message' in error && typeof error.message === 'string'
|
||||
? error.message
|
||||
: Object.prototype.toString.call(error)
|
||||
const stack = 'stack' in error && typeof error.stack === 'string' ? error.stack : undefined
|
||||
return { message, ...stack === undefined ? {} : { stack } }
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* What the authoring session reads about one render crash. The slot says where it
|
||||
* happened, the crash message says what broke, and a withheld global named in that
|
||||
* text pulls in its redirect — a package that reached `window.setInterval` around
|
||||
* the closure trap crashes with the engine's bare message, which teaches nothing.
|
||||
*/
|
||||
function renderFailureMessage(slot: string, message: string): string {
|
||||
const redirect = Object.entries(DYNAMIC_CLIENT_REDIRECTS)
|
||||
.find(([name, text]) => message.includes(name) && !message.includes(text))?.[1]
|
||||
return `your entry in slot "${slot}" crashed while React rendered it: ${message}`
|
||||
+ (redirect === undefined ? '' : `\n${redirect}`)
|
||||
}
|
||||
1710
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
Normal file
1710
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
216
packages/extensions/cordis-client-runner/src/client/timer.ts
Normal file
216
packages/extensions/cordis-client-runner/src/client/timer.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/** Browser implementation of the Cordis timer Service. */
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/*
|
||||
* The browser Service preserves the vendored Host TimerService's erased callback tuples and arbitrary
|
||||
* async-iterator return and rejection values, so narrowing these positions would change the public API.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-explicit-any -- Exact Host TimerService API compatibility; see above. */
|
||||
/* oxlint-disable typescript/no-unsafe-argument -- The erased callback tuples pass through unchanged. */
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- The erased callback tuples pass through unchanged. */
|
||||
/* oxlint-disable typescript/no-unsafe-member-access -- The returned wrapper retains its dispose property. */
|
||||
/* oxlint-disable typescript/no-unsafe-return -- The erased generic return values pass through unchanged. */
|
||||
/* oxlint-disable typescript/prefer-promise-reject-errors -- Async iterators preserve arbitrary throw reasons. */
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
|
||||
/** Browser timer Service used by the mixed-in Context helpers. */
|
||||
timer: ClientTimerService
|
||||
}
|
||||
}
|
||||
|
||||
type WithDispose<T> = T & { dispose: () => void }
|
||||
|
||||
// These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
|
||||
// return/rejection values must pass through without narrowing them to one caller's invocation.
|
||||
|
||||
/** Browser timer Service with the same public API as the Host Cordis TimerService. */
|
||||
export class ClientTimerService extends Service {
|
||||
/** Register the Service and mix its lifecycle-safe helpers onto Context. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'timer')
|
||||
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback once through {@link timeout}.
|
||||
* @param callback - Work to run after the delay.
|
||||
* @param delay - Delay in milliseconds.
|
||||
* @returns Disposer that cancels the pending callback early.
|
||||
* @deprecated Use `ctx.timeout()` instead.
|
||||
*/
|
||||
setTimeout(callback: () => void, delay: number): () => void {
|
||||
return this.timeout(callback, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback repeatedly through {@link interval}.
|
||||
* @param callback - Work to run on each tick.
|
||||
* @param delay - Interval in milliseconds.
|
||||
* @returns Disposer that stops the interval early.
|
||||
* @deprecated Use `ctx.interval()` instead.
|
||||
*/
|
||||
setInterval(callback: () => void, delay: number): () => void {
|
||||
return this.interval(callback, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback once after a delay.
|
||||
* @param callback - work to run.
|
||||
* @param delay - delay in milliseconds.
|
||||
* @returns disposer that cancels the callback.
|
||||
*/
|
||||
timeout(callback: () => void, delay: number): () => void
|
||||
/**
|
||||
* Wait for a delay.
|
||||
* @param delay - delay in milliseconds.
|
||||
* @returns promise resolved after the delay.
|
||||
*/
|
||||
timeout(delay: number): Promise<void>
|
||||
timeout(...args: any[]): any {
|
||||
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
|
||||
const delay = args[0] as number
|
||||
if (callback !== undefined) {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void dispose()
|
||||
callback()
|
||||
}, delay)
|
||||
return () => { globalThis.clearTimeout(timer) }
|
||||
}, 'ctx.timeout()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>()
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setTimeout(resolve, delay)
|
||||
return () => {
|
||||
globalThis.clearTimeout(timer)
|
||||
reject(new Error('Context has been disposed'))
|
||||
}
|
||||
}, 'ctx.timeout()')
|
||||
return promise.finally(() => { void dispose() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback repeatedly.
|
||||
* @param callback - work to run on each tick.
|
||||
* @param delay - interval in milliseconds.
|
||||
* @returns disposer that stops the interval.
|
||||
*/
|
||||
interval(callback: () => void, delay: number): () => void
|
||||
/**
|
||||
* Iterate over timer ticks.
|
||||
* @param delay - interval in milliseconds.
|
||||
* @returns async iterator of ticks.
|
||||
*/
|
||||
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
|
||||
interval(...args: any[]): any {
|
||||
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
|
||||
const delay = args[0] as number
|
||||
if (callback !== undefined) {
|
||||
return this.ctx.effect(() => {
|
||||
const timer = globalThis.setInterval(callback, delay)
|
||||
return () => { globalThis.clearInterval(timer) }
|
||||
}, 'ctx.interval()')
|
||||
}
|
||||
|
||||
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
|
||||
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setInterval(() => {
|
||||
nextTask?.resolve({ done: false, value: undefined })
|
||||
}, delay)
|
||||
return () => {
|
||||
globalThis.clearInterval(timer)
|
||||
if (done !== undefined) return
|
||||
done = { kind: 'throw', reason: new Error('Context has been disposed') }
|
||||
nextTask?.reject(done.reason)
|
||||
}
|
||||
}, 'ctx.interval()')
|
||||
return {
|
||||
next: () => {
|
||||
if (done === undefined) return (nextTask = Promise.withResolvers()).promise
|
||||
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
|
||||
return Promise.reject(done.reason)
|
||||
},
|
||||
return: (value: any) => {
|
||||
if (done === undefined) done = { kind: 'return', value }
|
||||
nextTask?.resolve({ done: true, value })
|
||||
void dispose()
|
||||
return Promise.resolve({ done: true, value })
|
||||
},
|
||||
throw: (reason: any) => {
|
||||
if (done === undefined) done = { kind: 'throw', reason }
|
||||
nextTask?.reject(reason)
|
||||
void dispose()
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this
|
||||
},
|
||||
} satisfies AsyncIterableIterator<void>
|
||||
}
|
||||
|
||||
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
|
||||
private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any {
|
||||
let timer: number | undefined
|
||||
const dispose = this.ctx.effect(() => () => {
|
||||
disposed = true
|
||||
globalThis.clearTimeout(timer)
|
||||
}, label)
|
||||
const wrapper: any = (...args: any[]): void => {
|
||||
globalThis.clearTimeout(timer)
|
||||
timer = trigger(args, disposed)
|
||||
}
|
||||
wrapper.dispose = dispose
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a throttled function whose timer is disposed with the calling Fiber.
|
||||
* @param callback - Function to throttle.
|
||||
* @param delay - Minimum interval between calls in milliseconds.
|
||||
* @param noTrailing - Whether to suppress a delayed trailing call.
|
||||
* @returns Throttled function with an early disposer.
|
||||
*/
|
||||
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
|
||||
let lastCall = -Infinity
|
||||
const execute = (...args: Parameters<F>): void => {
|
||||
lastCall = Date.now()
|
||||
callback(...args)
|
||||
}
|
||||
return this.schedule('ctx.throttle()', (args, disposed) => {
|
||||
const remaining = delay - Date.now() + lastCall
|
||||
if (remaining <= 0) {
|
||||
execute(...args as Parameters<F>)
|
||||
} else if (!disposed) {
|
||||
return globalThis.setTimeout(execute, remaining, ...args)
|
||||
}
|
||||
}, noTrailing)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a debounced function whose timer is disposed with the calling Fiber.
|
||||
* @param callback - Function to debounce.
|
||||
* @param delay - Quiet period in milliseconds.
|
||||
* @returns Debounced function with an early disposer.
|
||||
*/
|
||||
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
|
||||
return this.schedule('ctx.debounce()', (args, disposed) => {
|
||||
if (disposed) return
|
||||
return globalThis.setTimeout(callback, delay, ...args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the browser timer Service on one Client composition.
|
||||
* @param ctx - Client context that owns the Service and mixed-in helpers.
|
||||
* @returns Nothing after registering the Service.
|
||||
*/
|
||||
export function provideClientTimer(ctx: Context): void {
|
||||
new ClientTimerService(ctx)
|
||||
}
|
||||
9
packages/extensions/cordis-client-runner/src/index.ts
Normal file
9
packages/extensions/cordis-client-runner/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Dynamic-package runner plugin, node half. Pure browser-side capability: the
|
||||
* empty apply exists so the row appears in the host cordis.yml / Loader, while
|
||||
* the browser half ships through exports["./client"], discovered from the
|
||||
* package.json dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — this package contributes nothing host-side. */
|
||||
export function apply(): void {}
|
||||
33
packages/extensions/cordis-client-runner/src/invariant.ts
Normal file
33
packages/extensions/cordis-client-runner/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-cordis-client-runner`.
|
||||
* @module @deepseek-ai/dsh-cordis-client-runner/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-cordis-client-runner'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'cordis-client-runner-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the owned relation (a live
|
||||
* Plugin's loader entry exists exactly while one Plugin Run ID is live) is
|
||||
* browser-only state reachable through the client half's service, which the
|
||||
* node-plane companion cannot observe. The relation is asserted by the
|
||||
* package's own load/teardown coverage instead.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user