Merge remote-tracking branch 'origin/master' into worktree/minimal-no-runtime-context
# Conflicts: # packages/core/system-prompt/README.i18n.yaml # packages/core/system-prompt/README.md # packages/core/system-prompt/README.zh.md # packages/examples/agent-spine-demo/README.i18n.yaml # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/README.zh.md # packages/self-modification/tool-cordis/src/api-catalog.ts
This commit is contained in:
6
packages/extensions/README.i18n.yaml
Normal file
6
packages/extensions/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/README.md
|
||||
README.md: 12717c43b0cf0a2a7958ed2e18f608da23c67963
|
||||
README.zh.md: f905d0da10137a6d47fb3be2b6f51a905165e063
|
||||
12
packages/extensions/README.md
Normal file
12
packages/extensions/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# extensions/ — the agent modifies its own runtime
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service API, define and run model-written dynamic packages, and retract them again — plus the restricted repository Plugin runtime. Both browser-half packages live here rather than under `packages/client/` because they are halves of this subsystem's dual-half packages; the host aggregate excludes them so each face keeps its own compiler program. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and dynamic-package tools | registers on `ctx.tools` |
|
||||
| [`cordis-host-runner/`](cordis-host-runner/README.md) | Definition registry, the `node:vm` sandbox for host halves, and the request-run round trip | provides `ctx.dynamicCordisRunner` |
|
||||
| [`cordis-client-runner/`](cordis-client-runner/README.md) | Browser half of a dual-half package: evaluates the definition into a live browser plugin and answers the run request | client face; provides the browser `ctx.dynamicCordisRunner` |
|
||||
| [`ui-cordis/`](ui-cordis/README.md) | Browser surfaces: the frame-wide panel that operates every definition, and the read-only define card | client face; registers slots |
|
||||
12
packages/extensions/README.zh.md
Normal file
12
packages/extensions/README.zh.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# extensions/:agent(智能体)修改自身运行时
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
agent 修改自身运行时:检查已加载的插件与服务接口、定义并运行模型编写的动态包(dynamic package)并再次撤下,外加受限 repository Plugin 运行时。两个浏览器半的包住在这里而不是 `packages/client/`,因为它们是本子系统双半包的其中一半;host 聚合把它们排除在外,让两个契约面各自保有独立的编译 program。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
| 包 | 角色 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_define`/`cordis_run`/`cordis_stop`/`cordis_undefine` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的动态包 | 注册到 `ctx.tools` |
|
||||
| [`cordis-host-runner/`](cordis-host-runner/README.md) | 定义注册表、host 半的 `node:vm` 沙箱,以及 request-run 往返 | 提供 `ctx.dynamicCordisRunner` |
|
||||
| [`cordis-client-runner/`](cordis-client-runner/README.md) | 双半包的浏览器半:把定义求值成活的浏览器插件,并应答运行请求 | client 面;提供浏览器侧 `ctx.dynamicCordisRunner` |
|
||||
| [`ui-cordis/`](ui-cordis/README.md) | 浏览器面:操作全部定义的全局面板,与只读的 define 卡片 | client 面;注册 slot |
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/cordis-client-runner/README.md
|
||||
README.md: ba60e3256ca6c80792645daa26c6872cfc846940
|
||||
README.zh.md: 2d8712de6b0444847eb731d84140fe2c1cad5b63
|
||||
68
packages/extensions/cordis-client-runner/README.md
Normal file
68
packages/extensions/cordis-client-runner/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# @deepseek-ai/dsh-cordis-client-runner
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Browser half of dynamic dual-half plugin packages. The host-side runner holds every definition's code in process memory and asks the open pages, over a `cordis/request-run` event, whether to run one; this package answers that request, turns the definition into a live browser plugin, and turns a `dynamicCordisRunner/retract` event back into a clean page.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Event subscription** — the four announcements are forwarded host cordis events, so this package consumes `cordis/request-run`, `cordis/request-run-resolved`, and `dynamicCordisRunner/retract` through `ctx.remote.$on`, whose key set IS the api-remotes allowlist.
|
||||
2. **Closure evaluation** — the browser half's source runs as an async function body whose parameters are its symbol surface (`React`, `console`, `styles`, `host`, plus teaching traps shadowing `setTimeout`/`fetch`/`require`). No JSX, no TypeScript, no module imports.
|
||||
3. **Guard facade** — `apply` receives a whitelisting proxy over the real fiber ctx: lifecycle verbs plus the services the returned plugin declared in its own `inject` (so the object form `{ inject: ['slots'], apply(ctx) {} }` is what reaches a service; a plain function has no declaration site and reaches none). The `slots` seat assigns the shadowing priority (registering IS shadowing, newest run wins); the `theme` seat pins the override layer's source to the package id and hangs its disposer on the fiber.
|
||||
4. **Loader entries** — the guarded plugin is seated in the module table and mounted through `loader.create`, so a dynamic package rides the same activation gating, fiber-effect cleanup, and status projection as a static one. Unload is entry removal plus factory invalidation plus style removal.
|
||||
5. **Run orchestration** — a `cordis/request-run` event asks this page whether to run a definition. Whoever answers drives the run in order: the host half first, then the source fetch, then the browser half, then one resolution carrying what happened. A user pressing "run" is itself the authorization and orchestrates the same way with nothing to answer — and for a host-only definition the run ends at the host half, because there is no second half to fetch or load here.
|
||||
6. **Package-internal RPC** — a package's `host.call` routes to its own host half through the `dynamicCordisRunner` Remote namespace (`invoke`), and each routing failure code becomes its own teaching error. Both directions carry JSON only: an omitted argument travels as `null` (so `host.call('listServices')` is legal and the handler receives `null`), and a payload the generated codec refuses — a function, `undefined`, a class instance — becomes a teaching error naming the call and the contract instead of the codec's bare field name.
|
||||
7. **Render-failure reflow** — the slot registry's supervision seam (`slots.onEntryError`) fires for every entry-boundary crash on the page; the ones belonging to a package this runner seated go to two outlets from that one observation: upstream to the authoring session (`reportRenderFailure`, for the model) and onto this package's own `renderFailures` face field (for the panel row). Ownership is keyed on component identity, recorded when the guard's `register` proxy seats it, because the registry stores the component verbatim — so no parallel ledger of entries has to be kept in step. This is post-settle diagnosis only: it carries no settle authority, never touches a run resolution, and a failed report is swallowed rather than turning one crash into two.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Loads converge by `(id, rev)` against live state: loading a revision this page already runs answers from live state without reloading (so a replayed run does not look unanswered), a newer revision replaces it, and the same revision after a retract loads afresh. Operations serialize per definition.
|
||||
|
||||
Nothing loads at activation, and nothing is restored after a refresh — a page runs a dynamic package only when someone answers a run request or asks for it here.
|
||||
|
||||
## What a run surface reads and calls
|
||||
|
||||
`ctx.dynamicCordisRunner` is the whole face:
|
||||
|
||||
- `activeRuns` — each definition's single in-flight activity: `awaiting-approval` (the request id to answer plus the ask's session, package name, and purpose) or `orchestrating` (the session the run is being carried out for). Both arms name the session because grouping belongs to the run, not to its phase; the waiting arm carries the ask's own text because `cordis_define` broadcasts nothing, so a request can name a definition the last registry read does not cover and then this entry is the only source that row has. A surface renders from it and keeps no copy, which is what makes the affordance survive a remount.
|
||||
- `renderFailures` — this page's last render crash per definition (slot, teaching message, and whether the crash retired the entry from its cell), on the same notification channel as the live set. Page-local and current by construction: it clears when the package stops, is retracted, or loads again, so a row can render it directly. The host keeps its own last-across-pages copy for the model — the two have different owners and lifetimes, and a surface must not read the host's back in place of this one.
|
||||
- `lastRunError` — why this page's own attempt failed, per definition. It outlives the activity, because the host disposes only the half a failed request started: a page can be looking at a definition the host reports as running while having nothing loaded itself.
|
||||
- `approve(requestId)` / `decline(requestId)` / `startUserRun({ agentId, id, hasClientHalf })` — the two entries. All three are idempotent (per request id, and per definition for the user's own run), so a double press cannot start two runs. `hasClientHalf` is required: a host-only definition has no source to fetch, so the caller states the shape from the registry row it is acting on rather than the orchestrator learning it from a failed fetch. An answerable request always has a browser half, because the host runs a host-only definition itself instead of asking a page.
|
||||
- `subscribe()` / `getSnapshot()` / `isLoaded(id)` — what this page has loaded. `isLoaded` is page-local truth, never the host's "it is running".
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Run resolution, when a model asked for the run
|
||||
|
||||
#### What the model sees
|
||||
|
||||
This package contributes no tool, prompt, or context of its own; the first thing it authors that reaches a model is the resolution it sends back for a `cordis/request-run` round trip, which the host turns into the blocked `cordis_run` result. A success carries the loaded revision and, for a browser half parked on services this page does not have, their names. A failure carries one reason — `rejected` when the user refused, `host-half-failed`, or `client-half-failed` — and, for the browser half, this package's own text: the failing stage (`evaluate`, `module-import`, or `activate`) followed by the closure's, guard's, or fiber's message. The guard's teaching errors (an undeclared service, a shadowed browser global, a plugin that returned no `apply`) reach the model through exactly that field. A crash that happens later, while React renders the loaded half, travels the separate post-settle path below.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional and bounded: at most one resolution per run request, spent inside the `cordis_run` tool result the host already emits. The text is data-dependent (a definition's own error message) and this package retains nothing across requests — a page's later load failures are page-local diagnostics with no model-visible carrier.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only. A resolution reaches the model only as the tool result for the request that was already in flight, extending the history tail; nothing this package authors rewrites or reorders earlier request tokens, so an otherwise reusable prefix stays reusable. Repeated runs of the same definition each produce their own result rather than replacing an earlier one.
|
||||
|
||||
### Render failure, after the run settled
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A browser half that loads cleanly can still crash when React renders it, and that crash lands after the run was answered — so the model would otherwise be told "ok" and never learn. Every entry-boundary crash of a package this page seated is sent to the host (`reportRenderFailure`) naming the slot, whether the crash retired the entry from its cell (`abdicated`: the package's UI is gone, not merely broken), and a message written for the author: the crash text, plus the redirect for a withheld browser global the text names but does not teach — `window.setInterval` around the closure trap crashes as `is not a function`, which explains nothing on its own. The host keeps the last one per package and shows it through `cordis_inspect`; nothing here reaches a run resolution. The same observation also lands on `renderFailures` for the page's own surface — one observer, two outlets, because "the last crash across pages, for the model" and "what this page is showing now" are different facts with different lifetimes.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional and bounded by the host's retention, not by this page: one report per crash, and the host keeps only the latest per package, so a repeatedly crashing entry costs the model one paragraph rather than a growing list. The report never enters a tool result of its own — the model pays for it only when it asks.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None of its own. Reports travel over RPC and are stored, not appended to the conversation; the model reads them through an inspection it chose to make, which extends the tail like any other tool result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A refused resolution is not retried.** The acknowledgement of `resolveRequestRun` is not read, so when the host declines a stale success (`accepted: false`, because the definition's revision moved on while this page was loading) the page keeps what it loaded and does not orchestrate again. The request stays answerable — another page's answer or the caller's cancellation settles it — and the stop that bumped the revision retracts the stale load. Retrying was evaluated and deferred: the window is one revision bump inside a single round trip.
|
||||
- The plugin declares `remote.dynamic`, so it stays parked until the host-side namespace exists rather than loading packages whose host half it could never reach.
|
||||
- Slot admission (allow/deny lists per deployment) has no carrier: the dispatched row declares services, not target slots.
|
||||
- Guard whitelists are hand-mirrored twins of the host-side sandbox facade; sharing one specification is deferred.
|
||||
68
packages/extensions/cordis-client-runner/README.zh.md
Normal file
68
packages/extensions/cordis-client-runner/README.zh.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# @deepseek-ai/dsh-cordis-client-runner
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
动态双半插件包的浏览器半。host 侧 runner 把每个定义的代码留在进程内存里,并经一条 `cordis/request-run` 事件向打开的页面发问「要不要运行它」;本包回答这个请求、把定义变成活的浏览器插件,并把 `dynamicCordisRunner/retract` 事件变回干净的页面。
|
||||
|
||||
## 它做什么
|
||||
|
||||
1. **事件订阅** —— 四条公告是转发的 host cordis 事件,所以本包经 `ctx.remote.$on` 消费 `cordis/request-run`、`cordis/request-run-resolved` 与 `dynamicCordisRunner/retract`,而 `$on` 的键面就是 api-remotes 的白名单。
|
||||
2. **闭包求值** —— 浏览器半的源码作为一个 async 函数体运行,其参数即符号面(`React`、`console`、`styles`、`host`,外加遮蔽 `setTimeout`/`fetch`/`require` 的教学陷阱)。无 JSX、无 TypeScript、不能 import 模块。
|
||||
3. **guard 门面** —— `apply` 收到的是真 fiber ctx 之上的白名单代理:生命周期动词,加上**返回的 plugin 自己在 `inject` 里声明**的服务(所以要用对象形态 `{ inject: ['slots'], apply(ctx) {} }` 才拿得到服务;裸函数没有声明位,拿不到任何服务)。`slots` 座位分配遮蔽 priority(注册即遮蔽,最新一次运行者胜出);`theme` 座位把覆盖层的 source 钉成包 id,并把它的 disposer 挂到 fiber 上。
|
||||
4. **loader entry** —— 加了 guard 的插件被塞进模块表,再经 `loader.create` 挂载,于是动态包与静态包共享同一套激活门控、fiber effect 清理与状态投影。卸载 = 移除 entry + 失效 factory + 撤下样式。
|
||||
5. **run 编排** —— 一条 `cordis/request-run` 事件问这一页要不要运行某个定义。回答的那一方按顺序把 run 跑完:先 host 半、再取源码、再浏览器半,最后一次回答带上结果。用户按下「运行」本身就是授权,同样走这条编排,只是没有要回答的对象;而纯 host 定义的 run 到 host 半就结束了 —— 这里没有第二半可取、也没有第二半可装。
|
||||
6. **包内 RPC** —— 包内的 `host.call` 经 `dynamicCordisRunner` Remote namespace(`invoke`)转给它自己的 host 半,三种路由失败码各自变成对应的教学错误。两个方向都只驮 JSON:省略入参会以 `null` 过线(所以 `host.call('listServices')` 合法,handler 收到 `null`),而生成的 codec 拒收的载荷(函数、`undefined`、类实例)会变成一条点明「哪次调用 + 约定是什么」的教学错误,而不是 codec 那个光秃秃的字段名。
|
||||
7. **渲染期失败回流** —— 槽位注册表的 supervision 接缝(`slots.onEntryError`)对页面上每一次 entry 边界崩溃都会通知;凡属于本 runner 落座过的包,那**一次**观察会分两个出口:一路上行给撰写它的会话(`reportRenderFailure`,给模型看),一路发布到本包 face 上的 `renderFailures`(给面板那一行看)。归属以 component 身份为键,在 guard 的 `register` 代理落座时记下 —— 注册表原样保存 component,所以不需要再维护一份与之同步的 entry 台账。这条通道纯属事后诊断:不驮任何 settle 权威、绝不触碰 run 的最终回答,而且报告本身失败时只吞不抛 —— 不让一次崩溃变成两次。
|
||||
|
||||
## 生命周期
|
||||
|
||||
装载按 `(id, rev)` 对 live 态收敛:装载这一页已在运行的那个 revision 会**直接从 live 态回答**而不重装(所以被重播的 run 不会看起来没人回答),更新的 revision 顶替旧的,同一 revision 在 retract 之后再装则重新装载。同一定义的操作串行执行。
|
||||
|
||||
激活时什么都不装,刷新之后也不恢复 —— 一页只在有人回答了一次 run 请求、或有人在这一页主动要求时,才运行动态包。
|
||||
|
||||
## run 界面读什么、调什么
|
||||
|
||||
`ctx.dynamicCordisRunner` 就是全部的面:
|
||||
|
||||
- `activeRuns` —— 每个定义唯一的在途活动:`awaiting-approval`(要回答的 requestId,加上这次询问的会话、包名与用途)或 `orchestrating`(这次 run 是为哪个会话在跑)。两条臂都带会话,因为归组属于这次 run 而不属于它的阶段;待确认那条还带着询问自己的文字,因为 `cordis_define` 什么都不播 —— 一个请求可以点名上一次注册表读取没覆盖到的定义,那时这条活动就是那一行唯一的来源。界面从它渲染、自己不留副本 —— 这正是控件能活过 remount 的原因。
|
||||
- `renderFailures` —— **本页**最后一次渲染崩溃,按定义索引(槽位、教学 message、以及这次崩溃是否已把 entry 从格位上摘掉),与 live 集合共用同一条通知通道。它按构造就是「本页当前」:包 stop、被 retract、或重新装载成功时即清空,所以界面可以直接照着渲染。host 那边另存一份「跨页面最后一次」给模型 —— 两份的归属与寿命本来就不同,界面**不要**改成回读 host 那份。
|
||||
- `lastRunError` —— 本页自己那次尝试为何失败,按定义索引。它比活动活得更久:host 只拆失败请求自己启动的那半,所以一个页面可能看着 host 报告为「在跑」的定义,而自己什么都没装上。
|
||||
- `approve(requestId)` / `decline(requestId)` / `startUserRun({ agentId, id, hasClientHalf })` —— 两条入口。三者都幂等(按 requestId,用户自发的 run 按定义 id),所以连点两次不会起两次 run。`hasClientHalf` 是必填:纯 host 定义没有源码可取,所以由调用方从它正在操作的注册表行里把这个事实说出来,而不是让编排器从一次失败的取码里反推。可回答的请求必然带浏览器半 —— 纯 host 定义是 host 自己起的,它不会去问页面。
|
||||
- `subscribe()` / `getSnapshot()` / `isLoaded(id)` —— 这一页装了什么。`isLoaded` 是页面本地的事实,永远不等于 host 说的「在跑」。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 由模型发起那次 run 的最终回答
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
本包自己不贡献任何工具、提示词或上下文;它为一次 `cordis/request-run` 往返发回的回答,是它撰写并到达模型的第一样内容 —— host 把它变成那个被阻塞的 `cordis_run` 的结果。成功时带上已装载的 revision,以及(当浏览器半挂在这一页没有的服务上时)那些服务的名字。失败时带一个 reason:用户拒绝的 `rejected`、`host-half-failed`、或 `client-half-failed`;后者还带上本包自己的文本 —— 出错阶段(`evaluate` / `module-import` / `activate`)加上闭包、guard 或 fiber 的消息。guard 的教学错误(未声明的服务、被遮蔽的浏览器全局、返回值里没有 `apply`)正是经这个字段到达模型的。而装载之后、React 渲染时才发生的崩溃,走下面那条独立的事后通道。
|
||||
|
||||
#### token 影响
|
||||
|
||||
有条件且有界:每次 run 请求最多一个回答,花在 host 本来就会发出的那个 `cordis_run` 结果里。文本随数据而定(某个定义自己的错误消息),本包跨请求不留存任何东西 —— 一页后续的装载失败是页面本地诊断,在模型侧没有任何承载物。
|
||||
|
||||
#### KV cache 影响
|
||||
|
||||
只追加。回答只作为「本来就在途的那次请求」的工具结果到达模型、延长历史尾部;本包撰写的内容不会重写或重排更早的请求 token,因此原本可复用的前缀仍然可复用。同一定义的多次运行各自产出各自的结果,而不是替换更早那一个。
|
||||
|
||||
### run 落定之后的渲染期失败
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
一个装载得干干净净的浏览器半,仍可能在 React 渲染时崩溃,而那次崩溃发生在 run 已经被回答之后 —— 否则模型只会被告知「ok」,永远学不到。凡是本页落座过的包,其 entry 边界的每一次崩溃都会发回 host(`reportRenderFailure`):点名槽位、说明这次崩溃是否已把 entry 从格位上摘掉(`abdicated`:包的 UI 是没了、而不只是坏了),以及一条写给作者的 message —— 崩溃文本,外加「文本里点到了某个被摘掉的浏览器全局、但文本自己没教」时补上的那句教学:绕过闭包陷阱的 `window.setInterval` 只会崩成 `is not a function`,它自己什么都解释不了。host 每包只留最后一条,经 `cordis_inspect` 透给模型;这条通道上的任何东西都不会进入 run 的最终回答。同一次观察还会落到 `renderFailures` 上给本页界面用 —— 一个观察者、两个出口,因为「跨页面最后一次崩溃(给模型)」与「这一页此刻正在显示什么」是两件寿命不同的事实。
|
||||
|
||||
#### token 影响
|
||||
|
||||
有条件,且其上界由 host 的留存策略决定、不由这一页决定:每次崩溃一条报告,而 host 每包只留最新一条 —— 所以一个反复崩溃的 entry 对模型的代价是一段话,而不是一张越来越长的清单。报告本身不会自带任何工具结果:模型只在主动去问的时候才为它付费。
|
||||
|
||||
#### KV cache 影响
|
||||
|
||||
自身没有。报告经 RPC 送出并被存起来,而不是追加进对话;模型是通过自己发起的一次查看读到它的,那次查看与任何工具结果一样只延长尾部。
|
||||
|
||||
## 已知限制与欠账
|
||||
|
||||
- **被拒绝的回答不会重试。** `resolveRequestRun` 的 ack 不读,所以当 host 拒绝一个陈旧的成功答复(`accepted: false` —— 这一页装载期间定义的 revision 被顶掉了),这一页会保留已装的东西、也不再重新编排。那次请求仍可作答(别的页面作答或调用方取消都能收尾),而顶掉 revision 的那次 stop 会 retract 掉这一页的陈旧装载。重试评估过、延后:竞态窗口只是一次往返内的一次 revision 递增。
|
||||
- 插件声明了 `remote.dynamic`,因此在 host 侧 namespace 存在之前一直挂起,而不是装载一些永远够不到自己 host 半的包。
|
||||
- 槽位准入(按部署的允许/拒绝清单)没有载体:下发行声明的是服务,不是目标槽位。
|
||||
- guard 白名单是 host 侧沙箱门面的手抄孪生;抽取共享规格留待后续。
|
||||
79
packages/extensions/cordis-client-runner/package.json
Normal file
79
packages/extensions/cordis-client-runner/package.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cordis-client-runner",
|
||||
"description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries",
|
||||
"version": "0.0.1-rc.5",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/cordis-client-runner"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-modules",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
1722
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
Normal file
1722
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 */
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Closure evaluation account: the symbol surface a browser half receives, the
|
||||
* teaching traps shadowing ambient globals, the parse/return diagnostics, and
|
||||
* the style bookkeeping whose disposal the runner owns.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
DynamicCordisStyles,
|
||||
DYNAMIC_CLIENT_REDIRECTS,
|
||||
evaluateClientHalf,
|
||||
isDynamicCordisPlugin,
|
||||
} from '../src/client/evaluator.ts'
|
||||
import type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from '../src/client/evaluator.ts'
|
||||
|
||||
const ID = 'dyn-1' as CordisDynamicPluginId
|
||||
|
||||
function env(overrides: Partial<DynamicCordisClosureEnv> = {}): DynamicCordisClosureEnv {
|
||||
return {
|
||||
invoke: () => Promise.resolve(null),
|
||||
noteError: () => {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate one source with fresh style bookkeeping. */
|
||||
async function run(source: string, closure: DynamicCordisClosureEnv = env()): Promise<{
|
||||
plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
|
||||
styles: DynamicCordisStyles
|
||||
}> {
|
||||
const styles = new DynamicCordisStyles(ID)
|
||||
const plugin = await evaluateClientHalf(ID, source, closure, styles)
|
||||
return { plugin, styles }
|
||||
}
|
||||
|
||||
describe('evaluateClientHalf', () => {
|
||||
it('returns the object-form plugin and hands the page React instance to the closure', async () => {
|
||||
const { plugin } = await run(`
|
||||
if (React.createElement === undefined) throw new Error('React symbol missing')
|
||||
return { name: 'ignored', inject: ['slots'], apply(ctx) { return React } }
|
||||
`)
|
||||
expect(typeof plugin).toBe('object')
|
||||
const object = plugin as DynamicCordisEvaluatedPlugin
|
||||
expect(object.inject).toEqual(['slots'])
|
||||
// Same instance as the page's React: a second copy would break hooks.
|
||||
expect(object.apply({})).toBe(React)
|
||||
})
|
||||
|
||||
it('accepts the function form', async () => {
|
||||
const { plugin } = await run('return (ctx) => "applied"')
|
||||
expect(typeof plugin).toBe('function')
|
||||
expect((plugin as (ctx: unknown) => unknown)({})).toBe('applied')
|
||||
})
|
||||
|
||||
it('redirects browser timers to the ctx facade', async () => {
|
||||
for (const timer of ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] as const) {
|
||||
const { plugin } = await run(`return () => ${timer}(() => {}, 1)`)
|
||||
expect(() => (plugin as (ctx: unknown) => unknown)({}))
|
||||
.toThrow(DYNAMIC_CLIENT_REDIRECTS[timer])
|
||||
}
|
||||
})
|
||||
|
||||
it('redirects fetch to the host half and require to the closure symbols', async () => {
|
||||
const { plugin: fetcher } = await run('return () => fetch("/x")')
|
||||
expect(() => (fetcher as (ctx: unknown) => unknown)({})).toThrow(/network belongs to the HOST half/)
|
||||
const { plugin: importer } = await run('return () => require("react")')
|
||||
expect(() => (importer as (ctx: unknown) => unknown)({})).toThrow(/React arrives as the `React` closure symbol/)
|
||||
})
|
||||
|
||||
it('teaches the half split on any harness access', async () => {
|
||||
const { plugin } = await run('return () => harness.handle("m", () => {})')
|
||||
expect(() => (plugin as (ctx: unknown) => unknown)({}))
|
||||
.toThrow(/harness\.handle belongs to the HOST half/)
|
||||
})
|
||||
|
||||
it('routes host.call to the runner invoke seam', async () => {
|
||||
const invoke = vi.fn(() => Promise.resolve({ ok: 1 }))
|
||||
const { plugin } = await run('return { apply: (ctx) => host.call("ping", { a: 1 }) }', env({ invoke }))
|
||||
await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual({ ok: 1 })
|
||||
expect(invoke).toHaveBeenCalledWith('ping', { a: 1 })
|
||||
})
|
||||
|
||||
it('sends null for a host.call written without arguments', async () => {
|
||||
const invoke = vi.fn(() => Promise.resolve(['fs', 'web']))
|
||||
// A handler that takes nothing is the natural case ("list the services"), and
|
||||
// `undefined` is not JSON — so the omission travels as null rather than
|
||||
// making the wire refuse the call.
|
||||
const { plugin } = await run('return { apply: (ctx) => host.call("listServices") }', env({ invoke }))
|
||||
await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual(['fs', 'web'])
|
||||
expect(invoke).toHaveBeenCalledWith('listServices', null)
|
||||
})
|
||||
|
||||
it('reports a parse failure as a plain-JavaScript teaching error', async () => {
|
||||
await expect(run('return (')).rejects.toThrow(/client half failed to parse in this browser/)
|
||||
await expect(run('return (')).rejects.toThrow(/no JSX, no TypeScript/)
|
||||
})
|
||||
|
||||
it('names the missing return, and rejects a non-plugin value', async () => {
|
||||
await expect(run('const x = 1')).rejects.toThrow(/did you forget `return`/)
|
||||
await expect(run('return 42')).rejects.toThrow(/must `return` a plugin/)
|
||||
})
|
||||
|
||||
it('propagates a non-syntax construction failure untouched', async () => {
|
||||
const boom = new TypeError('engine refused')
|
||||
// The constructor is the only failure seam before evaluation; a
|
||||
// non-SyntaxError must not be reinterpreted as a source problem.
|
||||
vi.stubGlobal('Function', function stub(): never { throw boom })
|
||||
try {
|
||||
await expect(run('return () => {}')).rejects.toBe(boom)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(typeof Function).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagged console', () => {
|
||||
it('mirrors only error lines, and stringifies every argument shape', async () => {
|
||||
const seen: string[] = []
|
||||
const closure = env({ noteError: message => seen.push(message) })
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
const { plugin } = await run(`
|
||||
return { apply: (ctx) => {
|
||||
console.log('quiet')
|
||||
console.warn('also quiet')
|
||||
console.error('text', new Error('boom'), { a: 1 }, undefined, ctx.circular)
|
||||
console.debug('quiet too')
|
||||
} }
|
||||
`, closure)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
;(plugin as DynamicCordisEvaluatedPlugin).apply({ circular })
|
||||
vi.restoreAllMocks()
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]).toBe('text boom {"a":1} undefined [unserializable console argument]')
|
||||
})
|
||||
|
||||
it('truncates a long mirrored error', async () => {
|
||||
const seen: string[] = []
|
||||
const { plugin } = await run(
|
||||
'return { apply: () => console.error("x".repeat(900)) }',
|
||||
env({ noteError: message => seen.push(message) }),
|
||||
)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
;(plugin as DynamicCordisEvaluatedPlugin).apply({})
|
||||
vi.restoreAllMocks()
|
||||
expect(seen[0]).toHaveLength(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DynamicCordisStyles', () => {
|
||||
it('stamps ownership, counts live tags, and disposes one tag or all of them', () => {
|
||||
const styles = new DynamicCordisStyles(ID)
|
||||
const first = styles.insert('.a { color: red }')
|
||||
styles.insert('.b { color: blue }')
|
||||
expect(styles.count).toBe(2)
|
||||
const tags = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
|
||||
expect(tags).toHaveLength(2)
|
||||
expect(tags[0]?.textContent).toBe('.a { color: red }')
|
||||
first()
|
||||
expect(styles.count).toBe(1)
|
||||
expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(1)
|
||||
styles.dispose()
|
||||
expect(styles.count).toBe(0)
|
||||
expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a non-string stylesheet', () => {
|
||||
const styles = new DynamicCordisStyles(ID)
|
||||
expect(() => styles.insert(42 as unknown as string)).toThrow(/needs a CSS string/)
|
||||
})
|
||||
|
||||
it('exposes styles.insert to the closure', async () => {
|
||||
const { plugin, styles } = await run('return { apply: () => styles.insert(".c {}") }')
|
||||
;(plugin as DynamicCordisEvaluatedPlugin).apply({})
|
||||
expect(styles.count).toBe(1)
|
||||
styles.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDynamicCordisPlugin', () => {
|
||||
it('accepts both mountable forms and rejects everything else', () => {
|
||||
expect(isDynamicCordisPlugin(() => {})).toBe(true)
|
||||
expect(isDynamicCordisPlugin({ apply: () => {} })).toBe(true)
|
||||
expect(isDynamicCordisPlugin({})).toBe(false)
|
||||
expect(isDynamicCordisPlugin(null)).toBe(false)
|
||||
expect(isDynamicCordisPlugin(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Guard facade account: the whitelist a dynamic plugin's `apply` sees, the
|
||||
* automatic shadowing priority on the slots seat, the theme seat's pinned
|
||||
* override source and fiber-owned disposer, and the Context denial that keeps a
|
||||
* dynamic package from reaching a foreign context. Registrations ride the
|
||||
* CALLING fiber, so disposing it must remove them (HMR safety).
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { FC } from 'react'
|
||||
import type {
|
||||
CordisDynamicPackageId,
|
||||
CordisDynamicPluginId,
|
||||
CordisDynamicPluginRunId,
|
||||
DynamicCordisPackage,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { dynamicCordisContext } from '../src/client/guard.ts'
|
||||
import type { DynamicCordisSlotLedgerRow } from '../src/client/guard.ts'
|
||||
|
||||
const C: FC<object> = () => null
|
||||
|
||||
/** The exact running package carried by a Client dispatch. */
|
||||
function pkg(): DynamicCordisPackage {
|
||||
return {
|
||||
pluginId: 'dyn-1' as CordisDynamicPluginId,
|
||||
packageId: 'pkg-1' as CordisDynamicPackageId,
|
||||
pluginRunId: 'run-1' as CordisDynamicPluginRunId,
|
||||
name: 'demo',
|
||||
}
|
||||
}
|
||||
|
||||
/** Erased facade view: a dynamic package reads services off plain properties. */
|
||||
type Facade = Record<string, unknown> & { get(name: string): unknown }
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
slots: SlotRegistry
|
||||
facade: Facade
|
||||
ledger: DynamicCordisSlotLedgerRow[]
|
||||
/** Components the facade claimed for the package, in registration order. */
|
||||
claimed: unknown[]
|
||||
dispose: () => Promise<void>
|
||||
overrideTokens: ReturnType<typeof vi.fn>
|
||||
themeLayerDispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a dynamic-plugin fiber declaring `inject`, and capture the facade its
|
||||
* apply receives (the real product path: the facade wraps the fiber's own ctx).
|
||||
*/
|
||||
async function boot(inject: string[], extras: Record<string, unknown> = {}): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const themeLayerDispose = vi.fn()
|
||||
const overrideTokens = vi.fn(() => themeLayerDispose)
|
||||
ctx.reflect.provide('theme', {
|
||||
overrideTokens,
|
||||
getTheme: () => ({ preference: 'light' }),
|
||||
reload: () => Promise.resolve('reloaded'),
|
||||
revision: 3,
|
||||
escape: () => new Context(),
|
||||
escapeLater: () => Promise.resolve(new Context()),
|
||||
})
|
||||
for (const [name, value] of Object.entries(extras)) ctx.reflect.provide(name, value)
|
||||
const ledger: DynamicCordisSlotLedgerRow[] = []
|
||||
const claimed: unknown[] = []
|
||||
let nextPriority = 0
|
||||
let facade: Facade | undefined
|
||||
const fiber = ctx.plugin({
|
||||
name: 'dyn/dyn-1',
|
||||
inject,
|
||||
apply: (own: Context) => {
|
||||
facade = dynamicCordisContext(own, {
|
||||
pkg: pkg(),
|
||||
ledger,
|
||||
claim: (component) => { claimed.push(component) },
|
||||
allocatePriority: () => --nextPriority,
|
||||
reportFailure: () => {},
|
||||
}) as unknown as Facade
|
||||
},
|
||||
})
|
||||
await fiber
|
||||
if (facade === undefined) throw new Error('facade was not captured')
|
||||
return {
|
||||
ctx,
|
||||
slots: ctx.slots,
|
||||
facade,
|
||||
ledger,
|
||||
claimed,
|
||||
dispose: async () => { await fiber.dispose() },
|
||||
overrideTokens,
|
||||
themeLayerDispose,
|
||||
}
|
||||
}
|
||||
|
||||
describe('facade surface', () => {
|
||||
it('forwards whitelisted lifecycle verbs to the real ctx', async () => {
|
||||
const bench = await boot([])
|
||||
const seen: string[] = []
|
||||
const on = bench.facade.on as (event: string, listener: (key: string) => void) => void
|
||||
on('slots/changed', key => seen.push(key))
|
||||
bench.ctx.emit('slots/changed', 'root')
|
||||
expect(seen).toEqual(['root'])
|
||||
})
|
||||
|
||||
it('teaches the object form when an existing service was not declared', async () => {
|
||||
const bench = await boot([])
|
||||
expect(() => bench.facade.slots).toThrow(/service "slots" is not declared by your plugin/)
|
||||
expect(() => bench.facade.slots).toThrow(/a plain `function` has no declaration site/)
|
||||
})
|
||||
|
||||
it('withholds framework internals with a teaching list', async () => {
|
||||
const bench = await boot([])
|
||||
expect(() => bench.facade.registry).toThrow(/dynamic ctx does not expose "registry"/)
|
||||
expect(() => bench.facade.registry).toThrow(/any service your returned plugin declared in inject/)
|
||||
})
|
||||
|
||||
it('answers `get` and `has` over the same whitelist, and refuses writes', async () => {
|
||||
const bench = await boot(['slots'])
|
||||
expect(typeof bench.facade.get('slots')).toBe('object')
|
||||
expect('get' in bench.facade).toBe(true)
|
||||
expect('on' in bench.facade).toBe(true)
|
||||
expect('slots' in bench.facade).toBe(true)
|
||||
expect('registry' in bench.facade).toBe(false)
|
||||
expect(Symbol.iterator in bench.facade).toBe(false)
|
||||
expect((bench.facade as unknown as Record<symbol, unknown>)[Symbol.iterator]).toBeUndefined()
|
||||
expect(() => { bench.facade.slots = 1 }).toThrow(/dynamic ctx is read-only/)
|
||||
})
|
||||
|
||||
it('denies a service value or return that is a cordis Context', async () => {
|
||||
const bench = await boot(['leaky'], {
|
||||
leaky: { escape: () => new Context(), later: () => Promise.resolve(new Context()), plain: 7 },
|
||||
})
|
||||
const leaky = bench.facade.leaky as { escape(): unknown; later(): Promise<unknown>; plain: number }
|
||||
expect(() => leaky.escape()).toThrow(/returned a cordis Context/)
|
||||
await expect(leaky.later()).rejects.toThrow(/returned a cordis Context/)
|
||||
expect(leaky.plain).toBe(7)
|
||||
})
|
||||
|
||||
it('passes a primitive service through untouched', async () => {
|
||||
const bench = await boot(['flag'], { flag: 'on' })
|
||||
expect(bench.facade.flag).toBe('on')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slots seat', () => {
|
||||
it('assigns a descending shadowing priority per registration and ledgers it', async () => {
|
||||
const bench = await boot(['slots'])
|
||||
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
|
||||
slots.register({ name: 'root' }, C)
|
||||
slots.register({ name: 'root' }, C)
|
||||
expect(bench.ledger).toEqual([
|
||||
{ slot: 'root', priority: -1 },
|
||||
{ slot: 'root', priority: -2 },
|
||||
])
|
||||
// Newest-wins ordering is what "registering IS shadowing" means.
|
||||
const priorities = bench.slots.entries('root').map(entry => entry.options.priority)
|
||||
expect(priorities).toContain(-1)
|
||||
expect(priorities).toContain(-2)
|
||||
})
|
||||
|
||||
it('keeps an explicit priority when the target elects its own order', async () => {
|
||||
const bench = await boot(['slots'])
|
||||
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
|
||||
const spec = vi.spyOn(bench.slots, 'spec').mockReturnValue({ kind: 'chain', scope: 'root' })
|
||||
slots.register({ name: 'root', priority: 5 }, C)
|
||||
spec.mockRestore()
|
||||
expect(bench.ledger).toEqual([{ slot: 'root', priority: 5 }])
|
||||
})
|
||||
|
||||
it('rejects a malformed register call before touching the registry', async () => {
|
||||
const bench = await boot(['slots'])
|
||||
const slots = bench.facade.slots as { register(options: unknown, component: unknown): () => void }
|
||||
expect(() => slots.register(null, C)).toThrow(/needs an options object with a `name`/)
|
||||
expect(() => slots.register({}, C)).toThrow(/need a string `name`/)
|
||||
expect(bench.slots.entries('root')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('forwards non-register slot methods through the generic guard', async () => {
|
||||
const bench = await boot(['slots'])
|
||||
const slots = bench.facade.slots as {
|
||||
register(options: object, component: unknown): () => void
|
||||
entries(key: string): readonly unknown[]
|
||||
}
|
||||
slots.register({ name: 'root' }, C)
|
||||
expect(slots.entries('root')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('denies a non-callable slots member that would hand out a context', async () => {
|
||||
const bench = await boot(['slots'])
|
||||
const slots = bench.facade.slots as { ctx: unknown }
|
||||
// The service's own ctx is the classic escape route out of the facade.
|
||||
expect(() => slots.ctx).toThrow(/service "slots" returned a cordis Context/)
|
||||
})
|
||||
|
||||
it('removes its registrations when the calling fiber unloads (HMR safety)', async () => {
|
||||
const bench = await boot(['slots'])
|
||||
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
|
||||
slots.register({ name: 'root' }, C)
|
||||
expect(bench.slots.entries('root')).toHaveLength(1)
|
||||
await bench.dispose()
|
||||
expect(bench.slots.entries('root')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme seat', () => {
|
||||
it('pins the override source to the package id whatever the caller passes', async () => {
|
||||
const bench = await boot(['theme'])
|
||||
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens: unknown): () => void }
|
||||
const tokens = { '--dsw-alias-x': { light: '#fff', dark: '#000' } }
|
||||
theme.overrideTokens('pretend-to-be-someone-else', tokens)
|
||||
expect(bench.overrideTokens).toHaveBeenCalledWith('dyn-1.pkg-1', tokens)
|
||||
})
|
||||
|
||||
it('teaches the two-argument shape when the token map arrives first', async () => {
|
||||
const bench = await boot(['theme'])
|
||||
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens?: unknown): () => void }
|
||||
expect(() => theme.overrideTokens({ '--x': { light: 'a', dark: 'b' } }))
|
||||
.toThrow(/takes two arguments; source is replaced with your package id/)
|
||||
expect(bench.overrideTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hangs the layer disposer on the fiber while still returning it', async () => {
|
||||
const bench = await boot(['theme'])
|
||||
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens: unknown): () => void }
|
||||
const handle = theme.overrideTokens('mine', {})
|
||||
expect(handle).toBe(bench.themeLayerDispose)
|
||||
expect(bench.themeLayerDispose).not.toHaveBeenCalled()
|
||||
// Model code cannot be trusted to keep the handle: unload must restore.
|
||||
await bench.dispose()
|
||||
expect(bench.themeLayerDispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards other theme methods, including asynchronous ones', async () => {
|
||||
const bench = await boot(['theme'])
|
||||
const theme = bench.facade.theme as {
|
||||
getTheme(): { preference: string }
|
||||
reload(): Promise<string>
|
||||
revision: number
|
||||
}
|
||||
expect(theme.getTheme().preference).toBe('light')
|
||||
await expect(theme.reload()).resolves.toBe('reloaded')
|
||||
expect(theme.revision).toBe(3)
|
||||
})
|
||||
|
||||
it('denies a Context a theme method hands back, synchronously or awaited', async () => {
|
||||
const bench = await boot(['theme'])
|
||||
const theme = bench.facade.theme as { escape(): unknown; escapeLater(): Promise<unknown> }
|
||||
expect(() => theme.escape()).toThrow(/service "theme" returned a cordis Context/)
|
||||
await expect(theme.escapeLater()).rejects.toThrow(/service "theme" returned a cordis Context/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Run-orchestration account: the order the halves run in (and what a host-only
|
||||
* definition skips), what each failure answers the host, and what a surface can
|
||||
* read while it happens. The host seam and the load engine are stood in, because
|
||||
* what is under test is the round trip itself — the engine has its own account in
|
||||
* runner.spec.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
DynamicCordisClientSource, DynamicCordisHostHalfResult, DynamicCordisResolveAck,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { CordisRunOrchestrator } from '../src/client/orchestrator.ts'
|
||||
import type { CordisUserRunRequest } from '../src/client/orchestrator.ts'
|
||||
import type { DynamicCordisLoadResult, DynamicCordisPackageRunner } from '../src/client/runtime.ts'
|
||||
|
||||
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
|
||||
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
|
||||
const RUN = 'run-1' as CordisDynamicPluginRunId
|
||||
const AGENT = 's-1' as SessionId
|
||||
const REQ = 'rr-1' as ApprovalRequestId
|
||||
const HOST_OK: Extract<DynamicCordisHostHalfResult, { ok: true }> = {
|
||||
ok: true,
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: RUN,
|
||||
waitingFor: [],
|
||||
startedHere: true,
|
||||
}
|
||||
/** A user's own run of a two-half definition: the host half, then this page's half. */
|
||||
const DUAL: CordisUserRunRequest = {
|
||||
agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run', hasClientHalf: true,
|
||||
}
|
||||
/** A user's own run of a host-only definition: nothing for this page to load. */
|
||||
const HOST_ONLY: CordisUserRunRequest = { ...DUAL, hasClientHalf: false }
|
||||
|
||||
interface Bench {
|
||||
orchestrator: CordisRunOrchestrator
|
||||
host: {
|
||||
runHostHalf: ReturnType<typeof vi.fn>
|
||||
getClientCode: ReturnType<typeof vi.fn>
|
||||
resolveRequestRun: ReturnType<typeof vi.fn>
|
||||
settleUserRun: ReturnType<typeof vi.fn>
|
||||
}
|
||||
load: ReturnType<typeof vi.fn>
|
||||
/** Resolutions the host received, in order. */
|
||||
answers: unknown[]
|
||||
}
|
||||
|
||||
function boot(overrides: {
|
||||
hostHalf?: () => Promise<DynamicCordisHostHalfResult>
|
||||
clientCode?: () => Promise<DynamicCordisClientSource>
|
||||
loaded?: () => Promise<DynamicCordisLoadResult>
|
||||
resolve?: () => Promise<DynamicCordisResolveAck>
|
||||
} = {}): Bench {
|
||||
const answers: unknown[] = []
|
||||
const host = {
|
||||
runHostHalf: vi.fn(overrides.hostHalf ?? (() => Promise.resolve(HOST_OK))),
|
||||
getClientCode: vi.fn(overrides.clientCode ?? (() => Promise.resolve({
|
||||
code: 'return {}', name: 'demo', pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
|
||||
}))),
|
||||
resolveRequestRun: vi.fn((_requestId: unknown, resolution: unknown) => {
|
||||
answers.push(resolution)
|
||||
return (overrides.resolve ?? (() => Promise.resolve({ accepted: true })))()
|
||||
}),
|
||||
settleUserRun: vi.fn((_agentId: SessionId, _pluginId: CordisDynamicPluginId, resolution: unknown) =>
|
||||
Promise.resolve({
|
||||
ok: true as const,
|
||||
status: 'running' as const,
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: (resolution as { pluginRunId: CordisDynamicPluginRunId }).pluginRunId,
|
||||
waitingFor: [],
|
||||
mode: 'run' as const,
|
||||
})),
|
||||
}
|
||||
const load = vi.fn(overrides.loaded ?? (() => Promise.resolve({ ok: true as const, pluginRunId: RUN })))
|
||||
const orchestrator = new CordisRunOrchestrator({
|
||||
runner: { load } as unknown as DynamicCordisPackageRunner,
|
||||
host,
|
||||
})
|
||||
return { orchestrator, host, load, answers }
|
||||
}
|
||||
|
||||
/** Register one request the way the `cordis/request-run` event does. */
|
||||
function ask(bench: Bench, requestId: ApprovalRequestId = REQ): void {
|
||||
bench.orchestrator.open({
|
||||
requestId,
|
||||
agentId: AGENT,
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
mode: 'run',
|
||||
name: 'demo',
|
||||
purpose: 'draw a clock',
|
||||
requiresApproval: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('the waiting affordance', () => {
|
||||
it('publishes failures on their own observable', async () => {
|
||||
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'nope' }) })
|
||||
let notified = 0
|
||||
const unsubscribe = bench.orchestrator.lastRunError.subscribe(() => { notified++ })
|
||||
const empty = bench.orchestrator.lastRunError.getSnapshot()
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot()).toBe(empty)
|
||||
await bench.orchestrator.startUserRun(DUAL)
|
||||
expect(notified).toBeGreaterThan(0)
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN)?.reason).toBe('host-half-failed')
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('publishes one activity per definition, carrying the whole ask', () => {
|
||||
const bench = boot()
|
||||
ask(bench)
|
||||
// Everything a surface needs to show and group the row without a registry
|
||||
// read: the ask names the session, the plugin, and the model's reason.
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
|
||||
phase: 'awaiting-approval',
|
||||
requestId: REQ,
|
||||
agentId: AGENT,
|
||||
packageId: PACKAGE,
|
||||
mode: 'run',
|
||||
name: 'demo',
|
||||
purpose: 'draw a clock',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps naming the session once the decision is made', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
|
||||
const running = bench.orchestrator.startUserRun(DUAL)
|
||||
// A run must not fall out of its session group by advancing past the decision.
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
|
||||
phase: 'orchestrating',
|
||||
agentId: AGENT,
|
||||
packageId: PACKAGE,
|
||||
mode: 'run',
|
||||
})
|
||||
release()
|
||||
await running
|
||||
})
|
||||
|
||||
it('keeps a stable snapshot reference between mutations, and notifies on each', () => {
|
||||
const bench = boot()
|
||||
let notified = 0
|
||||
const unsubscribe = bench.orchestrator.activeRuns.subscribe(() => { notified++ })
|
||||
const empty = bench.orchestrator.activeRuns.getSnapshot()
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot()).toBe(empty)
|
||||
ask(bench)
|
||||
expect(notified).toBe(1)
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot()).not.toBe(empty)
|
||||
unsubscribe()
|
||||
bench.orchestrator.close(REQ)
|
||||
expect(notified).toBe(1)
|
||||
})
|
||||
|
||||
it('drops only the waiting affordance when the request settles elsewhere', async () => {
|
||||
const bench = boot()
|
||||
ask(bench)
|
||||
bench.orchestrator.close(REQ)
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
// Answering a settled request is a no-op, not an error.
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
await bench.orchestrator.decline(REQ)
|
||||
expect(bench.host.runHostHalf).not.toHaveBeenCalled()
|
||||
expect(bench.answers).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves an orchestration alone when its own request settles elsewhere', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
|
||||
ask(bench)
|
||||
const running = bench.orchestrator.approve(REQ, false)
|
||||
// The host announced the request settled (this page answered it) — the work
|
||||
// this page is doing owns its entry until it finishes.
|
||||
bench.orchestrator.close(REQ)
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
|
||||
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
|
||||
})
|
||||
release()
|
||||
await running
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('refuses to decline a request whose definition is already orchestrating', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
|
||||
const running = bench.orchestrator.startUserRun(DUAL)
|
||||
const late = 'rr-late-decline' as ApprovalRequestId
|
||||
ask(bench, late)
|
||||
await bench.orchestrator.decline(late)
|
||||
expect(bench.answers).toEqual([]) // the decision was made; a refusal now would contradict it
|
||||
release()
|
||||
await running
|
||||
})
|
||||
|
||||
it('ignores a close for a request it never saw', () => {
|
||||
const bench = boot()
|
||||
bench.orchestrator.close('rr-unknown' as ApprovalRequestId)
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('closes a request whose activity is already gone', async () => {
|
||||
const bench = boot()
|
||||
const second = 'rr-second' as ApprovalRequestId
|
||||
ask(bench)
|
||||
ask(bench, second) // same definition asked twice: the first keeps the affordance
|
||||
await bench.orchestrator.approve(REQ, false) // settles and clears the activity
|
||||
bench.orchestrator.close(second)
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not downgrade an orchestration to a waiting decision', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
|
||||
const started = bench.orchestrator.startUserRun(DUAL)
|
||||
ask(bench, 'rr-late' as ApprovalRequestId)
|
||||
// A request arriving mid-orchestration must not offer a decision already made.
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
|
||||
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
|
||||
})
|
||||
release()
|
||||
await started
|
||||
})
|
||||
})
|
||||
|
||||
describe('approve', () => {
|
||||
it('runs the host half first, then loads the browser half, then answers', async () => {
|
||||
const bench = boot()
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', REQ, false)
|
||||
expect(bench.host.getClientCode).toHaveBeenCalledWith(AGENT, PLUGIN, RUN)
|
||||
// The load carries the session too: a crash while React renders it is
|
||||
// reported back to whoever the run was carried out for.
|
||||
expect(bench.load).toHaveBeenCalledWith({
|
||||
pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, agentId: AGENT, name: 'demo', code: 'return {}',
|
||||
})
|
||||
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN }])
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('carries the services a parked browser half waits for', async () => {
|
||||
const bench = boot({ loaded: () => Promise.resolve({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] }) })
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN, waitingFor: ['absent'] }])
|
||||
})
|
||||
|
||||
it('short-circuits when the host half fails: nothing is fetched or loaded', async () => {
|
||||
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'vm exploded' }) })
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.host.getClientCode).not.toHaveBeenCalled()
|
||||
expect(bench.load).not.toHaveBeenCalled()
|
||||
expect(bench.answers).toEqual([{ ok: false, reason: 'host-half-failed', message: 'vm exploded' }])
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
|
||||
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'vm exploded' })
|
||||
})
|
||||
|
||||
it('folds a transport rejection of the host verb into its own failure shape', async () => {
|
||||
const bench = boot({ hostHalf: () => Promise.reject(new Error('socket closed')) })
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.answers).toEqual([{
|
||||
ok: false,
|
||||
reason: 'host-half-failed',
|
||||
message: 'socket closed',
|
||||
stack: expect.any(String),
|
||||
}])
|
||||
})
|
||||
|
||||
it('reports a source fetch that failed as the browser half failing', async () => {
|
||||
const bench = boot({ clientCode: () => Promise.reject(new Error('definition vanished')) })
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.load).not.toHaveBeenCalled()
|
||||
expect(bench.answers).toEqual([{
|
||||
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true,
|
||||
message: 'definition vanished', stack: expect.any(String),
|
||||
}])
|
||||
})
|
||||
|
||||
it('carries the failing load stage into the answer', async () => {
|
||||
const bench = boot({ loaded: () => Promise.resolve({ ok: false, cause: 'activate', message: 'apply threw' }) })
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.answers).toEqual([{
|
||||
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true, message: 'activate: apply threw',
|
||||
}])
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
|
||||
.toEqual({ packageId: PACKAGE, reason: 'client-half-failed', message: 'activate: apply threw' })
|
||||
})
|
||||
|
||||
it('treats a load that rejects outright as a browser-half failure', async () => {
|
||||
const bench = boot({ loaded: () => Promise.reject(new Error('module table missing')) })
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.answers).toEqual([{
|
||||
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true,
|
||||
message: 'evaluate: module table missing', stack: expect.any(String),
|
||||
}])
|
||||
})
|
||||
|
||||
it('joins a second approve into the orchestration already in flight', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
|
||||
ask(bench)
|
||||
const first = bench.orchestrator.approve(REQ, false)
|
||||
const second = bench.orchestrator.approve(REQ, false)
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
expect(bench.host.runHostHalf).toHaveBeenCalledTimes(1)
|
||||
expect(bench.answers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('logs an answer the host refused, and settles anyway', async () => {
|
||||
const bench = boot({ resolve: () => Promise.reject(new Error('stream gone')) })
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
const complaints = logged.mock.calls.filter(call => String(call[0]).includes('answering run request'))
|
||||
logged.mockRestore()
|
||||
expect(complaints).toHaveLength(1)
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('clears a previous failure when the same definition is tried again', async () => {
|
||||
const outcomes: DynamicCordisLoadResult[] = [
|
||||
{ ok: false, cause: 'activate', message: 'first try' },
|
||||
{ ok: true, pluginRunId: RUN },
|
||||
]
|
||||
const bench = boot({ loaded: () => Promise.resolve(outcomes.shift() ?? { ok: true, pluginRunId: RUN }) })
|
||||
ask(bench)
|
||||
await bench.orchestrator.approve(REQ, false)
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(1)
|
||||
await bench.orchestrator.startUserRun(DUAL)
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decline', () => {
|
||||
it('answers rejected without touching either half', async () => {
|
||||
const bench = boot()
|
||||
ask(bench)
|
||||
await bench.orchestrator.decline(REQ)
|
||||
expect(bench.host.runHostHalf).not.toHaveBeenCalled()
|
||||
expect(bench.load).not.toHaveBeenCalled()
|
||||
expect(bench.answers).toEqual([{ ok: false, reason: 'rejected' }])
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
// A refusal is not this page failing.
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('is a no-op once the decision was already made', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
|
||||
ask(bench)
|
||||
const running = bench.orchestrator.approve(REQ, false)
|
||||
await bench.orchestrator.decline(REQ)
|
||||
expect(bench.answers).toEqual([])
|
||||
release()
|
||||
await running
|
||||
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN }])
|
||||
})
|
||||
|
||||
it('ignores an unknown request', async () => {
|
||||
const bench = boot()
|
||||
await bench.orchestrator.decline('rr-unknown' as ApprovalRequestId)
|
||||
expect(bench.answers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('startUserRun', () => {
|
||||
it('orchestrates both halves with nothing to answer', async () => {
|
||||
const bench = boot()
|
||||
await bench.orchestrator.startUserRun(DUAL)
|
||||
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', null, false)
|
||||
expect(bench.load).toHaveBeenCalledTimes(1)
|
||||
// No request was asked, so there is no blocked tool call to settle.
|
||||
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('records its own failure for the surface to show', async () => {
|
||||
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'no definition' }) })
|
||||
await bench.orchestrator.startUserRun(DUAL)
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
|
||||
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'no definition' })
|
||||
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a source fetch failure with nothing to answer', async () => {
|
||||
const bench = boot({ clientCode: () => Promise.reject(new Error('gone')) })
|
||||
await bench.orchestrator.startUserRun(DUAL)
|
||||
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
|
||||
.toEqual({
|
||||
packageId: PACKAGE,
|
||||
reason: 'client-half-failed',
|
||||
message: 'gone',
|
||||
stack: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
it('records a load failure, stringifying a non-Error rejection', async () => {
|
||||
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the case under test
|
||||
const bench = boot({ loaded: () => Promise.reject('plain rejection') })
|
||||
await bench.orchestrator.startUserRun(DUAL)
|
||||
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
|
||||
.toEqual({ packageId: PACKAGE, reason: 'client-half-failed', message: 'evaluate: plain rejection' })
|
||||
})
|
||||
|
||||
it('is idempotent per definition while one attempt is in flight', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
|
||||
const first = bench.orchestrator.startUserRun(DUAL)
|
||||
const second = bench.orchestrator.startUserRun(DUAL)
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
expect(bench.host.runHostHalf).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('brings a host-only definition up without fetching or loading anything', async () => {
|
||||
const bench = boot()
|
||||
await bench.orchestrator.startUserRun(HOST_ONLY)
|
||||
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', null, false)
|
||||
// There is no second half: asking for source that does not exist would be a
|
||||
// mistake, and folding its error into `client-half-failed` would report a run
|
||||
// that succeeded as a failure of a half the definition never had.
|
||||
expect(bench.host.getClientCode).not.toHaveBeenCalled()
|
||||
expect(bench.load).not.toHaveBeenCalled()
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('publishes a host-only run while it is in flight, and its own failure', async () => {
|
||||
let release = (): void => {}
|
||||
const bench = boot({
|
||||
hostHalf: () => new Promise((resolve) => {
|
||||
release = (): void => { resolve({ ok: false, message: 'vm exploded' }) }
|
||||
}),
|
||||
})
|
||||
const running = bench.orchestrator.startUserRun(HOST_ONLY)
|
||||
// The control a surface disables comes from this entry, and a host half can
|
||||
// take real time to evaluate — so a host-only run is in flight like any other.
|
||||
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
|
||||
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
|
||||
})
|
||||
release()
|
||||
await running
|
||||
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
|
||||
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'vm exploded' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Plugin composition account: the dispatch family reaches the runner with its
|
||||
* envelope rpcId, the service face is provided for UI surfaces, a load failure
|
||||
* always reaches the console, and the fiber owns the runner's teardown. Plus the two plane-level companions: the
|
||||
* node half's empty apply and the invariant registration.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: resolves `ctx.remote` and with it the `$on`/`$dispatch` surface.
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import * as NodeHalf from '../src/index.ts'
|
||||
import * as Invariant from '../src/invariant.ts'
|
||||
import * as ClientHalf from '../src/client/index.ts'
|
||||
|
||||
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
|
||||
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
|
||||
const RUN = 'run-1' as CordisDynamicPluginRunId
|
||||
const AGENT = 's-1' as SessionId
|
||||
const USER_RUN = {
|
||||
agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' as const, hasClientHalf: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one forwarded Host event the way the runtime's frame bridge does: the
|
||||
* bridge hands `host/remote-event` to the Remote service, which fans it out to
|
||||
* `$on` subscribers with the Host's own argument list.
|
||||
*/
|
||||
function forward(ctx: Context, event: string, payload: object): void {
|
||||
ctx.remote.$dispatch(event, [payload])
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
/** Source the host hands over for the next run. */
|
||||
source: { current: {
|
||||
code: string
|
||||
name: string
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
} }
|
||||
/** Resolutions the host received. */
|
||||
resolved: { requestId: string; resolution: unknown }[]
|
||||
/** What the namespace received. */
|
||||
invoked: { pluginId: CordisDynamicPluginId; pluginRunId: CordisDynamicPluginRunId; method: string; args: unknown }[]
|
||||
/** Answer of the next invoke call. */
|
||||
invokeResult: { current: DynamicCordisInvokeResult }
|
||||
/** Rejection the namespace throws instead of answering (the codec refusing a payload). */
|
||||
invokeThrow: { current: unknown }
|
||||
/** Render failures the namespace received, in order. */
|
||||
renderFailures: {
|
||||
agentId: string
|
||||
pluginId: CordisDynamicPluginId
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
failure: unknown
|
||||
}[]
|
||||
/** Whether the namespace refuses the next render-failure report. */
|
||||
reportRefused: { current: boolean }
|
||||
/**
|
||||
* Report one entry crash the way the renderer's boundary does. Production calls
|
||||
* this from web-react's boundary through the render host; a test has no React
|
||||
* tree, so it stands in for that caller on the same core seam.
|
||||
*/
|
||||
crash: (slot: string, entry: unknown, abdicate: boolean, error: unknown) => void
|
||||
dispose: () => Promise<void>
|
||||
settle: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Mount the browser half over a module table and a loader standing on real fibers. */
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const factories = new Map<string, () => unknown>()
|
||||
const fibers = new Map<string, { fiber: unknown }>()
|
||||
let next = 0
|
||||
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
|
||||
load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
|
||||
}
|
||||
ctx.reflect.provide('loader', {
|
||||
create: (options: { name: string }) => {
|
||||
const entryId = `entry-${++next}`
|
||||
const fiber = ctx.plugin(factories.get(options.name)?.() as Parameters<Context['plugin']>[0])
|
||||
// The runner reads activation failure through fiber.await(); terminate this
|
||||
// handle too, or a failing package also lands as an unhandled rejection.
|
||||
void Promise.resolve(fiber).catch(() => {})
|
||||
fibers.set(entryId, { fiber })
|
||||
return Promise.resolve(entryId)
|
||||
},
|
||||
resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
|
||||
remove: async (entryId: string) => {
|
||||
const entry = fibers.get(entryId)
|
||||
fibers.delete(entryId)
|
||||
await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
|
||||
},
|
||||
})
|
||||
ctx.reflect.provide('modules', { invalidate: () => {} })
|
||||
const invoked: Bench['invoked'] = []
|
||||
const invokeResult: { current: DynamicCordisInvokeResult } = { current: { ok: true, value: 'pong' } }
|
||||
const invokeThrow: { current: unknown } = { current: undefined }
|
||||
const source: Bench['source'] = { current: {
|
||||
code: 'return { apply(ctx) {} }',
|
||||
name: 'demo',
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: RUN,
|
||||
} }
|
||||
const resolved: { requestId: string; resolution: unknown }[] = []
|
||||
const renderFailures: Bench['renderFailures'] = []
|
||||
const reportRefused = { current: false }
|
||||
// Every generated Remote method resolves to a RemoteResult: the carrier folds
|
||||
// its own failures into the error branch, and only an assembly fault rejects.
|
||||
const answered = <T>(value: T): Promise<{ ok: true; value: T }> => Promise.resolve({ ok: true as const, value })
|
||||
const namespace = {
|
||||
syncInspectManifest: () => answered(null),
|
||||
resolveInspectQuery: () => answered({ accepted: true }),
|
||||
runHostHalf: () => answered({
|
||||
ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [], startedHere: true,
|
||||
}),
|
||||
settleUserRun: () => answered({
|
||||
ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [],
|
||||
}),
|
||||
reportRenderFailure: (
|
||||
agentId: string,
|
||||
pluginId: CordisDynamicPluginId,
|
||||
pluginRunId: CordisDynamicPluginRunId,
|
||||
failure: unknown,
|
||||
) => {
|
||||
renderFailures.push({ agentId, pluginId, pluginRunId, failure })
|
||||
return reportRefused.current ? Promise.reject(new Error('stream gone')) : answered(undefined)
|
||||
},
|
||||
getClientCode: () => answered(source.current),
|
||||
resolveRequestRun: (requestId: string, resolution: unknown) => {
|
||||
resolved.push({ requestId, resolution })
|
||||
return answered({ accepted: true })
|
||||
},
|
||||
invoke: (
|
||||
pluginId: CordisDynamicPluginId,
|
||||
pluginRunId: CordisDynamicPluginRunId,
|
||||
method: string,
|
||||
args: unknown,
|
||||
) => {
|
||||
invoked.push({ pluginId, pluginRunId, method, args })
|
||||
const refusal = invokeThrow.current
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is a case under test
|
||||
if (refusal !== undefined) return Promise.reject(refusal)
|
||||
return answered(invokeResult.current)
|
||||
},
|
||||
}
|
||||
// Minimal stand-in for the gateway's Client Remote: the fan-out under test is
|
||||
// this plugin's subscriptions, so registration order and delivery are all the
|
||||
// stub owes (api-gateway covers isolation and disposal on the real one).
|
||||
const listeners = new Map<string, ((...args: never[]) => void)[]>()
|
||||
const remote = {
|
||||
dynamicCordisRunner: namespace,
|
||||
$on: (event: string, listener: (...args: never[]) => void) => {
|
||||
const bucket = listeners.get(event) ?? []
|
||||
bucket.push(listener)
|
||||
listeners.set(event, bucket)
|
||||
return () => {
|
||||
const at = bucket.indexOf(listener)
|
||||
if (at >= 0) bucket.splice(at, 1)
|
||||
}
|
||||
},
|
||||
$dispatch: (event: string, args: readonly unknown[]) => {
|
||||
for (const listener of [...listeners.get(event) ?? []]) {
|
||||
(listener as (...a: readonly unknown[]) => void)(...args)
|
||||
}
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('remote', remote)
|
||||
ctx.reflect.provide('remote.dynamicCordisRunner', namespace)
|
||||
const fiber = ctx.plugin(ClientHalf)
|
||||
await fiber
|
||||
return {
|
||||
ctx,
|
||||
source,
|
||||
resolved,
|
||||
invoked,
|
||||
invokeResult,
|
||||
invokeThrow,
|
||||
renderFailures,
|
||||
reportRefused,
|
||||
crash: (slot, entry, abdicate, error) => {
|
||||
const core = (ctx.slots as unknown as {
|
||||
_core: { reportEntryError(key: string, entry: unknown, error: unknown, info: { abdicate: boolean }): void }
|
||||
})._core
|
||||
core.reportEntryError(slot, entry, error, { abdicate })
|
||||
},
|
||||
dispose: async () => { await fiber.dispose() },
|
||||
settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser half', () => {
|
||||
it('provides the load engine as the page run-state face', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
|
||||
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
|
||||
})
|
||||
|
||||
it('unloads on a forwarded withdrawal event', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
|
||||
forward(bench.ctx, 'cordis/dynamic-retract', {
|
||||
pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
|
||||
})
|
||||
await bench.settle()
|
||||
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
|
||||
})
|
||||
|
||||
it('runs a host-only definition through the face without loading anything here', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun({ ...USER_RUN, hasClientHalf: false })
|
||||
// The host half is up and this page has nothing — and no failure, which is
|
||||
// what the surface's control promised.
|
||||
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
|
||||
expect(bench.ctx.dynamicCordisRunner.lastRunError.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('routes host.call through the namespace and unwraps the result', async () => {
|
||||
const bench = await boot()
|
||||
bench.source.current = { ...bench.source.current,
|
||||
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", { a: 1 })'
|
||||
+ '.then((value) => value, (error) => error.message) } }',
|
||||
}
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
|
||||
delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
|
||||
await expect(call).resolves.toBe('pong')
|
||||
expect(bench.invoked).toEqual([{
|
||||
pluginId: PLUGIN, pluginRunId: RUN, method: 'ping', args: { a: 1 },
|
||||
}])
|
||||
})
|
||||
|
||||
it('carries an omitted host.call argument to the namespace as null', async () => {
|
||||
const bench = await boot()
|
||||
bench.source.current = { ...bench.source.current,
|
||||
code: 'return { apply: () => { globalThis.__dynCall = host.call("listServices") } }',
|
||||
}
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
|
||||
delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
|
||||
await call
|
||||
// `undefined` is not JSON, so the wire would refuse the call the model wrote
|
||||
// most naturally; the omission travels as null instead.
|
||||
expect(bench.invoked).toEqual([{
|
||||
pluginId: PLUGIN, pluginRunId: RUN, method: 'listServices', args: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it('teaches the JSON contract when the namespace refuses the payload', async () => {
|
||||
const bench = await boot()
|
||||
// What the generated codec throws for a value that is not JSON: a bare field
|
||||
// name, with no idea which call it belonged to or what to write instead.
|
||||
bench.invokeThrow.current = new Error('client api: dynamicCordisRunner/invoke rejected "args"')
|
||||
bench.source.current = { ...bench.source.current,
|
||||
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
|
||||
+ '.then(() => "resolved", (error) => error.message) } }',
|
||||
}
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
|
||||
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
|
||||
await expect(call).resolves.toMatch(/host\.call\("ping"\) on dyn-1 did not complete: client api: .*rejected "args"/)
|
||||
await expect(call).resolves.toMatch(/omit it, and the handler receives null/)
|
||||
await expect(call).resolves.toMatch(/`return null` when there is nothing to report/)
|
||||
})
|
||||
|
||||
it('stringifies a non-Error refusal into the same teaching error', async () => {
|
||||
const bench = await boot()
|
||||
bench.invokeThrow.current = 'stream gone'
|
||||
bench.source.current = { ...bench.source.current,
|
||||
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping")'
|
||||
+ '.then(() => "resolved", (error) => error.message) } }',
|
||||
}
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
|
||||
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
|
||||
await expect(call).resolves.toMatch(/did not complete: stream gone/)
|
||||
})
|
||||
|
||||
it('sends a render crash of its own entry to the host, and survives a refused report', async () => {
|
||||
const bench = await boot()
|
||||
bench.source.current = { ...bench.source.current,
|
||||
code: `return {
|
||||
inject: ['slots'],
|
||||
apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
|
||||
}`,
|
||||
}
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
const [entry] = bench.ctx.slots.entries('root')
|
||||
bench.crash('root', entry, true, new Error('Cannot read properties of undefined'))
|
||||
expect(bench.renderFailures).toEqual([{
|
||||
agentId: AGENT,
|
||||
pluginId: PLUGIN,
|
||||
pluginRunId: RUN,
|
||||
failure: {
|
||||
slot: 'root',
|
||||
message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
|
||||
stack: expect.any(String),
|
||||
abdicated: true,
|
||||
},
|
||||
}])
|
||||
// The same observation also reaches the page's own surface, so a row can show
|
||||
// it without reading the host back.
|
||||
expect(bench.ctx.dynamicCordisRunner.renderFailures.getSnapshot().get(PLUGIN)).toEqual(bench.renderFailures[0]?.failure)
|
||||
// A report the host refuses is logged and dropped: one crash must not become
|
||||
// two, and nothing waits on this answer.
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
bench.reportRefused.current = true
|
||||
bench.crash('root', entry, false, new Error('again'))
|
||||
await bench.settle()
|
||||
const complaints = logged.mock.calls.filter(call => String(call[0]).includes('reporting a render failure'))
|
||||
logged.mockRestore()
|
||||
expect(complaints).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('turns each routing failure code into its own teaching error', async () => {
|
||||
const codes = [
|
||||
['plugin-not-running', /found no active Host half/],
|
||||
['stale-run', /activation that has already been replaced/],
|
||||
['method-not-found', /must declare it with harness\.handle\("ping", fn\)/],
|
||||
['handler-error', /failed inside the host handler: boom/],
|
||||
] as const
|
||||
for (const [code, expected] of codes) {
|
||||
const bench = await boot()
|
||||
bench.invokeResult.current = { ok: false, code, message: 'boom' }
|
||||
bench.source.current = { ...bench.source.current,
|
||||
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
|
||||
+ '.then(() => "resolved", (error) => error.message) } }',
|
||||
}
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
|
||||
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
|
||||
await expect(call).resolves.toMatch(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('answers a run request after the surface approves it', async () => {
|
||||
const bench = await boot()
|
||||
const request = 'rr-1' as ApprovalRequestId
|
||||
forward(bench.ctx, 'cordis/request-run', {
|
||||
requestId: request,
|
||||
agentId: AGENT,
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
mode: 'run',
|
||||
name: 'demo',
|
||||
purpose: 'show a clock',
|
||||
requiresApproval: true,
|
||||
})
|
||||
await bench.settle()
|
||||
// The event's own fields reach the activity: a surface groups the row by
|
||||
// session and shows the reason without a registry read.
|
||||
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
|
||||
phase: 'awaiting-approval',
|
||||
requestId: request,
|
||||
agentId: AGENT,
|
||||
packageId: PACKAGE,
|
||||
mode: 'run',
|
||||
name: 'demo',
|
||||
purpose: 'show a clock',
|
||||
})
|
||||
await bench.ctx.dynamicCordisRunner.approve(request, false)
|
||||
expect(bench.resolved).toEqual([{
|
||||
requestId: request, resolution: { ok: true, pluginRunId: RUN },
|
||||
}])
|
||||
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
|
||||
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('drops the affordance when another page answers the request', async () => {
|
||||
const bench = await boot()
|
||||
const request = 'rr-2' as ApprovalRequestId
|
||||
forward(bench.ctx, 'cordis/request-run', {
|
||||
requestId: request,
|
||||
agentId: AGENT,
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
mode: 'run',
|
||||
name: 'demo',
|
||||
purpose: 'p',
|
||||
requiresApproval: true,
|
||||
})
|
||||
await bench.settle()
|
||||
forward(bench.ctx, 'cordis/request-run-resolved', {
|
||||
requestId: request, outcome: 'approved',
|
||||
})
|
||||
await bench.settle()
|
||||
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
|
||||
// Answering a settled request is a no-op, not an error.
|
||||
await bench.ctx.dynamicCordisRunner.approve(request, false)
|
||||
expect(bench.resolved).toEqual([])
|
||||
})
|
||||
|
||||
it('exposes the refusal and the load observer on the face', async () => {
|
||||
const bench = await boot()
|
||||
const request = 'rr-3' as ApprovalRequestId
|
||||
forward(bench.ctx, 'cordis/request-run', {
|
||||
requestId: request,
|
||||
agentId: AGENT,
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
mode: 'run',
|
||||
name: 'demo',
|
||||
purpose: 'p',
|
||||
requiresApproval: true,
|
||||
})
|
||||
await bench.settle()
|
||||
let loads = 0
|
||||
const unsubscribe = bench.ctx.dynamicCordisRunner.subscribe(() => { loads++ })
|
||||
await bench.ctx.dynamicCordisRunner.decline(request)
|
||||
expect(bench.resolved).toEqual([{ requestId: request, resolution: { ok: false, reason: 'rejected' } }])
|
||||
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
expect(loads).toBeGreaterThan(0)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('unloads every package when its own fiber goes away', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
|
||||
const runner = bench.ctx.dynamicCordisRunner
|
||||
await bench.dispose()
|
||||
await bench.settle()
|
||||
expect(runner.getSnapshot()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('node half', () => {
|
||||
it('contributes nothing host-side', () => {
|
||||
NodeHalf.apply()
|
||||
expect(typeof NodeHalf.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('reserves package ownership with an explained empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = ctx.plugin(Invariant)
|
||||
await fiber
|
||||
expect(Invariant.name).toBe('cordis-client-runner-invariant')
|
||||
// No relation to audit here: the owned one is browser-local runner state.
|
||||
// An event this plugin declares nothing about: the bridge must not route it here.
|
||||
expect(() => { (ctx.emit as (type: string) => void)('unrelated/event') }).not.toThrow()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Load-engine account: what `load` answers its caller (that answer is what the
|
||||
* run orchestration reports to the host), Plugin Run convergence against live
|
||||
* state, per-Plugin serialization, the three-step teardown, and each failing stage.
|
||||
*
|
||||
* The loader is stood in by real `ctx.plugin` fibers: entry creation must run the
|
||||
* guarded surface as a genuine plugin, or neither activation gating nor the
|
||||
* disposal cascade under test would be real.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
} 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 { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts'
|
||||
import { DynamicCordisPackageRunner } from '../src/client/runtime.ts'
|
||||
import type { DynamicCordisClientHalf, DynamicCordisRenderFailure } from '../src/client/runtime.ts'
|
||||
|
||||
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
|
||||
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
|
||||
const RUN = 'run-1' as CordisDynamicPluginRunId
|
||||
const AGENT = 's-1' as SessionId
|
||||
|
||||
function runId(value: number): CordisDynamicPluginRunId {
|
||||
return `run-${value}` as CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
/** One browser half as the host hands it over. */
|
||||
function half(overrides: Partial<DynamicCordisClientHalf> = {}): DynamicCordisClientHalf {
|
||||
return {
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: RUN,
|
||||
agentId: AGENT,
|
||||
name: 'demo',
|
||||
code: 'return { apply(ctx) {} }',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
slots: SlotRegistry
|
||||
runner: DynamicCordisPackageRunner
|
||||
invalidated: string[]
|
||||
removed: string[]
|
||||
created: string[]
|
||||
invoke: ReturnType<typeof vi.fn>
|
||||
/** Render failures the runner sent upstream, in order. */
|
||||
reported: {
|
||||
agentId: SessionId
|
||||
pluginId: CordisDynamicPluginId
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
failure: DynamicCordisRenderFailure
|
||||
}[]
|
||||
/**
|
||||
* Report one entry crash the way the renderer's boundary does: the runner
|
||||
* subscribed through the supervision seam, and this calls what it registered.
|
||||
*/
|
||||
crash: (slot: string, entry: unknown, error: unknown, abdicated?: boolean) => void
|
||||
/** Whether the runner released its subscription. */
|
||||
watching: () => boolean
|
||||
settle: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the awaitable fiber handle. The runner reads activation failure
|
||||
* through `fiber.await()`; without a handler on the fiber itself, a deliberately
|
||||
* failing package would also surface as an unhandled rejection.
|
||||
*/
|
||||
function seated<T>(fiber: T): T {
|
||||
void Promise.resolve(fiber).catch(() => {})
|
||||
return fiber
|
||||
}
|
||||
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const invalidated: string[] = []
|
||||
const removed: string[] = []
|
||||
const created: string[] = []
|
||||
const factories = new Map<string, () => unknown>()
|
||||
const fibers = new Map<string, { fiber: unknown }>()
|
||||
let next = 0
|
||||
|
||||
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
|
||||
load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
|
||||
}
|
||||
const loader = {
|
||||
create: (options: { name: string }) => {
|
||||
created.push(options.name)
|
||||
const factory = factories.get(options.name)
|
||||
if (factory === undefined) throw new Error(`no factory for ${options.name}`)
|
||||
const entryId = `entry-${++next}`
|
||||
fibers.set(entryId, { fiber: seated(ctx.plugin(factory() as Parameters<Context['plugin']>[0])) })
|
||||
return Promise.resolve(entryId)
|
||||
},
|
||||
resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
|
||||
remove: async (entryId: string) => {
|
||||
removed.push(entryId)
|
||||
const entry = fibers.get(entryId)
|
||||
fibers.delete(entryId)
|
||||
await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
|
||||
},
|
||||
} as unknown as Loader
|
||||
|
||||
const invoke = vi.fn(() => Promise.resolve(null))
|
||||
const reported: Bench['reported'] = []
|
||||
// The crash seam is stood in so a test can report an entry failure without a
|
||||
// React render, exactly as the renderer's boundary would; registrations still
|
||||
// go through the real service, so the entries are real.
|
||||
type EntryErrorListener = (slot: string, entry: unknown, error: unknown, info: { abdicated: boolean }) => void
|
||||
let listener: EntryErrorListener | undefined
|
||||
const runner = new DynamicCordisPackageRunner({
|
||||
ctx,
|
||||
loader,
|
||||
modules: { invalidate: (id: string) => { invalidated.push(id) } } as unknown as ClientModuleSystem,
|
||||
slots: {
|
||||
onEntryError: (fn: EntryErrorListener) => {
|
||||
listener = fn
|
||||
return () => { listener = undefined }
|
||||
},
|
||||
} as unknown as SlotRegistry,
|
||||
invoke,
|
||||
reportGuardFailure: () => {},
|
||||
reportRenderFailure: (agentId, pluginId, pluginRunId, failure) => {
|
||||
reported.push({ agentId, pluginId, pluginRunId, failure })
|
||||
},
|
||||
})
|
||||
return {
|
||||
ctx,
|
||||
slots: ctx.slots,
|
||||
runner,
|
||||
invalidated,
|
||||
removed,
|
||||
created,
|
||||
invoke,
|
||||
reported,
|
||||
crash: (slot, entry, error, abdicated = true) => {
|
||||
if (listener === undefined) throw new Error('the runner is not watching the crash seam')
|
||||
listener(slot, entry, error, { abdicated })
|
||||
},
|
||||
watching: () => listener !== undefined,
|
||||
settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('load', () => {
|
||||
it('mounts a browser half through the module table and the loader, then answers active', async () => {
|
||||
const bench = await boot()
|
||||
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
|
||||
expect(bench.invalidated).toEqual(['dyn/dyn-1'])
|
||||
expect(bench.created).toEqual(['dyn/dyn-1'])
|
||||
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
|
||||
expect(bench.runner.getSnapshot()).toEqual([
|
||||
{ pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: [], styleCount: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects the contributions the package made', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({
|
||||
code: `return {
|
||||
inject: ['slots'],
|
||||
apply(ctx) {
|
||||
styles.insert('.x {}')
|
||||
ctx.slots.register({ name: 'root' }, () => null)
|
||||
},
|
||||
}`,
|
||||
}))
|
||||
expect(bench.runner.getSnapshot()).toEqual([
|
||||
{ pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: ['root'], styleCount: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('answers from live state when the revision is already loaded here', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half())
|
||||
// A replayed run must not look unacknowledged, and must not reload.
|
||||
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
|
||||
expect(bench.created).toEqual(['dyn/dyn-1'])
|
||||
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
|
||||
})
|
||||
|
||||
it('replays the parked services a live package still waits for', async () => {
|
||||
const bench = await boot()
|
||||
const parked = half({ code: "return { inject: ['absent'], apply() {} }" })
|
||||
await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
|
||||
await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
|
||||
expect(bench.created).toEqual(['dyn/dyn-1'])
|
||||
})
|
||||
|
||||
it('replaces a live load when a newer revision arrives', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half())
|
||||
await expect(bench.runner.load(half({ pluginRunId: runId(2) }))).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
|
||||
expect(bench.removed).toEqual(['entry-1'])
|
||||
expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1', 'dyn/dyn-1'])
|
||||
expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
|
||||
expect(bench.runner.getSnapshot()[0]?.pluginRunId).toBe(runId(2))
|
||||
})
|
||||
|
||||
it('loads the function form, which declares no services', async () => {
|
||||
const bench = await boot()
|
||||
await expect(bench.runner.load(half({ code: 'return (ctx) => { globalThis.__dynFnForm = true }' })))
|
||||
.resolves.toEqual({ ok: true, pluginRunId: RUN })
|
||||
expect((globalThis as { __dynFnForm?: boolean }).__dynFnForm).toBe(true)
|
||||
delete (globalThis as { __dynFnForm?: boolean }).__dynFnForm
|
||||
})
|
||||
|
||||
it('serializes operations of one package id', async () => {
|
||||
const bench = await boot()
|
||||
const first = bench.runner.load(half())
|
||||
const second = bench.runner.load(half({ pluginRunId: runId(2) }))
|
||||
await expect(first).resolves.toEqual({ ok: true, pluginRunId: RUN })
|
||||
await expect(second).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
|
||||
expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
|
||||
})
|
||||
|
||||
it('keeps the queue usable after a failed operation', async () => {
|
||||
const bench = await boot()
|
||||
const sink = (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
|
||||
delete (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
|
||||
await expect(bench.runner.load(half())).rejects.toThrow(/__ModuleLoader__ is missing/)
|
||||
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = sink
|
||||
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure stages', () => {
|
||||
it('classifies a closure that will not evaluate, and leaves no styles behind', async () => {
|
||||
const bench = await boot()
|
||||
await expect(bench.runner.load(half({ code: 'styles.insert(".leak {}"); return 42' }))).resolves.toEqual({
|
||||
ok: false,
|
||||
cause: 'evaluate',
|
||||
message: expect.stringContaining('must `return` a plugin') as string,
|
||||
stack: expect.any(String),
|
||||
error: expect.any(Error),
|
||||
})
|
||||
const leaked = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
|
||||
.filter(tag => tag.textContent === '.leak {}')
|
||||
expect(leaked).toHaveLength(0)
|
||||
expect(bench.created).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies an apply that throws, and tears the entry down', async () => {
|
||||
const bench = await boot()
|
||||
await expect(bench.runner.load(half({ code: 'return { apply() { throw new Error("apply exploded") } }' })))
|
||||
.resolves.toEqual({
|
||||
ok: false,
|
||||
cause: 'activate',
|
||||
message: 'apply exploded',
|
||||
stack: expect.any(String),
|
||||
error: expect.any(Error),
|
||||
})
|
||||
expect(bench.removed).toEqual(['entry-1'])
|
||||
expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
|
||||
})
|
||||
|
||||
it('stringifies a closure that rejects with a non-Error value', async () => {
|
||||
const bench = await boot()
|
||||
await expect(bench.runner.load(half({ code: 'throw "raw rejection"' })))
|
||||
.resolves.toEqual({ ok: false, cause: 'evaluate', message: 'raw rejection', error: 'raw rejection' })
|
||||
})
|
||||
|
||||
it('classifies a loader entry that produced no fiber', async () => {
|
||||
const bench = await boot()
|
||||
const env = bench.runner as unknown as { env: { loader: { resolve: (id: string) => unknown } } }
|
||||
vi.spyOn(env.env.loader, 'resolve').mockReturnValue({ fiber: undefined })
|
||||
await expect(bench.runner.load(half())).resolves.toEqual({
|
||||
ok: false,
|
||||
cause: 'module-import',
|
||||
message: 'module import failed (see the browser console)',
|
||||
})
|
||||
vi.restoreAllMocks()
|
||||
expect(bench.removed).toEqual(['entry-1'])
|
||||
})
|
||||
|
||||
it('mirrors a loaded package runtime error to the console without unloading it', async () => {
|
||||
const bench = await boot()
|
||||
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await bench.runner.load(half({
|
||||
code: 'return { apply: (ctx) => { ctx.on("t/ping", () => console.error("after load")) } }',
|
||||
}))
|
||||
;(bench.ctx.emit as (type: string) => void)('t/ping')
|
||||
const mirrored = logged.mock.calls.filter(call => String(call[0]).includes('logged an error'))
|
||||
logged.mockRestore()
|
||||
expect(mirrored).toHaveLength(1)
|
||||
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retract', () => {
|
||||
it('unloads at the named revision', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half())
|
||||
bench.runner.retract(PLUGIN, RUN)
|
||||
await bench.settle()
|
||||
expect(bench.removed).toEqual(['entry-1'])
|
||||
expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
|
||||
expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a retract of a superseded revision', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ pluginRunId: runId(3) }))
|
||||
bench.runner.retract(PLUGIN, runId(2))
|
||||
await bench.settle()
|
||||
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a retract of a package this page never loaded', async () => {
|
||||
const bench = await boot()
|
||||
bench.runner.retract(PLUGIN, RUN)
|
||||
await bench.settle()
|
||||
expect(bench.removed).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('observation and disposal', () => {
|
||||
it('notifies subscribers and re-derives the snapshot after each convergence', async () => {
|
||||
const bench = await boot()
|
||||
let notified = 0
|
||||
const unsubscribe = bench.runner.subscribe(() => { notified++ })
|
||||
const empty = bench.runner.getSnapshot()
|
||||
expect(bench.runner.getSnapshot()).toBe(empty) // stable between mutations
|
||||
await bench.runner.load(half())
|
||||
expect(notified).toBe(1)
|
||||
expect(bench.runner.getSnapshot()).not.toBe(empty)
|
||||
unsubscribe()
|
||||
bench.runner.retract(PLUGIN, RUN)
|
||||
await bench.settle()
|
||||
expect(notified).toBe(1)
|
||||
})
|
||||
|
||||
it('unloads every live package on disposal', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half())
|
||||
await bench.runner.dispose()
|
||||
expect(bench.removed).toEqual(['entry-1'])
|
||||
expect(bench.runner.getSnapshot()).toEqual([])
|
||||
expect(bench.slots.entries('root')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('routes host.call through the invoke seam it was given', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: 'return { apply: () => host.call("ping", 1) }' }))
|
||||
expect(bench.invoke).toHaveBeenCalledWith(PLUGIN, RUN, 'ping', 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('render failures', () => {
|
||||
/** A package that seats one component in `root`, so a crash has something to name. */
|
||||
const CONTRIBUTOR = `return {
|
||||
inject: ['slots'],
|
||||
apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
|
||||
}`
|
||||
|
||||
it('reports a crash of an entry it seated, under the session the run was for', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
const [entry] = bench.slots.entries('root')
|
||||
bench.crash('root', entry, new Error('Cannot read properties of undefined'))
|
||||
expect(bench.reported).toEqual([{
|
||||
agentId: AGENT,
|
||||
pluginId: PLUGIN,
|
||||
pluginRunId: RUN,
|
||||
failure: {
|
||||
slot: 'root',
|
||||
message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
|
||||
stack: expect.any(String),
|
||||
abdicated: true,
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('carries the retirement bit as the seam reported it', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
const [entry] = bench.slots.entries('root')
|
||||
// A chain crash keeps its cell: the package's UI is broken, not gone, and the
|
||||
// author needs to be able to tell those apart.
|
||||
bench.crash('root', entry, new Error('boom'), false)
|
||||
expect(bench.reported[0]?.failure.abdicated).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a crash of an entry no dynamic package seated', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
// Factory UI crashing is not this runner's business, and neither is an entry
|
||||
// whose component cannot even be indexed by identity.
|
||||
bench.crash('root', { component: () => null }, new Error('boom'))
|
||||
bench.crash('root', { component: 'not-a-component' }, new Error('boom'))
|
||||
bench.crash('root', { component: null }, new Error('boom'))
|
||||
expect(bench.reported).toEqual([])
|
||||
})
|
||||
|
||||
it('seats a package that registers an unindexable component without claiming it', async () => {
|
||||
const bench = await boot()
|
||||
// A component that is not an object has no identity to key ownership on; the
|
||||
// registration still stands, and a crash on it simply goes unattributed.
|
||||
await expect(bench.runner.load(half({
|
||||
code: `return {
|
||||
inject: ['slots'],
|
||||
apply(ctx) {
|
||||
ctx.slots.register({ name: 'root' }, 'not-a-component')
|
||||
ctx.slots.register({ name: 'root' }, null)
|
||||
},
|
||||
}`,
|
||||
}))).resolves.toEqual({ ok: true, pluginRunId: RUN })
|
||||
for (const entry of bench.slots.entries('root')) bench.crash('root', entry, new Error('boom'))
|
||||
expect(bench.reported).toEqual([])
|
||||
})
|
||||
|
||||
it('appends the redirect a bare crash text is missing, and never twice', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
const [entry] = bench.slots.entries('root')
|
||||
// Reaching the global around the closure trap (window.setInterval) crashes
|
||||
// with the engine's own text, which teaches nothing on its own.
|
||||
bench.crash('root', entry, new TypeError('window.setInterval is not a function'))
|
||||
const bare = bench.reported[0]?.failure.message ?? ''
|
||||
expect(bare).toMatch(/is not a function\n/)
|
||||
const timerRedirect = DYNAMIC_CLIENT_REDIRECTS.setInterval
|
||||
if (timerRedirect === undefined) throw new Error('setInterval redirect is missing')
|
||||
expect(bare).toContain(timerRedirect)
|
||||
// The trap's own error already carries that sentence: appending it again
|
||||
// would make the model read the same paragraph twice.
|
||||
bench.crash('root', entry, new Error(
|
||||
`setInterval is not available in a dynamic client half — ${timerRedirect}`,
|
||||
))
|
||||
const trapped = bench.reported[1]?.failure.message ?? ''
|
||||
expect(trapped.indexOf(timerRedirect)).toBe(trapped.lastIndexOf(timerRedirect))
|
||||
})
|
||||
|
||||
it('stops watching the seam when the engine is disposed', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
expect(bench.watching()).toBe(true)
|
||||
await bench.runner.dispose()
|
||||
expect(bench.watching()).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes the crash on the live set\'s own notification channel', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
let notified = 0
|
||||
let alsoNotified = 0
|
||||
const unsubscribe = bench.runner.subscribe(() => { notified++ })
|
||||
const unobserve = bench.runner.renderFailures.subscribe(() => { alsoNotified++ })
|
||||
const empty = bench.runner.renderFailures.getSnapshot()
|
||||
expect(bench.runner.renderFailures.getSnapshot()).toBe(empty) // stable between mutations
|
||||
const [entry] = bench.slots.entries('root')
|
||||
bench.crash('root', entry, new Error('boom'), false)
|
||||
// A surface already subscribed for load changes learns about a crash too: one
|
||||
// channel, two derived snapshots — and the observable's own subscribe is that
|
||||
// same channel, so a surface may take either handle.
|
||||
expect(notified).toBe(1)
|
||||
expect(alsoNotified).toBe(1)
|
||||
const published = bench.runner.renderFailures.getSnapshot().get(PLUGIN)
|
||||
expect(published?.slot).toBe('root')
|
||||
expect(published?.abdicated).toBe(false)
|
||||
expect(published?.message).toMatch(/boom/)
|
||||
unsubscribe()
|
||||
unobserve()
|
||||
})
|
||||
|
||||
it('keeps only the latest crash per package', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
const [entry] = bench.slots.entries('root')
|
||||
bench.crash('root', entry, new Error('first'))
|
||||
bench.crash('root', entry, new Error('second'))
|
||||
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
|
||||
expect(bench.runner.renderFailures.getSnapshot().get(PLUGIN)?.message).toMatch(/second/)
|
||||
})
|
||||
|
||||
it('clears the crash when the package is retracted', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
const [entry] = bench.slots.entries('root')
|
||||
bench.crash('root', entry, new Error('boom'))
|
||||
bench.runner.retract(PLUGIN, RUN)
|
||||
await bench.settle()
|
||||
// A row must never show a failure of something that no longer renders here.
|
||||
expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the crash when the package loads again', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
const [entry] = bench.slots.entries('root')
|
||||
bench.crash('root', entry, new Error('boom'))
|
||||
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR, pluginRunId: runId(2) }))
|
||||
expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the crash when a replayed run loads nothing', async () => {
|
||||
const bench = await boot()
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
const [entry] = bench.slots.entries('root')
|
||||
bench.crash('root', entry, new Error('boom'))
|
||||
// Same revision: nothing was re-run, so the failure the page is showing is
|
||||
// still true of what is mounted.
|
||||
await bench.runner.load(half({ code: CONTRIBUTOR }))
|
||||
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
|
||||
})
|
||||
})
|
||||
39
packages/extensions/cordis-client-runner/tsconfig.json
Normal file
39
packages/extensions/cordis-client-runner/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/modules"
|
||||
},
|
||||
{
|
||||
"path": "../../client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-cordis-client-runner', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/extensions/cordis-host-runner/README.i18n.yaml
Normal file
6
packages/extensions/cordis-host-runner/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/cordis-host-runner/README.md
|
||||
README.md: f09e7506fe24676ea95b3ae490b55ae120bfad5f
|
||||
README.zh.md: f60d3a64ecb1e90427b966c820a857e62d65b375
|
||||
72
packages/extensions/cordis-host-runner/README.md
Normal file
72
packages/extensions/cordis-host-runner/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# @deepseek-ai/dsh-cordis-host-runner
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The host half of model-mounted dynamic packages: the definition registry, the `node:vm` sandbox and fiber lifecycle for host halves, the invoke handler table, and the run round trip a browser page carries out. Provided as `ctx.dynamicCordisRunner`. The model-facing tools live in [`@deepseek-ai/dsh-tool-cordis`](../tool-cordis/README.md); the browser half is loaded by [`@deepseek-ai/dsh-cordis-client-runner`](../cordis-client-runner/README.md).
|
||||
|
||||
## What it does
|
||||
|
||||
Two phases: `define` only records, and everything with an effect hangs off a run.
|
||||
|
||||
- `define` / `undefine` own a definition's life. `define` trims and requires the metadata, prechecks each half's syntax by compiling it (running nothing), mints `dyn-<n>`, and records the definition against the session that asked — it has no effect to roll back, so unparseable code is refused before an id exists. `undefine` stops a running definition first, then forgets it. Neither crosses the wire: only the model's own tool call defines.
|
||||
- `run` answers the model's request to run one definition, and its two shapes differ by whose business the package is. A host-only package is this process's own: the host half is evaluated in the vm under the `cordis-dynamic` group fiber and the call returns. A package with a browser half has to be carried out by a page, so `run` becomes an answerable round trip — it emits `cordis/request-run`, suspends, and is settled by a person allowing or declining it. There is no timer; the caller's `AbortSignal` (the asking turn was cancelled) is the only other way out, and it announces the cancellation so other pages stop offering an answer. Whether any page will answer is not knowable when the request is sent — a page that received it may still never answer, so a deployment with no page connected suspends like any other unanswered request and ends in `cancelled`. `run` has no wire face — `cordis_run` calls it in process.
|
||||
- `runHostHalf` / `getClientCode` are the steps an allowed page walks, host half first, so a host-half failure short-circuits before the browser has moved. `runHostHalf` is idempotent by contract: a running package is bound rather than evaluated again, concurrent calls for one definition evaluate it once, and `startedHere` names the caller that did. `getClientCode` then hands that one page the browser-half source, refusing a definition that is gone, has no browser half, or is not running. Code never rides an announcement, so this is the only way it reaches a browser.
|
||||
- `resolveRequestRun` closes the round trip with the answering page's verdict, and broadcasts `cordis/request-run-resolved` so every other page drops the pending affordance. The first answer wins; a later or unknown request id is accepted and ignored. A success naming a revision the registry has moved past is refused rather than applied (`accepted: false`, request still suspended), because the page that answered loaded a dispatch that is no longer live. A failing verdict unwinds the host half only when this same request evaluated it, so a page that cannot load its own half never stops a package the other pages are using.
|
||||
- `stop` unwinds one live dispatch — handlers dropped, host-half fiber disposed to quiescence, `dynamicCordisRunner/retract` broadcast — and leaves the definition runnable.
|
||||
- `inventory` answers the whole registry, unaddressed by session and with each row naming the session that owns it, because the run-control surface is global. Listing is not acting: every acting verb still checks that ownership. Each row also names whether the definition has a browser half, so a run-control surface offers loading it into the current page only when there is a half to load. `snapshot` is its session-scoped host-local counterpart, carrying each live host half's fiber so `cordis_inspect` can render provides/waiting/state itself (a fiber cannot cross the wire).
|
||||
- `reportRenderFailure` records what a page saw a LOADED browser half do wrong at render time. Rendering happens strictly after a load succeeded, so a run has already answered `ok` by then: this report is fire-and-forget, carries no settle authority, and never touches `resolveRequestRun` or any part of the run outcome — **it is not the retired v2 `report`/ack**. The host keeps the last failure per definition across every page (a second page reporting overwrites), and a fresh run, a stop, or an undefine clears it, so the model is never shown a failure from a dispatch that no longer exists. The browser-half face keeps its own "what THIS page is showing now"; the two answer different questions rather than duplicating one. A report for a definition the reporting session does not own is dropped, because the reporting path must never fail a render.
|
||||
- `invoke` routes one call from a package's browser half to a method its own host half registered with `harness.handle`. The infrastructure only routes — no host-to-browser direction exists.
|
||||
|
||||
A refusal from `run` or `stop` names one of `definition-missing`, `host-half-failed`, `client-half-failed`, `rejected`, `cancelled`, or `not-running`; the last three are answers rather than defects — a person declined, the asking turn ended, or there was nothing running to stop.
|
||||
|
||||
A definition another session defined reads as absent rather than forbidden, so nothing leaks across sessions. `invoke` and `resolveRequestRun` carry no session at all: a component's call and a page's answer are page-global facts, not one session's.
|
||||
|
||||
Four forwarded events belong to this feature, declared by this package on its client-safe [`./types`](src/types.ts) subpath and allowlisted for delivery by [`@deepseek-ai/dsh-api-remotes`](../../api/remotes/README.md), which is what lets a browser reach them through `ctx.remote.$on`: `cordis/request-run` (`{requestId, agentId, id, name, purpose}` — metadata, never code), `cordis/request-run-resolved` (`{requestId, outcome}`), `dynamicCordisRunner/package` (`{id, name, rev}`), and `dynamicCordisRunner/retract` (`{id, rev}`). The last two are a symmetric pair announcing run state — every fresh start and every stop, whether or not the package has a browser half.
|
||||
|
||||
## Storage stance
|
||||
|
||||
The registry is process memory and the only source of truth. The session log carries a define call's metadata — never its code — so a restarted process legitimately has no definitions, and a card whose id no longer resolves says exactly that rather than pretending it can run. Nothing here is written to disk, and no definition is restored automatically; a reloaded page holds nothing until someone runs a package again, which is what makes it bind the live host half and re-fetch the browser half.
|
||||
|
||||
## Trust stance
|
||||
|
||||
The vm sandbox isolates globals but is not a security boundary: Node globals are absent or redirect to Cordis services (`ctx.fs`, `ctx.web`, `ctx.bash`, the timer helpers), and a host half receives a façade without framework internals, yet the services it declares reach the live runtime. Treat a dynamic package like bash access — see the [self-referential toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `vmTimeoutMs` | `5000` | Milliseconds the synchronous portion of a host half may run in the vm before evaluation is aborted |
|
||||
|
||||
One field is all there is: a run request waits for a person, so the round trip has no deadline of its own.
|
||||
|
||||
## Export shape
|
||||
|
||||
Service package: default-exports `DynamicCordisRunnerService` (service key `dynamicCordisRunner`), with `./types` carrying the payload shapes the `dynamicCordisRunner` remote namespace and its consumers share. The `define` / `undefine` shapes stay inside the package, because they never cross the wire.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Refusals and teaching errors relayed by the cordis tools
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing directly: this package registers no tool and injects no prompt. Its refusals reach the model through the `cordis_*` tool results that call it — an unparseable half names the offending line, a missing definition explains that definitions live in memory only, a `rejected` or `cancelled` run reports that a person declined or the turn ended rather than that anything failed, and a failed browser-half load carries the answering page's own error text.
|
||||
|
||||
#### Token effect
|
||||
|
||||
None of its own: every message above is carried by the calling tool's result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A host half that registers tools changes the next request's tool view, which invalidates prefix reuse from the first changed schema token; running or stopping a package with no tool registrations is prefix-neutral.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A successful run does not mean the UI rendered.** `run` returns once the answering page has LOADED the browser half; React renders afterwards, so a component that throws cannot possibly appear in the run receipt. The failure surfaces through `reportRenderFailure` and is read back with `cordis_inspect what:"temporary"`; the run result says so rather than implying success.
|
||||
|
||||
- A package with a browser half **suspends where no page is connected** — headless and ACP deployments hold the run until the asking turn is cancelled, because a forwarded event reports nothing about who received it. Host-only packages are unaffected.
|
||||
- A suspended run request has **no timeout**: it waits for a person until the asking turn is cancelled, so unattended automation cannot use packages with a browser half.
|
||||
- `vmTimeoutMs` bounds only synchronous evaluation; an async host-half body escapes it, matching the toolset's cooperative trust stance.
|
||||
- `runHostHalf` carries no request id, so "which request evaluated this host half" is attributed host-side to the most recently armed request for that definition; several concurrent run requests for one definition would need that rule revisited.
|
||||
- A success answer naming a superseded revision is refused (`accepted: false`) and leaves the request suspended, so the model's call ends only through a valid answer or its own cancellation. Settling it would take a fresh orchestration against the live revision, and no page does that today — the [browser half](../cordis-client-runner/README.md) does not read the ack — so in practice such a request is closed by another page's answer or by the caller's cancellation.
|
||||
- A browser half's declared `inject` is read from the plugin it returns in the page, so the announcement carries no service-declaration field at all.
|
||||
- **`zod` is a runtime dependency of the generated TypeRT faces, not of `src`.** `./typert` and `./remote` resolve to `lib/typert.*.js`, which `tsc` emits unbundled with a bare `import { z } from 'zod'`, so the package must declare it (the `@deepseek-ai/dsh-goal` precedent) and `knip.json` must ignore it for this workspace — knip reads source, and these faces are build products. Nothing in `src` imports zod.
|
||||
72
packages/extensions/cordis-host-runner/README.zh.md
Normal file
72
packages/extensions/cordis-host-runner/README.zh.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# @deepseek-ai/dsh-cordis-host-runner
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
由模型挂载的动态包在 host 侧的那一半:定义注册表、host 半所用的 `node:vm` 沙箱与 fiber 生命周期、invoke handler 表,以及由某个浏览器页面执行的 run 往返。以 `ctx.dynamicCordisRunner` 提供。面向模型的工具在 [`@deepseek-ai/dsh-tool-cordis`](../tool-cordis/README.md) 中;浏览器半由 [`@deepseek-ai/dsh-cordis-client-runner`](../cordis-client-runner/README.md) 装载。
|
||||
|
||||
## 功能
|
||||
|
||||
分两个阶段:`define` 只做登记,一切带副作用的动作都挂在一次 run 上。
|
||||
|
||||
- `define`/`undefine` 掌管一个定义的生命周期。`define` 对元数据做首尾去空白与必填校验,通过编译预检每一半的语法(不执行任何代码),铸出 `dyn-<n>`,并把该定义登记在发起调用的会话名下——它没有任何可回滚的副作用,所以无法解析的代码在拿到 id 之前就被拒绝。`undefine` 先停掉正在运行的定义,再把它忘掉。两者都不上 wire:只有模型自己的工具调用才会 define。
|
||||
- `run` 回答模型「运行某个定义」的请求,它的两种形态取决于这个包是谁的事。只有 host 半的包是本进程自己的事:host 半在 `cordis-dynamic` group fiber 之下于 vm 中求值,调用随即返回。带浏览器半的包必须由一个页面来执行,于是 `run` 变成一次可作答的往返——它 emit `cordis/request-run`、挂起,并由某个人允许或拒绝来结束。这里没有定时器;调用方的 `AbortSignal`(提问的那一轮次被取消)是唯一的另一条出路,而且它会把这次取消播报出去,让其他页面不再提供作答入口。请求发出时**并不知道**会不会有人作答——收到它的页面也可能永远不答,所以没有页面连接的部署与其他未作答请求一样挂起,最终以 `cancelled` 收场。`run` 没有 wire 面——`cordis_run` 在进程内调用它。
|
||||
- `runHostHalf`/`getClientCode` 是获得允许的页面依次走的步骤,host 半在先,因此 host 半失败会在浏览器还没动作之前短路。`runHostHalf` 在约定上是幂等的:已在运行的包只做绑定,不再求值;针对同一个定义的并发调用只求值一次,`startedHere` 指出求值的是哪一个调用方。随后 `getClientCode` 把浏览器半的源码交给这一个页面;定义已消失、没有浏览器半、或未在运行时,它会拒绝。代码从不搭乘任何播报,所以这是它到达浏览器的唯一途径。
|
||||
- `resolveRequestRun` 用作答页面的结论结束这次往返,并 emit `cordis/request-run-resolved`,让其他每个页面撤下待作答的入口。首答即成;更晚的或未知的 request id 会被接受并忽略。命名了注册表已越过的版本的成功结论会被拒绝而非应用(`accepted: false`,请求仍处于挂起),因为作答的那个页面装载的是一个已不再存活的下发。失败的结论只会在 host 半正是由这次请求求值时才将它回退,因此某个页面装不上自己那一半,绝不会把其他页面正在使用的包停掉。
|
||||
- `stop` 回退一次存活的下发——丢弃 handler、把 host 半 fiber dispose(资源释放)到完全停稳、emit `dynamicCordisRunner/retract`——并让该定义仍然可运行。
|
||||
- `inventory` 回答整个注册表,不按会话寻址,且每一行都指明拥有该定义的会话,因为运行控制面是全局的。能列出不等于能操作:每个有实际动作的动词仍会检查这份归属。每一行还会指明该定义有没有浏览器半,因此运行控制面只在确有可装载的半时,才提供「装入当前页面」。`snapshot` 是它按会话限定的 host 本地对侧,携带每个存活 host 半的 fiber,供 `cordis_inspect` 自行渲染 provides/waiting/state(fiber 无法跨 wire)。
|
||||
- `reportRenderFailure` 记录某个页面看到一个**已装载**的浏览器半在渲染时做错了什么。渲染严格发生在装载成功之后,因此到那时 run 早已回答了 `ok`:这份上报是 fire-and-forget 的,不带任何结算权威,也绝不触碰 `resolveRequestRun` 或 run 结论的任何部分——**它不是那个已退役的 v2 `report`/ack**。host 按定义保留跨所有页面的最后一次失败(第二个页面上报即覆盖),而一次全新的 run、一次 stop 或一次 undefine 都会清掉它,因此模型绝不会看到一次已不存在的下发留下的失败。浏览器半的契约面自己保留一份「**这个页面**当前正在显示什么」;两者回答的是不同的问题,不是同一个问题的两份答案。上报的会话若并不拥有该定义,这次上报会被丢弃,因为上报路径绝不能让一次渲染失败。
|
||||
- `invoke` 把一个包的浏览器半发起的一次调用,路由到它自己的 host 半用 `harness.handle` 注册的方法。这套基础设施只做路由:不存在 host 到浏览器的方向。
|
||||
|
||||
`run` 或 `stop` 的拒绝会给出 `definition-missing`、`host-half-failed`、`client-half-failed`、`rejected`、`cancelled`、`not-running` 之一;后三者是答复而非缺陷——有人拒绝了、提问的那一轮次已结束,或本来就没有在运行的东西可停。
|
||||
|
||||
别的会话登记的定义读起来是不存在,而不是被禁止,因此不会跨会话泄漏任何东西。`invoke` 与 `resolveRequestRun` 完全不携带会话:组件的一次调用和页面的一次作答都是页面全局的事实,不属于某一个会话。
|
||||
|
||||
本功能拥有四条转发事件,由本包在其 client-safe 的 [`./types`](src/types.ts) 子路径上声明,并由 [`@deepseek-ai/dsh-api-remotes`](../../api/remotes/README.md) 的白名单准许投递——正是这一点让浏览器能经 `ctx.remote.$on` 收到它们:`cordis/request-run`(`{requestId, agentId, id, name, purpose}`——只有元数据,绝无代码)、`cordis/request-run-resolved`(`{requestId, outcome}`)、`dynamicCordisRunner/package`(`{id, name, rev}`),以及 `dynamicCordisRunner/retract`(`{id, rev}`)。后两者是对称的一对运行状态播报:每次全新启动与每次停止都播,与该包有没有浏览器半无关。
|
||||
|
||||
## 存储立场
|
||||
|
||||
注册表就是进程内存,也是唯一真源。会话日志只承载一次 define 调用的元数据,绝不承载它的代码:因此进程重启后确实没有任何定义,这是合理的;而 id 已无法解析的卡片会如实说明这一点,不会假装自己还能运行。本包不向磁盘写任何东西,也不会自动恢复任何定义;刷新过的页面手上什么都没有,直到有人再次运行某个包——正是这一步让它绑定存活的 host 半并重新取回浏览器半。
|
||||
|
||||
## 信任立场
|
||||
|
||||
vm 沙箱隔离全局变量,但不是安全边界:Node 全局变量不存在,或重定向到 Cordis 服务(`ctx.fs`、`ctx.web`、`ctx.bash` 以及定时器 helper),host 半收到的是不含框架内部机制的 façade,但它声明的服务仍会触达存活运行时。应当像对待 bash 访问一样对待动态包,参见[自引用工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `vmTimeoutMs` | `5000` | host 半在 vm 中同步执行的那部分被中止求值前可运行的毫秒数 |
|
||||
|
||||
就这一个字段:一次 run 请求等的是人,所以这趟往返本身没有任何截止期限。
|
||||
|
||||
## 导出形状
|
||||
|
||||
服务包:默认导出 `DynamicCordisRunnerService`(服务键 `dynamicCordisRunner`),`./types` 则承载 `dynamicCordisRunner` remote namespace 与其消费方共享的载荷形状。`define`/`undefine` 的形状留在包内部,因为它们从不跨 wire。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 经 cordis 工具转达的拒绝与教学式错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
没有直接可见的内容:本包不注册任何工具,也不注入提示词。它的拒绝经调用它的 `cordis_*` 工具结果到达模型——无法解析的半会指出出错的那一行,缺失的定义会解释定义只活在内存里,`rejected` 或 `cancelled` 的 run 报告的是有人拒绝或该轮次已结束而非出了故障,浏览器半装载失败则带上作答页面自己的错误文本。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
本包自身没有:上述每条消息都由调用它的那个工具的结果承载。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
注册工具的 host 半会改变下一次请求的工具视图,从第一个变化的 schema token 起使前缀复用失效;运行或停止一个不注册任何工具的包对前缀不产生影响。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **run 成功不等于 UI 渲染成功。** 只要作答页面**已装载**浏览器半,`run` 就会返回;React 是随后才渲染的,因此一个抛异常的组件根本不可能出现在 run 的回执里。该失败经 `reportRenderFailure` 浮现,并通过 `cordis_inspect what:"temporary"` 读回;run 的结果会把这一点说出来,而不是暗示成功。
|
||||
|
||||
- 带浏览器半的包在**没有页面连接的地方会挂起**——headless 与 ACP(Agent Client Protocol)部署会把这次 run 一直挂到提问的轮次被取消,因为转发事件不回报谁收到了它。只有 host 半的包不受影响。
|
||||
- 挂起的 run 请求**没有超时**:它一直等人,直到提问的那一轮次被取消,因此无人值守的自动化用不了带浏览器半的包。
|
||||
- `vmTimeoutMs` 只约束同步求值;async 的 host 半函数体会逃出该上限,这与该工具集基于协作的信任立场一致。
|
||||
- `runHostHalf` 不携带 request id,因此「这个 host 半是哪次请求求值的」由 host 侧归因到该定义最近一次挂起的请求;若同一个定义出现多个并发 run 请求,这条规则需要重新审议。
|
||||
- 命名了已被取代版本的成功结论会被拒绝(`accepted: false`)并让该请求继续挂起,因此模型这次调用只能靠一次有效作答或自身被取消才结束。要把它结算掉,需要对着存活版本重新走一遍编排,而当前没有任何页面会这么做——[浏览器半](../cordis-client-runner/README.md)不读这个 ack——所以这类请求实际上由别的页面作答、或由调用方取消来收尾。
|
||||
- 浏览器半声明的 `inject` 是从它在页面里返回的插件上读出的,因此播报完全不携带服务声明字段。
|
||||
- **`zod` 是生成的 TypeRT 契约面的运行时依赖,不是 `src` 的依赖。** `./typert` 与 `./remote` 解析到 `lib/typert.*.js`,`tsc` 以不打包的形式产出它们,其中带有裸的 `import { z } from 'zod'`,所以本包必须声明它(沿用 `@deepseek-ai/dsh-goal` 的先例),而 `knip.json` 必须在这个 workspace 里忽略它:knip 读的是源码,而这些契约面是构建产物。`src` 里没有任何代码 import zod。
|
||||
78
packages/extensions/cordis-host-runner/package.json
Normal file
78
packages/extensions/cordis-host-runner/package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cordis-host-runner",
|
||||
"description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages",
|
||||
"version": "0.0.1-rc.5",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/cordis-host-runner"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
}
|
||||
}
|
||||
836
packages/extensions/cordis-host-runner/src/guard.ts
Normal file
836
packages/extensions/cordis-host-runner/src/guard.ts
Normal file
@@ -0,0 +1,836 @@
|
||||
/**
|
||||
* The registration boundary between a sandboxed host half and the real runtime: ParameterSchemaSpec
|
||||
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
|
||||
* `harness.registerTool` pair, the `harness.handle` invoke-handler normalizer, the SANDBOX CONTEXT
|
||||
* FAÇADE a running plugin's `apply` receives in place of the real `ctx`, and the plugin-shape
|
||||
* helpers the run lifecycle narrows sandbox return values with. The façade is a whitelist of
|
||||
* lifecycle-safe verbs and declared services; framework internals and context-valued service
|
||||
* returns are denied.
|
||||
*
|
||||
* VM-realm schemas and canonical values are rebuilt as host objects, while rendered content and
|
||||
* presentation metadata are shape-checked before entering the registry. Common JSON-Schema spellings are normalized when they
|
||||
* have one meaning; invalid vocabulary fails during registration with a teaching error.
|
||||
* @module @deepseek-ai/dsh-cordis-host-runner/guard
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Plugin } from '@deepseek-ai/cordis'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const DYNAMIC_TOOL = Symbol('cordis-host-runner.dynamic-tool')
|
||||
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
|
||||
const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
|
||||
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
|
||||
|
||||
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
|
||||
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
return prototype === null
|
||||
|| typeof prototype === 'object'
|
||||
&& Object.getPrototypeOf(prototype) === null
|
||||
&& hasIntrinsicConstructor(prototype, 'Object')
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
|
||||
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
if (typeof constructor !== 'function') return false
|
||||
try {
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
|
||||
function hasPlainArrayPrototype(value: unknown[]): boolean {
|
||||
const prototype: unknown = Object.getPrototypeOf(value)
|
||||
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
|
||||
const objectPrototype: unknown = Object.getPrototypeOf(prototype)
|
||||
return typeof objectPrototype === 'object'
|
||||
&& objectPrototype !== null
|
||||
&& Object.getPrototypeOf(objectPrototype) === null
|
||||
&& hasIntrinsicConstructor(objectPrototype, 'Object')
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Whether a schema list is a dense intrinsic array with no JSON-invisible decorations. */
|
||||
function isDensePlainArray(value: unknown): value is unknown[] {
|
||||
if (!Array.isArray(value) || !hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Reject schema records whose declarations would disappear from object enumeration. */
|
||||
function assertSchemaContainerKeys(value: Record<string, unknown>, path: string): void {
|
||||
if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
|
||||
throw new Error(`harness.defineTool ${path} must contain only own enumerable string keys`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where one cloned JSON value is installed. */
|
||||
type CloneDestination =
|
||||
| { kind: 'root' }
|
||||
| { kind: 'array'; target: unknown[]; index: number }
|
||||
| { kind: 'object'; target: Record<string, unknown>; key: string }
|
||||
|
||||
/** Deferred work for stack-safe cross-realm JSON cloning. */
|
||||
type CloneTask =
|
||||
| { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
|
||||
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
|
||||
| { kind: 'leave'; source: object }
|
||||
|
||||
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions; `path` carries the caller's own error prefix. */
|
||||
function cloneJson(value: unknown, path: string): unknown {
|
||||
const ancestors = new Set<object>()
|
||||
let root: unknown
|
||||
const assign = (destination: CloneDestination, item: unknown): void => {
|
||||
if (destination.kind === 'root') {
|
||||
root = item
|
||||
return
|
||||
}
|
||||
if (destination.kind === 'array') {
|
||||
destination.target[destination.index] = item
|
||||
return
|
||||
}
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
const reject = (at: string): never => {
|
||||
// Naming the executable next step matters more than naming the rule: the
|
||||
// usual cause is a handler that returns whatever its last call produced,
|
||||
// and the fix is one keyword.
|
||||
throw new Error(`${at} must be lossless JSON data (objects, arrays, strings, numbers, booleans, null) — `
|
||||
+ 'not a class instance, function, Map/Set, Date, or undefined. Return a plain object built from the '
|
||||
+ 'values you need, or `return null` when the caller needs no value back.')
|
||||
}
|
||||
|
||||
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
ancestors.delete(task.source)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'array-item') {
|
||||
if (!Object.hasOwn(task.source, task.index)) reject(task.path)
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: task.source[task.index],
|
||||
path: `${task.path}[${task.index}]`,
|
||||
destination: { kind: 'array', target: task.target, index: task.index },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const current = task.value
|
||||
if (current === null || typeof current === 'string' || typeof current === 'boolean') {
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current === 'number') {
|
||||
if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
|
||||
assign(task.destination, current)
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
|
||||
const output: unknown[] = []
|
||||
assign(task.destination, output)
|
||||
ancestors.add(current)
|
||||
tasks.push({ kind: 'leave', source: current })
|
||||
for (let index = current.length - 1; index >= 0; index--) {
|
||||
tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!isPlainRecord(current)) reject(task.path)
|
||||
const record = current as Record<string, unknown>
|
||||
if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
|
||||
reject(task.path)
|
||||
}
|
||||
const output: Record<string, unknown> = {}
|
||||
assign(task.destination, output)
|
||||
ancestors.add(record)
|
||||
tasks.push({ kind: 'leave', source: record })
|
||||
const entries = Object.entries(record)
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'visit',
|
||||
value: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
destination: { kind: 'object', target: output, key: entry[0] },
|
||||
})
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Copy and realm-materialize the shared annotation vocabulary. */
|
||||
function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void {
|
||||
if (Object.hasOwn(value, 'description')) output.description = value.description
|
||||
if (Object.hasOwn(value, 'title')) output.title = value.title
|
||||
if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `harness.defineTool ${path}.default`)
|
||||
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `harness.defineTool ${path}.examples`)
|
||||
}
|
||||
|
||||
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
|
||||
function assertSchemaKeys(value: Record<string, unknown>, path: string, allowed: readonly string[]): void {
|
||||
assertSchemaContainerKeys(value, path)
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
|
||||
* ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
|
||||
* default, while the direct DSL is already an implicit open property map.
|
||||
*/
|
||||
function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
|
||||
spec: Record<string, unknown>
|
||||
rootAnnotations?: Record<string, unknown>
|
||||
} {
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`)
|
||||
}
|
||||
if (value.type === 'object') {
|
||||
assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
|
||||
if (!isPlainRecord(value.properties)) {
|
||||
throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'required') && value.required === undefined) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
|
||||
}
|
||||
const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`)
|
||||
const rootAnnotations: Record<string, unknown> = {}
|
||||
copyAnnotations(value, rootAnnotations, path)
|
||||
return {
|
||||
spec: normalizePropertyMap(value.properties, path, required, true),
|
||||
...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }),
|
||||
}
|
||||
}
|
||||
return { spec: normalizePropertyMap(value, path, new Set(), false) }
|
||||
}
|
||||
|
||||
/** Validate raw required names and return their lookup set. */
|
||||
function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
|
||||
if (value === undefined) return new Set()
|
||||
if (!isDensePlainArray(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
|
||||
}
|
||||
const names = new Set<string>()
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const name = value[index]
|
||||
if (typeof name !== 'string') {
|
||||
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
|
||||
}
|
||||
names.add(name)
|
||||
if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** Mutable holder used only while one normalized property-map root is unresolved. */
|
||||
interface NormalizeRoot {
|
||||
value?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Where a normalized value node is installed. */
|
||||
type NormalizeValueDestination =
|
||||
| { kind: 'property'; target: Record<string, unknown>; key: string }
|
||||
| { kind: 'item'; target: Record<string, unknown> }
|
||||
| { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
|
||||
|
||||
/** Where a normalized property map is installed. */
|
||||
type NormalizeMapDestination =
|
||||
| { kind: 'root'; holder: NormalizeRoot }
|
||||
| { kind: 'properties'; target: Record<string, unknown> }
|
||||
|
||||
/** Deferred work for stack-safe sandbox schema normalization. */
|
||||
type NormalizeTask =
|
||||
| {
|
||||
kind: 'map'
|
||||
entries: Record<string, unknown>
|
||||
path: string
|
||||
requiredNames: ReadonlySet<string>
|
||||
raw: boolean
|
||||
destination: NormalizeMapDestination
|
||||
}
|
||||
| {
|
||||
kind: 'value'
|
||||
value: unknown
|
||||
path: string
|
||||
forceRequired: boolean
|
||||
raw: boolean
|
||||
parameterProperty: boolean
|
||||
destination: NormalizeValueDestination
|
||||
}
|
||||
| { kind: 'leave'; value: object }
|
||||
|
||||
/** Install one normalized node without `__proto__` assignment semantics. */
|
||||
function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
|
||||
if (destination.kind === 'property') {
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
} else if (destination.kind === 'item') {
|
||||
destination.target.items = value
|
||||
} else {
|
||||
destination.target[destination.index] = value
|
||||
}
|
||||
}
|
||||
|
||||
/** Install one normalized property map at its root or containing object. */
|
||||
function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
|
||||
if (destination.kind === 'root') destination.holder.value = value
|
||||
else destination.target.properties = value
|
||||
}
|
||||
|
||||
/** Normalize one implicit property map and all descendants with explicit work frames. */
|
||||
function normalizePropertyMap(
|
||||
entries: Record<string, unknown>,
|
||||
path: string,
|
||||
requiredNames: ReadonlySet<string>,
|
||||
raw: boolean,
|
||||
): Record<string, unknown> {
|
||||
const holder: NormalizeRoot = {}
|
||||
const ancestors = new Set<object>()
|
||||
const tasks: NormalizeTask[] = [{
|
||||
kind: 'map',
|
||||
entries,
|
||||
path,
|
||||
requiredNames,
|
||||
raw,
|
||||
destination: { kind: 'root', holder },
|
||||
}]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
ancestors.delete(task.value)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'map') {
|
||||
if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
|
||||
assertSchemaContainerKeys(task.entries, task.path)
|
||||
ancestors.add(task.entries)
|
||||
const spec: Record<string, unknown> = {}
|
||||
assignNormalizedMap(task.destination, spec)
|
||||
tasks.push({ kind: 'leave', value: task.entries })
|
||||
const mapEntries = Object.entries(task.entries)
|
||||
for (let index = mapEntries.length - 1; index >= 0; index--) {
|
||||
const entry = mapEntries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
forceRequired: task.requiredNames.has(entry[0]),
|
||||
raw: task.raw,
|
||||
parameterProperty: true,
|
||||
destination: { kind: 'property', target: spec, key: entry[0] },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { value, path } = task
|
||||
if (!isPlainRecord(value)) {
|
||||
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
|
||||
}
|
||||
assertSchemaContainerKeys(value, path)
|
||||
if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
|
||||
ancestors.add(value)
|
||||
const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
|
||||
if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
|
||||
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
|
||||
}
|
||||
if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be true when present`)
|
||||
}
|
||||
const prop: Record<string, unknown> = {}
|
||||
assignNormalizedValue(task.destination, prop)
|
||||
tasks.push({ kind: 'leave', value })
|
||||
if (task.forceRequired || value.required === true) prop.required = true
|
||||
copyAnnotations(value, prop, path)
|
||||
|
||||
if (Object.hasOwn(value, 'oneOf')) {
|
||||
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (!isDensePlainArray(value.oneOf) || value.oneOf.length < 2) {
|
||||
throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
|
||||
}
|
||||
const oneOf: Record<string, unknown>[] = []
|
||||
prop.oneOf = oneOf
|
||||
for (let index = value.oneOf.length - 1; index >= 0; index--) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: value.oneOf[index],
|
||||
path: `${path}.oneOf[${index}]`,
|
||||
forceRequired: false,
|
||||
raw: task.raw,
|
||||
parameterProperty: false,
|
||||
destination: { kind: 'one-of', target: oneOf, index },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (task.raw && !Object.hasOwn(value, 'type')) {
|
||||
assertSchemaKeys(value, path, ANNOTATION_KEYS)
|
||||
prop.type = 'json'
|
||||
continue
|
||||
}
|
||||
if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
|
||||
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
|
||||
}
|
||||
const type = value.type
|
||||
prop.type = type
|
||||
|
||||
switch (type) {
|
||||
case 'object': {
|
||||
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
|
||||
if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
|
||||
}
|
||||
if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
|
||||
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
|
||||
}
|
||||
if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
|
||||
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
|
||||
}
|
||||
prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
|
||||
if (Object.hasOwn(value, 'properties')) {
|
||||
const properties = value.properties
|
||||
if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
|
||||
const nestedRequired = task.raw
|
||||
? normalizeRequiredNames(value.required, properties, `${path}.required`)
|
||||
: new Set<string>()
|
||||
tasks.push({
|
||||
kind: 'map',
|
||||
entries: properties,
|
||||
path: `${path}.properties`,
|
||||
requiredNames: nestedRequired,
|
||||
raw: task.raw,
|
||||
destination: { kind: 'properties', target: prop },
|
||||
})
|
||||
} else if (task.raw && value.required !== undefined) {
|
||||
normalizeRequiredNames(value.required, {}, `${path}.required`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'array':
|
||||
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (Object.hasOwn(value, 'items')) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
value: value.items,
|
||||
path: `${path}.items`,
|
||||
forceRequired: false,
|
||||
raw: task.raw,
|
||||
parameterProperty: false,
|
||||
destination: { kind: 'item', target: prop },
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
if (Object.hasOwn(value, 'enum')) {
|
||||
if (!isDensePlainArray(value.enum) || value.enum.length === 0) {
|
||||
throw new Error(`harness.defineTool ${path}.enum must be a non-empty array`)
|
||||
}
|
||||
prop.enum = cloneJson(value.enum, `harness.defineTool ${path}.enum`)
|
||||
}
|
||||
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `harness.defineTool ${path}.const`)
|
||||
break
|
||||
case 'json':
|
||||
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
|
||||
break
|
||||
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
|
||||
default:
|
||||
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- the root map task assigns before scheduling descendants. */
|
||||
return holder.value ?? {}
|
||||
}
|
||||
|
||||
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
|
||||
Object.defineProperty(tool, DYNAMIC_TOOL, { value: true })
|
||||
return tool as DynamicToolDefinition
|
||||
}
|
||||
|
||||
function assertDynamicTool(tool: unknown): asserts tool is DynamicToolDefinition {
|
||||
if (!isPlainRecord(tool) || (tool as DynamicToolMarker)[DYNAMIC_TOOL] !== true) {
|
||||
throw new Error('dynamic tool registration must use a tool returned by harness.defineTool(...)')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally a content block, checked AFTER the JSON round-trip: a plain
|
||||
* object carrying a string `type` tag. Deliberately nothing deeper — the
|
||||
* ContentBlock union is merge-extensible (an unknown tag must pass), and every
|
||||
* downstream consumer dispatches on `type` and falls through unknowns.
|
||||
*/
|
||||
function isContentBlockShape(value: unknown): boolean {
|
||||
return isPlainRecord(value) && typeof value.type === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of an invalid execute return the teaching error echoes back — a
|
||||
* huge blob would burn the model turn the error is trying to save.
|
||||
*/
|
||||
const RETURN_PREVIEW_LIMIT = 120
|
||||
|
||||
/**
|
||||
* Compact JSON preview of an invalid execute return for the teaching error
|
||||
* (`String(…)` for the un-stringifiable undefined case), truncated to
|
||||
* {@link RETURN_PREVIEW_LIMIT}.
|
||||
*/
|
||||
function describeReturn(value: JsonValue): string {
|
||||
// The caller has already crossed cloneJson, so this value is lossless JSON
|
||||
// and serialization cannot produce undefined.
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > RETURN_PREVIEW_LIMIT ? `${json.slice(0, RETURN_PREVIEW_LIMIT)}…` : json
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and host-materialize a sandbox renderer's content blocks.
|
||||
*/
|
||||
function assertRenderedContent(value: JsonValue): ContentBlock[] {
|
||||
if (Array.isArray(value) && value.every(isContentBlockShape)) {
|
||||
return value as unknown as ContentBlock[]
|
||||
}
|
||||
throw new Error(
|
||||
`output.render returned ${describeReturn(value)} — it must return an ARRAY of content blocks:\n`
|
||||
+ ' ✓ return [{ type: \'text\', text: String(value) }]',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
|
||||
* into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
|
||||
* required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm
|
||||
* via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
|
||||
* the session log.
|
||||
* @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
|
||||
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
|
||||
*/
|
||||
export function sandboxDefineTool(options: unknown): ToolDefinition {
|
||||
if (!isPlainRecord(options)) throw new Error('harness.defineTool options must be an object')
|
||||
const normalized = normalizeParameterSchemaSpec(options.parameters)
|
||||
if (!isPlainRecord(options.output)) {
|
||||
throw new Error('harness.defineTool output must declare { schema, render, presentationMeta? }')
|
||||
}
|
||||
const output = options.output
|
||||
if (typeof output.render !== 'function') throw new Error('harness.defineTool output.render must be a function')
|
||||
if (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function') {
|
||||
throw new Error('harness.defineTool output.presentationMeta must be a function when present')
|
||||
}
|
||||
if (typeof options.execute !== 'function') throw new Error('harness.defineTool execute must be a function')
|
||||
const schema = cloneJson(output.schema, 'harness.defineTool output.schema')
|
||||
const rawExecute = options.execute as (args: unknown, exec: unknown) => Promise<unknown>
|
||||
const rawRender = output.render as (args: unknown, value: unknown) => unknown
|
||||
const rawPresentationMeta = output.presentationMeta as ((args: unknown, value: unknown) => unknown) | undefined
|
||||
const erasedDefineTool = defineTool as unknown as (definition: unknown) => ToolDefinition
|
||||
const tool = erasedDefineTool({
|
||||
...options,
|
||||
parameters: normalized.spec,
|
||||
output: {
|
||||
schema,
|
||||
render(args: unknown, value: unknown): ContentBlock[] {
|
||||
return assertRenderedContent(cloneJson(rawRender(args, value), 'harness.defineTool output.render result') as JsonValue)
|
||||
},
|
||||
...rawPresentationMeta !== undefined ? {
|
||||
presentationMeta(args: unknown, value: unknown): JsonValue {
|
||||
return cloneJson(rawPresentationMeta(args, value), 'harness.defineTool output.presentationMeta result') as JsonValue
|
||||
},
|
||||
} : {},
|
||||
},
|
||||
async execute(args: unknown, exec: unknown): Promise<JsonValue> {
|
||||
return cloneJson(await rawExecute(args, exec), 'harness.defineTool execute result') as JsonValue
|
||||
},
|
||||
})
|
||||
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
|
||||
assertSupportedJsonSchema(parameters)
|
||||
return markDynamicTool({
|
||||
...tool,
|
||||
parameters,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one `harness.handle` registration at the sandbox boundary: the
|
||||
* method name must be a non-empty string and the handler a function whose
|
||||
* result is host-materialized through the same cross-realm JSON clone as tool
|
||||
* `execute` returns (a VM-realm object would otherwise escape the wire's
|
||||
* plain-object contract).
|
||||
* @param method - handler name the package's browser half calls through `host.call`.
|
||||
* @param fn - sandbox handler receiving the wire-decoded JSON arguments.
|
||||
* @returns the validated name and the clone-wrapped handler.
|
||||
*/
|
||||
export function normalizeHandler(method: unknown, fn: unknown): { method: string; handler: (args: unknown) => Promise<unknown> } {
|
||||
if (typeof method !== 'string' || method.length === 0) {
|
||||
throw new Error('harness.handle(method, fn) needs a non-empty string method name')
|
||||
}
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(`harness.handle("${method}") needs a handler function as its second argument`)
|
||||
}
|
||||
const rawHandler = fn as (args: unknown) => unknown
|
||||
return {
|
||||
method,
|
||||
handler: async (args: unknown): Promise<unknown> =>
|
||||
cloneJson(await rawHandler(args), `harness.handle("${method}") result`),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `harness.registerTool` handed into the sandbox: registers a
|
||||
* marker-verified dynamic tool on the given context's registry.
|
||||
* @param ctx - the (guarded) context whose `tools` service receives the tool.
|
||||
* @param tool - a definition produced by {@link sandboxDefineTool}; anything else is rejected.
|
||||
* @returns the registry disposer for the registration.
|
||||
*/
|
||||
export function sandboxRegisterTool(ctx: Context, tool: unknown): () => void {
|
||||
assertDynamicTool(tool)
|
||||
return ctx.tools.register(tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* The verbs a running host half may reach through the sandbox `ctx` façade, beyond its injected
|
||||
* services. `on`/`once` observe events, `provide` exposes a service to other packages, and the
|
||||
* timer helpers schedule work — each a fiber effect that unwinds when the package stops.
|
||||
*/
|
||||
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'])
|
||||
|
||||
/**
|
||||
* The tool-registry façade: `register` (marker-guarded) plus READ-ONLY
|
||||
* metadata (`schemas`, and `get` returning a schema view, never the live
|
||||
* `ToolDefinition`). Exposing the raw definition would hand package code the
|
||||
* tool's `execute` function, letting it call another tool directly and bypass
|
||||
* `ToolRuntime.execute` — identity protection, pre-policy, monotonic guards,
|
||||
* around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
|
||||
* name/description/parameters view as `schemas()`, and nothing invocable.
|
||||
*/
|
||||
function sandboxTools(ctx: Context): Record<string, unknown> {
|
||||
// Resolve reads and writes through the package's own scope.
|
||||
return {
|
||||
register: (tool: unknown): (() => void) => sandboxRegisterTool(ctx, tool),
|
||||
schemas: () => ctx.tools.schemas(scopeOf(ctx)),
|
||||
get: (name: string) => ctx.tools.schemas(scopeOf(ctx)).find(schema => schema.name === name),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject any injected-service return that is a cordis `Context`. Harness
|
||||
* services return data, never a context; a value that is one would be a
|
||||
* fresh, unguarded handle back into the runtime — the exact escape the façade
|
||||
* exists to close — so it fails loud instead of reaching sandbox code.
|
||||
*/
|
||||
// Twinned with the browser half's guard for the same reason as the ctx façade
|
||||
// below: this is the rule "a service must never hand sandboxed code a Context",
|
||||
// and each half must test against the Context class of ITS OWN face. Moving the
|
||||
// rule into a shared package would move a security invariant out of the halves
|
||||
// that enforce it, which is a design decision rather than a duplication fix.
|
||||
/* jscpd:ignore-start */
|
||||
function denyContext(value: unknown, service: string, reportFailure: (error: Error) => void): unknown {
|
||||
if (value instanceof Context) {
|
||||
return rejectGuard(reportFailure,
|
||||
`service "${service}" returned a cordis Context, which the sandbox does not expose. `
|
||||
+ 'Operate through your own plugin ctx (ctx.on / ctx.provide / ctx.tools.register) '
|
||||
+ 'and the services you inject — never another context.',
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an injected service so its methods forward to the real instance but
|
||||
* their return values pass through {@link denyContext}. Non-function members
|
||||
* (plain data) pass through as-is; a returned Promise is guarded on resolve.
|
||||
*/
|
||||
function guardedService(service: object, name: string, reportFailure: (error: Error) => void): 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, reportFailure)
|
||||
return (...args: unknown[]): unknown => {
|
||||
const result = Reflect.apply(value, target, args) as unknown
|
||||
if (result instanceof Promise) return result.then(v => denyContext(v, name, reportFailure))
|
||||
return denyContext(result, name, reportFailure)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* The service names a plugin declared in `inject`, as a lookup set. Whatever
|
||||
* declaration style the plugin used — an `inject: ['bash', 'tools']` array or
|
||||
* the `{ required, optional }` object form — cordis resolves it into a single
|
||||
* name-keyed map on the fiber before `apply` runs (`{ bash: null, tools: null }`),
|
||||
* so the gate just reads that map's keys. A host half may reach only the services
|
||||
* it declared — that is what lets cordis park it when a declared provider
|
||||
* goes away.
|
||||
*/
|
||||
function declaredInjects(ctx: Context): Set<string> {
|
||||
return new Set(Object.keys(ctx.fiber.inject))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist context for running host halves: lifecycle-safe verbs, guarded
|
||||
* tools, optional `ctx.get()` lookup, and declared-service property access.
|
||||
* Framework plumbing is denied, and service methods cannot return a Context.
|
||||
*/
|
||||
function sandboxContext(ctx: Context, reportFailure: (error: Error) => void): Context {
|
||||
const tools = sandboxTools(ctx)
|
||||
const declared = declaredInjects(ctx)
|
||||
// A framework member or an undeclared service — distinguish the two so the
|
||||
// error teaches the right fix (declare it in inject vs it is withheld).
|
||||
const denyRead = (prop: string): never => {
|
||||
if (ctx.get(prop) !== undefined) {
|
||||
return rejectGuard(reportFailure,
|
||||
`service "${prop}" is not injected. Declare it: inject: ['${prop}', …] on your plugin, `
|
||||
+ 'so cordis parks this dynamic package if the provider later goes away.',
|
||||
)
|
||||
}
|
||||
return rejectGuard(reportFailure,
|
||||
`sandbox ctx does not expose "${prop}". Available: ctx.tools.register / ctx.on / ctx.provide / `
|
||||
+ 'the timer helpers after injecting timer, and any service you declared in inject. '
|
||||
+ 'Framework internals (root, fiber, registry, extend, plugin, …) are withheld by design.',
|
||||
)
|
||||
}
|
||||
// `get` is optional lookup; property access requires a declaration. `tools`
|
||||
// is the façade's own API on either path.
|
||||
const readService = (name: string, requireDeclaration: boolean): unknown => {
|
||||
if (name === 'tools') return tools
|
||||
if (requireDeclaration && !declared.has(name)) return denyRead(name)
|
||||
const service = denyContext(ctx.get(name), name, reportFailure)
|
||||
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
|
||||
return guardedService(service, name, reportFailure)
|
||||
}
|
||||
const get = (name: string): unknown => readService(name, false)
|
||||
// The browser half builds the same façade over its own Context
|
||||
// (`@deepseek-ai/dsh-cordis-client-runner`, whose CTX_VERBS names this one its
|
||||
// twin), and the sameness is the point: a package author meets ONE contract on
|
||||
// both halves. Folding them together is not available — the two halves compile
|
||||
// in separate programs where `Context` merges different service keys — so the
|
||||
// duplication is declared here instead of hidden behind a config exception.
|
||||
/* jscpd:ignore-start */
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'tools') return tools
|
||||
if (prop === 'get') return get
|
||||
if (typeof prop !== 'string') return undefined
|
||||
// Lazy verb forwarder — reads `ctx[verb]` only when called. Timer mixins
|
||||
// additionally require the Service declaration before Cordis resolves them.
|
||||
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)
|
||||
},
|
||||
// A façade is not the real ctx; block writes rather than let package code
|
||||
// stash state on a throwaway object and think it persisted.
|
||||
set(_target, prop) {
|
||||
return rejectGuard(reportFailure, `sandbox ctx is read-only; cannot assign "${String(prop)}"`)
|
||||
},
|
||||
// `in` reflects reachability: the façade API plus DECLARED services
|
||||
// (whether or not currently live). Does not resolve/wrap — no throw.
|
||||
has: (_target, prop) => prop === 'tools' || prop === 'get'
|
||||
|| (typeof prop === 'string'
|
||||
&& ((CTX_VERBS.has(prop) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))),
|
||||
}) as unknown as Context
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an arbitrary sandbox return value to a runnable cordis plugin: a
|
||||
* function, or an object with an `apply` function. (A bare function passes the
|
||||
* first arm, so the object arm never sees `Function.prototype.apply`.)
|
||||
* @param value - whatever the host half returned.
|
||||
* @returns whether the value can be started via `ctx.plugin`.
|
||||
*/
|
||||
export function isPlugin(value: unknown): value is Plugin {
|
||||
if (typeof value === 'function') return true
|
||||
return typeof value === 'object' && value !== null
|
||||
&& typeof (value as { apply?: unknown }).apply === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plugin so `apply` receives the sandbox context while preserving injection metadata.
|
||||
* @param plugin - the plugin the host half returned.
|
||||
* @param reportFailure - reports a guard rejection to the owning Agent.
|
||||
* @returns an equivalent plugin whose `apply` sees the sandbox context façade.
|
||||
*/
|
||||
export function guardedPlugin(plugin: Plugin, reportFailure: (error: Error) => void): Plugin {
|
||||
if (typeof plugin === 'function') {
|
||||
const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown
|
||||
return {
|
||||
name: pluginName(plugin),
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return functionPlugin(sandboxContext(ctx, reportFailure), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
const objectPlugin = plugin as { apply(ctx: Context, config?: unknown): unknown }
|
||||
return {
|
||||
...plugin,
|
||||
apply(ctx: Context, config?: unknown) {
|
||||
return objectPlugin.apply(sandboxContext(ctx, reportFailure), config)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function rejectGuard(reportFailure: (error: Error) => void, message: string): never {
|
||||
const error = new Error(message)
|
||||
reportFailure(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for a running plugin: its `name` property, else anonymous.
|
||||
* @param plugin - the plugin the host half returned.
|
||||
* @returns the human-readable name used in run results and inspect output.
|
||||
*/
|
||||
export function pluginName(plugin: Plugin): string {
|
||||
const named = (plugin as { name?: unknown }).name
|
||||
if (typeof named === 'string' && named.length > 0) return named
|
||||
return '<anonymous>'
|
||||
}
|
||||
1274
packages/extensions/cordis-host-runner/src/index.ts
Normal file
1274
packages/extensions/cordis-host-runner/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
248
packages/extensions/cordis-host-runner/src/inspect-registry.ts
Normal file
248
packages/extensions/cordis-host-runner/src/inspect-registry.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/** Host registry for model-visible, read-only Cordis capability queries. */
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
|
||||
import { assertSupportedJsonSchema, validateJsonSchemaValue } from '@deepseek-ai/dsh-tools'
|
||||
import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
CordisInspectMethodManifest, CordisInspectPlatform, CordisInspectProviderManifest,
|
||||
CordisInspectProviderView, CordisInspectQueryRequest, CordisInspectQueryResolution,
|
||||
CordisInspectRequestId, CordisInspectResolveAck,
|
||||
} from './types.ts'
|
||||
|
||||
/** Context supplied to a Host inspect query. */
|
||||
export interface HostCordisInspectQueryContext {
|
||||
/** Tool-call cancellation. */
|
||||
signal: AbortSignal
|
||||
/** Agent whose scoped runtime is being inspected. */
|
||||
agent: Agent
|
||||
}
|
||||
|
||||
/** Local registration paired with its serializable manifest. */
|
||||
export interface HostCordisInspectProviderRegistration {
|
||||
/** Provider and explicit method directory. */
|
||||
manifest: CordisInspectProviderManifest
|
||||
/** Execute one declared method. */
|
||||
query(method: string, input: JsonValue | undefined, context: HostCordisInspectQueryContext): Promise<JsonValue>
|
||||
}
|
||||
|
||||
interface PendingClientQuery {
|
||||
request: CordisInspectQueryRequest
|
||||
method: CordisInspectMethodManifest
|
||||
settle(resolution: CordisInspectQueryResolution): void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host registry for Cordis inspect providers and Client manifest/query routing. */
|
||||
cordisInspect: CordisInspectRegistryService
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry and cross-page router behind the two model-facing inspect tools. */
|
||||
export class CordisInspectRegistryService extends Service {
|
||||
private readonly providers = new Map<string, HostCordisInspectProviderRegistration>()
|
||||
private readonly pending = new Map<CordisInspectRequestId, PendingClientQuery>()
|
||||
private clientManifest: readonly CordisInspectProviderManifest[] | undefined
|
||||
private nextRequest = 1
|
||||
|
||||
/** Register the process-global Host registry. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'cordisInspect')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one Host provider.
|
||||
* @param registration - manifest and local query handler.
|
||||
* @returns idempotent disposer.
|
||||
*/
|
||||
register(registration: HostCordisInspectProviderRegistration): () => void {
|
||||
const manifest = validateManifest(registration.manifest)
|
||||
if (this.providers.has(manifest.id)) throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
|
||||
const stored = { ...registration, manifest }
|
||||
this.providers.set(manifest.id, stored)
|
||||
return () => {
|
||||
if (this.providers.get(manifest.id) === stored) this.providers.delete(manifest.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the mirrored Client provider directory.
|
||||
* @param providers - complete Client manifest snapshot.
|
||||
*/
|
||||
syncClientManifest(providers: readonly CordisInspectProviderManifest[]): void {
|
||||
const ids = new Set<string>()
|
||||
const validated = providers.map((provider) => {
|
||||
const manifest = validateManifest(provider)
|
||||
if (ids.has(manifest.id)) throw new Error(`Client Cordis inspect manifest repeats provider "${manifest.id}"`)
|
||||
ids.add(manifest.id)
|
||||
return manifest
|
||||
})
|
||||
this.clientManifest = Object.freeze(validated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the complete known Host and Client provider directory.
|
||||
* @returns Host providers followed by the Client providers.
|
||||
*/
|
||||
list(): CordisInspectProviderView[] {
|
||||
return [
|
||||
...[...this.providers.values()].map(provider => view('host', provider.manifest)),
|
||||
...(this.clientManifest ?? []).map(provider => view('client', provider)),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one provider query on its owning platform.
|
||||
* @param platform - Host or Client runtime.
|
||||
* @param providerId - provider selected from {@link list}.
|
||||
* @param methodName - declared method name.
|
||||
* @param input - optional lossless JSON input.
|
||||
* @param agent - requesting Agent and scope.
|
||||
* @param signal - tool-call cancellation.
|
||||
* @returns provider JSON data.
|
||||
*/
|
||||
async query(
|
||||
platform: CordisInspectPlatform,
|
||||
providerId: string,
|
||||
methodName: string,
|
||||
input: JsonValue | undefined,
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
): Promise<JsonValue> {
|
||||
if (platform === 'host') {
|
||||
const registration = this.providers.get(providerId)
|
||||
if (registration === undefined) throw new Error(`Host Cordis inspect provider "${providerId}" is not registered`)
|
||||
const method = findMethod(registration.manifest, methodName)
|
||||
validateInput('Host', providerId, method, input)
|
||||
signal.throwIfAborted()
|
||||
const data = await registration.query(methodName, input, { agent, signal })
|
||||
signal.throwIfAborted()
|
||||
return validateOutput('Host', providerId, method, data)
|
||||
}
|
||||
return await this.queryClient(providerId, methodName, input, agent, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the first valid Client response for a pending query.
|
||||
* @param agent - Agent whose Session owns the query.
|
||||
* @param requestId - Pending Client query identity.
|
||||
* @param resolution - Client provider result or failure.
|
||||
* @returns whether this response settled the still-pending query.
|
||||
*/
|
||||
resolveClientQuery(
|
||||
agent: Agent,
|
||||
requestId: CordisInspectRequestId,
|
||||
resolution: CordisInspectQueryResolution,
|
||||
): CordisInspectResolveAck {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (pending === undefined || pending.request.agentId !== agent.id) return { accepted: false }
|
||||
if (!resolution.ok) return { accepted: false }
|
||||
try {
|
||||
resolution = {
|
||||
ok: true,
|
||||
data: validateOutput('Client', pending.request.provider, pending.method, resolution.data),
|
||||
}
|
||||
} catch {
|
||||
return { accepted: false }
|
||||
}
|
||||
this.pending.delete(requestId)
|
||||
pending.settle(resolution)
|
||||
this.ctx.emit('cordis/inspect-query-resolved', { requestId })
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
private async queryClient(
|
||||
providerId: string,
|
||||
methodName: string,
|
||||
input: JsonValue | undefined,
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
): Promise<JsonValue> {
|
||||
const provider = this.clientManifest?.find(candidate => candidate.id === providerId)
|
||||
if (provider === undefined) throw new Error(`Client Cordis inspect provider "${providerId}" is not registered`)
|
||||
const method = findMethod(provider, methodName)
|
||||
validateInput('Client', providerId, method, input)
|
||||
signal.throwIfAborted()
|
||||
const requestId = `inspect-${this.nextRequest++}` as CordisInspectRequestId
|
||||
const request: CordisInspectQueryRequest = {
|
||||
requestId,
|
||||
agentId: agent.id,
|
||||
provider: providerId,
|
||||
method: methodName,
|
||||
...input === undefined ? {} : { input },
|
||||
}
|
||||
const result = new Promise<CordisInspectQueryResolution>((resolve) => {
|
||||
this.pending.set(requestId, { request, method, settle: resolve })
|
||||
})
|
||||
const onAbort = (): void => {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (pending === undefined) return
|
||||
this.pending.delete(requestId)
|
||||
pending.settle({ ok: false, reason: 'cancelled', message: `Client inspect query ${providerId}.${methodName} was cancelled` })
|
||||
this.ctx.emit('cordis/inspect-query-resolved', { requestId })
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
else this.ctx.emit('cordis/inspect-query', request)
|
||||
try {
|
||||
const resolution = await result
|
||||
if (!resolution.ok) throw new Error(`${providerId}.${methodName}: ${resolution.message}`)
|
||||
return resolution.data
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
|
||||
return { platform, ...manifest, methods: [...manifest.methods] }
|
||||
}
|
||||
|
||||
function validateManifest(manifest: CordisInspectProviderManifest): CordisInspectProviderManifest {
|
||||
if (manifest.id.trim() === '') throw new Error('Cordis inspect provider id must not be empty')
|
||||
if (manifest.description.trim() === '') throw new Error(`Cordis inspect provider "${manifest.id}" needs a description`)
|
||||
const names = new Set<string>()
|
||||
const methods = manifest.methods.map((method) => {
|
||||
if (method.name.trim() === '') throw new Error(`Cordis inspect provider "${manifest.id}" has an empty method name`)
|
||||
if (names.has(method.name)) throw new Error(`Cordis inspect provider "${manifest.id}" repeats method "${method.name}"`)
|
||||
if (method.description.trim() === '') throw new Error(`Cordis inspect method ${manifest.id}.${method.name} needs a description`)
|
||||
assertSupportedJsonSchema(method.inputSchema)
|
||||
assertSupportedJsonSchema(method.outputSchema)
|
||||
names.add(method.name)
|
||||
return Object.freeze({ ...method })
|
||||
})
|
||||
return Object.freeze({ ...manifest, methods: Object.freeze(methods) })
|
||||
}
|
||||
|
||||
function findMethod(manifest: CordisInspectProviderManifest, name: string): CordisInspectMethodManifest {
|
||||
const method = manifest.methods.find(candidate => candidate.name === name)
|
||||
if (method === undefined) throw new Error(`Cordis inspect provider "${manifest.id}" has no method "${name}"`)
|
||||
return method
|
||||
}
|
||||
|
||||
function validateInput(
|
||||
platform: 'Host' | 'Client',
|
||||
provider: string,
|
||||
method: CordisInspectMethodManifest,
|
||||
input: JsonValue | undefined,
|
||||
): void {
|
||||
const violations = validateJsonSchemaValue(method.inputSchema as JsonSchemaNode, input ?? {}, 'input')
|
||||
if (violations.length > 0) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} rejected input: ${violations.join('; ')}`)
|
||||
}
|
||||
|
||||
function validateOutput(
|
||||
platform: 'Host' | 'Client',
|
||||
provider: string,
|
||||
method: CordisInspectMethodManifest,
|
||||
data: JsonValue,
|
||||
): JsonValue {
|
||||
const snapshot = snapshotJsonValue(data)
|
||||
if (snapshot === undefined) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} returned a non-JSON value`)
|
||||
const violations = validateJsonSchemaValue(method.outputSchema as JsonSchemaNode, snapshot, 'output')
|
||||
if (violations.length > 0) throw new Error(`${platform} Cordis inspect ${provider}.${method.name} returned invalid output: ${violations.join('; ')}`)
|
||||
return snapshot
|
||||
}
|
||||
32
packages/extensions/cordis-host-runner/src/invariant.ts
Normal file
32
packages/extensions/cordis-host-runner/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-cordis-host-runner`.
|
||||
* @module @deepseek-ai/dsh-cordis-host-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-host-runner'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'cordis-host-runner-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the definition registry is process memory with no event
|
||||
* stream to observe, and its one owned relation (a running definition owns a
|
||||
* settled host-half fiber and its handler table) is established and unwound
|
||||
* inside single awaited verbs, so package tests assert it directly.
|
||||
*/
|
||||
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 */
|
||||
57
packages/extensions/cordis-host-runner/src/lifecycle.ts
Normal file
57
packages/extensions/cordis-host-runner/src/lifecycle.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Host-half fiber lifecycle over the `cordis-dynamic` group: settle a
|
||||
* sandbox-produced plugin as a child fiber (never leaving a failed fiber
|
||||
* mounted), and report the services a settled-but-pending fiber still waits
|
||||
* for. Stopping needs no helper — a host half unwinds through an ordinary
|
||||
* awaited `fiber.dispose()`, because everything the plugin registered is an
|
||||
* effect on its fiber.
|
||||
* @module @deepseek-ai/dsh-cordis-host-runner/lifecycle
|
||||
*/
|
||||
|
||||
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis'
|
||||
import { guardedPlugin } from './guard.ts'
|
||||
|
||||
/**
|
||||
* Await the group, start and settle one guarded child, and dispose it before rethrowing any
|
||||
* startup failure so a failed run never lingers. A valid unresolved inject may remain pending.
|
||||
* @param group - the `cordis-dynamic` group fiber every host half hangs under.
|
||||
* @param plugin - the plugin the sandbox returned; wrapped with the registration guard before starting.
|
||||
* @param reportGuardFailure - reports post-activation Host guard rejections to the owning Agent.
|
||||
* @returns the settled child fiber (possibly pending on unsatisfied `inject`).
|
||||
*/
|
||||
export async function startHostHalf(
|
||||
group: Fiber,
|
||||
plugin: Plugin,
|
||||
reportGuardFailure: (error: Error) => void,
|
||||
): Promise<Fiber> {
|
||||
await group.await()
|
||||
const fiber = group.ctx.plugin(guardedPlugin(plugin, reportGuardFailure))
|
||||
try {
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
await fiber.dispose()
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// The commonest startup collision is running a NEW version of a package
|
||||
// while the old run still holds the name — teach the replace recipe.
|
||||
if (message.includes('already registered')) {
|
||||
throw new Error(
|
||||
`${message} — to REPLACE something an earlier dynamic package registered, first cordis_stop that package's id `
|
||||
+ '(find it with cordis_runtime_inspect what:"temporary"), then run the new version.',
|
||||
)
|
||||
}
|
||||
throw error instanceof Error ? error : new Error(message)
|
||||
}
|
||||
return fiber
|
||||
}
|
||||
|
||||
/**
|
||||
* The services a fiber declared in `inject` that do not exist yet — a settled
|
||||
* fiber that is not active is waiting on exactly these (legal cordis
|
||||
* semantics: it activates when the service appears).
|
||||
* @param ctx - the context to resolve service existence against.
|
||||
* @param fiber - the host-half fiber whose `inject` declarations are checked.
|
||||
* @returns the missing service names, in declaration order.
|
||||
*/
|
||||
export function missingServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
}
|
||||
276
packages/extensions/cordis-host-runner/src/registry.ts
Normal file
276
packages/extensions/cordis-host-runner/src/registry.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Process-local dynamic Plugin registry and its opaque identity mints.
|
||||
* @module @deepseek-ai/dsh-cordis-host-runner/registry
|
||||
*/
|
||||
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
CordisDynamicRunMode, DynamicCordisRenderFailure, DynamicCordisRunAttempt,
|
||||
} from './types.ts'
|
||||
|
||||
/** One Host method exposed to this package's Client half. */
|
||||
export type DynamicCordisHandler = (args: unknown) => Promise<unknown>
|
||||
|
||||
/** One live activation and everything its teardown owns. */
|
||||
export interface DynamicCordisRun {
|
||||
/** Exact activation identity. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
/** Immutable package version being run. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Host-half Fiber, absent for Client-only packages. */
|
||||
fiber?: Fiber
|
||||
/** Active Host methods. */
|
||||
handlers: Map<string, DynamicCordisHandler>
|
||||
/** Method registration cleanup. */
|
||||
handlerDisposers: (() => void)[]
|
||||
/** Runtime failures already sent to the owning Agent during this activation. */
|
||||
reportedRuntimeErrors: Set<string>
|
||||
/** Last render failure observed for this version's current run. */
|
||||
renderFailure?: DynamicCordisRenderFailure
|
||||
/** Approval whose transition started this run, when model-driven. */
|
||||
startedForRequest?: ApprovalRequestId
|
||||
}
|
||||
|
||||
/** One immutable package version. */
|
||||
export interface DynamicCordisDefinition {
|
||||
/** Package identity. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Package label. */
|
||||
name: string
|
||||
/** User-facing purpose. */
|
||||
purpose: string
|
||||
/** Host source. */
|
||||
hostCode?: string
|
||||
/** Client source. */
|
||||
clientCode?: string
|
||||
}
|
||||
|
||||
/** Stable plugin instance containing immutable package versions. */
|
||||
export interface DynamicCordisPlugin {
|
||||
/** Stable identity. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Owning session. */
|
||||
sessionId: SessionId
|
||||
/** Versions in define order. */
|
||||
packages: Map<CordisDynamicPackageId, DynamicCordisDefinition>
|
||||
/** Client-bearing Packages individually authorized by the user. */
|
||||
approvedClientPackages: Set<CordisDynamicPackageId>
|
||||
/** Whether one user decision authorized future Package versions of this Plugin. */
|
||||
clientVersionUpdatesApproved: boolean
|
||||
/** Last successfully activated version. */
|
||||
currentPackageId?: CordisDynamicPackageId
|
||||
/** Failed or in-progress target version. */
|
||||
nextPackageId?: CordisDynamicPackageId
|
||||
/** Current activation. */
|
||||
run?: DynamicCordisRun
|
||||
/** Latest activation attempt, including approval and asynchronous failure state. */
|
||||
latestRun?: DynamicCordisRunAttempt
|
||||
}
|
||||
|
||||
/** One suspended model-driven activation. */
|
||||
export interface DynamicCordisPendingRequest {
|
||||
/** Session whose model requested this activation. */
|
||||
agentId: SessionId
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
mode: CordisDynamicRunMode
|
||||
/** Whether this request must wait for an explicit user decision. */
|
||||
requiresApproval: boolean
|
||||
}
|
||||
|
||||
/** Request accepted by `define`; it never crosses the Remote transport. */
|
||||
export interface DynamicCordisDefineRequest {
|
||||
/** Session that owns the plugin. */
|
||||
sessionId: SessionId
|
||||
/** Create a plugin or append to an existing one. */
|
||||
plugin:
|
||||
| { kind: 'new'; idPrefix: string }
|
||||
| { kind: 'existing'; pluginId: CordisDynamicPluginId }
|
||||
/** Package label. */
|
||||
name: string
|
||||
/** User-facing purpose. */
|
||||
purpose: string
|
||||
/** At least one source half. */
|
||||
code: { host?: string; client?: string }
|
||||
}
|
||||
|
||||
/** Successful `define` result. */
|
||||
export interface DynamicCordisDefineReceipt {
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
name: string
|
||||
purpose: string
|
||||
hasHostHalf: boolean
|
||||
hasClientHalf: boolean
|
||||
}
|
||||
|
||||
/** Source-free modification context for an explicit `@pluginId` reference. */
|
||||
export interface DynamicCordisReference {
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
name: string
|
||||
purpose: string
|
||||
currentPackageId?: CordisDynamicPackageId
|
||||
nextPackageId?: CordisDynamicPackageId
|
||||
activeRun?: { pluginRunId: CordisDynamicPluginRunId; packageId: CordisDynamicPackageId }
|
||||
latestRun?: DynamicCordisRunAttempt
|
||||
}
|
||||
|
||||
/** Source-free Plugin summary returned by layered self inspection. */
|
||||
export interface DynamicCordisPluginInspection extends DynamicCordisReference {
|
||||
/** Immutable Package summaries in define order. */
|
||||
packages: Array<{
|
||||
packageId: CordisDynamicPackageId
|
||||
name: string
|
||||
purpose: string
|
||||
hasHostHalf: boolean
|
||||
hasClientHalf: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/** Exact immutable Package metadata and source returned by explicit inspection. */
|
||||
export interface DynamicCordisPackageInspection extends DynamicCordisReference {
|
||||
/** Host and Client function bodies stored for this Package. */
|
||||
code: { host?: string; client?: string }
|
||||
}
|
||||
|
||||
/** Registry, identity mints, and pending approval index. */
|
||||
export class DynamicCordisRegistry {
|
||||
private readonly plugins = new Map<CordisDynamicPluginId, DynamicCordisPlugin>()
|
||||
private readonly pendingRequests = new Map<ApprovalRequestId, DynamicCordisPendingRequest>()
|
||||
private nextPlugin = 1
|
||||
private nextPackage = 1
|
||||
private nextRun = 1
|
||||
private nextApproval = 1
|
||||
|
||||
/**
|
||||
* Mint a semantic plugin ID without reusing a prior suffix.
|
||||
* @param prefix - validated lowercase semantic prefix proposed by the model.
|
||||
* @returns a process-unique Plugin ID.
|
||||
*/
|
||||
mintPluginId(prefix: string): string {
|
||||
let id: CordisDynamicPluginId
|
||||
do id = `${prefix}-${this.nextPlugin++}` as CordisDynamicPluginId
|
||||
while (this.plugins.has(id))
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an immutable package ID.
|
||||
* @returns a process-unique Package ID.
|
||||
*/
|
||||
mintPackageId(): string {
|
||||
return `pkg-${this.nextPackage++}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an activation ID.
|
||||
* @returns a process-unique Plugin Run ID.
|
||||
*/
|
||||
mintPluginRunId(): string {
|
||||
return `run-${this.nextRun++}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an approval ID.
|
||||
* @returns a process-unique approval request ID.
|
||||
*/
|
||||
mintApprovalRequestId(): string {
|
||||
return `approval-${this.nextApproval++}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one stable plugin.
|
||||
* @param plugin - Plugin record to retain under its stable ID.
|
||||
*/
|
||||
add(plugin: DynamicCordisPlugin): void {
|
||||
this.plugins.set(plugin.pluginId, plugin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one plugin.
|
||||
* @param id - stable Plugin ID.
|
||||
* @returns the Plugin record, or `undefined` when absent.
|
||||
*/
|
||||
get(id: CordisDynamicPluginId): DynamicCordisPlugin | undefined {
|
||||
return this.plugins.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one plugin and all package versions.
|
||||
* @param id - stable Plugin ID to remove.
|
||||
* @returns whether a Plugin record was removed.
|
||||
*/
|
||||
delete(id: CordisDynamicPluginId): boolean {
|
||||
return this.plugins.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all plugins in creation order.
|
||||
* @returns a snapshot of every Plugin record.
|
||||
*/
|
||||
all(): DynamicCordisPlugin[] {
|
||||
return [...this.plugins.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one session's plugins in creation order.
|
||||
* @param sessionId - owning session to filter by.
|
||||
* @returns a snapshot of matching Plugin records.
|
||||
*/
|
||||
ofSession(sessionId: SessionId): DynamicCordisPlugin[] {
|
||||
return this.all().filter(plugin => plugin.sessionId === sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish one pending approval.
|
||||
* @param id - approval request ID.
|
||||
* @param pending - resolver and Plugin metadata retained until settlement.
|
||||
*/
|
||||
armRequest(id: ApprovalRequestId, pending: DynamicCordisPendingRequest): void {
|
||||
this.pendingRequests.set(id, pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one pending approval without claiming it.
|
||||
* @param id - approval request ID.
|
||||
* @returns the pending request, or `undefined` when absent.
|
||||
*/
|
||||
peekRequest(id: ApprovalRequestId): DynamicCordisPendingRequest | undefined {
|
||||
return this.pendingRequests.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim one pending approval; first answer wins.
|
||||
* @param id - approval request ID.
|
||||
* @returns the claimed request, or `undefined` when already settled.
|
||||
*/
|
||||
claimRequest(id: ApprovalRequestId): DynamicCordisPendingRequest | undefined {
|
||||
const pending = this.pendingRequests.get(id)
|
||||
if (pending !== undefined) this.pendingRequests.delete(id)
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel one pending approval.
|
||||
* @param id - approval request ID to remove.
|
||||
*/
|
||||
disarmRequest(id: ApprovalRequestId): void {
|
||||
this.pendingRequests.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pending approval for one Plugin.
|
||||
* @param pluginId - stable Plugin ID.
|
||||
* @returns its approval request ID, or `undefined` when none is pending.
|
||||
*/
|
||||
pendingRequestFor(pluginId: CordisDynamicPluginId): ApprovalRequestId | undefined {
|
||||
for (const [requestId, request] of this.pendingRequests) {
|
||||
if (request.pluginId === pluginId) return requestId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
238
packages/extensions/cordis-host-runner/src/sandbox.ts
Normal file
238
packages/extensions/cordis-host-runner/src/sandbox.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* The `node:vm` sandbox a dynamic package's HOST half evaluates in: a fresh realm whose globals
|
||||
* are a tagged write-through console, the `harness` registration helpers, the encoding primitives
|
||||
* a bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
|
||||
* withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
|
||||
* `ctx.bash`, and Cordis timers. This keeps cooperative packages inspectable and disposable but
|
||||
* is not containment: host-realm helper functions remain an escape route.
|
||||
*
|
||||
* The browser half never reaches this module — it is evaluated by the client-side runner in a
|
||||
* closure, with its own facade.
|
||||
* @module @deepseek-ai/dsh-cordis-host-runner/sandbox
|
||||
*/
|
||||
|
||||
import { createContext, runInContext, Script } from 'node:vm'
|
||||
import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
|
||||
|
||||
/** Exact Host closure symbols exposed by the sandbox and guarded Context. */
|
||||
export const HOST_BUILTIN_INSPECTION = [
|
||||
{
|
||||
name: 'ctx',
|
||||
description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject 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: 'harness',
|
||||
description: 'Host helpers for Package-private Client RPC and model-visible dynamic Tools.',
|
||||
signatures: [
|
||||
'harness.handle(method: string, handler: (args: JsonValue) => JsonValue | Promise<JsonValue>): () => void',
|
||||
'harness.defineTool(definition: ToolDefinition): ToolDefinition',
|
||||
'harness.registerTool(ctx: Context, tool: ToolDefinition): () => void',
|
||||
],
|
||||
},
|
||||
{ name: 'console', description: 'Package-tagged Host logging.', signatures: ['console.log(...values): void', 'console.error(...values): void'] },
|
||||
{ name: 'btoa', description: 'Encode UTF-8 text as base64.', signatures: ['btoa(value: string): string'] },
|
||||
{ name: 'atob', description: 'Decode base64 as UTF-8 text.', signatures: ['atob(value: string): string'] },
|
||||
{ name: 'TextEncoder', description: 'Standard UTF-8 encoder constructor.', signatures: ['new TextEncoder()'] },
|
||||
{ name: 'TextDecoder', description: 'Standard text decoder constructor.', signatures: ['new TextDecoder(label?: string)'] },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* A write-through console for one package, tagging every line with the package
|
||||
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
|
||||
* a registered listener fires long after the run call returned, and its output
|
||||
* must land somewhere the user can see — for a terminal entry point, the host terminal.
|
||||
*/
|
||||
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
|
||||
const tag = `[cordis:${id}]`
|
||||
const log = (...args: unknown[]): void => { console.log(tag, ...args) }
|
||||
const error = (...args: unknown[]): void => { console.error(tag, ...args) }
|
||||
return { log, info: log, warn: log, debug: log, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch only VM constructors so `instanceof` accepts both VM values and host values passed as
|
||||
* arguments, events, or service results; host intrinsics remain untouched.
|
||||
*/
|
||||
const DUAL_REALM_INSTANCEOF_PRELUDE = `
|
||||
(hostIntrinsics) => {
|
||||
'use strict'
|
||||
const ordinary = Function.prototype[Symbol.hasInstance]
|
||||
for (const name of Object.keys(hostIntrinsics)) {
|
||||
const VmCtor = globalThis[name]
|
||||
const HostCtor = hostIntrinsics[name]
|
||||
if (typeof VmCtor !== 'function' || typeof HostCtor !== 'function') continue
|
||||
Object.defineProperty(VmCtor, Symbol.hasInstance, {
|
||||
value: (instance) => ordinary.call(VmCtor, instance) || ordinary.call(HostCtor, instance),
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/** Run {@link DUAL_REALM_INSTANCEOF_PRELUDE} in a freshly created sandbox, handing it the host intrinsics to pair up. */
|
||||
function patchDualRealmInstanceof(sandbox: object): void {
|
||||
const patch = runInContext(DUAL_REALM_INSTANCEOF_PRELUDE, sandbox) as (intrinsics: Record<string, unknown>) => void
|
||||
patch({ Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set })
|
||||
}
|
||||
|
||||
const TIMER_REDIRECT
|
||||
= 'Node timers are unavailable. Use the cordis timer service instead: declare inject: [\'timer\'] on your plugin '
|
||||
+ 'and call ctx.timeout / ctx.interval after querying Host Service.listService for the exact overloads. '
|
||||
+ 'Those calls are fiber effects, cleaned up automatically when stopped.'
|
||||
|
||||
/**
|
||||
* The callable Node APIs the sandbox deliberately disables, each mapped to the
|
||||
* cordis alternative its trap error names. Only function-valued globals are
|
||||
* trapped; a data-valued global such as `process` stays `undefined`, because a
|
||||
* throwing accessor would detonate the common `typeof process` feature probe
|
||||
* at resolution time.
|
||||
*/
|
||||
const NODE_API_REDIRECTS: Record<string, string> = {
|
||||
require:
|
||||
'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
|
||||
+ '[\'web\'] for HTTP, [\'bash\'] for processes; query Service.listService with cordis_inspect_query first.',
|
||||
setTimeout: TIMER_REDIRECT,
|
||||
setInterval: TIMER_REDIRECT,
|
||||
setImmediate: TIMER_REDIRECT,
|
||||
clearTimeout: TIMER_REDIRECT,
|
||||
clearInterval: TIMER_REDIRECT,
|
||||
fetch:
|
||||
'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
|
||||
+ '(query Host Service.listService with cordis_inspect_query for its methods).',
|
||||
}
|
||||
|
||||
/** Build the trap functions for {@link NODE_API_REDIRECTS}: calling one throws the redirect. */
|
||||
function nodeApiTraps(): Record<string, () => never> {
|
||||
const traps: Record<string, () => never> = {}
|
||||
for (const [name, redirect] of Object.entries(NODE_API_REDIRECTS)) {
|
||||
traps[name] = () => {
|
||||
throw new Error(`${name} is not available in the dynamic package sandbox — ${redirect}`)
|
||||
}
|
||||
}
|
||||
return traps
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the vm context one host half evaluates in: the tagged console, the
|
||||
* `harness` registration helpers, the encoding primitives, the Node-API traps,
|
||||
* and the dual-realm `instanceof` patch, already `createContext`-ed.
|
||||
* @param id - the package id (`dyn-<n>`), used as the console tag and filename stem.
|
||||
* @param harnessExtras - per-package `harness` verbs beyond the registration pair (`handle`).
|
||||
* @returns the contextified sandbox object to pass to {@link evaluateHostCode}.
|
||||
*/
|
||||
export function createSandbox(id: string, harnessExtras: Record<string, unknown> = {}): object {
|
||||
const sandbox = {
|
||||
...nodeApiTraps(),
|
||||
console: taggedConsole(id),
|
||||
harness: { defineTool: sandboxDefineTool, registerTool: sandboxRegisterTool, ...harnessExtras },
|
||||
// Web APIs absent from fresh vm contexts — made available so the model
|
||||
// can encode/decode base64 without Buffer (which is also absent). Host
|
||||
// closures over Buffer, never Buffer itself.
|
||||
btoa: (s: string) => Buffer.from(s, 'utf-8').toString('base64'),
|
||||
atob: (s: string) => Buffer.from(s, 'base64').toString('utf-8'),
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
}
|
||||
createContext(sandbox)
|
||||
patchDualRealmInstanceof(sandbox)
|
||||
return sandbox
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-realm SyntaxError detection: a compile failure inside `runInContext`
|
||||
* constructs its error in the SANDBOX realm, so a host `instanceof
|
||||
* SyntaxError` is silently false — the `name` property is the realm-safe tag.
|
||||
*/
|
||||
function isSyntaxError(error: unknown): error is Error {
|
||||
return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'SyntaxError'
|
||||
}
|
||||
|
||||
/**
|
||||
* The parse-failure context a vm `SyntaxError` carries: the vm prints the
|
||||
* offending source line and a caret before the message, which is exactly what
|
||||
* a model needs to self-correct — surface it instead of the bare message.
|
||||
* Falls back to `String(error)` when the stack carries no such prelude.
|
||||
* @param error - the `SyntaxError` (host- or sandbox-realm) thrown while compiling package code.
|
||||
* @returns the stack prefix up to and including the `SyntaxError: …` line.
|
||||
*/
|
||||
export function syntaxErrorContext(error: Error): string {
|
||||
const lines = (error.stack ?? '').split('\n')
|
||||
const messageIndex = lines.findIndex(line => line.startsWith('SyntaxError'))
|
||||
if (messageIndex === -1) return String(error)
|
||||
return lines.slice(0, messageIndex + 1).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The teaching text one parse failure produces, shared by the define-time
|
||||
* precheck and the run-time evaluation so a model reads the same diagnosis
|
||||
* whichever verb caught it.
|
||||
* @param half - which half failed to parse, named as the define argument that carried it.
|
||||
* @param context - the {@link syntaxErrorContext} of the failure.
|
||||
* @returns the model-facing error message.
|
||||
*/
|
||||
export function parseErrorMessage(half: 'code.host' | 'code.client', context: string): string {
|
||||
// Scope the TypeScript heuristic to the OFFENDING line, not the whole code:
|
||||
// an ` as ` inside an ordinary description string must not turn a plain
|
||||
// syntax error into a misleading remove-annotations message.
|
||||
const offendingLine = context.split('\n')[1] ?? ''
|
||||
if (/\bas\b/.test(offendingLine)) {
|
||||
return `dynamic package \`${half}\` failed to parse:\n${context}\n`
|
||||
+ 'The sandbox runs plain JavaScript, not TypeScript. Remove type annotations:\n'
|
||||
+ ' ✗ { type: \'text\' as const, text: x }\n'
|
||||
+ ' ✓ { type: \'text\', text: x }'
|
||||
}
|
||||
return `dynamic package \`${half}\` failed to parse:\n${context}\n`
|
||||
+ 'Note: it runs as the BODY of an async function (line numbers are offset by the 1-line wrapper). '
|
||||
+ 'Check bracket balance — ending the returned plugin object with `});` closes a call that was never opened; '
|
||||
+ 'a plain `return { … }` ends with `}` (an optional `;`), never `)`.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one half's source without running it: the define-time precheck that
|
||||
* keeps unparseable code out of the registry, so a model fixes it and defines
|
||||
* again instead of discovering the failure at run time. Compiling through `vm`
|
||||
* rather than `new Function` is what makes the two agree — same wrapper, same
|
||||
* compiler, and the same source-line-and-caret prelude in the failure.
|
||||
* @param code - the model-written function body.
|
||||
* @param half - which define argument carried it, for the error text.
|
||||
* @throws when the body does not parse, with the offending line and a teaching hint.
|
||||
*/
|
||||
export function precheckCode(code: string, half: 'code.host' | 'code.client'): void {
|
||||
try {
|
||||
// Compile-only: constructing the Script parses the source and runs nothing.
|
||||
new Script(`(async () => {\n${code}\n})()`, { filename: `cordis-dyn-${half}.js` })
|
||||
} catch (error) {
|
||||
if (!isSyntaxError(error)) throw error
|
||||
throw new Error(parseErrorMessage(half, syntaxErrorContext(error)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a host half as the body of an async function inside the sandbox. `vmTimeoutMs` only
|
||||
* bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's
|
||||
* trust stance. Parse errors include the offending line and a TypeScript-removal or bracket-
|
||||
* balance hint.
|
||||
* @param sandbox - the contextified object from {@link createSandbox}.
|
||||
* @param code - the model-written function body; must `return` a plugin.
|
||||
* @param id - the package id, used as the vm filename (`cordis-dyn-<id>.js`).
|
||||
* @param vmTimeoutMs - the synchronous evaluation bound in milliseconds.
|
||||
* @returns whatever the code returned, still un-narrowed (the run lifecycle checks plugin shape).
|
||||
*/
|
||||
export async function evaluateHostCode(sandbox: object, code: string, id: string, vmTimeoutMs: number): Promise<unknown> {
|
||||
try {
|
||||
return await runInContext(
|
||||
`(async () => {\n${code}\n})()`,
|
||||
sandbox,
|
||||
{ filename: `cordis-dyn-${id}.js`, timeout: vmTimeoutMs },
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isSyntaxError(error)) throw error
|
||||
throw new Error(parseErrorMessage('code.host', syntaxErrorContext(error)))
|
||||
}
|
||||
}
|
||||
399
packages/extensions/cordis-host-runner/src/types.ts
Normal file
399
packages/extensions/cordis-host-runner/src/types.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Client-safe wire vocabulary of the dynamic Cordis plugin runner.
|
||||
* @module @deepseek-ai/dsh-cordis-host-runner/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Stable identity of one dynamic plugin instance. */
|
||||
export type CordisDynamicPluginId = Branded<'CordisDynamicPluginId'>
|
||||
|
||||
/** Identity of one immutable package version belonging to a dynamic plugin. */
|
||||
export type CordisDynamicPackageId = Branded<'CordisDynamicPackageId'>
|
||||
|
||||
/** Identity of one successful activation attempt. */
|
||||
export type CordisDynamicPluginRunId = Branded<'CordisDynamicPluginRunId'>
|
||||
|
||||
/** Identity of one human approval request. */
|
||||
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||
|
||||
/** Identity of one cross-page inspect query. */
|
||||
export type CordisInspectRequestId = Branded<'CordisInspectRequestId'>
|
||||
|
||||
/** Runtime plane that owns an inspect provider. */
|
||||
export type CordisInspectPlatform = 'host' | 'client'
|
||||
|
||||
/** One model-callable read-only query exposed by an inspect provider. */
|
||||
export interface CordisInspectMethodManifest {
|
||||
/** Method name, unique within its provider. */
|
||||
name: string
|
||||
/** What the query returns and when to use it. */
|
||||
description: string
|
||||
/** JSON Schema accepted by the query. */
|
||||
inputSchema: JsonValue
|
||||
/** JSON Schema produced by the query. */
|
||||
outputSchema: JsonValue
|
||||
}
|
||||
|
||||
/** Serializable directory entry for one inspect provider. */
|
||||
export interface CordisInspectProviderManifest {
|
||||
/** Provider identity, unique within one platform. */
|
||||
id: string
|
||||
/** Capability described by this provider. */
|
||||
description: string
|
||||
/** Explicit read-only queries. */
|
||||
methods: readonly CordisInspectMethodManifest[]
|
||||
}
|
||||
|
||||
/** Provider directory row returned by `cordis_inspect_list`. */
|
||||
export interface CordisInspectProviderView extends CordisInspectProviderManifest {
|
||||
/** Runtime plane that executes these methods. */
|
||||
platform: CordisInspectPlatform
|
||||
}
|
||||
|
||||
/** Host broadcast requesting one live Client inspect result. */
|
||||
export interface CordisInspectQueryRequest {
|
||||
/** Correlation identity. */
|
||||
requestId: CordisInspectRequestId
|
||||
/** Session whose model requested the query. */
|
||||
agentId: SessionId
|
||||
/** Provider selected from the Client manifest. */
|
||||
provider: string
|
||||
/** Method selected from the provider manifest. */
|
||||
method: string
|
||||
/** JSON query input, omitted when the method has no fields. */
|
||||
input?: JsonValue
|
||||
}
|
||||
|
||||
/** Result sent from a Client provider to the waiting Host query. */
|
||||
export type CordisInspectQueryResolution =
|
||||
| { ok: true; data: JsonValue }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'provider-missing' | 'method-missing' | 'invalid-input' | 'provider-error' | 'cancelled'
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Notification that a Client inspect request can no longer be answered. */
|
||||
export interface CordisInspectQueryResolved {
|
||||
/** Query that left the pending state. */
|
||||
requestId: CordisInspectRequestId
|
||||
}
|
||||
|
||||
/** Whether a Client answer claimed the still-pending query. */
|
||||
export interface CordisInspectResolveAck {
|
||||
/** False for unknown, cancelled, stale, or late answers. */
|
||||
accepted: boolean
|
||||
}
|
||||
|
||||
/** Whether a package starts the current version or replaces it. */
|
||||
export type CordisDynamicRunMode = 'run' | 'update'
|
||||
|
||||
/** How a model-driven Client activation request left the pending state. */
|
||||
export type RequestRunOutcome = 'approved' | 'completed' | 'rejected' | 'cancelled' | 'failed'
|
||||
|
||||
/** Error fields preserved across the Host/Client transport. */
|
||||
export interface CordisErrorDetails {
|
||||
/** Original error message. */
|
||||
message: string
|
||||
/** Original stack when the thrown value supplied one. */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
/** Persisted state of the latest activation attempt. */
|
||||
export type CordisRunStatus =
|
||||
| 'awaiting-approval'
|
||||
| 'starting-host'
|
||||
| 'client-pending'
|
||||
| 'running'
|
||||
| 'waiting'
|
||||
| 'rejected'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'stopped'
|
||||
|
||||
/** One platform half within an activation attempt. */
|
||||
export interface CordisHalfState {
|
||||
/** Lifecycle state of this half. */
|
||||
status: 'absent' | 'pending' | 'stopped' | 'running' | 'waiting' | 'failed'
|
||||
/** Services still needed by a successfully created Fiber. */
|
||||
waitingFor: readonly string[]
|
||||
/** Failure text for this half. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Structured failure associated with an exact activation attempt. */
|
||||
export interface CordisRunDiagnostic {
|
||||
/** Stage that failed. */
|
||||
phase: 'approval' | 'host-load' | 'host-apply' | 'client-load' | 'client-apply' | 'client-render'
|
||||
/** Original failure text. */
|
||||
message: string
|
||||
/** Original failure stack when available. */
|
||||
stack?: string
|
||||
/** Stable Plugin identity. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Immutable Package identity. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Exact attempt identity. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
/** Latest activation attempt retained independently from the physical run. */
|
||||
export interface DynamicCordisRunAttempt {
|
||||
/** Exact attempt identity. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
/** Target Package. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Explicit run/update intent. */
|
||||
mode: CordisDynamicRunMode
|
||||
/** Current attempt state. */
|
||||
status: CordisRunStatus
|
||||
/** Pending Client activation request; it represents approval only when `requiresApproval` is true. */
|
||||
approvalRequestId?: ApprovalRequestId
|
||||
/** Whether the pending Client activation requires a user decision. */
|
||||
requiresApproval?: boolean
|
||||
/** Host-half state. */
|
||||
host: CordisHalfState
|
||||
/** Client-half state. */
|
||||
client: CordisHalfState
|
||||
/** Most recent failure. */
|
||||
error?: CordisRunDiagnostic
|
||||
}
|
||||
|
||||
/** One running package announced to browser pages. */
|
||||
export interface DynamicCordisPackage {
|
||||
/** Stable plugin instance. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Immutable package version currently active. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** This activation's identity. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
/** Package label. */
|
||||
name: string
|
||||
}
|
||||
|
||||
/** One pending model-driven Client activation forwarded to browser pages. */
|
||||
export interface DynamicCordisRunRequest {
|
||||
/** Correlation identity of the activation request. */
|
||||
requestId: ApprovalRequestId
|
||||
/** Session whose plugin and tool call own the request. */
|
||||
agentId: SessionId
|
||||
/** Stable plugin instance being acted on. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Package version the request will activate. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Explicit lifecycle intent. */
|
||||
mode: CordisDynamicRunMode
|
||||
/** Package label. */
|
||||
name: string
|
||||
/** User-facing reason supplied at define time. */
|
||||
purpose: string
|
||||
/** Whether a page must wait for an explicit user decision before activation. */
|
||||
requiresApproval: boolean
|
||||
}
|
||||
|
||||
/** One settled model-driven Client activation request broadcast to all pages. */
|
||||
export interface DynamicCordisRequestResolved {
|
||||
/** Request that left the pending state. */
|
||||
requestId: ApprovalRequestId
|
||||
/** How the request settled. */
|
||||
outcome: RequestRunOutcome
|
||||
}
|
||||
|
||||
/** One activation withdrawn from every page. */
|
||||
export interface DynamicCordisRetracted {
|
||||
/** Stable plugin instance. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Package version that was active. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Exact activation being withdrawn. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
/** Package metadata exposed by the inventory without source code. */
|
||||
export interface DynamicCordisInventoryPackage {
|
||||
/** Immutable package version. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Package label. */
|
||||
name: string
|
||||
/** User-facing purpose. */
|
||||
purpose: string
|
||||
/** Whether this version contains Host code. */
|
||||
hasHostHalf: boolean
|
||||
/** Whether this version contains Client code. */
|
||||
hasClientHalf: boolean
|
||||
}
|
||||
|
||||
/** One stable plugin row in the frame-wide inventory. */
|
||||
export interface DynamicCordisInventoryRow {
|
||||
/** Stable plugin instance. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Session that owns this plugin. */
|
||||
agentId: SessionId
|
||||
/** Immutable versions in define order. */
|
||||
packages: readonly DynamicCordisInventoryPackage[]
|
||||
/** Last package that completed activation successfully. */
|
||||
currentPackageId?: CordisDynamicPackageId
|
||||
/** Package selected for a failed or in-progress transition. */
|
||||
nextPackageId?: CordisDynamicPackageId
|
||||
/** Current activation, absent while stopped. */
|
||||
activeRun?: {
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
packageId: CordisDynamicPackageId
|
||||
}
|
||||
/** Latest activation attempt, including pending approval and diagnostics. */
|
||||
latestRun?: DynamicCordisRunAttempt
|
||||
}
|
||||
|
||||
/** Answer to removing a plugin and all of its package versions. */
|
||||
export type DynamicCordisUndefineReceipt =
|
||||
| { ok: true; wasRunning: boolean }
|
||||
| { ok: false; reason: 'plugin-missing'; message: string }
|
||||
|
||||
/** One render failure observed after a Client half loaded. */
|
||||
export interface DynamicCordisRenderFailure {
|
||||
/** Slot whose component failed. */
|
||||
slot: string
|
||||
/** Render failure text. */
|
||||
message: string
|
||||
/** Original render failure stack when available. */
|
||||
stack?: string
|
||||
/** Whether the failing contribution relinquished its slot. */
|
||||
abdicated: boolean
|
||||
}
|
||||
|
||||
/** Result shared by model-driven and panel-driven activation. */
|
||||
export type DynamicCordisRunResponse =
|
||||
| {
|
||||
ok: true
|
||||
/** Whether activation completed synchronously, is starting in a Client, or awaits user approval. */
|
||||
status: 'awaiting-approval' | 'starting' | 'running'
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
/** Missing Host services; a parked Fiber is a successful activation. */
|
||||
waitingFor: readonly string[]
|
||||
/** Missing Client services reported by the approving page. */
|
||||
clientWaitingFor?: readonly string[]
|
||||
/** Last fully successful Package. */
|
||||
currentPackageId?: CordisDynamicPackageId
|
||||
/** Selected transition target. */
|
||||
nextPackageId?: CordisDynamicPackageId
|
||||
/** Explicit lifecycle intent. */
|
||||
mode: CordisDynamicRunMode
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
reason:
|
||||
| 'plugin-missing'
|
||||
| 'package-missing'
|
||||
| 'invalid-mode'
|
||||
| 'transition-in-flight'
|
||||
| 'host-half-failed'
|
||||
| 'client-half-failed'
|
||||
| 'rejected'
|
||||
| 'cancelled'
|
||||
| 'not-running'
|
||||
message: string
|
||||
/** Original failure stack when available. */
|
||||
stack?: string
|
||||
}
|
||||
|
||||
/** Result of stopping a Plugin without deleting its Packages. */
|
||||
export type DynamicCordisStopResponse =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'plugin-missing' | 'not-running'; message: string }
|
||||
|
||||
/** Result of bringing up the Host half before loading the Client half. */
|
||||
export type DynamicCordisHostHalfResult =
|
||||
| {
|
||||
ok: true
|
||||
pluginId: CordisDynamicPluginId
|
||||
packageId: CordisDynamicPackageId
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
waitingFor: readonly string[]
|
||||
/** False when a panel merely attaches this page to an already active run. */
|
||||
startedHere: boolean
|
||||
}
|
||||
| ({ ok: false } & CordisErrorDetails)
|
||||
|
||||
/** Client-half source for one exact activation. */
|
||||
export interface DynamicCordisClientSource {
|
||||
/** Browser JavaScript body. */
|
||||
code: string
|
||||
/** Package label. */
|
||||
name: string
|
||||
/** Stable plugin instance. */
|
||||
pluginId: CordisDynamicPluginId
|
||||
/** Immutable source version. */
|
||||
packageId: CordisDynamicPackageId
|
||||
/** Exact activation the source belongs to. */
|
||||
pluginRunId: CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
/** Browser verdict used for both approved tool runs and panel runs. */
|
||||
export type DynamicCordisRunResolution =
|
||||
| { ok: true; pluginRunId: CordisDynamicPluginRunId; waitingFor?: readonly string[] }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'rejected' | 'host-half-failed' | 'client-half-failed'
|
||||
/** Activation that failed; absent for a refusal before activation. */
|
||||
pluginRunId?: CordisDynamicPluginRunId
|
||||
/** Whether this page created the failed activation instead of attaching to it. */
|
||||
startedHere?: boolean
|
||||
message?: string
|
||||
stack?: string
|
||||
}
|
||||
|
||||
/** Whether a Client activation resolution reached the still-pending request. */
|
||||
export interface DynamicCordisResolveAck {
|
||||
/** False for late, unknown, or stale answers. */
|
||||
accepted: boolean
|
||||
}
|
||||
|
||||
/** Result of routing one Client call to the active Host half. */
|
||||
export type DynamicCordisInvokeResult =
|
||||
| { ok: true; value: JsonValue }
|
||||
| ({ ok: false; code: 'plugin-not-running' | 'stale-run' | 'method-not-found' | 'handler-error' } & CordisErrorDetails)
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A Client-bearing activation needs a browser page, and may require a user decision.
|
||||
* @param request - correlation identity, owner, target version, mode, and approval requirement.
|
||||
* @mode emit
|
||||
*/
|
||||
'cordis/request-run'(request: DynamicCordisRunRequest): void
|
||||
/**
|
||||
* A pending Client activation request left the answerable state.
|
||||
* @param resolved - request identity and outcome.
|
||||
* @mode emit
|
||||
*/
|
||||
'cordis/request-run-resolved'(resolved: DynamicCordisRequestResolved): void
|
||||
/**
|
||||
* One exact Plugin/Package activation is now live in the Host.
|
||||
* @param pkg - stable plugin, immutable package, run identity, and label.
|
||||
* @mode emit
|
||||
*/
|
||||
'cordis/dynamic-package'(pkg: DynamicCordisPackage): void
|
||||
/**
|
||||
* One exact activation was withdrawn.
|
||||
* @param retracted - plugin, package, and run identity.
|
||||
* @mode emit
|
||||
*/
|
||||
'cordis/dynamic-retract'(retracted: DynamicCordisRetracted): void
|
||||
/**
|
||||
* Request a live read-only query from the Client inspect registry.
|
||||
* @param request - correlation, Session, provider, method, and JSON input.
|
||||
* @mode emit
|
||||
*/
|
||||
'cordis/inspect-query'(request: CordisInspectQueryRequest): void
|
||||
/**
|
||||
* Notify every Client that an inspect query has settled or been cancelled.
|
||||
* @param resolved - exact query identity that is no longer answerable.
|
||||
* @mode emit
|
||||
*/
|
||||
'cordis/inspect-query-resolved'(resolved: CordisInspectQueryResolved): void
|
||||
}
|
||||
}
|
||||
180
packages/extensions/cordis-host-runner/tests/composition.spec.ts
Normal file
180
packages/extensions/cordis-host-runner/tests/composition.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CordisDynamicPackageId, CordisDynamicPluginId } from '../src/types.ts'
|
||||
import { missingServices } from '../src/lifecycle.ts'
|
||||
import {
|
||||
AGENT_A, call, CONSUMER_CODE, CONTENT_OUTPUT_CODE, dummyTool, LISTENER_CODE, mount,
|
||||
PROVIDER_CODE, REVERSE_TOOL_CODE, setup, text,
|
||||
running,
|
||||
} from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Cross-package composition through ordinary cordis provide/inject semantics:
|
||||
* one package's host half provides a service, another injects it, and definition
|
||||
* ids stay the lifecycle handles across stop and run again. Every assertion is
|
||||
* against the WORLD — the registry, the service store, real tool dispatch — not
|
||||
* a rendered summary (that is the tool package's job).
|
||||
*/
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function latestPackage(harness: Awaited<ReturnType<typeof setup>>, pluginId: CordisDynamicPluginId): CordisDynamicPackageId {
|
||||
const row = harness.runner.inventory().find(candidate => candidate.pluginId === pluginId)
|
||||
const packageId = row?.packages.at(-1)?.packageId
|
||||
if (packageId === undefined) throw new Error(`missing package for ${pluginId}`)
|
||||
return packageId
|
||||
}
|
||||
|
||||
describe('cross-package provide/inject', () => {
|
||||
it('provider first: the consumer activates immediately and its tool reaches the provided service', async () => {
|
||||
const harness = await setup()
|
||||
await mount(harness, PROVIDER_CODE)
|
||||
await mount(harness, CONSUMER_CODE)
|
||||
|
||||
// The vm-realm service value is callable across packages, and the result
|
||||
// normalizes into the host realm like any dynamic tool result.
|
||||
const greeted = await call(harness.ctx, 'greet', { name: 'harness' })
|
||||
expect(greeted.isError).toBe(false)
|
||||
expect(text(greeted)).toBe('hi harness')
|
||||
})
|
||||
|
||||
it('consumer first: runs but stays parked on the missing service, then activates when the provider runs', async () => {
|
||||
const harness = await setup()
|
||||
const consumer = await mount(harness, CONSUMER_CODE)
|
||||
|
||||
// A settled-but-pending host half is a successful run in legal cordis
|
||||
// semantics; the fiber names what it waits for.
|
||||
const [row] = harness.runner.snapshot(AGENT_A)
|
||||
expect(row?.activeRun?.fiber).toBeDefined()
|
||||
expect(missingServices(harness.ctx, row?.activeRun?.fiber as never)).toEqual(['greeter'])
|
||||
expect(harness.ctx.tools.get('greet')).toBeUndefined()
|
||||
expect(running(harness.runner, AGENT_A)).toEqual([{ id: consumer, running: true }])
|
||||
|
||||
await mount(harness, PROVIDER_CODE)
|
||||
expect(harness.ctx.tools.get('greet')).toBeDefined()
|
||||
expect(text(await call(harness.ctx, 'greet', { name: 'late' }))).toBe('hi late')
|
||||
})
|
||||
|
||||
it('stopping the provider sends the consumer back to pending and unwinds its registrations', async () => {
|
||||
const harness = await setup()
|
||||
const provider = await mount(harness, PROVIDER_CODE)
|
||||
await mount(harness, CONSUMER_CODE)
|
||||
expect(harness.ctx.tools.get('greet')).toBeDefined()
|
||||
|
||||
await expect(harness.runner.stop(AGENT_A, provider)).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(harness.ctx.tools.get('greet')).toBeUndefined()
|
||||
expect(harness.ctx.get('greeter')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('running the provider again re-runs the consumer through a fresh guard (tool back)', async () => {
|
||||
const harness = await setup()
|
||||
const provider = await mount(harness, PROVIDER_CODE)
|
||||
await mount(harness, CONSUMER_CODE)
|
||||
await harness.runner.stop(AGENT_A, provider)
|
||||
expect(harness.ctx.tools.get('greet')).toBeUndefined()
|
||||
|
||||
// The same definition, a new dispatch: the consumer's apply re-runs through
|
||||
// a new façade rather than needing its own re-definition.
|
||||
await expect(harness.runner.run(
|
||||
AGENT_A, provider, latestPackage(harness, provider), 'run',
|
||||
)).resolves.toMatchObject({ ok: true })
|
||||
expect(harness.ctx.tools.get('greet')).toBeDefined()
|
||||
expect(text(await call(harness.ctx, 'greet', { name: 'again' }))).toBe('hi again')
|
||||
})
|
||||
|
||||
it('a duplicate provide fails loud and leaves the second package not running', async () => {
|
||||
const harness = await setup()
|
||||
await mount(harness, PROVIDER_CODE)
|
||||
|
||||
await expect(mount(harness, PROVIDER_CODE)).rejects.toThrow('has been registered')
|
||||
|
||||
const rows = harness.runner.snapshot(AGENT_A)
|
||||
expect(rows.map(row => row.activeRun !== undefined)).toEqual([true, false])
|
||||
// The service still belongs to the first package's fiber.
|
||||
expect(harness.ctx.get('greeter')).toBeDefined()
|
||||
})
|
||||
|
||||
it('a primitive (or null) provided value passes through the façade unwrapped, on both read paths', async () => {
|
||||
const harness = await setup()
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'answer-provider',
|
||||
apply(ctx) {
|
||||
ctx.provide('answer', 42)
|
||||
ctx.provide('nothing', null)
|
||||
},
|
||||
}
|
||||
`)
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'answer-consumer',
|
||||
inject: ['answer', 'nothing', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'answer',
|
||||
description: 'Read the provided primitive services.',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: ctx.answer + '/' + ctx.get('answer') + '/' + ctx.nothing }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`)
|
||||
|
||||
expect(text(await call(harness.ctx, 'answer', {}))).toBe('42/42/null')
|
||||
})
|
||||
|
||||
it('stopping the consumer leaves the provider and its service intact', async () => {
|
||||
const harness = await setup()
|
||||
await mount(harness, PROVIDER_CODE)
|
||||
const consumer = await mount(harness, CONSUMER_CODE)
|
||||
|
||||
await harness.runner.stop(AGENT_A, consumer)
|
||||
|
||||
expect(harness.ctx.tools.get('greet')).toBeUndefined()
|
||||
expect(harness.ctx.get('greeter')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stop reaches quiescence', () => {
|
||||
it('the host half\'s listeners have stopped by the time stop returns', async () => {
|
||||
const harness = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const id = await mount(harness, LISTENER_CODE)
|
||||
|
||||
harness.ctx.tools.register(dummyTool('trigger_before'))
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
|
||||
await expect(harness.runner.stop(AGENT_A, id)).resolves.toEqual({ ok: true })
|
||||
|
||||
// Immediately after the awaited stop, the listener must be gone — no grace
|
||||
// period, no eventual consistency.
|
||||
harness.ctx.tools.register(dummyTool('trigger_after'))
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('unregisters a self-made tool on stop, and registers it again on the next run', async () => {
|
||||
const harness = await setup()
|
||||
const id = await mount(harness, REVERSE_TOOL_CODE)
|
||||
expect(harness.ctx.tools.get('reverse_text')).toBeDefined()
|
||||
|
||||
await harness.runner.stop(AGENT_A, id)
|
||||
expect(harness.ctx.tools.get('reverse_text')).toBeUndefined()
|
||||
|
||||
await harness.runner.run(AGENT_A, id, latestPackage(harness, id), 'run')
|
||||
expect(harness.ctx.tools.get('reverse_text')).toBeDefined()
|
||||
})
|
||||
|
||||
it('names the replace recipe when a run collides with a live registration', async () => {
|
||||
const harness = await setup()
|
||||
await mount(harness, REVERSE_TOOL_CODE)
|
||||
|
||||
// A second package registering the same tool name collides; the teaching
|
||||
// error points at the stop-then-run recipe rather than a bare conflict.
|
||||
await expect(mount(harness, REVERSE_TOOL_CODE)).rejects.toThrow('first cordis_stop that package\'s id')
|
||||
})
|
||||
})
|
||||
250
packages/extensions/cordis-host-runner/tests/helpers.ts
Normal file
250
packages/extensions/cordis-host-runner/tests/helpers.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { CordisDynamicPluginId } from '../src/types.ts'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import DynamicCordisRunnerService from '../src/index.ts'
|
||||
import type { Config } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Shared spec harness: a real `SystemPrompt` + `ToolRegistry` + timer tree with
|
||||
* the runner mounted and a recording stand-in for the web gateway. Only the
|
||||
* model and the browser are absent — the code strings below stand in for what
|
||||
* the model would write, and the gateway records (and optionally answers) every
|
||||
* dispatch.
|
||||
*/
|
||||
|
||||
/** One recorded broadcast plus how the fake browser answers a run request. */
|
||||
interface Gateway {
|
||||
/** Every forwarded event the runner emitted, in order, as `[name, payload]`. */
|
||||
events: [name: string, payload: unknown][]
|
||||
/**
|
||||
* How the fake browser answers the next run request, standing in for a person
|
||||
* at the panel: it orchestrates exactly as the real client runner does (bring
|
||||
* the host half up, fetch the source, answer), or declines.
|
||||
*/
|
||||
answer?: 'approve' | 'reject' | { clientFails: string }
|
||||
/** Services the fake browser reports its half is parked on. */
|
||||
clientWaitingFor?: string[]
|
||||
/** Completion of the fake page's latest asynchronous answer. */
|
||||
answering?: Promise<void>
|
||||
}
|
||||
|
||||
/** The session that owns every definition these suites define. */
|
||||
export const AGENT_A = { id: 'S-a' as SessionId, steer() {}, inject() {} } as unknown as Agent
|
||||
/** A second session, for the authority-scoping cases. */
|
||||
export const AGENT_B = { id: 'S-b' as SessionId, steer() {}, inject() {} } as unknown as Agent
|
||||
|
||||
/** One live tree: the context, the runner, and the recording gateway. */
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
runner: DynamicCordisRunnerService
|
||||
gateway: Gateway
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a real tree with the runner mounted and a recording gateway provided.
|
||||
* @param config - runner config overrides (the vm bound).
|
||||
* @returns the context, the runner service, and the gateway recorder.
|
||||
*/
|
||||
export async function setup(config?: Config): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const gateway: Gateway = { events: [] }
|
||||
ctx.on('cordis/request-run', (request) => {
|
||||
gateway.events.push(['cordis/request-run', request])
|
||||
// The fake browser: a request reaches it, and it answers the way the real
|
||||
// client runner does — nothing here is a shortcut through the host's own
|
||||
// verbs, so the round trip under test is the real one.
|
||||
if (gateway.answer === undefined) return
|
||||
const answer = gateway.answer
|
||||
const { requestId, pluginId, packageId, mode } = request
|
||||
gateway.answering = Promise.resolve().then(async (): Promise<void> => {
|
||||
if (answer === 'reject') {
|
||||
await runner.resolveRequestRun(requestId, { ok: false, reason: 'rejected', message: 'not now' })
|
||||
return
|
||||
}
|
||||
const half = await runner.runHostHalf(AGENT_A, pluginId, packageId, mode, requestId, false)
|
||||
if (!half.ok) {
|
||||
await runner.resolveRequestRun(requestId, {
|
||||
ok: false, reason: 'host-half-failed', message: half.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (typeof answer === 'object') {
|
||||
await runner.resolveRequestRun(requestId, {
|
||||
ok: false,
|
||||
reason: 'client-half-failed',
|
||||
pluginRunId: half.pluginRunId,
|
||||
startedHere: half.startedHere,
|
||||
message: answer.clientFails,
|
||||
})
|
||||
return
|
||||
}
|
||||
const source = runner.getClientCode(AGENT_A, pluginId, half.pluginRunId)
|
||||
await runner.resolveRequestRun(requestId, {
|
||||
ok: true,
|
||||
pluginRunId: source.pluginRunId,
|
||||
...gateway.clientWaitingFor === undefined ? {} : { waitingFor: gateway.clientWaitingFor },
|
||||
})
|
||||
})
|
||||
})
|
||||
for (const name of ['cordis/request-run-resolved', 'cordis/dynamic-package', 'cordis/dynamic-retract'] as const) {
|
||||
ctx.on(name, (payload: unknown) => { gateway.events.push([name, payload]) })
|
||||
}
|
||||
await ctx.plugin(DynamicCordisRunnerService, config)
|
||||
const runner = ctx.dynamicCordisRunner
|
||||
return { ctx, runner, gateway }
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's packages and whether each runs, projected from the global
|
||||
* inventory — the reading a surface takes now that there is no session-scoped
|
||||
* list verb.
|
||||
* @param runner - the live runner service.
|
||||
* @param agent - the session to project.
|
||||
* @returns id/running pairs in define order.
|
||||
*/
|
||||
export function running(runner: DynamicCordisRunnerService, agent: Agent): { id: string; running: boolean }[] {
|
||||
return runner.inventory()
|
||||
.filter(row => row.agentId === agent.id)
|
||||
.map(row => ({ id: String(row.pluginId), running: row.activeRun !== undefined }))
|
||||
}
|
||||
|
||||
let definitionCounter = 0
|
||||
|
||||
/**
|
||||
* Define and run one host half in one step, the way the ported suites exercise
|
||||
* the sandbox: a failure in either verb rejects with the runner's own
|
||||
* model-facing message, so a spec asserts teaching text through `rejects`.
|
||||
* @param harness - the live tree.
|
||||
* @param code - the host-half source.
|
||||
* @returns the definition id of the running package.
|
||||
* @throws the runner's refusal message when define prechecks or the run fails.
|
||||
*/
|
||||
export async function mount(harness: Harness, code: string): Promise<CordisDynamicPluginId> {
|
||||
const { pluginId, packageId } = harness.runner.define({
|
||||
sessionId: AGENT_A.id,
|
||||
plugin: { kind: 'new', idPrefix: 'probe' },
|
||||
name: `probe-${++definitionCounter}`,
|
||||
purpose: 'spec fixture',
|
||||
code: { host: code },
|
||||
})
|
||||
const receipt = await harness.runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
if (!receipt.ok) throw new Error(receipt.message)
|
||||
return pluginId
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
|
||||
/** Execute a registered tool through the real registry pipeline. */
|
||||
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
})
|
||||
}
|
||||
|
||||
/** Concatenated text blocks of one tool result. */
|
||||
export function text(result: ToolExecutionResult): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
/** Explicit content-array output declaration for dynamic-tool behavior fixtures. */
|
||||
export const CONTENT_OUTPUT_CODE = `
|
||||
output: {
|
||||
schema: { type: 'array', items: { type: 'json' } },
|
||||
render(_args, value) { return value },
|
||||
},`
|
||||
|
||||
/** Browser-half source the fake browser "loads"; its content never runs in these suites. */
|
||||
export const CLIENT_CODE = 'return () => {}'
|
||||
|
||||
/** Host-half source for a listener package: logs on every `tools/change`. */
|
||||
export const LISTENER_CODE = `
|
||||
return {
|
||||
name: 'change-logger',
|
||||
apply(ctx) {
|
||||
ctx.on('tools/change', () => console.log('tools changed'))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Host-half source registering a self-made tool through the sandbox harness helpers. */
|
||||
export const REVERSE_TOOL_CODE = `
|
||||
return {
|
||||
name: 'reverse-text',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'reverse_text',
|
||||
description: 'Reverse a string.',
|
||||
parameters: { text: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) {
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
return args.text.split('').reverse().join('')
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Host-half source providing a `greeter` service other packages can inject. */
|
||||
export const PROVIDER_CODE = `
|
||||
return {
|
||||
name: 'greeter-provider',
|
||||
apply(ctx) {
|
||||
ctx.provide('greeter', { greet: (name) => 'hi ' + name })
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** Host-half source consuming the `greeter` service through inject, exposing it as a tool. */
|
||||
export const CONSUMER_CODE = `
|
||||
return {
|
||||
name: 'greeter-consumer',
|
||||
inject: ['greeter', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'greet',
|
||||
description: 'Greet someone via the greeter service.',
|
||||
parameters: { name: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) {
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
},
|
||||
async execute(args) {
|
||||
return ctx.greeter.greet(args.name)
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
/** A registrable no-op tool the tests use as a schema-view target. */
|
||||
export function dummyTool(name: string): ToolDefinition {
|
||||
return {
|
||||
name,
|
||||
description: 'test trigger',
|
||||
parameters: { type: 'object' as const, properties: {} },
|
||||
output: { schema: { type: 'null' }, render: () => [] },
|
||||
async execute(): Promise<null> {
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
578
packages/extensions/cordis-host-runner/tests/runner.spec.ts
Normal file
578
packages/extensions/cordis-host-runner/tests/runner.spec.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApprovalRequestId } from '../src/index.ts'
|
||||
import type {
|
||||
ApprovalRequestId as ApprovalRequestIdType, CordisDynamicPluginId,
|
||||
} from '../src/types.ts'
|
||||
import { AGENT_A, AGENT_B, CLIENT_CODE, setup, running } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The runner's own chain on a real cordis tree: define records without running,
|
||||
* run starts a real host-half fiber and broadcasts one request, the first answer
|
||||
* settles it, and stop/undefine unwind both halves. Only the model and the
|
||||
* browser are stand-ins (code strings and a recording gateway).
|
||||
*/
|
||||
|
||||
/** A host half that registers one invoke handler and provides a service. */
|
||||
const HOST_CODE = `
|
||||
harness.handle('double', async (args) => args.value * 2)
|
||||
return {
|
||||
name: 'doubler',
|
||||
apply(ctx) {
|
||||
ctx.provide('dynDoubler', { ok: true })
|
||||
},
|
||||
}
|
||||
`
|
||||
|
||||
type Runner = Awaited<ReturnType<typeof setup>>['runner']
|
||||
|
||||
function define(
|
||||
runner: Runner,
|
||||
request: {
|
||||
sessionId: typeof AGENT_A.id
|
||||
name: string
|
||||
purpose: string
|
||||
host?: string
|
||||
client?: string
|
||||
},
|
||||
) {
|
||||
return runner.define({
|
||||
sessionId: request.sessionId,
|
||||
plugin: { kind: 'new', idPrefix: 'dyn' },
|
||||
name: request.name,
|
||||
purpose: request.purpose,
|
||||
code: {
|
||||
...request.host === undefined ? {} : { host: request.host },
|
||||
...request.client === undefined ? {} : { client: request.client },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('dynamic runner definitions', () => {
|
||||
it('lists the whole registry for a global surface, each row carrying its owning session', async () => {
|
||||
const { runner } = await setup()
|
||||
const mine = define(runner, { sessionId: AGENT_A.id, name: 'mine', purpose: 'ours', host: HOST_CODE })
|
||||
const theirs = define(runner, { sessionId: AGENT_B.id, name: 'theirs', purpose: 'not ours', client: CLIENT_CODE })
|
||||
|
||||
// Global by design: a run-control surface that is not inside a session can
|
||||
// still name every package, and each row carries the address later verbs need.
|
||||
expect(runner.inventory()).toEqual([
|
||||
{
|
||||
pluginId: mine.pluginId,
|
||||
agentId: AGENT_A.id,
|
||||
packages: [{
|
||||
packageId: mine.packageId, name: 'mine', purpose: 'ours', hasHostHalf: true, hasClientHalf: false,
|
||||
}],
|
||||
},
|
||||
{
|
||||
pluginId: theirs.pluginId,
|
||||
agentId: AGENT_B.id,
|
||||
packages: [{
|
||||
packageId: theirs.packageId, name: 'theirs', purpose: 'not ours', hasHostHalf: false, hasClientHalf: true,
|
||||
}],
|
||||
},
|
||||
])
|
||||
// Authority did not move with the listing: acting still needs the owner.
|
||||
await expect(runner.run(AGENT_A, theirs.pluginId, theirs.packageId, 'run'))
|
||||
.resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
|
||||
})
|
||||
|
||||
it('tells a global surface which definitions even have a browser half to load', async () => {
|
||||
const { runner } = await setup()
|
||||
define(runner, { sessionId: AGENT_A.id, name: 'host only', purpose: 'no UI', host: HOST_CODE })
|
||||
define(runner, {
|
||||
sessionId: AGENT_A.id,
|
||||
name: 'both halves',
|
||||
purpose: 'UI too',
|
||||
host: HOST_CODE,
|
||||
client: CLIENT_CODE,
|
||||
})
|
||||
|
||||
// A host-only package cannot be loaded into a page, so the surface must be
|
||||
// able to tell the two apart from the listing alone.
|
||||
expect(runner.inventory().map(row => [String(row.pluginId), row.packages[0]?.hasClientHalf])).toEqual([
|
||||
['dyn-1', false],
|
||||
['dyn-2', true],
|
||||
])
|
||||
})
|
||||
|
||||
it('records a definition without running it, and mints ids that are never reused', async () => {
|
||||
const { runner } = await setup()
|
||||
|
||||
const first = define(runner, { sessionId: AGENT_A.id, name: 'first', purpose: 'do a thing', host: HOST_CODE })
|
||||
const second = define(runner, { sessionId: AGENT_A.id, name: 'second', purpose: 'do another', client: 'return () => {}' })
|
||||
|
||||
expect(first).toEqual({
|
||||
pluginId: 'dyn-1', packageId: 'pkg-1', name: 'first', purpose: 'do a thing',
|
||||
hasHostHalf: true, hasClientHalf: false,
|
||||
})
|
||||
expect(second).toEqual({
|
||||
pluginId: 'dyn-2', packageId: 'pkg-2', name: 'second', purpose: 'do another',
|
||||
hasHostHalf: false, hasClientHalf: true,
|
||||
})
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: 'dyn-1', running: false }, { id: 'dyn-2', running: false }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ name: ' ', purpose: 'p', host: 'return () => {}' }, 'non-empty `name`'],
|
||||
[{ name: 'n', purpose: '', host: 'return () => {}' }, 'non-empty `purpose`'],
|
||||
[{ name: 'n', purpose: 'p' }, 'needs `code.host`, `code.client`, or both'],
|
||||
])('refuses an incomplete define request: %j', async (request, message) => {
|
||||
const { runner } = await setup()
|
||||
expect(() => define(runner, { sessionId: AGENT_A.id, ...request })).toThrow(message)
|
||||
})
|
||||
|
||||
it('keeps unparseable code out of the registry, teaching the TypeScript removal', async () => {
|
||||
const { runner } = await setup()
|
||||
|
||||
expect(() => define(runner, {
|
||||
sessionId: AGENT_A.id,
|
||||
name: 'broken',
|
||||
purpose: 'p',
|
||||
client: 'return { type: \'text\' as const }',
|
||||
})).toThrow('The sandbox runs plain JavaScript, not TypeScript')
|
||||
expect(running(runner, AGENT_A)).toEqual([])
|
||||
})
|
||||
|
||||
it('hides another session\'s definition, so only its own card can address it', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'owned', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
|
||||
expect(running(runner, AGENT_B)).toEqual([])
|
||||
await expect(runner.run(AGENT_B, pluginId, packageId, 'run'))
|
||||
.resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
|
||||
await expect(runner.stop(AGENT_B, pluginId)).resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic runner dispatch', () => {
|
||||
it('starts a host-only package immediately, with no request and no approval', async () => {
|
||||
const { ctx, runner, gateway } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
|
||||
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
expect(receipt).toEqual({
|
||||
ok: true,
|
||||
status: 'running',
|
||||
pluginId,
|
||||
packageId,
|
||||
pluginRunId: 'run-1',
|
||||
waitingFor: [],
|
||||
currentPackageId: packageId,
|
||||
mode: 'run',
|
||||
})
|
||||
expect(ctx.get('dynDoubler')).toEqual({ ok: true })
|
||||
// Its own business: the only announcement is the run-state one.
|
||||
expect(gateway.events).toEqual([
|
||||
['cordis/dynamic-package', { pluginId, packageId, pluginRunId: 'run-1', name: 'doubler' }],
|
||||
])
|
||||
await expect(runner.invoke(pluginId, 'run-1' as never, 'double', { value: 21 }))
|
||||
.resolves.toEqual({ ok: true, value: 42 })
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: true }])
|
||||
})
|
||||
|
||||
it('returns awaiting approval, then records the page activation asynchronously', async () => {
|
||||
const { ctx, runner, gateway } = await setup()
|
||||
gateway.answer = 'approve'
|
||||
gateway.clientWaitingFor = ['slots']
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
|
||||
})
|
||||
|
||||
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
expect(receipt).toEqual({
|
||||
ok: true,
|
||||
status: 'awaiting-approval',
|
||||
pluginId,
|
||||
packageId,
|
||||
pluginRunId: 'run-1',
|
||||
mode: 'run',
|
||||
waitingFor: [],
|
||||
nextPackageId: packageId,
|
||||
})
|
||||
await gateway.answering
|
||||
expect(ctx.get('dynDoubler')).toEqual({ ok: true })
|
||||
expect(runner.inventory()[0]?.latestRun).toMatchObject({
|
||||
status: 'waiting',
|
||||
client: { status: 'waiting', waitingFor: ['slots'] },
|
||||
})
|
||||
expect(gateway.events.map(([name]) => name)).toEqual([
|
||||
'cordis/request-run', 'cordis/dynamic-package', 'cordis/request-run-resolved',
|
||||
])
|
||||
expect(gateway.events.at(-1)?.[1]).toMatchObject({ outcome: 'approved' })
|
||||
})
|
||||
|
||||
it('returns awaiting approval, then records a refusal without starting', async () => {
|
||||
const { ctx, runner, gateway } = await setup()
|
||||
gateway.answer = 'reject'
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
|
||||
})
|
||||
|
||||
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
expect(receipt).toMatchObject({ ok: true, status: 'awaiting-approval' })
|
||||
await gateway.answering
|
||||
expect(ctx.get('dynDoubler')).toBeUndefined()
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
|
||||
expect(gateway.events.at(-1)).toMatchObject(['cordis/request-run-resolved', { outcome: 'rejected' }])
|
||||
})
|
||||
|
||||
it('records an asynchronous Client failure and unwinds the Host half it started', async () => {
|
||||
const { ctx, runner, gateway } = await setup()
|
||||
gateway.answer = { clientFails: 'createElement is not defined' }
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
|
||||
})
|
||||
|
||||
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
expect(receipt).toMatchObject({ ok: true, status: 'awaiting-approval' })
|
||||
await gateway.answering
|
||||
expect(runner.inventory()[0]?.latestRun).toMatchObject({
|
||||
status: 'failed',
|
||||
error: { message: 'createElement is not defined' },
|
||||
})
|
||||
// Rollback restores the state the request found: nothing was running before.
|
||||
expect(ctx.get('dynDoubler')).toBeUndefined()
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
|
||||
expect(gateway.events.at(-1)?.[1]).toMatchObject({ outcome: 'failed' })
|
||||
})
|
||||
|
||||
it('replaces a prior run and records failure when the repeated run cannot load Client code', async () => {
|
||||
const { ctx, runner, gateway } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'both', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
|
||||
})
|
||||
// A first page ran it; a second request finds it already up.
|
||||
gateway.answer = 'approve'
|
||||
await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
await gateway.answering
|
||||
gateway.answer = { clientFails: 'this page could not load it' }
|
||||
|
||||
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
expect(receipt).toMatchObject({ ok: true, status: 'starting' })
|
||||
await gateway.answering
|
||||
expect(runner.inventory()[0]?.latestRun).toMatchObject({
|
||||
status: 'failed',
|
||||
error: { message: 'this page could not load it' },
|
||||
})
|
||||
expect(ctx.get('dynDoubler')).toBeUndefined()
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
|
||||
})
|
||||
|
||||
it('binds a running host half instead of evaluating it twice', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
|
||||
const first = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
|
||||
const second = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
|
||||
|
||||
expect(first).toEqual({
|
||||
ok: true, pluginId, packageId, pluginRunId: 'run-1', waitingFor: [], startedHere: true,
|
||||
})
|
||||
// Re-evaluating would collide on the provided service; binding is what lets
|
||||
// a reloaded page take a live package back.
|
||||
expect(second).toEqual({
|
||||
ok: true, pluginId, packageId, pluginRunId: 'run-1', waitingFor: [], startedHere: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('shares one activation when two pages start the same Package concurrently', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false),
|
||||
runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false),
|
||||
])
|
||||
|
||||
expect(first).toMatchObject({ ok: true, pluginRunId: 'run-1', startedHere: true })
|
||||
expect(second).toEqual(first)
|
||||
})
|
||||
|
||||
it('hands the browser half\'s source only to the owning session, and only while it runs', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
|
||||
})
|
||||
|
||||
expect(() => runner.getClientCode(AGENT_A, pluginId, 'run-0' as never)).toThrow('is not running')
|
||||
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
|
||||
if (!started.ok) throw new Error(started.message)
|
||||
expect(runner.getClientCode(AGENT_A, pluginId, started.pluginRunId)).toEqual({
|
||||
code: CLIENT_CODE, name: 'ui', pluginId, packageId, pluginRunId: started.pluginRunId,
|
||||
})
|
||||
expect(() => runner.getClientCode(AGENT_B, pluginId, started.pluginRunId)).toThrow('no dynamic plugin')
|
||||
})
|
||||
|
||||
it('accepts and ignores an answer to a request nobody is waiting for', async () => {
|
||||
const { runner } = await setup()
|
||||
|
||||
await expect(runner.resolveRequestRun(ApprovalRequestId('approval-404'), {
|
||||
ok: true, pluginRunId: 'run-1' as never,
|
||||
}))
|
||||
.resolves.toEqual({ accepted: false })
|
||||
})
|
||||
|
||||
it('refuses an answer after stop cancels the request and allows a fresh direct run', async () => {
|
||||
const { runner, gateway } = await setup()
|
||||
// No auto-answer: this suite drives the round trip by hand so the dispatch
|
||||
// can be replaced underneath the page that is still loading run 1.
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
|
||||
})
|
||||
const pending = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
expect(pending).toMatchObject({ ok: true, status: 'awaiting-approval' })
|
||||
await Promise.resolve()
|
||||
const asked = gateway.events.find(([name]) => name === 'cordis/request-run')?.[1]
|
||||
const requestId = (asked as { requestId: ApprovalRequestIdType }).requestId
|
||||
const first = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', requestId, false)
|
||||
if (!first.ok) throw new Error(first.message)
|
||||
expect(runner.getClientCode(AGENT_A, pluginId, first.pluginRunId).pluginRunId).toBe(first.pluginRunId)
|
||||
// The user stops it while that page is still loading, cancelling the request.
|
||||
await runner.stop(AGENT_A, pluginId)
|
||||
|
||||
await expect(runner.resolveRequestRun(requestId, { ok: true, pluginRunId: first.pluginRunId }))
|
||||
.resolves.toEqual({ accepted: false })
|
||||
expect(gateway.events).toContainEqual([
|
||||
'cordis/request-run-resolved',
|
||||
{ requestId, outcome: 'cancelled' },
|
||||
])
|
||||
await expect(runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false))
|
||||
.resolves.toMatchObject({ ok: true, pluginRunId: 'run-2', startedHere: true })
|
||||
})
|
||||
|
||||
it('cancels a pending request after its provisional activation is stopped', async () => {
|
||||
const { runner, gateway } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = await runner.run(AGENT_A, pluginId, packageId, 'run', controller.signal)
|
||||
expect(pending).toMatchObject({ ok: true, status: 'awaiting-approval' })
|
||||
await Promise.resolve()
|
||||
const asked = gateway.events.find(([name]) => name === 'cordis/request-run')?.[1]
|
||||
const requestId = (asked as { requestId: ApprovalRequestIdType }).requestId
|
||||
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', requestId, false)
|
||||
if (!started.ok) throw new Error(started.message)
|
||||
await runner.stop(AGENT_A, pluginId)
|
||||
|
||||
await expect(runner.resolveRequestRun(requestId, { ok: true, pluginRunId: started.pluginRunId }))
|
||||
.resolves.toEqual({ accepted: false })
|
||||
controller.abort()
|
||||
expect(gateway.events).toContainEqual([
|
||||
'cordis/request-run-resolved',
|
||||
{ requestId, outcome: 'cancelled' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a published request answerable after the creating Tool call ends', async () => {
|
||||
const { runner, gateway } = await setup()
|
||||
// No answer configured: the request stays pending until the signal fires.
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'ui', purpose: 'p', client: CLIENT_CODE,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = await runner.run(AGENT_A, pluginId, packageId, 'run', controller.signal)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
|
||||
expect(pending).toMatchObject({ ok: true, status: 'awaiting-approval' })
|
||||
const asked = gateway.events.find(([name]) => name === 'cordis/request-run')?.[1]
|
||||
const requestId = (asked as { requestId: ApprovalRequestIdType }).requestId
|
||||
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', requestId, false)
|
||||
if (!started.ok) throw new Error(started.message)
|
||||
await expect(runner.resolveRequestRun(requestId, { ok: true, pluginRunId: started.pluginRunId }))
|
||||
.resolves.toEqual({ accepted: true })
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: true }])
|
||||
})
|
||||
|
||||
it('reports the sandbox failure and starts nothing when the host half throws', async () => {
|
||||
const { runner, gateway } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id,
|
||||
name: 'broken',
|
||||
purpose: 'p',
|
||||
host: 'harness.handle(\'never\', async () => 1)\nthrow new Error(\'host half exploded\')',
|
||||
})
|
||||
|
||||
const receipt = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
expect(receipt).toMatchObject({ ok: false, reason: 'host-half-failed' })
|
||||
expect(gateway.events).toEqual([])
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
|
||||
await expect(runner.invoke(pluginId, 'run-1' as never, 'never', null))
|
||||
.resolves.toMatchObject({ code: 'plugin-not-running' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic runner teardown', () => {
|
||||
it('stops both halves while keeping the definition runnable', async () => {
|
||||
const { ctx, runner, gateway } = await setup()
|
||||
gateway.answer = 'approve'
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE, client: CLIENT_CODE,
|
||||
})
|
||||
const first = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
if (!first.ok) throw new Error(first.message)
|
||||
await gateway.answering
|
||||
|
||||
await expect(runner.stop(AGENT_A, pluginId)).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ctx.get('dynDoubler')).toBeUndefined()
|
||||
await expect(runner.invoke(pluginId, first.pluginRunId, 'double', { value: 1 }))
|
||||
.resolves.toMatchObject({ code: 'plugin-not-running' })
|
||||
expect(gateway.events.at(-1)).toEqual(['cordis/dynamic-retract', {
|
||||
pluginId, packageId, pluginRunId: first.pluginRunId,
|
||||
}])
|
||||
expect(running(runner, AGENT_A)).toEqual([{ id: pluginId, running: false }])
|
||||
// Runnable again, with a fresh activation identity.
|
||||
await expect(runner.run(AGENT_A, pluginId, packageId, 'run'))
|
||||
.resolves.toMatchObject({ ok: true, pluginRunId: 'run-2' })
|
||||
})
|
||||
|
||||
it('announces the stop of a host-only package too, so a global surface drops its row', async () => {
|
||||
const { runner, gateway } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
await expect(runner.stop(AGENT_A, pluginId)).resolves.toEqual({ ok: true })
|
||||
|
||||
// The retract mirrors the start announcement: a run-control surface tracks
|
||||
// "is it running", which is independent of whether a browser half existed.
|
||||
expect(gateway.events).toEqual([
|
||||
['cordis/dynamic-package', { pluginId, packageId, pluginRunId: 'run-1', name: 'doubler' }],
|
||||
['cordis/dynamic-retract', { pluginId, packageId, pluginRunId: 'run-1' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses to stop a definition that is not running', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'idle', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
|
||||
await expect(runner.stop(AGENT_A, pluginId)).resolves.toMatchObject({ ok: false, reason: 'not-running' })
|
||||
})
|
||||
|
||||
it('stops a running definition on undefine and forgets it', async () => {
|
||||
const { ctx, runner, gateway } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
|
||||
await expect(runner.undefine(AGENT_A, pluginId)).resolves.toEqual({ ok: true, wasRunning: true })
|
||||
|
||||
expect(ctx.get('dynDoubler')).toBeUndefined()
|
||||
expect(running(runner, AGENT_A)).toEqual([])
|
||||
expect(gateway.events.map(([name]) => name))
|
||||
.toEqual(['cordis/dynamic-package', 'cordis/dynamic-retract'])
|
||||
await expect(runner.run(AGENT_A, pluginId, packageId, 'run'))
|
||||
.resolves.toMatchObject({ ok: false, reason: 'plugin-missing' })
|
||||
})
|
||||
|
||||
it('answers a missing definition with the memory-only explanation', async () => {
|
||||
const { runner } = await setup()
|
||||
const receipt = await runner.undefine(AGENT_A, 'dyn-404' as CordisDynamicPluginId)
|
||||
|
||||
expect(receipt).toMatchObject({ ok: false, reason: 'plugin-missing' })
|
||||
expect((receipt as { message: string }).message).toContain('lost on DSH restart')
|
||||
})
|
||||
|
||||
it('unwinds every host half when the runner itself is disposed', async () => {
|
||||
const { ctx, runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'doubler', purpose: 'p', host: HOST_CODE,
|
||||
})
|
||||
await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
expect(ctx.get('dynDoubler')).toEqual({ ok: true })
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
expect(ctx.get('dynDoubler')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('render failure reports', () => {
|
||||
it('keeps the last report per package and shows it to a snapshot reader', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'ui', purpose: 'renders', client: CLIENT_CODE,
|
||||
})
|
||||
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
|
||||
if (!started.ok) throw new Error(started.message)
|
||||
|
||||
await runner.reportRenderFailure(
|
||||
AGENT_A, pluginId, started.pluginRunId,
|
||||
{ slot: 'settings.section', message: 'boom', abdicated: true },
|
||||
)
|
||||
// Cross-page and last-writer-wins: a second page reporting overwrites,
|
||||
// because "did this package's UI fail anywhere" has one answer.
|
||||
await runner.reportRenderFailure(
|
||||
AGENT_A, pluginId, started.pluginRunId,
|
||||
{ slot: 'shell.overlay', message: 'later', abdicated: false },
|
||||
)
|
||||
|
||||
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure)
|
||||
.toEqual({ slot: 'shell.overlay', message: 'later', abdicated: false })
|
||||
})
|
||||
|
||||
it('drops a report for a definition the reporting session does not own', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'ui', purpose: 'renders', client: CLIENT_CODE,
|
||||
})
|
||||
const started = await runner.runHostHalf(AGENT_A, pluginId, packageId, 'run', null, false)
|
||||
if (!started.ok) throw new Error(started.message)
|
||||
|
||||
// The reporting path must never fail a render, so a report it cannot place
|
||||
// is dropped rather than thrown.
|
||||
await expect(runner.reportRenderFailure(
|
||||
AGENT_B, pluginId, started.pluginRunId, { slot: 's', message: 'm', abdicated: true },
|
||||
))
|
||||
.resolves.toBeNull()
|
||||
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears the report when a fresh dispatch starts and when one stops', async () => {
|
||||
const { runner } = await setup()
|
||||
const { pluginId, packageId } = define(runner, {
|
||||
sessionId: AGENT_A.id, name: 'ui', purpose: 'renders', host: 'return () => {}',
|
||||
})
|
||||
const first = await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
if (!first.ok) throw new Error(first.message)
|
||||
await runner.reportRenderFailure(
|
||||
AGENT_A, pluginId, first.pluginRunId,
|
||||
{ slot: 'settings.section', message: 'boom', abdicated: true },
|
||||
)
|
||||
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeDefined()
|
||||
|
||||
// Stop clears it: nothing is mounted to have failed any more.
|
||||
await runner.stop(AGENT_A, pluginId)
|
||||
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeUndefined()
|
||||
|
||||
await runner.reportRenderFailure(
|
||||
AGENT_A, pluginId, first.pluginRunId,
|
||||
{ slot: 'settings.section', message: 'boom', abdicated: true },
|
||||
)
|
||||
// A fresh dispatch clears it too: a failure from the previous run would
|
||||
// describe something that is no longer there.
|
||||
await runner.run(AGENT_A, pluginId, packageId, 'run')
|
||||
expect(runner.snapshot(AGENT_A)[0]?.activeRun?.renderFailure).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { call, CONTENT_OUTPUT_CODE, dummyTool, mount, setup, text } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The sandbox context façade is a whitelist, not a pass-through proxy. A running
|
||||
* host half reaches only registration/eventing verbs, timer helpers, guarded
|
||||
* tools, and injected services. Framework members that expose an unguarded
|
||||
* context are denied because they could bypass marker checks and host-realm
|
||||
* normalization; these tests pin that escape class.
|
||||
*/
|
||||
|
||||
/** Run a host half whose `apply` touches one framework member, and report the error text. */
|
||||
async function runTouching(harness: Awaited<ReturnType<typeof setup>>, expr: string): Promise<string> {
|
||||
try {
|
||||
await mount(harness, `return { name: 'probe', inject: ['tools'], apply(ctx) { ${expr} } }`)
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
throw new Error('expected the host half to fail')
|
||||
}
|
||||
|
||||
describe('sandbox context façade — escape surface is closed', () => {
|
||||
it.each([
|
||||
['ctx.root', 'const c = ctx.root'],
|
||||
['ctx.parent', 'const c = ctx.parent'],
|
||||
['ctx.scope', 'const c = ctx.scope'],
|
||||
['ctx.fiber', 'const f = ctx.fiber'],
|
||||
['ctx.reflect', 'const r = ctx.reflect'],
|
||||
['ctx.registry', 'const r = ctx.registry'],
|
||||
['ctx.events', 'const e = ctx.events'],
|
||||
['ctx.extend()', 'ctx.extend({})'],
|
||||
['ctx.isolate()', 'ctx.isolate("x")'],
|
||||
['ctx.intercept()', 'ctx.intercept("x", {})'],
|
||||
['ctx.plugin()', 'ctx.plugin({ apply() {} })'],
|
||||
['ctx.set()', 'ctx.set("tools", 1)'],
|
||||
['ctx.mixin()', 'ctx.mixin("x", [])'],
|
||||
])('denies %s with a teaching error', async (_label, expr) => {
|
||||
const harness = await setup()
|
||||
const message = await runTouching(harness, expr)
|
||||
expect(message).toContain('sandbox ctx does not expose')
|
||||
expect(message).toContain('withheld by design')
|
||||
})
|
||||
|
||||
it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => {
|
||||
const harness = await setup()
|
||||
const message = await runTouching(harness, `
|
||||
ctx.root.tools.register({
|
||||
name: 'smuggled',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
`)
|
||||
expect(message).toContain('sandbox ctx does not expose "root"')
|
||||
// The whole point: the bypass never reaches the registry.
|
||||
expect(harness.ctx.tools.get('smuggled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects assignment to the façade rather than silently dropping it', async () => {
|
||||
const harness = await setup()
|
||||
await expect(mount(harness, 'return { name: \'writer\', apply(ctx) { ctx.stash = 1 } }'))
|
||||
.rejects.toThrow('sandbox ctx is read-only')
|
||||
})
|
||||
|
||||
it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => {
|
||||
// A cordis Service instance carries `.ctx` (a real Context), so
|
||||
// `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return
|
||||
// guards reject that Context before the registration lands.
|
||||
const harness = await setup()
|
||||
const message = await (async (): Promise<string> => {
|
||||
try {
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'svc-ctx-escape',
|
||||
inject: ['systemPrompt', 'tools'],
|
||||
apply(ctx) {
|
||||
ctx.systemPrompt.ctx.root.tools.register({
|
||||
name: 'smuggled_via_service',
|
||||
description: 'raw, unguarded',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() { return [] },
|
||||
})
|
||||
},
|
||||
}
|
||||
`)
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
throw new Error('expected the host half to fail')
|
||||
})()
|
||||
expect(message).toContain('returned a cordis Context, which the sandbox does not expose')
|
||||
expect(harness.ctx.tools.get('smuggled_via_service')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards an async injected-service method: a host-realm Promise resolves through the guard', async () => {
|
||||
// The return guard's Promise arm only fires for a HOST-realm Promise (a vm-realm one is not
|
||||
// `instanceof` the host `Promise`).
|
||||
const harness = await setup()
|
||||
harness.ctx.plugin({
|
||||
name: 'host-async-svc',
|
||||
apply(c) { c.provide('hostAsync', { grab: async () => 'host-fetched' }) },
|
||||
})
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'async-consumer',
|
||||
inject: ['hostAsync', 'tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'do_fetch',
|
||||
description: 'awaits the host async service',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const value = await ctx.hostAsync.grab()
|
||||
return [{ type: 'text', text: value }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`)
|
||||
const result = await call(harness.ctx, 'do_fetch', {})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe('host-fetched')
|
||||
})
|
||||
|
||||
it('reads a symbol property as undefined and answers the `in` operator without throwing', async () => {
|
||||
const harness = await setup()
|
||||
await expect(mount(harness, `
|
||||
return {
|
||||
name: 'introspector',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
const sym = ctx[Symbol.iterator]
|
||||
console.log('probe', sym === undefined, 'tools' in ctx, 'on' in ctx, 'root' in ctx)
|
||||
},
|
||||
}
|
||||
`)).resolves.toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox context façade — inject gate on services', () => {
|
||||
it('denies an undeclared live service (property access), naming the inject fix', async () => {
|
||||
// `systemPrompt` is a live global service in the setup harness, but this
|
||||
// host half does not declare it — reaching it would let the package depend
|
||||
// on a provider cordis does not know about, so it is refused.
|
||||
const harness = await setup()
|
||||
const message = await runTouching(harness, 'const s = ctx.systemPrompt')
|
||||
expect(message).toContain('service "systemPrompt" is not injected')
|
||||
expect(message).toContain('inject: [\'systemPrompt\', …]')
|
||||
})
|
||||
|
||||
it('allows optional undeclared services through ctx.get', async () => {
|
||||
const harness = await setup()
|
||||
await expect(mount(harness, `
|
||||
return {
|
||||
name: 'optional-reader',
|
||||
apply(ctx) {
|
||||
const service = ctx.get('systemPrompt')
|
||||
if (service !== undefined) console.log('optional service is available')
|
||||
},
|
||||
}
|
||||
`)).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('allows a service the host half DID declare in inject', async () => {
|
||||
const harness = await setup()
|
||||
await expect(mount(harness, `
|
||||
return {
|
||||
name: 'declared',
|
||||
inject: ['systemPrompt', 'tools'],
|
||||
apply(ctx) { console.log('has systemPrompt:', typeof ctx.systemPrompt) }
|
||||
}
|
||||
`)).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('a cross-package consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => {
|
||||
// Without declared inject, Cordis cannot park the consumer when its provider stops. The
|
||||
// façade refuses access up front instead of leaving a zombie tool.
|
||||
const harness = await setup()
|
||||
await mount(harness, 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }')
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'sloppy-consumer',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'greet_undeclared',
|
||||
description: 'uses greeter without declaring it',
|
||||
parameters: { n: { type: 'string', required: true } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) { return [{ type: 'text', text: ctx.greeter.greet(args.n) }] },
|
||||
}))
|
||||
},
|
||||
}
|
||||
`)
|
||||
// The tool registers (its execute is lazy), but calling it hits the gate:
|
||||
// `ctx.greeter` is undeclared, so it fails with the teaching error rather
|
||||
// than silently working and later stranding.
|
||||
const called = await call(harness.ctx, 'greet_undeclared', { n: 'x' })
|
||||
expect(called.isError).toBe(true)
|
||||
expect(text(called)).toContain('service "greeter" is not injected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox tools façade — get is a read-only schema view', () => {
|
||||
it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => {
|
||||
// The finding: returning the raw ToolDefinition hands package code the tool's execute
|
||||
// function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now
|
||||
// returns the same name/description/parameters view as schemas(), with no execute.
|
||||
const harness = await setup()
|
||||
harness.ctx.tools.register(dummyTool('host_tool'))
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'reporter',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'report_view',
|
||||
description: 'reports the shape of a tool view',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
const view = ctx.tools.get('host_tool')
|
||||
return [{ type: 'text', text: JSON.stringify({
|
||||
hasExecute: 'execute' in view,
|
||||
hasPresentCall: 'presentCall' in view,
|
||||
name: view.name,
|
||||
keys: Object.keys(view).sort(),
|
||||
}) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`)
|
||||
const reported = await call(harness.ctx, 'report_view', {})
|
||||
expect(reported.isError).toBe(false)
|
||||
const shape = JSON.parse(text(reported)) as { hasExecute: boolean; hasPresentCall: boolean; name: string; keys: string[] }
|
||||
expect(shape.hasExecute).toBe(false)
|
||||
expect(shape.hasPresentCall).toBe(false)
|
||||
expect(shape.name).toBe('host_tool')
|
||||
expect(shape.keys).toEqual(['description', 'name', 'parameters'])
|
||||
})
|
||||
|
||||
it('ctx.tools.get returns undefined for an unknown tool', async () => {
|
||||
const harness = await setup()
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'unknown-probe',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'probe_unknown',
|
||||
description: 'reports whether an unknown tool resolves',
|
||||
parameters: {},
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute() {
|
||||
return [{ type: 'text', text: String(ctx.tools.get('no_such_tool') === undefined) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`)
|
||||
expect(text(await call(harness.ctx, 'probe_unknown', {}))).toBe('true')
|
||||
})
|
||||
})
|
||||
218
packages/extensions/cordis-host-runner/tests/sandbox.spec.ts
Normal file
218
packages/extensions/cordis-host-runner/tests/sandbox.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { sandboxDefineTool } from '../src/guard.ts'
|
||||
import { syntaxErrorContext } from '../src/sandbox.ts'
|
||||
import { AGENT_A, call, CONTENT_OUTPUT_CODE, mount, setup, text, running } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* The vm sandbox contract a host half runs under: isolated globals, Node-API
|
||||
* traps that redirect to cordis services, the encoding primitives a bare vm
|
||||
* context lacks, the dual-realm `instanceof` patch, the synchronous evaluation
|
||||
* bound, and the teaching text a parse or runtime failure carries. Failures
|
||||
* leave nothing running.
|
||||
*/
|
||||
|
||||
describe('dynamic tool declaration boundary', () => {
|
||||
it.each([
|
||||
[42, 'options must be an object'],
|
||||
[{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' } }, execute: async (): Promise<null> => null }, 'output.render must be a function'],
|
||||
[{ parameters: {}, output: { schema: { type: 'json' }, render: () => [] }, execute: true }, 'execute must be a function'],
|
||||
[{
|
||||
parameters: {},
|
||||
output: { schema: { type: 'json' }, render: () => [], presentationMeta: true },
|
||||
execute: async (): Promise<null> => null,
|
||||
}, 'output.presentationMeta must be a function'],
|
||||
])('rejects an invalid dynamic tool declaration before registration: %j', (definition, message) => {
|
||||
expect(() => sandboxDefineTool(definition)).toThrow(message)
|
||||
})
|
||||
|
||||
it('bounds the preview of an invalid dynamic renderer return', () => {
|
||||
const definition = sandboxDefineTool({
|
||||
name: 'invalid-renderer',
|
||||
description: 'invalid renderer',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: () => ['x'.repeat(500)],
|
||||
},
|
||||
execute: async () => 'ok',
|
||||
})
|
||||
expect(() => definition.output.render({}, 'ok')).toThrow(/output\.render returned \["x+…/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox isolation and Node-API traps', () => {
|
||||
it('isolates sandbox globals: no process/Buffer, and globalThis writes do not leak to the host', async () => {
|
||||
const harness = await setup()
|
||||
await mount(harness, `
|
||||
globalThis.__cordis_runner_leak = 'leaked'
|
||||
return { name: 'probe-' + typeof process + '-' + typeof Buffer, apply(ctx) {} }
|
||||
`)
|
||||
expect((globalThis as Record<string, unknown>).__cordis_runner_leak).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['require(\'fs\')', 'require is not available in the dynamic package sandbox', 'inject: [\'fs\']'],
|
||||
['setTimeout(() => {}, 5)', 'setTimeout is not available in the dynamic package sandbox', 'ctx.timeout / ctx.interval'],
|
||||
['fetch(\'https://example.com\')', 'fetch is not available in the dynamic package sandbox', 'ctx.web'],
|
||||
])('traps the Node API call %s with a redirect to the cordis alternative', async (invocation, trapMessage, redirect) => {
|
||||
const harness = await setup()
|
||||
const failure = await mount(harness, `${invocation}\nreturn (ctx) => {}`).catch((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error))
|
||||
expect(failure).toContain(trapMessage)
|
||||
expect(failure).toContain(redirect)
|
||||
expect(running(harness.runner, AGENT_A)).toEqual([{ id: 'probe-1', running: false }])
|
||||
})
|
||||
|
||||
it('lets a host half schedule through the cordis timer service (inject: [\'timer\'])', async () => {
|
||||
const harness = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const id = await mount(harness, `
|
||||
return {
|
||||
name: 'ticker',
|
||||
inject: ['timer'],
|
||||
apply(ctx) {
|
||||
ctx.setTimeout(() => console.log('tick'), 10)
|
||||
},
|
||||
}
|
||||
`)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(log).toHaveBeenCalledWith(`[cordis:${id}]`, 'tick')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('provides btoa/atob and the tagged console variants inside the sandbox', async () => {
|
||||
const harness = await setup()
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const id = await mount(harness, `
|
||||
console.warn('warned')
|
||||
console.error('errored')
|
||||
const round = atob(btoa('hi'))
|
||||
const bytes = new TextEncoder().encode(round)
|
||||
return { name: 'codec-' + new TextDecoder().decode(bytes), apply(ctx) { console.log('applied', typeof ctx.on) } }
|
||||
`)
|
||||
expect(log).toHaveBeenCalledWith(`[cordis:${id}]`, 'warned')
|
||||
expect(log).toHaveBeenCalledWith(`[cordis:${id}]`, 'applied', 'function')
|
||||
expect(error).toHaveBeenCalledWith(`[cordis:${id}]`, 'errored')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('makes instanceof inside the sandbox see BOTH realms (patched vm constructors, host untouched)', async () => {
|
||||
// The args a tool's execute receives are HOST-realm objects; without the dual-realm
|
||||
// Symbol.hasInstance prelude, `args.items instanceof Array` in sandbox code is silently
|
||||
// false.
|
||||
const harness = await setup()
|
||||
await mount(harness, `
|
||||
return {
|
||||
name: 'probe-instanceof',
|
||||
inject: ['tools'],
|
||||
apply(ctx) {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'probe_instanceof',
|
||||
description: 'report instanceof checks across realms',
|
||||
parameters: { items: { type: 'array', required: true, items: { type: 'string' } } },
|
||||
${CONTENT_OUTPUT_CODE}
|
||||
async execute(args) {
|
||||
const checks = {
|
||||
hostArray: args.items instanceof Array,
|
||||
hostObject: args instanceof Object,
|
||||
vmArray: [] instanceof Array,
|
||||
vmObject: ({}) instanceof Object,
|
||||
}
|
||||
return [{ type: 'text', text: JSON.stringify(checks) }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
`)
|
||||
const probed = await call(harness.ctx, 'probe_instanceof', { items: ['a'] })
|
||||
expect(probed.isError).toBe(false)
|
||||
expect(JSON.parse(text(probed))).toEqual({ hostArray: true, hostObject: true, vmArray: true, vmObject: true })
|
||||
// The host realm's constructors keep their default instanceof: no own
|
||||
// Symbol.hasInstance was added to them.
|
||||
expect(Object.getOwnPropertySymbols(Object)).not.toContain(Symbol.hasInstance)
|
||||
expect(Object.getOwnPropertySymbols(Array)).not.toContain(Symbol.hasInstance)
|
||||
})
|
||||
|
||||
it('honors the configured vmTimeoutMs for the synchronous portion', async () => {
|
||||
const harness = await setup({ vmTimeoutMs: 50 })
|
||||
await expect(mount(harness, 'while (true) {}')).rejects.toThrow(/timed? ?out/i)
|
||||
expect(running(harness.runner, AGENT_A)).toEqual([{ id: 'probe-1', running: false }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('host-half failures leave nothing running', () => {
|
||||
it.each([
|
||||
['throw new Error(\'boom in sandbox\')', 'boom in sandbox'],
|
||||
['throw \'plain-string-throw\'', 'plain-string-throw'],
|
||||
['return 42', 'must return a Plugin'],
|
||||
['const plugin = (ctx) => {}', 'did you forget `return`?'],
|
||||
['return { name: \'broken\', apply(ctx) { throw new Error(\'apply exploded\') } }', 'apply exploded'],
|
||||
])('refuses %j with a teaching message', async (code, message) => {
|
||||
const harness = await setup()
|
||||
await expect(mount(harness, code)).rejects.toThrow(message)
|
||||
expect(running(harness.runner, AGENT_A)).toEqual([{ id: 'probe-1', running: false }])
|
||||
})
|
||||
|
||||
it('passes a null throw through untouched (no SyntaxError misclassification)', async () => {
|
||||
const harness = await setup()
|
||||
await expect(mount(harness, 'throw null')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parse failures teach the fix', () => {
|
||||
it('answers TypeScript syntax in the plain-JS sandbox with the fix, at define time', async () => {
|
||||
const harness = await setup()
|
||||
// The precheck runs inside define, so unparseable code never reaches the registry.
|
||||
expect(() => harness.runner.define({
|
||||
sessionId: AGENT_A.id,
|
||||
plugin: { kind: 'new', idPrefix: 'probe' },
|
||||
name: 'ts',
|
||||
purpose: 'p',
|
||||
code: { host: 'return { name: \'ts\' as const, apply(ctx) {} }' },
|
||||
})).toThrow('plain JavaScript, not TypeScript')
|
||||
expect(running(harness.runner, AGENT_A)).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces the offending line + caret and the bracket-balance hint on a syntax error', async () => {
|
||||
const harness = await setup()
|
||||
// The canonical model mistake: closing the returned object with `});` as
|
||||
// if it were a callback argument. The word "as" in a STRING elsewhere must
|
||||
// not trigger the TypeScript hint — the heuristic reads the failing line.
|
||||
let message = ''
|
||||
try {
|
||||
harness.runner.define({
|
||||
sessionId: AGENT_A.id,
|
||||
plugin: { kind: 'new', idPrefix: 'probe' },
|
||||
name: 'oops',
|
||||
purpose: 'p',
|
||||
code: { host: 'const note = \'treat pattern as regex\'\nreturn {\n name: \'oops\',\n apply(ctx) {}\n});' },
|
||||
})
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
expect(message).toContain('failed to parse')
|
||||
expect(message).toContain('});')
|
||||
expect(message).toContain('^')
|
||||
expect(message).toContain('BODY of an async function')
|
||||
expect(message).not.toContain('TypeScript')
|
||||
})
|
||||
|
||||
it('syntaxErrorContext falls back to String(error) when the stack has no vm prelude', () => {
|
||||
const doctored = new SyntaxError('boom')
|
||||
delete (doctored as { stack?: string }).stack
|
||||
expect(syntaxErrorContext(doctored)).toBe('SyntaxError: boom')
|
||||
const plain = new SyntaxError('bang')
|
||||
plain.stack = 'not-a-vm-stack'
|
||||
expect(syntaxErrorContext(plain)).toBe('SyntaxError: bang')
|
||||
})
|
||||
|
||||
it('handles a runtime-thrown SyntaxError (no source-line prelude) with the generic hint', async () => {
|
||||
const harness = await setup()
|
||||
// Thrown at RUN time (the define precheck compiles fine), so the evaluator's
|
||||
// own SyntaxError branch classifies it.
|
||||
await expect(mount(harness, 'throw new SyntaxError(\'user-crafted\')'))
|
||||
.rejects.toThrow('user-crafted')
|
||||
})
|
||||
})
|
||||
108
packages/extensions/cordis-host-runner/tests/versioning.spec.ts
Normal file
108
packages/extensions/cordis-host-runner/tests/versioning.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_A, CLIENT_CODE, setup } from './helpers.ts'
|
||||
|
||||
const HOST = 'return { apply() {} }'
|
||||
|
||||
describe('dynamic Plugin versions', () => {
|
||||
it('keeps currentPackageId when an update fails and clears nextPackageId after rollback', async () => {
|
||||
const { runner } = await setup()
|
||||
const first = runner.define({
|
||||
sessionId: AGENT_A.id,
|
||||
plugin: { kind: 'new', idPrefix: 'clock' },
|
||||
name: 'clock v1',
|
||||
purpose: 'show time',
|
||||
code: { host: HOST },
|
||||
})
|
||||
await expect(runner.run(AGENT_A, first.pluginId, first.packageId, 'run')).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const second = runner.define({
|
||||
sessionId: AGENT_A.id,
|
||||
plugin: { kind: 'existing', pluginId: first.pluginId },
|
||||
name: 'clock v2',
|
||||
purpose: 'show time',
|
||||
code: { host: 'throw new Error("broken update")' },
|
||||
})
|
||||
await expect(runner.run(AGENT_A, first.pluginId, second.packageId, 'update'))
|
||||
.resolves.toMatchObject({ ok: false, reason: 'host-half-failed' })
|
||||
expect(runner.inventory()[0]).toMatchObject({
|
||||
currentPackageId: first.packageId,
|
||||
nextPackageId: second.packageId,
|
||||
})
|
||||
expect(runner.inventory()[0]?.activeRun).toBeUndefined()
|
||||
|
||||
await expect(runner.run(AGENT_A, first.pluginId, first.packageId, 'run')).resolves.toMatchObject({ ok: true })
|
||||
expect(runner.inventory()[0]).toMatchObject({
|
||||
currentPackageId: first.packageId,
|
||||
activeRun: { packageId: first.packageId },
|
||||
})
|
||||
expect(runner.inventory()[0]?.nextPackageId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cancels and retracts a Host activation owned by the pending approval', async () => {
|
||||
const { runner, gateway } = await setup()
|
||||
const defined = runner.define({
|
||||
sessionId: AGENT_A.id,
|
||||
plugin: { kind: 'new', idPrefix: 'panel' },
|
||||
name: 'panel',
|
||||
purpose: 'render a panel',
|
||||
code: { host: HOST, client: CLIENT_CODE },
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = runner.run(AGENT_A, defined.pluginId, defined.packageId, 'run', controller.signal)
|
||||
await Promise.resolve()
|
||||
const request = gateway.events.find(([event]) => event === 'cordis/request-run')?.[1]
|
||||
expect(request).toBeDefined()
|
||||
const approval = request as {
|
||||
requestId: Parameters<typeof runner.runHostHalf>[4]
|
||||
}
|
||||
await expect(runner.runHostHalf(
|
||||
AGENT_A,
|
||||
defined.pluginId,
|
||||
defined.packageId,
|
||||
'run',
|
||||
approval.requestId,
|
||||
false,
|
||||
)).resolves.toMatchObject({ ok: true, startedHere: true })
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ ok: true, status: 'awaiting-approval' })
|
||||
expect(runner.inventory()[0]?.activeRun).toBeDefined()
|
||||
await runner.stop(AGENT_A, defined.pluginId)
|
||||
expect(runner.inventory()[0]?.activeRun).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not stop an existing Host run when an attaching page fails to load Client code', async () => {
|
||||
const { runner } = await setup()
|
||||
const defined = runner.define({
|
||||
sessionId: AGENT_A.id,
|
||||
plugin: { kind: 'new', idPrefix: 'panel' },
|
||||
name: 'panel',
|
||||
purpose: 'render a panel',
|
||||
code: { host: HOST, client: CLIENT_CODE },
|
||||
})
|
||||
const first = await runner.runHostHalf(AGENT_A, defined.pluginId, defined.packageId, 'run', null, false)
|
||||
expect(first).toMatchObject({ ok: true, startedHere: true })
|
||||
if (!first.ok) throw new Error(first.message)
|
||||
await expect(runner.settleUserRun(AGENT_A, defined.pluginId, {
|
||||
ok: true,
|
||||
pluginRunId: first.pluginRunId,
|
||||
})).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const attached = await runner.runHostHalf(AGENT_A, defined.pluginId, defined.packageId, 'run', null, false)
|
||||
expect(attached).toMatchObject({ ok: true, startedHere: false })
|
||||
if (!attached.ok) throw new Error(attached.message)
|
||||
await expect(runner.settleUserRun(AGENT_A, defined.pluginId, {
|
||||
ok: false,
|
||||
reason: 'client-half-failed',
|
||||
pluginRunId: attached.pluginRunId,
|
||||
startedHere: attached.startedHere,
|
||||
message: 'this page cannot load it',
|
||||
})).resolves.toMatchObject({ ok: false, reason: 'client-half-failed' })
|
||||
|
||||
expect(runner.inventory()[0]?.activeRun).toEqual({
|
||||
packageId: defined.packageId,
|
||||
pluginRunId: first.pluginRunId,
|
||||
})
|
||||
})
|
||||
})
|
||||
48
packages/extensions/cordis-host-runner/tsconfig.json
Normal file
48
packages/extensions/cordis-host-runner/tsconfig.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/extensions/tool-cordis/README.i18n.yaml
Normal file
6
packages/extensions/tool-cordis/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/tool-cordis/README.md
|
||||
README.md: 396a844014328da8cfad57fa7728793b91a9aff7
|
||||
README.zh.md: b38a4b518a28f9af76ab22325606b8faa772c1c1
|
||||
104
packages/extensions/tool-cordis/README.md
Normal file
104
packages/extensions/tool-cordis/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @deepseek-ai/dsh-tool-cordis
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The self-referential Cordis toolset: five model-facing tools over the live runtime in the current DSH process. The registry, the vm sandbox, and the browser broadcast belong to [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md) (`ctx.dynamic`), which this toolset injects — a composition with these tools but no runner never activates them. Design home — sandbox semantics, dynamic-package lifecycle and composition, standing decisions: [the toolset Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## What it does
|
||||
|
||||
Two paired verbs, plus the read-only report.
|
||||
|
||||
- `cordis_inspect` — read-only report over the current process: services, all live plugin fibers, registered tools, this session's dynamic packages, the reflection-backed `api` / `events` references, and the compile-time `client` slot surface a browser half can contribute UI into. An exact `name` with `what: "api"`, `what: "events"`, or `what: "client"` narrows the report and adds the full contract.
|
||||
- `cordis_define` — records a package (`name`, `purpose`, and a host half `code` and/or a browser half `client`) after syntax-checking both halves. Nothing runs; the user sees a card for it in the conversation with a start control. The minted `dyn-<n>` id rides the result value AND the durable presentation metadata, which is how that card addresses the run verbs on replay.
|
||||
- `cordis_run` — evaluates the host half in the sandbox and delivers the browser half to every open web page. Running an already-running package re-delivers the live version instead of failing, which is how a reloaded page gets it back.
|
||||
- `cordis_stop` — disposes the host half to quiescence and withdraws the browser half; the definition survives and can run again.
|
||||
- `cordis_undefine` — stops the package if needed and forgets the definition; its card stays in the conversation as an unloaded record.
|
||||
|
||||
Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-catalog.md).
|
||||
|
||||
Dynamic packages live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_stop`/`cordis_undefine`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. Every verb is session-scoped: a package is visible and controllable only in the session that defined it.
|
||||
|
||||
## Trust stance
|
||||
|
||||
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded; records with JSON-invisible keys and subclassed or decorated schema arrays reject before normalization. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
|
||||
## Config
|
||||
|
||||
None. The vm evaluation bound (`vmTimeoutMs`) and the browser acknowledgement window (`ackTimeoutMs`) belong to the runner service that owns the sandbox and the broadcast — see [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md#config).
|
||||
|
||||
## The generated client slot catalog
|
||||
|
||||
`src/client-catalog.ts` describes the browser half's seats, generated by `scripts/gen-client-catalog.ts` (freshness-gated by `pnpm run verify-client-catalog` in `doc-sync`) from a lexical scan of every `SlotMap` declaration merge and every `slots.register` call site. It carries the one surface a browser half can act on — the slot keys, each register call's options, the props a component receives, who already occupies the seat, and which owner's mount makes the seat exist — as plain data: this package stays host-side and imports no client module, so the strings are the only thing that crosses. The generator fails loud rather than shipping an entry a model cannot act on: a slot with no registrant-facing prose, a non-literal `kind`/`scope`, owner props no export provides, a duplicate key, or a registration into an undeclared slot all break the gate. Owner props expand one level — the owner declaration with its own member documentation, and the names of the shapes its fields reference — and one slot's whole report is budget-capped, because narrowing to a single slot exists to spend less context, not more.
|
||||
|
||||
A slot's teaching text is its declaration's JSDoc, so improving what the model reads means editing the contract at its declaring package — not this catalog.
|
||||
|
||||
## Where the API report comes from
|
||||
|
||||
`cordis_inspect what:"api"` / `what:"events"` renders `src/api-catalog.ts`, the generated projection of the workspace's Cordis declarations: rendered method signatures, source JSDoc, harness events with their dispatch modes, and the type shapes those signatures reference, all produced by the same AST walk as `docs/subsystems`, so the data a model reads and the rendered docs cannot diverge. It is a compile-time fact about the REPOSITORY, so `pnpm run gen-cordis-api` regenerates it and `pnpm run verify-cordis-api` gates its freshness.
|
||||
|
||||
`src/inspect.ts` intersects that catalog with the LIVE service store: what is RUNNING comes from the store, what each service CAN DO comes from the catalog, and a live service the catalog does not cover is reported as reachable with no signatures rather than omitted. A package that needs the list in its own code copies it out of a report — the catalog is a compile-time fact about the repository, so a copied list and a freshly read one say the same thing for any one deployment.
|
||||
|
||||
Two model-facing judgements live in this package rather than in the artifacts, because reflection data is faithful to the code while a report has to be useful:
|
||||
|
||||
- **Only callable methods are shown.** Non-method members are state rather than a verb, and their rendered form carries initializers from the implementation body; symbol-keyed members are internal seams between plugins that a package façade deliberately cannot reach, so naming one would advertise a call that cannot be made.
|
||||
- **Only keys a host half can reach are named to a model.** The reflection model covers every `ctx.<key>` a package declares, including launcher-supplied boot values (`agent`, `headlessIo`, …) and browser-half services (`connection`). `src/curation.ts` classifies each one's `reach` — `injectable`, `not-a-service`, or `other-face` — and only `injectable` keys reach a report: naming a key a package cannot reach advertises a call that cannot be made. The classification is carried as data on each catalog entry rather than applied while rendering, so the exclusion is testable on its own, and `verify-cordis-catalog` pins the classified set to exactly the keys the documentation projection does not render — a newly declared key stops the gate instead of quietly inviting a model to `inject` something that will never arrive. A classified key that nonetheless has a live provider is still reported as running and injectable: the service store is the authority on what exists.
|
||||
|
||||
The generated `INHERITED_CTX_API` closes the `api` report with the framework-inherited `ctx` surface (`ctx.on`, `ctx.effect`, `ctx.loader`, the timer helpers): those members are the Context itself rather than service keys, and the framework tier lives in pinned vendor packages outside every analyzed face, so the generator curates that one tier and renders it into both this catalog and `docs/cordis-api/inherited.md`. A live service the catalog does not describe is reported as running and still injectable rather than as absent. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud.
|
||||
|
||||
## Rendering
|
||||
|
||||
Every tool renders a `generic` card (`read` / `execute` / `delete`); `cordis_define` carries the submitted halves as `rawInput` and titles the card with the label and purpose. Presenters are pure functions of the args, and results keep the default text rendering. A Web client registers its own keyed `cordis_define` row (`@deepseek-ai/dsh-client-ui-cordis`) and reads the label, purpose, and minted id from the call arguments and the result metadata; the generic card is what a surface without that registration falls back to.
|
||||
|
||||
## Export shape
|
||||
|
||||
Namespace plugin: named exports `name` / `inject` / `apply`, no default export ([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). It injects `tools` and `dynamicCordisRunner`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The conversation model sees the generated [`cordis_inspect`, `cordis_define`, `cordis_run`, `cordis_stop`, and `cordis_undefine` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request in that tool view.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token.
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Inspect joins selected sections exactly as `## <section>` then a newline and the data-dependent body, with one blank line between sections; `what: "temporary"` uses the `## Dynamic Packages` heading. Each row reports the id, label, purpose, which halves exist, run state and revision, provided and awaited services, registered host methods, and the last browser-half load report. The empty state explains that definitions live only in this process's memory. Broad API/event reports omit JSDoc; `name` with `what: "api"`, `what: "events"`, or `what: "client"` returns one exact target with its full contract. The `client` section lists one seat per line with its cardinality, scope, summary, and whether registering there replaces shipped UI, then the cross-cutting registrant rules; the per-seat register options, owner and framework props, and runnable example arrive only under an exact `name`. Define answers that the package is defined and NOT running yet with the id to run; run reports the revision, what the host half provides or waits for, and whether a page acknowledged the browser half; stop and undefine acknowledge in one line. Every refusal is a tool error carrying the runner's teaching text. The submitted program remains in assistant tool-call history.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Inspect output and submitted package code are data-dependent and resent until compaction; lifecycle acknowledgements are small. The `client` section is bounded by the shipped slot count (two lines each) and its per-seat detail is opt-in, so the default report grows with the slot surface rather than with its documentation.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Later requests after cordis_run
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A running package may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; `cordis_stop` and `cordis_undefine` remove those contributions after quiescence.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Indirect token impact equals the running package's contributions and lasts only for its process-local lifetime.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Running or stopping a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged running set remains prefix-stable.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so package code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance).
|
||||
- **The `ctx` façade exposes no `effect()`** — package code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths.
|
||||
- **The vm and acknowledgement bounds belong to the runner** — see its [Known Limitations](../cordis-host-runner/README.md#known-limitations-and-deferred-work); an async host-half body escapes `vmTimeoutMs`.
|
||||
104
packages/extensions/tool-cordis/README.zh.md
Normal file
104
packages/extensions/tool-cordis/README.zh.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# @deepseek-ai/dsh-tool-cordis
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
自引用 Cordis 工具集:五个面向模型的工具,操作当前 DSH 进程中的实时运行时。注册表、vm 沙箱与浏览器广播属于 [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md)(`ctx.dynamic`),本工具集注入它——只装这些工具而不装 runner 的组合永远不会激活它们。沙箱语义、动态包生命周期与组合及既定决策详见[工具集 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
## 功能
|
||||
|
||||
两组配对动词,外加只读报告。
|
||||
|
||||
- `cordis_inspect`:当前进程运行时的只读报告,包括服务、全部存活插件 fiber、已注册工具、本会话的动态包、反射支持的 `api`/`events` 参考,以及浏览器半可以向其贡献 UI 的编译期 `client` 槽面。精确的 `name` 配合 `what: "api"`、`what: "events"` 或 `what: "client"` 可缩窄报告,并附上完整约定。
|
||||
- `cordis_define`:在语法预检两个半之后登记一个包(`name`、`purpose`,以及 host 半 `code` 和/或浏览器半 `client`)。此时不运行任何东西;用户会在会话里看到它的卡片和一个启动控件。铸出的 `dyn-<n>` 标识同时进入结果 value **与**持久的呈现元数据,卡片正是靠后者在 replay 中寻址运行动词。
|
||||
- `cordis_run`:在沙箱中求值 host 半,并把浏览器半投递给每个打开的网页。对已在运行的包再次运行不会失败,而是重新投递当前版本——这正是被刷新过的页面把包取回来的方式。
|
||||
- `cordis_stop`:把 host 半 dispose 到完全停稳,并从各页面撤回浏览器半;定义存续,可以再次运行。
|
||||
- `cordis_undefine`:必要时先停止该包,再忘掉定义;它的卡片作为一条已卸载记录留在会话里。
|
||||
|
||||
面向模型的确切 schema 见[生成的工具目录](../../../docs/tool-catalog.md)。
|
||||
|
||||
动态包只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_stop`/`cordis_undefine`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现普通的本地、项目或仓库插件。每个动词都以会话为界:一个包只在定义它的那个会话里可见、可控。
|
||||
|
||||
## 信任立场
|
||||
|
||||
该沙箱隔离全局变量,但不是安全边界。Node 全局变量不存在,或会重定向到 `ctx.fs`、`ctx.web`、`ctx.bash` 等 Cordis 服务;写入 `globalThis` 的内容保持局部,但 host realm helper 使逃逸成为可能。运行中的 host 半收到不含框架内部机制的 façade,但获准服务仍会影响存活运行时。动态工具 schema 与 annotation 通过迭代式 JSON 克隆和 schema 规范化跨越 realm,因此有效的深层声明受内存而非调用栈限制;含 JSON 不可见 key 的 record,以及子类化或装饰过的 schema array,会在规范化前被拒绝。应当像对待 bash 访问一样对待该工具集;参见[设计与信任立场](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
无。vm 求值边界(`vmTimeoutMs`)与浏览器确认窗口(`ackTimeoutMs`)属于拥有沙箱与广播的 runner 服务——见 [`@deepseek-ai/dsh-cordis-host-runner`](../cordis-host-runner/README.md#config)。
|
||||
|
||||
## 生成的 client 槽目录
|
||||
|
||||
`src/client-catalog.ts` 描述浏览器半的座位,由 `scripts/gen-client-catalog.ts` 生成(新鲜度门禁为 `doc-sync` 中的 `pnpm run verify-client-catalog`),数据来自对每一处 `SlotMap` 声明合并与每一个 `slots.register` 调用点的词法扫描。它承载浏览器半唯一能动的那个面——槽键、每个 register 调用的选项、组件会收到的 props、谁已经占着这个座位、以及哪个 owner 挂着这个座位才存在——并且只以纯数据承载:本包始终在 host 侧、不 import 任何 client 模块,跨越两平面的只有这些字符串。生成器宁可高声失败也不吐出一条模型无法照做的条目:槽缺少面向 registrant 的 JSDoc 正文、`kind`/`scope` 不是字面量、owner props 没有任何导出声明、键重复、或注册进了没人声明的槽,都会让门禁变红。owner props 只展开一层——owner 声明本身连它的成员文档,加上其字段所引用的那些形状的名字——而单个槽的整份报告有行数上限:收窄到一个槽的意义是少花上下文,不是多花。
|
||||
|
||||
一个槽的教学文案就是它声明处的 JSDoc,所以要改模型读到的内容,改的是声明它的那个包里的约定,而不是这份目录。
|
||||
|
||||
## API 报告从哪里来
|
||||
|
||||
`cordis_inspect what:"api"`/`what:"events"` 渲染的是 `src/api-catalog.ts`,即工作区 Cordis 声明的生成投影:渲染好的方法签名、源码 JSDoc、带分发模式的 harness 事件,以及这些签名引用到的类型形状——全部由与 `docs/subsystems` 同一次 AST 遍历产出,因此模型读到的数据与渲染出的文档不可能彼此偏离。它是关于**仓库**的编译期事实,所以用 `pnpm run gen-cordis-api` 重新生成、用 `pnpm run verify-cordis-api` 守它的新鲜度。
|
||||
|
||||
`src/inspect.ts` 把这份目录与**活的**服务存储取交集:**谁在跑**由存储回答,**每个服务能做什么**由目录回答;目录没覆盖到的活服务会被报成可达但没有签名,而不是被省略。包代码若要在自己源码里用这份清单,就从报告里抄出来——目录是关于仓库的编译期事实,所以对任一个部署而言,抄出来的清单与现读的清单说的是同一件事。
|
||||
|
||||
有两项面向模型的判断住在本包里,而不住在产物里,因为反射数据忠于代码,而报告必须有用:
|
||||
|
||||
- **只展示可调用的方法。** 非方法成员是状态而不是动词,而它们渲染出来的形式会带上实现体里的初始值;以 symbol 为键的成员是插件之间的内部 seam,包的 façade 刻意无法触达,所以点出其中任何一个,都等于宣传一次根本发不出的调用。
|
||||
- **只有 host 半够得到的键,才会被点名给模型。** 反射模型覆盖包声明的每一个 `ctx.<key>`,其中包括 launcher 提供的 boot 值(`agent`、`headlessIo` 等)与浏览器半的服务(`connection`)。`src/curation.ts` 会为每一个这样的键归类它的 `reach`——`injectable`、`not-a-service` 或 `other-face`——而只有 `injectable` 的键能进报告:点名一个包够不到的键,就等于宣传一次根本发不出的调用。这份归类是作为每条目录条目上的数据携带的,而不是在渲染时才施加,因此这项排除可以单独测试;同时 `verify-cordis-catalog` 把被归类的集合钉成「文档投影不渲染的键」这个集合本身——新声明一个键会把门禁拦下来,而不是悄悄引诱模型去 `inject` 一个永远不会到来的东西。一个被归类、但确实有存活提供方的键,仍然会被报成在跑且可 inject:服务 store 才是「什么存在」的权威。
|
||||
|
||||
生成常量 `INHERITED_CTX_API` 为 `api` 报告收尾,列出框架继承来的 `ctx` 面(`ctx.on`、`ctx.effect`、`ctx.loader`、各 timer 辅助方法):这些成员本身就是 Context,不是某个服务键;而框架层住在 pinned vendor 包里,位于每一个被分析的契约面之外——所以生成器策展这**一层**,并把它同时渲染进本目录与 `docs/cordis-api/inherited.md`。一个活着、但目录并不描述的服务,会被报成“在跑、且仍可 inject”,而不是报成不存在。宽泛的 `api`/`events` 报告只渲染摘要与签名;精确 `name` 会选择保留的方法/事件 JSDoc,未知或未运行的服务目标会高声失败。
|
||||
|
||||
## 渲染
|
||||
|
||||
每个工具都渲染 `generic` 卡片(`read`/`execute`/`delete`);`cordis_define` 以 `rawInput` 携带提交的两个半,并用标签与用途作为卡片标题。presenter 是 args 的纯函数,结果保留默认文本渲染。Web 客户端注册自己的 keyed `cordis_define` 行(`@deepseek-ai/dsh-client-ui-cordis`),从调用参数与结果元数据里取标签、用途和铸出的标识;没有该注册的界面则退回到这张 generic 卡片。
|
||||
|
||||
## 导出形式
|
||||
|
||||
Namespace 插件:命名导出 `name`/`inject`/`apply`,无默认导出([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。它注入 `tools` 与 `dynamicCordisRunner`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件可见时,会话模型会看到生成的 [`cordis_inspect`、`cordis_define`、`cordis_run`、`cordis_stop` 和 `cordis_undefine` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
该工具视图中的每次请求承担固定 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要该工具视图不变,前缀就保持稳定。隐藏这些定义的 scope 或插件生命周期变更,可能使从第一个变化的 schema token 起的复用失效。
|
||||
|
||||
### 工具调用历史与结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
检查会精确地用 `## <section>` 加换行及取决于数据的正文来拼接选中区段,各区段之间留一个空行;`what: "temporary"` 使用 `## Dynamic Packages` 标题。每一行都会报告标识、标签、用途、存在哪些半、运行状态与版本号、提供和等待的服务、已注册的 host 方法,以及最后一次浏览器半装载上报;空状态说明定义只存在于本进程内存中。宽泛的 API/事件报告省略 JSDoc;`name` 配合 `what: "api"`、`what: "events"` 或 `what: "client"` 返回一个精确目标及其完整约定。`client` 区段每个座位一行,给出其基数、作用域、摘要,以及注册进去是否会替换出厂 UI,随后是跨座位通用的 registrant 纪律;每个座位的 register 选项、owner 与框架 props、可直接运行的示例,只在精确 `name` 时才吐出。define 回答该包已定义、尚未运行,并给出用于运行的标识;run 报告版本号、host 半提供或等待什么,以及是否有页面确认了浏览器半;stop 与 undefine 各以一行确认。每一次拒绝都是携带 runner 教学文案的工具错误。提交的程序保留在 assistant 工具调用历史中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
检查输出与提交的包代码取决于数据,并在压缩(compaction)前重复发送;生命周期确认文本很短。`client` 区段的体量由出厂槽数量决定(每座位两行),每座位细节按需索取,因此默认报告随槽面增长,而不是随其文档量增长。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
### cordis_run 后的后续请求
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
运行中的包可以注册工具、提示词贡献或监听器,改变其目标 scope 的后续请求;`cordis_stop` 与 `cordis_undefine` 会在完全停稳后移除这些贡献。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
间接 token 影响等于运行中包的贡献,且只在其进程内生命周期内持续。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
运行或停止提示词/工具贡献会改变后续请求前缀,并可能使从第一个变化的贡献起的复用失效;运行集合不变时,前缀保持稳定。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此包代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。
|
||||
- **`ctx` façade 不公开 `effect()`**:包代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。
|
||||
- **vm 与确认窗口这两个边界属于 runner**:见它的[已知限制](../cordis-host-runner/README.md#known-limitations-and-deferred-work);async 的 host 半主体可逃出 `vmTimeoutMs`。
|
||||
57
packages/extensions/tool-cordis/package.json
Normal file
57
packages/extensions/tool-cordis/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-cordis",
|
||||
"description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins",
|
||||
"version": "0.0.1-rc.5",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/tool-cordis"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
4751
packages/extensions/tool-cordis/src/api-catalog.ts
Normal file
4751
packages/extensions/tool-cordis/src/api-catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
31
packages/extensions/tool-cordis/src/fiber-state.ts
Normal file
31
packages/extensions/tool-cordis/src/fiber-state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Runtime mirror and labels for Cordis's `FiberState` const enum. A const enum has no runtime
|
||||
* object to import, so these values mirror the pinned vendored definition while retaining its
|
||||
* type.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/fiber-state
|
||||
*/
|
||||
|
||||
import type { FiberState as FiberStateEnum } from '@deepseek-ai/cordis'
|
||||
|
||||
/** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */
|
||||
export const FiberState = {
|
||||
PENDING: 0 as FiberStateEnum.PENDING,
|
||||
LOADING: 1 as FiberStateEnum.LOADING,
|
||||
ACTIVE: 2 as FiberStateEnum.ACTIVE,
|
||||
FAILED: 3 as FiberStateEnum.FAILED,
|
||||
DISPOSED: 4 as FiberStateEnum.DISPOSED,
|
||||
UNLOADING: 5 as FiberStateEnum.UNLOADING,
|
||||
} as const
|
||||
|
||||
/** The cordis `FiberState` enum type, re-exported so mirror consumers need one import. */
|
||||
export type FiberState = FiberStateEnum
|
||||
|
||||
/** Human-readable label for each {@link FiberState}, keyed by member (inlining-safe — no reverse mapping). */
|
||||
export const STATE_LABELS = {
|
||||
[FiberState.PENDING]: 'pending',
|
||||
[FiberState.LOADING]: 'loading',
|
||||
[FiberState.ACTIVE]: 'active',
|
||||
[FiberState.FAILED]: 'failed',
|
||||
[FiberState.DISPOSED]: 'disposed',
|
||||
[FiberState.UNLOADING]: 'unloading',
|
||||
} as const satisfies Record<FiberState, string>
|
||||
530
packages/extensions/tool-cordis/src/index.ts
Normal file
530
packages/extensions/tool-cordis/src/index.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Model-facing Cordis runtime/package inspection, define, run, stop, and remove tools.
|
||||
* @module @deepseek-ai/dsh-tool-cordis
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId,
|
||||
} from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { DynamicCordisReference } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { missingServices, providedServices } from './inspect.ts'
|
||||
import {
|
||||
presentDefineCall, presentInspectListCall, presentInspectQueryCall, presentInspectSelfCall, presentRunCall,
|
||||
presentStopCall, presentUndefineCall,
|
||||
} from './present.ts'
|
||||
import { CORDIS_SYSTEM_PROMPT } from './prompt.ts'
|
||||
import { hostInspectProviders } from './providers.ts'
|
||||
|
||||
export const name = 'tool-cordis'
|
||||
export const inject = ['tools', 'systemPrompt', 'dynamicCordisRunner', 'cordisInspect']
|
||||
|
||||
function requireAgent(exec: ToolExecution): Agent {
|
||||
if (exec.agent === undefined) throw new Error('Cordis dynamic tools require an Agent-backed session')
|
||||
return exec.agent
|
||||
}
|
||||
|
||||
/** Register the Cordis tools and explicit `@pluginId` context injection. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.systemPrompt.section({ name: 'tool:cordis', order: 115, text: CORDIS_SYSTEM_PROMPT })
|
||||
for (const provider of hostInspectProviders(ctx)) {
|
||||
ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`)
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_list',
|
||||
description:
|
||||
'List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest '
|
||||
+ 'manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and '
|
||||
+ 'input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and '
|
||||
+ 'method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business '
|
||||
+ 'Service that Plugin code can call.',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
execute(_args, _exec): Promise<JsonValue> {
|
||||
return Promise.resolve({ providers: ctx.cordisInspect.list() } as unknown as JsonValue)
|
||||
},
|
||||
presentCall: presentInspectListCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_query',
|
||||
description:
|
||||
'Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come '
|
||||
+ 'from cordis_inspect_list, and input must satisfy that method\'s schema. Use this Tool before cordis_define '
|
||||
+ 'to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot '
|
||||
+ 'trees and props. Host queries run locally. A Client query waits for the first valid page response and '
|
||||
+ 'remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service '
|
||||
+ 'methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate '
|
||||
+ 'the compact signature directory, then query the exact service or event for its structured contract and '
|
||||
+ 'referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the '
|
||||
+ 'exact root for its complete registration contract and props.',
|
||||
parameters: {
|
||||
platform: { type: 'string', required: true, enum: ['host', 'client'], description: 'Runtime platform that owns the Provider.' },
|
||||
provider: { type: 'string', required: true, description: 'Exact Provider ID returned by cordis_inspect_list.' },
|
||||
method: { type: 'string', required: true, description: 'Exact method name declared by the Provider manifest.' },
|
||||
input: { type: 'json', description: 'Optional query input; it must satisfy the method input schema.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const data = await ctx.cordisInspect.query(
|
||||
args.platform,
|
||||
args.provider,
|
||||
args.method,
|
||||
args.input,
|
||||
requireAgent(exec),
|
||||
exec.signal,
|
||||
)
|
||||
return { platform: args.platform, provider: args.provider, method: args.method, data }
|
||||
},
|
||||
presentCall: presentInspectQueryCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_inspect_self',
|
||||
description:
|
||||
'Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, '
|
||||
+ 'list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package '
|
||||
+ 'summary. Only pluginId plus packageId returns that immutable Package\'s Host/Client source and runtime '
|
||||
+ 'diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing '
|
||||
+ 'an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code '
|
||||
+ 'nor changes version pointers.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', description: 'Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin.' },
|
||||
packageId: { type: 'string', description: 'Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
||||
},
|
||||
execute(args, exec): Promise<JsonValue> {
|
||||
const agent = requireAgent(exec)
|
||||
if (args.packageId !== undefined && args.pluginId === undefined) {
|
||||
throw new Error('cordis_inspect_self packageId requires pluginId')
|
||||
}
|
||||
if (args.pluginId === undefined) {
|
||||
return Promise.resolve({
|
||||
mode: 'plugins',
|
||||
plugins: ctx.dynamicCordisRunner.listPlugins(agent).map(reference => selfSummary(reference)),
|
||||
} as unknown as JsonValue)
|
||||
}
|
||||
const pluginId = CordisDynamicPluginId(args.pluginId)
|
||||
if (args.packageId === undefined) {
|
||||
const plugin = ctx.dynamicCordisRunner.inspectPlugin(agent, pluginId)
|
||||
return Promise.resolve({
|
||||
mode: 'plugin',
|
||||
...selfSummary(plugin),
|
||||
packages: plugin.packages.map(pkg => ({
|
||||
...pkg,
|
||||
packageId: String(pkg.packageId),
|
||||
isCurrent: pkg.packageId === plugin.currentPackageId,
|
||||
isNext: pkg.packageId === plugin.nextPackageId,
|
||||
})),
|
||||
} as unknown as JsonValue)
|
||||
}
|
||||
return Promise.resolve(inspectSelfPackage(
|
||||
ctx,
|
||||
agent,
|
||||
pluginId,
|
||||
CordisDynamicPackageId(args.packageId),
|
||||
) as unknown as JsonValue)
|
||||
},
|
||||
presentCall: presentInspectSelfCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_define',
|
||||
description:
|
||||
'Define an immutable Cordis Package. For a new Plugin, use kind:"new" and provide only a semantic prefix of '
|
||||
+ '3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing '
|
||||
+ 'Plugin, use kind:"existing" with its exact pluginId to append a Package without overwriting older versions. '
|
||||
+ 'Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns '
|
||||
+ 'a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a '
|
||||
+ 'Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it '
|
||||
+ 'does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the '
|
||||
+ 'returned IDs.',
|
||||
parameters: {
|
||||
plugin: {
|
||||
required: true,
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', const: 'new', required: true },
|
||||
idPrefix: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: 'string', const: 'existing', required: true },
|
||||
pluginId: { type: 'string', required: true, description: 'Exact ID of an existing Plugin; the new Package is appended to that instance.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
name: { type: 'string', required: true, description: 'Short, readable Package name.' },
|
||||
purpose: { type: 'string', required: true, description: 'One-sentence, user-facing description of the Package purpose.' },
|
||||
code: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: true,
|
||||
properties: {
|
||||
host: { type: 'string', description: 'Plain JavaScript function body that returns the Host-half Cordis Plugin.' },
|
||||
client: { type: 'string', description: 'Plain JavaScript function body that returns the browser Client-half Cordis Plugin.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
pluginId: { type: 'string', required: true },
|
||||
packageId: { type: 'string', required: true },
|
||||
name: { type: 'string', required: true },
|
||||
purpose: { type: 'string', required: true },
|
||||
hasHostHalf: { type: 'boolean', required: true },
|
||||
hasClientHalf: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: `Defined ${value.pluginId}/${value.packageId} (${value.name}); it is not running yet. `
|
||||
+ 'Use cordis_run to activate this Package.',
|
||||
}],
|
||||
presentationMeta: (_args, value) => ({ pluginId: value.pluginId, packageId: value.packageId }),
|
||||
},
|
||||
execute(args, exec) {
|
||||
const plugin = args.plugin.kind === 'new'
|
||||
? { kind: 'new' as const, idPrefix: args.plugin.idPrefix }
|
||||
: { kind: 'existing' as const, pluginId: CordisDynamicPluginId(args.plugin.pluginId) }
|
||||
const receipt = ctx.dynamicCordisRunner.define({
|
||||
sessionId: requireAgent(exec).id,
|
||||
plugin,
|
||||
name: args.name,
|
||||
purpose: args.purpose,
|
||||
code: {
|
||||
...args.code.host === undefined ? {} : { host: args.code.host },
|
||||
...args.code.client === undefined ? {} : { client: args.code.client },
|
||||
},
|
||||
})
|
||||
return Promise.resolve({
|
||||
...receipt,
|
||||
pluginId: String(receipt.pluginId),
|
||||
packageId: String(receipt.packageId),
|
||||
})
|
||||
},
|
||||
presentCall: presentDefineCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_run',
|
||||
description:
|
||||
'Activate one exact Package of a dynamic Plugin. Use mode:"run" for the first activation, restarting '
|
||||
+ 'currentPackageId, or rollback. When current exists, use mode:"update" to switch to a different Package, '
|
||||
+ 'even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and '
|
||||
+ 'returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the '
|
||||
+ 'browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after '
|
||||
+ 'complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or '
|
||||
+ 'technical failure is reported through state and steering. After a technical failure, read diagnostics with '
|
||||
+ 'cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after '
|
||||
+ 'the user rejects it.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable Plugin ID returned by cordis_define.' },
|
||||
packageId: { type: 'string', required: true, description: 'Exact immutable Package ID to activate under that Plugin.' },
|
||||
mode: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['run', 'update'],
|
||||
description: 'Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'json' },
|
||||
render: (_args, value) => {
|
||||
const result = requireJsonObject(value)
|
||||
const pluginId = requireJsonString(result, 'pluginId')
|
||||
const packageId = requireJsonString(result, 'packageId')
|
||||
const pluginRunId = requireJsonString(result, 'pluginRunId')
|
||||
return [{
|
||||
type: 'text',
|
||||
text: result.status === 'awaiting-approval'
|
||||
? `${pluginId}/${packageId} is awaiting user approval (${pluginRunId}).`
|
||||
: result.status === 'starting'
|
||||
? `${pluginId}/${packageId} is starting asynchronously (${pluginRunId}).`
|
||||
: `${pluginId}/${packageId} is running (${pluginRunId}).`,
|
||||
}]
|
||||
},
|
||||
presentationMeta: (_args, value) => {
|
||||
const result = requireJsonObject(value)
|
||||
return {
|
||||
pluginId: requireJsonString(result, 'pluginId'),
|
||||
packageId: requireJsonString(result, 'packageId'),
|
||||
pluginRunId: requireJsonString(result, 'pluginRunId'),
|
||||
}
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const agent = requireAgent(exec)
|
||||
const pluginId = CordisDynamicPluginId(args.pluginId)
|
||||
const packageId = CordisDynamicPackageId(args.packageId)
|
||||
const receipt = await ctx.dynamicCordisRunner.run(agent, pluginId, packageId, args.mode, exec.signal)
|
||||
if (!receipt.ok) throw new Error(receipt.message)
|
||||
if (receipt.status !== 'running') {
|
||||
return {
|
||||
status: receipt.status,
|
||||
pluginId: args.pluginId,
|
||||
packageId: args.packageId,
|
||||
pluginRunId: String(receipt.pluginRunId),
|
||||
mode: receipt.mode,
|
||||
...receipt.currentPackageId === undefined ? {} : { currentPackageId: String(receipt.currentPackageId) },
|
||||
nextPackageId: String(receipt.nextPackageId),
|
||||
}
|
||||
}
|
||||
const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
|
||||
const fiber = row?.activeRun?.pluginRunId === receipt.pluginRunId ? row.activeRun.fiber : undefined
|
||||
return {
|
||||
status: 'running',
|
||||
pluginId: args.pluginId,
|
||||
packageId: args.packageId,
|
||||
pluginRunId: String(receipt.pluginRunId),
|
||||
currentPackageId: String(receipt.currentPackageId),
|
||||
...receipt.nextPackageId === undefined ? {} : { nextPackageId: String(receipt.nextPackageId) },
|
||||
host: {
|
||||
status: fiber === undefined ? 'absent' : missingServices(ctx, fiber).length === 0 ? 'running' : 'waiting',
|
||||
provides: fiber === undefined ? [] : providedServices(ctx, fiber),
|
||||
waitingFor: fiber === undefined ? [] : missingServices(ctx, fiber),
|
||||
},
|
||||
client: {
|
||||
status: receipt.clientWaitingFor === undefined
|
||||
? 'absent'
|
||||
: receipt.clientWaitingFor.length === 0 ? 'running' : 'waiting',
|
||||
waitingFor: [...(receipt.clientWaitingFor ?? [])],
|
||||
},
|
||||
}
|
||||
},
|
||||
presentCall: presentRunCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_stop',
|
||||
description:
|
||||
'Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the '
|
||||
+ 'Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update '
|
||||
+ 'directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects '
|
||||
+ 'temporarily; use cordis_undefine for permanent removal.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to stop.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'object', additionalProperties: false, properties: { pluginId: { type: 'string', required: true } } },
|
||||
render: (_args, value) => [{ type: 'text', text: `Dynamic Plugin ${value.pluginId} is stopped; its definition and versions remain.` }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const receipt = await ctx.dynamicCordisRunner.stop(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
|
||||
if (!receipt.ok && receipt.reason !== 'not-running') throw new Error(receipt.message)
|
||||
return { pluginId: args.pluginId }
|
||||
},
|
||||
presentCall: presentStopCall,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'cordis_undefine',
|
||||
description:
|
||||
'Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, '
|
||||
+ 'first stop it and cancel the request, then delete every Package, grant, and version pointer. After this '
|
||||
+ 'returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards '
|
||||
+ 'retain only a "Plugin removed" record. Do not call this Tool when versions must remain available for restart '
|
||||
+ 'or rollback; use cordis_stop instead.',
|
||||
parameters: {
|
||||
pluginId: { type: 'string', required: true, description: 'Stable dynamic Plugin ID to remove permanently.' },
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
pluginId: { type: 'string', required: true },
|
||||
wasRunning: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: `Removed dynamic Plugin ${value.pluginId} and all of its Packages.` }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const receipt = await ctx.dynamicCordisRunner.undefine(requireAgent(exec), CordisDynamicPluginId(args.pluginId))
|
||||
if (!receipt.ok) throw new Error(receipt.message)
|
||||
return { pluginId: args.pluginId, wasRunning: receipt.wasRunning }
|
||||
},
|
||||
presentCall: presentUndefineCall,
|
||||
}))
|
||||
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject') return decision
|
||||
const ids = referencedPluginIds(messages)
|
||||
if (ids.length === 0) return decision
|
||||
signal.throwIfAborted()
|
||||
const contexts = ids.map((id) => {
|
||||
const reference = ctx.dynamicCordisRunner.reference(agent, CordisDynamicPluginId(id))
|
||||
return createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: reference === undefined ? renderUnavailableReference(id) : renderReference(reference),
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: name, form: 'instructions' },
|
||||
})
|
||||
})
|
||||
return { kind: 'enter', messages: [...decision.messages, ...contexts] }
|
||||
})
|
||||
}
|
||||
|
||||
function requireJsonObject(value: JsonValue): Record<string, JsonValue> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('expected a JSON object')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function requireJsonString(value: Record<string, JsonValue>, key: string): string {
|
||||
const field = value[key]
|
||||
if (typeof field !== 'string') throw new Error(`expected JSON string field "${key}"`)
|
||||
return field
|
||||
}
|
||||
|
||||
type SelfState = 'defined' | 'awaiting-approval' | 'client-pending' | 'stopped' | 'running' | 'waiting' | 'failed'
|
||||
|
||||
function selfSummary(reference: DynamicCordisReference & { packages?: readonly unknown[] }): Record<string, JsonValue> {
|
||||
const latest = reference.latestRun
|
||||
const state = selfState(reference)
|
||||
return {
|
||||
pluginId: String(reference.pluginId),
|
||||
name: reference.name,
|
||||
packageCount: reference.packages?.length ?? 1,
|
||||
state,
|
||||
...reference.currentPackageId === undefined ? {} : { currentPackageId: String(reference.currentPackageId) },
|
||||
...reference.nextPackageId === undefined ? {} : { nextPackageId: String(reference.nextPackageId) },
|
||||
...reference.activeRun === undefined ? {} : {
|
||||
activeRun: {
|
||||
pluginRunId: String(reference.activeRun.pluginRunId),
|
||||
packageId: String(reference.activeRun.packageId),
|
||||
},
|
||||
},
|
||||
...latest?.status !== 'awaiting-approval' ? {} : {
|
||||
pendingApproval: {
|
||||
pluginRunId: String(latest.pluginRunId),
|
||||
packageId: String(latest.packageId),
|
||||
mode: latest.mode,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function selfState(reference: DynamicCordisReference): SelfState {
|
||||
const status = reference.latestRun?.status
|
||||
if (status === 'awaiting-approval') return 'awaiting-approval'
|
||||
if (status === 'client-pending' || status === 'starting-host') return 'client-pending'
|
||||
if (status === 'failed' || status === 'rejected' || status === 'cancelled') return 'failed'
|
||||
if (status === 'waiting') return 'waiting'
|
||||
if (status === 'running') return 'running'
|
||||
if (reference.activeRun !== undefined) return 'running'
|
||||
return reference.currentPackageId === undefined ? 'defined' : 'stopped'
|
||||
}
|
||||
|
||||
function inspectSelfPackage(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
pluginId: ReturnType<typeof CordisDynamicPluginId>,
|
||||
packageId: ReturnType<typeof CordisDynamicPackageId>,
|
||||
): Record<string, JsonValue> {
|
||||
const inspected = ctx.dynamicCordisRunner.inspectPackage(agent, pluginId, packageId)
|
||||
const row = ctx.dynamicCordisRunner.snapshot(agent).find(candidate => candidate.pluginId === pluginId)
|
||||
const pkg = row?.packages.find(candidate => candidate.packageId === packageId)
|
||||
const active = row?.activeRun?.packageId === packageId ? row.activeRun : undefined
|
||||
const latest = inspected.latestRun?.packageId === packageId ? inspected.latestRun : undefined
|
||||
const hostWaiting = active?.fiber === undefined ? [...(latest?.host.waitingFor ?? [])] : missingServices(ctx, active.fiber)
|
||||
const hostStatus = pkg?.hasHostHalf !== true
|
||||
? 'absent'
|
||||
: latest?.host.status ?? (active === undefined ? 'stopped' : hostWaiting.length === 0 ? 'running' : 'waiting')
|
||||
const clientStatus = pkg?.hasClientHalf !== true
|
||||
? 'absent'
|
||||
: latest?.client.status ?? 'stopped'
|
||||
return {
|
||||
mode: 'package',
|
||||
plugin: selfSummary(inspected),
|
||||
packageId: String(packageId),
|
||||
name: inspected.name,
|
||||
purpose: inspected.purpose,
|
||||
code: inspected.code,
|
||||
runtime: {
|
||||
state: selfState(inspected),
|
||||
host: {
|
||||
status: hostStatus,
|
||||
provides: active?.fiber === undefined ? [] : providedServices(ctx, active.fiber),
|
||||
waitingFor: hostWaiting,
|
||||
handlers: active?.handlers ?? [],
|
||||
...latest?.host.error === undefined ? {} : { error: latest.host.error },
|
||||
},
|
||||
client: {
|
||||
status: clientStatus,
|
||||
waitingFor: [...(latest?.client.waitingFor ?? [])],
|
||||
...latest?.client.error === undefined ? {} : { error: latest.client.error },
|
||||
...active?.renderFailure === undefined ? {} : { renderFailure: active.renderFailure },
|
||||
},
|
||||
},
|
||||
} as unknown as Record<string, JsonValue>
|
||||
}
|
||||
|
||||
function referencedPluginIds(messages: readonly UserMessage[]): string[] {
|
||||
const found = new Set<string>()
|
||||
const pattern = /(?:^|\s)@([a-z]{3,6}-\d+)(?=\s|$)/g
|
||||
for (const message of messages) {
|
||||
if (message.source.kind !== 'user') continue
|
||||
const text = message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
|
||||
for (const match of text.matchAll(pattern)) if (match[1] !== undefined) found.add(match[1])
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
function renderReference(reference: ReturnType<Context['dynamicCordisRunner']['reference']> & {}): string {
|
||||
const mode = reference.currentPackageId === undefined ? 'run' : 'update'
|
||||
return [
|
||||
'<cordis_dynamic_plugin_context>',
|
||||
JSON.stringify(reference, null, 2),
|
||||
'',
|
||||
`The user explicitly referenced @${reference.pluginId}. Use Package ${reference.packageId} as the base for this modification.`,
|
||||
`Before modifying it, call cordis_inspect_self with pluginId="${reference.pluginId}" and packageId="${reference.packageId}" to read the exact metadata and source.`,
|
||||
`Use cordis_define with plugin.kind="existing" and the original pluginId="${reference.pluginId}" to append an immutable Package.`,
|
||||
`Do not create a new Plugin for this request. After cordis_define succeeds, call cordis_run mode="${mode}" with the returned packageId.`,
|
||||
'</cordis_dynamic_plugin_context>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderUnavailableReference(id: string): string {
|
||||
return [
|
||||
'<cordis_dynamic_plugin_context>',
|
||||
`The user explicitly referenced @${id}, but this Plugin is unavailable in the current Session.`,
|
||||
'It may have been removed, belong to another Session, or have been lost when the DSH process restarted.',
|
||||
'Do not claim that it was updated or silently create a replacement Plugin. Tell the user that the reference is currently unavailable.',
|
||||
'</cordis_dynamic_plugin_context>',
|
||||
].join('\n')
|
||||
}
|
||||
332
packages/extensions/tool-cordis/src/inspect.ts
Normal file
332
packages/extensions/tool-cordis/src/inspect.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Text renderers for `cordis_runtime_inspect`. Live facts come from the service store and
|
||||
* the plugin registry; what each service CAN DO comes from the generated
|
||||
* `api-catalog.ts`. This module owns the join of the two plus presentation: which
|
||||
* lines a section prints, how compact the default report stays, and what an exact
|
||||
* `name` adds.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/inspect
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
// Type-only: resolves `ctx.dynamicCordisRunner` (the registry this report reads).
|
||||
import type {} from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts'
|
||||
import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, ServiceApiMethod, TypeApiEntry } from './api-catalog.ts'
|
||||
import { FiberState, STATE_LABELS } from './fiber-state.ts'
|
||||
|
||||
/** One live service joined with what the generated catalog knows about it. */
|
||||
interface LiveService {
|
||||
/** The `ctx.<name>` key. */
|
||||
name: string
|
||||
/** Plugin fiber providing it. */
|
||||
owner: string
|
||||
/** Lifecycle state of that fiber; `active` while it is serving. */
|
||||
state: string
|
||||
/** First sentence of the catalog summary; empty when the catalog has no entry. */
|
||||
summary: string
|
||||
/** Whether the generated catalog carries signatures for it. */
|
||||
catalogued: boolean
|
||||
/** Public method signatures from the catalog, empty for an uncatalogued service. */
|
||||
methods: readonly string[]
|
||||
}
|
||||
|
||||
/** The live service registrations, read from the reflect store. */
|
||||
function liveImpls(ctx: Context): { name: string; fiber: Fiber }[] {
|
||||
const store = ctx.reflect.store
|
||||
return Object.getOwnPropertySymbols(store)
|
||||
.map(key => store[key])
|
||||
.filter((impl): impl is NonNullable<typeof impl> => impl !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* A summary as prose. JSDoc may name a symbol with an inline `{@link Foo.bar}`
|
||||
* tag, which the generated catalog retains verbatim; a report is read, not
|
||||
* compiled, so the link syntax is spent context and the bare symbol says the same
|
||||
* thing.
|
||||
*/
|
||||
function plainSummary(summary: string): string {
|
||||
return summary.replace(/\{@link\s+([^}]+)\}/g, '$1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every service this process provides, joined with the generated catalog: what is
|
||||
* RUNNING comes from the store, what each service CAN DO comes from the catalog,
|
||||
* and a live service the catalog does not cover stays in the list as reachable
|
||||
* with no signatures rather than being dropped.
|
||||
*/
|
||||
function liveServices(ctx: Context, api: readonly ServiceApiEntry[]): LiveService[] {
|
||||
const catalogued = new Map(api.map(entry => [entry.key, entry]))
|
||||
return liveImpls(ctx)
|
||||
.map((impl) => {
|
||||
const entry = catalogued.get(impl.name)
|
||||
return {
|
||||
name: impl.name,
|
||||
owner: impl.fiber.name,
|
||||
state: STATE_LABELS[impl.fiber.state],
|
||||
summary: entry === undefined ? '' : plainSummary(entry.summary),
|
||||
catalogued: entry !== undefined,
|
||||
methods: entry === undefined ? [] : entry.methods.map(method => method.signature),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/** Catalogued services with no live provider: loadable in principle, absent here. */
|
||||
function absentServices(ctx: Context, api: readonly ServiceApiEntry[]): string[] {
|
||||
const live = new Set(liveImpls(ctx).map(impl => impl.name))
|
||||
return api.filter(entry => !live.has(entry.key)).map(entry => entry.key).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fiber is `root` itself or mounted anywhere inside `root`'s subtree.
|
||||
* @param fiber - the fiber to locate.
|
||||
* @param root - the subtree root to test against.
|
||||
* @returns true when `fiber` belongs to that subtree.
|
||||
*/
|
||||
export function withinFiber(fiber: Fiber, root: Fiber): boolean {
|
||||
let current = fiber
|
||||
while (true) {
|
||||
if (current === root) return true
|
||||
const parent = current.parent.fiber
|
||||
if (parent === current) return false
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service names provided by one mount's fiber subtree.
|
||||
* @param ctx - the runtime whose service registrations are inspected.
|
||||
* @param fiber - the root of the mounted fiber subtree.
|
||||
* @returns the provided service names in lexical order.
|
||||
*/
|
||||
export function providedServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return liveImpls(ctx)
|
||||
.filter(impl => withinFiber(impl.fiber, fiber))
|
||||
.map(impl => impl.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Services a fiber declared in `inject` that do not exist yet — a settled fiber
|
||||
* that is not active is waiting on exactly these (legal cordis semantics: it
|
||||
* activates when the service appears).
|
||||
* @param ctx - the context to resolve service existence against.
|
||||
* @param fiber - the fiber whose `inject` declarations are checked.
|
||||
* @returns the missing service names, in declaration order.
|
||||
*/
|
||||
export function missingServices(ctx: Context, fiber: Fiber): string[] {
|
||||
return Object.keys(fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `services` section: every live ctx service with its owning fiber and, when
|
||||
* the generated catalog covers it, a one-line summary. The `api` section is the
|
||||
* one that carries signatures; this one answers what exists and who provides it.
|
||||
* @param ctx - the runtime to enumerate.
|
||||
* @param api - the generated service entries whose summaries annotate the live ones.
|
||||
* @returns one line per service, or a single placeholder line when none are provided.
|
||||
*/
|
||||
export function describeServices(ctx: Context, api: readonly ServiceApiEntry[] = SERVICE_API): string[] {
|
||||
const live = liveServices(ctx, api)
|
||||
if (live.length === 0) return ['(no services provided)']
|
||||
return live.map((service) => {
|
||||
const state = service.state === STATE_LABELS[FiberState.ACTIVE] ? '' : `, ${service.state}`
|
||||
const summary = service.summary === '' ? '' : ` — ${service.summary}`
|
||||
return `- ${service.name} (provided by ${service.owner}${state})${summary}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `plugins` section: a flat list of every fiber the registry knows, one line
|
||||
* per fiber with its lifecycle state, sorted by plugin name (a plugin mounted
|
||||
* more than once repeats — one line per instance). Temporary plugins are listed
|
||||
* like any other plugin; their ids live in the `temporary` section.
|
||||
* @param ctx - the runtime whose registry is enumerated.
|
||||
* @returns one line per loaded plugin fiber.
|
||||
*/
|
||||
export function describePlugins(ctx: Context): string[] {
|
||||
const fibers: Fiber[] = []
|
||||
for (const runtime of ctx.registry.values()) {
|
||||
for (const fiber of runtime.fibers) fibers.push(fiber)
|
||||
}
|
||||
return fibers
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(fiber => `- ${fiber.name} [${STATE_LABELS[fiber.state]}]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `tools` section: the model-facing tool names the CALLING agent can see
|
||||
* (its scoped layer shadowing/joining the restricted global tool set) — the
|
||||
* honest answer to the tool description's "what you can call".
|
||||
* @param ctx - the runtime whose tool registry is read.
|
||||
* @param scope - the calling agent (the viewing scope); omitted = global view.
|
||||
* @returns one line per visible tool.
|
||||
*/
|
||||
export function describeTools(ctx: Context, scope?: ScopeKey): string[] {
|
||||
return ctx.tools.schemas(scope).map(schema => `- ${schema.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `temporary` section: one line per dynamic package this session defined,
|
||||
* with its metadata, which halves exist, the host half's lifecycle state and
|
||||
* provides/waits, the invoke methods it registered, and the last browser-half
|
||||
* load report. Session-scoped like every runner verb.
|
||||
* @param ctx - the runtime the packages live in.
|
||||
* @param agent - the calling agent; without one there is no definition space to report.
|
||||
* @returns one line per package, or a single placeholder line when none exist.
|
||||
*/
|
||||
export function describeDynamic(ctx: Context, agent?: Agent): string[] {
|
||||
const rows = agent === undefined ? [] : ctx.dynamicCordisRunner.snapshot(agent)
|
||||
if (rows.length === 0) {
|
||||
return ['No dynamic Plugins are defined in this session. Definitions live only in this process\'s memory, so a DSH restart clears them.']
|
||||
}
|
||||
return rows.flatMap((row) => {
|
||||
const head = `- Plugin ${row.pluginId}; current: ${row.currentPackageId ?? 'none'}; next: ${row.nextPackageId ?? 'none'}`
|
||||
+ (row.activeRun === undefined
|
||||
? '; stopped'
|
||||
: `; active: ${row.activeRun.packageId} as ${row.activeRun.pluginRunId}`)
|
||||
const packages = row.packages.map((pkg) => {
|
||||
const halves = [...pkg.hasHostHalf ? ['host'] : [], ...pkg.hasClientHalf ? ['client'] : []].join('+')
|
||||
const active = row.activeRun?.packageId === pkg.packageId ? row.activeRun : undefined
|
||||
if (active === undefined) return ` - ${pkg.packageId}: ${pkg.name} (${halves}) — ${pkg.purpose}`
|
||||
const fiber = active.fiber
|
||||
const state = fiber === undefined ? 'running' : fiber.state === FiberState.ACTIVE ? 'running' : STATE_LABELS[fiber.state]
|
||||
const provides = fiber === undefined ? [] : providedServices(ctx, fiber)
|
||||
const waiting = fiber === undefined ? [] : missingServices(ctx, fiber)
|
||||
const failure = active.renderFailure
|
||||
const rendered = failure === undefined
|
||||
? ''
|
||||
: `; CLIENT RENDER FAILED at ${failure.slot}: ${failure.message}${failure.abdicated ? ' (entry removed)' : ''}`
|
||||
return ` - ${pkg.packageId}: ${pkg.name} [${state}, ${active.pluginRunId}] (${halves}) — ${pkg.purpose}`
|
||||
+ `; provides: ${provides.join(', ') || 'none'}; waiting for: ${waiting.join(', ') || 'none'}`
|
||||
+ (active.handlers.length === 0 ? '' : `; host methods: ${active.handlers.join(', ')}`)
|
||||
+ rendered
|
||||
})
|
||||
return [head, ...packages]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The transitive closure of catalogued type shapes referenced (word-bounded)
|
||||
* by the seed texts — the runtime scoping that keeps the `api` section to the
|
||||
* shapes the LIVE signatures actually mention.
|
||||
*/
|
||||
function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEntry[] {
|
||||
const included = new Map<string, TypeApiEntry>()
|
||||
let frontier = seeds
|
||||
while (frontier.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const entry of types) {
|
||||
if (included.has(entry.name)) continue
|
||||
const pattern = new RegExp(`\\b${entry.name}\\b`)
|
||||
if (frontier.some(text => pattern.test(text))) {
|
||||
included.set(entry.name, entry)
|
||||
next.push(entry.declaration)
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return [...included.values()].sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/** Render one live catalogued service; `documented` is non-empty only for an exact-name report. */
|
||||
function serviceLines(
|
||||
service: LiveService,
|
||||
documented: readonly ServiceApiMethod[],
|
||||
): string[] {
|
||||
const lines = [`- ${service.name} — ${service.summary}`]
|
||||
for (const signature of service.methods) {
|
||||
const contract = documented.find(entry => entry.signature === signature)
|
||||
if (contract !== undefined) {
|
||||
lines.push(` ${contract.description}`)
|
||||
for (const parameter of contract.parameters) lines.push(` @param ${parameter.name} — ${parameter.description}`)
|
||||
if (contract.returns !== undefined) lines.push(` @returns ${contract.returns}`)
|
||||
for (const failure of contract.throws ?? []) lines.push(` @throws ${failure}`)
|
||||
}
|
||||
lines.push(` ${signature}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generated catalog against the live runtime: live catalogued services with methods,
|
||||
* uncatalogued live services with owners, absent loadable services, referenced type shapes, and
|
||||
* inherited Context APIs.
|
||||
* @param ctx - the runtime to intersect the catalog with.
|
||||
* @param api - generated service entries, replaceable in tests.
|
||||
* @param name - exact live service key whose methods should include structured contracts; omitted for the compact catalog.
|
||||
* @param inherited - inherited `ctx` entries, replaceable in tests.
|
||||
* @param types - public type shapes, replaceable in tests.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeApi(
|
||||
ctx: Context,
|
||||
api: readonly ServiceApiEntry[] = SERVICE_API,
|
||||
name?: string,
|
||||
inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API,
|
||||
types: readonly TypeApiEntry[] = TYPE_API,
|
||||
): string[] {
|
||||
const live = liveServices(ctx, api)
|
||||
const byKey = new Map(api.map(entry => [entry.key, entry]))
|
||||
const lines: string[] = []
|
||||
let selected = live.filter(service => service.catalogued)
|
||||
let documented: readonly ServiceApiMethod[] = []
|
||||
if (name !== undefined) {
|
||||
const entry = byKey.get(name)
|
||||
if (entry === undefined) throw new Error(`no catalogued service named "${name}"`)
|
||||
const service = live.find(candidate => candidate.name === name)
|
||||
if (service === undefined) throw new Error(`catalogued service "${name}" is not running`)
|
||||
selected = [service]
|
||||
documented = entry.methods
|
||||
}
|
||||
for (const service of selected) lines.push(...serviceLines(service, documented))
|
||||
if (name === undefined) {
|
||||
for (const service of live.filter(candidate => !candidate.catalogued)) {
|
||||
lines.push(`- ${service.name} (provided by ${service.owner}) — running, but this catalog has no signature for it;`
|
||||
+ ` inject: ['${service.name}'] still reaches it`)
|
||||
}
|
||||
const notRunning = absentServices(ctx, api)
|
||||
if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`)
|
||||
}
|
||||
const shapes = typeClosure(selected.flatMap(service => [...service.methods]), types)
|
||||
if (shapes.length > 0) {
|
||||
lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):')
|
||||
for (const shape of shapes) {
|
||||
for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`)
|
||||
}
|
||||
}
|
||||
if (name === undefined) {
|
||||
lines.push('inherited ctx API:')
|
||||
for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The `events` section: every harness event with its dispatch mode, one-line
|
||||
* summary, and exact signature, closed by the waterfall caution.
|
||||
* @param events - the event catalog (the generated one by default; injectable for tests).
|
||||
* @param name - exact event name whose signature should include its structured contract; omitted for the compact catalog.
|
||||
* @returns the section lines.
|
||||
*/
|
||||
export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] {
|
||||
let selected = events
|
||||
if (name !== undefined) {
|
||||
const event = events.find(candidate => candidate.name === name)
|
||||
if (!event) throw new Error(`no catalogued event named "${name}"`)
|
||||
selected = [event]
|
||||
}
|
||||
const lines = selected.flatMap((event) => {
|
||||
const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`]
|
||||
if (name !== undefined) {
|
||||
entry.push(` ${event.description}`)
|
||||
for (const parameter of event.parameters) entry.push(` @param ${parameter.name} — ${parameter.description}`)
|
||||
}
|
||||
entry.push(` ${event.signature}`)
|
||||
return entry
|
||||
})
|
||||
lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() short-circuits the chain.')
|
||||
return lines
|
||||
}
|
||||
30
packages/extensions/tool-cordis/src/invariant.ts
Normal file
30
packages/extensions/tool-cordis/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-cordis`.
|
||||
* @module @deepseek-ai/dsh-tool-cordis/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-tool-cordis'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-cordis-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
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 */
|
||||
102
packages/extensions/tool-cordis/src/present.ts
Normal file
102
packages/extensions/tool-cordis/src/present.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/** Pure replay-safe render intents for Cordis tools. */
|
||||
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* Render a runtime-inspection call.
|
||||
* @param args - requested runtime category and optional member name.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentRuntimeInspectCall(args: { what?: string; name?: string }): GenericCallView {
|
||||
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
|
||||
return { card: 'generic', kind: 'read', title: target === undefined ? 'Inspect Cordis runtime' : `Inspect Cordis runtime: ${target}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render provider-directory inspection.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentInspectListCall(): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: 'List Cordis Inspect Providers' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one provider query.
|
||||
* @param args - target platform, provider, and method.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentInspectQueryCall(args: { platform: string; provider: string; method: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: `Query Cordis ${args.platform} ${args.provider}.${args.method}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render layered self-inspection.
|
||||
* @param args - optional Plugin and Package identity.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentInspectSelfCall(args: { pluginId?: string; packageId?: string }): GenericCallView {
|
||||
const target = args.pluginId === undefined
|
||||
? 'dynamic Cordis Plugins'
|
||||
: args.packageId === undefined ? args.pluginId : `${args.pluginId}/${args.packageId}`
|
||||
return { card: 'generic', kind: 'read', title: `Inspect ${target}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an immutable Package source-inspection call.
|
||||
* @param args - exact Plugin and Package identity.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentPackageInspectCall(args: { pluginId: string; packageId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'read', title: `Inspect Cordis Package ${args.pluginId}/${args.packageId}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a new or appended Package definition.
|
||||
* @param args - target Plugin, Package metadata, and source halves.
|
||||
* @returns replay-safe generic call presentation with source in raw input.
|
||||
*/
|
||||
export function presentDefineCall(args: {
|
||||
plugin: { kind: 'new'; idPrefix: string } | { kind: 'existing'; pluginId: string }
|
||||
name: string
|
||||
purpose: string
|
||||
code: { host?: string; client?: string }
|
||||
}): GenericCallView {
|
||||
const target = args.plugin.kind === 'new' ? `new ${args.plugin.idPrefix}-*` : args.plugin.pluginId
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: `Register Cordis Plugin "${args.name}" for ${target}: ${args.purpose}`,
|
||||
rawInput: args.code,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Plugin removal.
|
||||
* @param args - Plugin identity to remove.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentUndefineCall(args: { pluginId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'delete', title: `Remove Cordis Plugin ${args.pluginId}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one exact Package activation.
|
||||
* @param args - Plugin, Package, and activation mode.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentRunCall(args: { pluginId: string; packageId: string; mode: 'run' | 'update' }): GenericCallView {
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'execute',
|
||||
title: `${args.mode === 'update' ? 'Update' : 'Run'} Cordis Plugin ${args.pluginId} · ${args.packageId}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Plugin stop.
|
||||
* @param args - Plugin identity to stop.
|
||||
* @returns replay-safe generic call presentation.
|
||||
*/
|
||||
export function presentStopCall(args: { pluginId: string }): GenericCallView {
|
||||
return { card: 'generic', kind: 'execute', title: `Stop Cordis Plugin ${args.pluginId}` }
|
||||
}
|
||||
107
packages/extensions/tool-cordis/src/prompt.ts
Normal file
107
packages/extensions/tool-cordis/src/prompt.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/** Model guidance shared by the Cordis dynamic-plugin tools. */
|
||||
|
||||
export const CORDIS_SYSTEM_PROMPT = `# Dynamic Cordis Plugins
|
||||
|
||||
Dynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.
|
||||
|
||||
- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.
|
||||
- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.
|
||||
|
||||
## Make the user-facing plan clear first
|
||||
|
||||
- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.
|
||||
- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.
|
||||
- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.
|
||||
- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.
|
||||
- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.
|
||||
- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.
|
||||
- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.
|
||||
- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.
|
||||
- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.
|
||||
|
||||
## Recommended workflow and Tools
|
||||
|
||||
Before creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.
|
||||
|
||||
1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.
|
||||
2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.
|
||||
3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.
|
||||
4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.
|
||||
5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.
|
||||
6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.
|
||||
7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.
|
||||
|
||||
- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.
|
||||
- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.
|
||||
- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.
|
||||
|
||||
## Identity, versions, and approval
|
||||
|
||||
- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.
|
||||
- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.
|
||||
- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.
|
||||
- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.
|
||||
- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.
|
||||
- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.
|
||||
- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.
|
||||
|
||||
When the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:
|
||||
|
||||
1. Call cordis_inspect_self(pluginId, packageId) to read the target source.
|
||||
2. Use cordis_define in existing mode to append a Package to the same Plugin.
|
||||
3. Call cordis_run in run or update mode according to the version relationship.
|
||||
|
||||
Never silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.
|
||||
|
||||
## High-frequency errors that must be avoided
|
||||
|
||||
### Services: ctx.get and inject
|
||||
|
||||
- Read an optional Service with ctx.get('serviceName') by default and handle undefined.
|
||||
- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.
|
||||
- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.
|
||||
|
||||
\`\`\`js
|
||||
return {
|
||||
inject: ['requiredService'],
|
||||
apply(ctx) {
|
||||
ctx.requiredService.someMethod()
|
||||
const optionalService = ctx.get('optionalService')
|
||||
if (optionalService !== undefined) optionalService.someMethod()
|
||||
},
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Code: use plain JavaScript only
|
||||
|
||||
- Host and Client code is not transformed by TypeScript, JSX, or a bundler.
|
||||
- Do not use TypeScript types, as, decorators, import, require, or JSX.
|
||||
- Client React code must use React.createElement(...); never write <Component />.
|
||||
- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.
|
||||
|
||||
### Data: do not serialize live data
|
||||
|
||||
- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.
|
||||
- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.
|
||||
- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.
|
||||
|
||||
### Lifecycle: every side effect must be reversible
|
||||
|
||||
- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.
|
||||
- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.
|
||||
- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.
|
||||
|
||||
## Host and Client
|
||||
|
||||
- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.
|
||||
- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.
|
||||
- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.
|
||||
- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.
|
||||
- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.
|
||||
|
||||
## Asynchronous results and recovery
|
||||
|
||||
- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.
|
||||
- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.
|
||||
- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.
|
||||
- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.`
|
||||
101
packages/extensions/tool-cordis/src/providers.ts
Normal file
101
packages/extensions/tool-cordis/src/providers.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/** First-party Host inspect providers registered by the Cordis tool package. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { HOST_BUILTIN_INSPECTION } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { HostCordisInspectProviderRegistration } from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import { EVENT_API, queryEventApi, queryServiceApi } from './api-catalog.ts'
|
||||
|
||||
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
|
||||
const HOST_EVENTS = EVENT_API.filter(event => !event.name.startsWith('cordis/'))
|
||||
|
||||
/**
|
||||
* Construct Host providers over generated Catalogs, evaluator declarations, and live Tool scope.
|
||||
* @param ctx - Host context used for Agent-scoped live Tool queries.
|
||||
* @returns registrations for static catalogs and live Host capabilities.
|
||||
*/
|
||||
export function hostInspectProviders(ctx: Context): HostCordisInspectProviderRegistration[] {
|
||||
return [
|
||||
registration(
|
||||
'Service',
|
||||
'Progressive Host 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 Host Event discovery: compact listener directory, then one exact event contract.',
|
||||
'listEvents',
|
||||
input => queryEventApi(readExact(input, 'event'), HOST_EVENTS) as unknown as JsonValue,
|
||||
EVENT_INPUT,
|
||||
EVENT_OUTPUT,
|
||||
),
|
||||
registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Host half.', 'listBuiltins', () => ({
|
||||
builtins: HOST_BUILTIN_INSPECTION,
|
||||
referencedTypes: [],
|
||||
} as unknown as JsonValue)),
|
||||
{
|
||||
manifest: {
|
||||
id: 'Tool',
|
||||
description: 'Tools visible to the requesting Agent, including scoped and dynamic registrations.',
|
||||
methods: [{
|
||||
name: 'listTools',
|
||||
description: 'Return every Tool schema currently callable by this Agent.',
|
||||
inputSchema: EMPTY_INPUT,
|
||||
outputSchema: ANY_OUTPUT,
|
||||
}],
|
||||
},
|
||||
query(method, _input, context) {
|
||||
if (method !== 'listTools') throw new Error(`unknown Tool inspect method "${method}"`)
|
||||
return Promise.resolve({ tools: ctx.tools.schemas(context.agent) } as unknown as JsonValue)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function registration(
|
||||
id: string,
|
||||
description: string,
|
||||
method: string,
|
||||
query: (input: JsonValue | undefined) => JsonValue | Promise<JsonValue>,
|
||||
inputSchema: JsonValue = EMPTY_INPUT,
|
||||
outputSchema: JsonValue = ANY_OUTPUT,
|
||||
): HostCordisInspectProviderRegistration {
|
||||
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
|
||||
}
|
||||
287
packages/extensions/tool-cordis/tests/cordis-lifecycle.spec.ts
Normal file
287
packages/extensions/tool-cordis/tests/cordis-lifecycle.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Context, CordisError, FiberState, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Direct regressions for the vendored Cordis ownership substrate used by
|
||||
* tool-cordis's dynamic plugin tree and every other harness plugin.
|
||||
*/
|
||||
|
||||
describe('Cordis effect ownership', () => {
|
||||
it('makes an effect visible to a reentrant owner restart and awaits setup plus cleanup', async () => {
|
||||
const ctx = new Context()
|
||||
const setupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
let setupFinished = false
|
||||
let cleanupFinished = false
|
||||
|
||||
ctx.effect(async () => {
|
||||
restarted = ctx.fiber.restart()
|
||||
await setupGate.promise
|
||||
setupFinished = true
|
||||
return async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}
|
||||
}, 'reentrant-restart')
|
||||
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
setupGate.resolve(undefined)
|
||||
await cleanupStarted.promise
|
||||
expect(setupFinished).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back collected cleanup and its owner-list entry when setup throws synchronously', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield () => { cleanups += 1 }
|
||||
throw new Error('setup failed')
|
||||
}, 'throwing-setup')).toThrow('setup failed')
|
||||
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('makes a reentrant owner restart await asynchronous rollback after synchronous setup failure', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let restarted!: Promise<void>
|
||||
|
||||
expect(() => ctx.effect(function* () {
|
||||
yield async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}
|
||||
restarted = ctx.fiber.restart()
|
||||
throw new Error('setup failed after restart')
|
||||
}, 'reentrant-throw')).toThrow('setup failed after restart')
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void restarted.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await restarted
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps ordinary teardown synchronous and the public disposer single-shot', () => {
|
||||
const ctx = new Context()
|
||||
let cleanups = 0
|
||||
const dispose = ctx.effect(() => () => { cleanups += 1 }, 'sync-effect')
|
||||
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(dispose()).toBeUndefined()
|
||||
expect(cleanups).toBe(1)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects cleanup-time registration while a restart is unloading', async () => {
|
||||
const ctx = new Context()
|
||||
let registrationError: unknown
|
||||
|
||||
ctx.effect(() => () => {
|
||||
try {
|
||||
ctx.effect(() => () => {}, 'too-late')
|
||||
} catch (error) {
|
||||
registrationError = error
|
||||
}
|
||||
}, 'restart-cleanup')
|
||||
|
||||
await ctx.fiber.restart()
|
||||
expect(registrationError).toBeInstanceOf(CordisError)
|
||||
expect((registrationError as CordisError).code).toBe('INACTIVE_EFFECT')
|
||||
expect(ctx.fiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(ctx.fiber.getEffects()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps effect registration legal while child fibers are PENDING and LOADING', async () => {
|
||||
const ctx = new Context()
|
||||
let pendingCleanup = false
|
||||
let loadingCleanup = false
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'state-probe' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => () => { pendingCleanup = true }, 'pending-effect')
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'state-probe',
|
||||
apply(inner) {
|
||||
expect(inner.fiber.state).toBe(FiberState.LOADING)
|
||||
inner.effect(() => () => { loadingCleanup = true }, 'loading-effect')
|
||||
},
|
||||
})
|
||||
await fiber.dispose()
|
||||
|
||||
expect(pendingCleanup).toBe(true)
|
||||
expect(loadingCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves dependencies that internal/plugin adds before child activation', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('late-inject', {})
|
||||
let applyCalls = 0
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loader-shaped' || fiber.uid === null) return
|
||||
fiber.inject['late-inject'] = {}
|
||||
})
|
||||
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'loader-shaped',
|
||||
apply() {
|
||||
applyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(applyCalls).toBe(1)
|
||||
expect(fiber.state).toBe(FiberState.ACTIVE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis child publication ownership', () => {
|
||||
it('rolls back parent and runtime ownership when internal/plugin publication throws', () => {
|
||||
const ctx = new Context()
|
||||
const plugin = { name: 'publication-failure', apply() {} }
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === plugin.name) throw new Error('publication failed')
|
||||
})
|
||||
|
||||
expect(() => ctx.plugin(plugin)).toThrow('publication failed')
|
||||
expect(ctx.registry.has(plugin)).toBe(false)
|
||||
})
|
||||
|
||||
it('contains teardown notification failures so ownership cleanup and peers complete', async () => {
|
||||
const ctx = new Context()
|
||||
const errors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
|
||||
const observed: string[] = []
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) {
|
||||
throw new Error('broken teardown observer')
|
||||
}
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name === 'contained-teardown' && fiber.uid === null) observed.push('disposed')
|
||||
})
|
||||
const child = await ctx.plugin({ name: 'contained-teardown', apply() {} })
|
||||
|
||||
await expect(child.dispose()).resolves.toBeUndefined()
|
||||
expect(observed).toEqual(['disposed'])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toEqual(expect.objectContaining({ message: 'broken teardown observer' }))
|
||||
expect(child.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('makes a LOADING parent join child cleanup started before its unload snapshot', async () => {
|
||||
const ctx = new Context()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let ownerFiber!: Fiber
|
||||
let ownerDisposal!: Promise<void>
|
||||
let childDisposal!: Promise<void>
|
||||
let childFiber!: Fiber
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'loading-child' || fiber.uid === null) return
|
||||
childFiber = fiber
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
}, 'loading-child-cleanup')
|
||||
ownerDisposal = ownerFiber.dispose()
|
||||
childDisposal = Promise.resolve(fiber.dispose())
|
||||
})
|
||||
|
||||
const ownerMount = ctx.plugin({
|
||||
name: 'loading-owner',
|
||||
apply(inner) {
|
||||
ownerFiber = inner.fiber
|
||||
inner.plugin({ name: 'loading-child', apply() {} })
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let ownerSettled = false
|
||||
void ownerDisposal.then(() => { ownerSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(ownerSettled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await Promise.all([ownerDisposal, childDisposal, ownerMount])
|
||||
expect(childFiber.uid).toBeNull()
|
||||
expect(ownerFiber.uid).toBeNull()
|
||||
})
|
||||
|
||||
it('lets parent disposal during internal/plugin await the unpublished child to quiescence', async () => {
|
||||
const ctx = new Context()
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin({
|
||||
name: 'owner',
|
||||
apply(inner) {
|
||||
ownerCtx = inner
|
||||
},
|
||||
})
|
||||
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
let childApplyCalls = 0
|
||||
let parentDisposal!: Promise<void>
|
||||
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
fiber.ctx.effect(() => async () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
cleanupFinished = true
|
||||
}, 'pending-child-cleanup')
|
||||
})
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (fiber.name !== 'child' || fiber.uid === null) return
|
||||
parentDisposal = owner.dispose()
|
||||
})
|
||||
|
||||
const child = ownerCtx.plugin({
|
||||
name: 'child',
|
||||
apply() {
|
||||
childApplyCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
await cleanupStarted.promise
|
||||
let settled = false
|
||||
void parentDisposal.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await parentDisposal
|
||||
expect(cleanupFinished).toBe(true)
|
||||
expect(childApplyCalls).toBe(0)
|
||||
expect(child.uid).toBeNull()
|
||||
expect(child.state).toBe(FiberState.DISPOSED)
|
||||
})
|
||||
})
|
||||
45
packages/extensions/tool-cordis/tsconfig.json
Normal file
45
packages/extensions/tool-cordis/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../cordis-host-runner"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/extensions/ui-cordis/README.i18n.yaml
Normal file
6
packages/extensions/ui-cordis/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/extensions/ui-cordis/README.md
|
||||
README.md: 5f354aa95939de57a385921199848d40d4486ed4
|
||||
README.zh.md: 7109c77c6947d80975eba7b2e2cca24bb6ef60b7
|
||||
36
packages/extensions/ui-cordis/README.md
Normal file
36
packages/extensions/ui-cordis/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-client-ui-cordis
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Cordis dynamic-plugin surfaces, browser half: a frame-wide panel that operates every definition the host holds, and a read-only `cordis_define` card that records what a session defined.
|
||||
|
||||
**The panel is global on purpose.** A model-driven `cordis_run` blocks host-side on a `cordis/request-run` round trip whose answer is a person pressing approve, and it can name a definition belonging to a session nobody is looking at — an approval reachable only inside that session's transcript would be unreachable exactly when it blocks the model. So the answering surface is a `shell.overlay` entry (a generic frame-wide floating seat this package contributed to `ui-layout`, not a React root of its own): a badge counting what runs plus what waits, opening a list of every definition with its run controls. The list is never filtered by session for the same reason; the selected session's rows are grouped first and everyone else's stay listed below. Rows come from the host's global `inventory` call, re-read rather than patched whenever an announcement changes what exists, because the announcements carry no labels. An open request whose definition that read does not cover still gets a row, rendered from the run activity's own session, label, reason and request identity: `cordis_define` broadcasts nothing, so a package defined after the last read is unknown to the page while its request already blocks the model, and a badge counting an answer the list cannot show would strand it. Such a request also triggers one registry read, which brings the row its run controls. Grouping reads one resolved field per row — the live run's session when there is one, the registry row's otherwise — rather than a different store per phase; both activity arms carry it, so answering a request cannot make its row leave the group it was answered in. Within a group the rows blocking a model come first.
|
||||
|
||||
**The card is a record.** It shows the name and purpose the model wrote, the source it wrote, and whether that definition is running — no switch, no approval, and a pointer to the panel. Its material comes only from the frozen call/result slice (labels from `argsRaw`, the host-minted id from the result's `meta.id`), so replay renders the same card, and one reading is the card's own to own: a successful `cordis_undefine` in this session's log is durable and terminal, and outranks the wire, where a retract announcement looks identical to a plain stop.
|
||||
|
||||
**A row reads two independent facts.** What the host runs comes from the inventory; what THIS page has loaded comes from the runner's live set. They diverge on every reload: the host keeps running everything while a fresh page holds nothing. A row the host runs and this page holds offers the global stop; a row the host runs and this page does NOT hold offers the load back first and that stop second — separate controls, because loading the browser half here and stopping the definition for every page are different acts. A host-only definition is exempt from all of it: with no browser half to load, "this page does not hold it" is simply what it always looks like, so its row reads plainly running and offers the stop alone. Idle, it offers a run labelled as just that — nothing is loaded here, so the control does not say it is — and the run request carries `hasClientHalf` to the runner, which is what makes it bring up the host half and fetch no browser half. Collapsing the two facts into the host's `running` alone left a reloaded page with nothing but the global stop, so the documented reload recovery (pull the inventory, load the definition back into this page) had no control to go through.
|
||||
|
||||
**A crash after a successful load still belongs on the row.** A browser half can load, answer `cordis_run` with ok, and only then throw when React renders it — the teaching error for a mistake like reaching for `setInterval` reaches the browser console, which neither the model nor the person watching the panel reads. So the row carries the runner's last render failure for this page inline, in the same slot as a load failure: one is "it never loaded", the other "it loaded and then threw", and a row can honestly show both. The line names the seat that crashed and answers the question that follows from it — whether anything of theirs is still on screen: a shadowing seat retires the crashed entry and the shipped UI comes back, while a chain entry keeps its place and its UI may simply be incomplete.
|
||||
|
||||
Neither surface keeps run state in component state — settling a define call moves its card in the chat flow, which remounts it. Facts live in observables owned by whoever can close them: the browser-side runner owns open requests, orchestration outcomes (it resolves them, including when another page answers first), this page's live set and its render failures — it is the only party watching `slots.onEntryError` and the only one that can map a crashed entry back to the package that registered it — and this package owns the inventory it read and the announcements it folded.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) plus the injected face, run-state, port and event payload types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the run and stop verbs these surfaces drive — the browser-side runner's orchestration for a run, and `dynamicCordisRunner.stop` for a stop, the same host verbs the model's `cordis_run` and `cordis_stop` tools reach — so whatever a running definition then contributes is the runner's effect while nothing model-visible originates in this package, which renders logged call/result slices and a host inventory read, adds no prompt content, writes no session event, and deliberately leaves no session-log trace of a person approving, declining, running or stopping anything.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None: no prompt input originates here, and answering a run request neither extends nor rewrites the history tail.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **An open panel does not see registry changes that announce nothing** — `cordis_define`, and an undefine of a definition that was not running, change the registry without a dispatch announcement, so a panel left open across one of them keeps its rows until it is closed and opened again (opening re-reads). A run request is the exception, because it blocks the model: it both renders its own row and triggers a read. Acting on a row the host no longer holds is still honest: the call answers `definition-missing` and the row goes terminal. Polling while open was considered and rejected as the wrong price for it.
|
||||
- **A request-only row is answerable but not operable** — it offers approve/decline only, because the run/stop switch needs the registry row the read has yet to deliver. Grouping and copy are unaffected (the ask carries the session, label and reason).
|
||||
- **A row disappears for the width of one read if its orchestration outruns it** — the activity's orchestrating arm carries the session but no label, deliberately: a user-initiated run has no ask to take one from, and naming the row after its id would be worse than briefly omitting it. So an approved request whose registry read has not landed leaves no row until it does. In practice the read is triggered when the request arrives, so it has almost always landed by the time anyone answers.
|
||||
- **A render failure is this page's own reading, and it arrives too late for the run receipt** — the panel shows the last crash the runner saw HERE, so a package that renders fine in this tab shows nothing even while it crashes in another. It also cannot appear in `cordis_run`'s answer: rendering happens after the run settles, so the model learns about it by asking (`cordis_inspect what:"temporary"`) rather than from the call it already made.
|
||||
- **A second page's load failure is invisible to the others** — the host settles a dispatch on the first load report and records later ones without acting, so a page whose browser half failed after another page acknowledged keeps reading as running. That page sees the reason on its own row (the runner reports it); the others cannot.
|
||||
- **Any page may answer any request** — approvals are frame-wide by design, so a person in one browser tab can approve a run the model asked for while another tab is in front of the defining session. First answer wins and the rest converge; narrowing who may answer is deferred.
|
||||
- **A card whose call head left the event window loses its labels** — the card derives name and purpose from the call arguments, so a session long enough to truncate them leaves it naming its call id. The panel is unaffected: the host inventory carries the labels.
|
||||
- **Window truncation degrades the unloaded reading** — the card calls a definition unloaded from a successful `cordis_undefine` in this session's log; a session long enough to push that result out of the window shows the definition as merely not running.
|
||||
36
packages/extensions/ui-cordis/README.zh.md
Normal file
36
packages/extensions/ui-cordis/README.zh.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-client-ui-cordis
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Cordis 动态插件的浏览器半:一个覆盖整个框架的面板,操作 host 持有的全部定义;以及一张只读的 `cordis_define` 卡片,记录某个会话定义了什么。
|
||||
|
||||
**面板做成全局是刻意的。** 模型发起的 `cordis_run` 会在 host 侧阻塞于一次 `cordis/request-run` 往返,而它的答案是**有人按下允许**;并且它可能指向一个当前没人在看的会话里的定义——审批入口若只存在于那个会话的对话流里,就会在它正阻塞模型的时候恰好不可达。所以应答面是一个 `shell.overlay` 条目(本包为 `ui-layout` 补的通用「框架级悬浮席位」,不是自建 React root):角标计数 = 在跑数 + 待确认数,点开后列出每个定义及其运行控件。列表**同理**从不按会话过滤;当前会话的行置顶成组,其他会话的行仍在下方列出。行来自 host 的全局 `inventory` 调用,并在公告改变「有哪些定义」时**重读而非打补丁**——因为公告不携带名字与用途。而那次读取覆盖不到的开放请求**仍然有行**:直接用运行活动自带的会话、标题、用途与请求标识渲染——`cordis_define` 不广播任何东西,所以在上一次读取之后定义的包,对本页是未知的,而它的请求已经在阻塞模型;此时角标数着一个列表给不出的答案,就等于把模型困死。这样一个请求还会额外触发一次注册表读取,把运行控件补给该行。**归组只读每行上一个已解析好的字段**——有活动的 run 用它自己的会话,其余用注册表行的——而不是按阶段去问不同的 store;活动的**两条臂都带这个字段**,所以应答一个请求不会让它的行跳出人刚刚操作的那一组。组内,阻塞着模型的行排在最前。
|
||||
|
||||
**卡片是一份记录。** 它显示模型写下的 name 与 purpose、它写的源码,以及那个定义是否在跑——没有开关、没有审批,只有一句指向面板的指引。素材只取自冻结的 call/result 切片(标签取自 `argsRaw`,host 铸出的 id 取自结果的 `meta.id`),因此 replay 渲染出同一张卡;而有一个读数天生属于卡片:本会话日志里成功的 `cordis_undefine` 是持久且终态的,它**压过 wire**——在 wire 上,retract 公告与一次普通 stop 长得一模一样。
|
||||
|
||||
**一行同时读两个互相独立的事实。** 「host 在跑什么」来自 inventory,「**本页**装载了什么」来自 runner 的 live set。每次刷新这两者必然分叉:host 照旧跑着全部,而全新页面什么都没装。host 在跑且本页已装的行,给出全局 stop;host 在跑但本页**没装**的行,先给「装回本页」、再给那个 stop——**两个独立控件**,因为「把浏览器半装到本页」与「为所有页面停掉这个定义」是两件不同的事。**只有 host 半的定义完全不参与这一套**:它没有浏览器半可装,「本页没装」就是它永远的样子,所以它的行如实读作「运行中」,并且只给 stop。未运行时它给出的是一个如实标注的「运行」——本页什么都不会装进来,控件就不这么说——而 run 请求把 `hasClientHalf` 一路带给 runner,正是这一位让它只起 host 半、不去取浏览器半。把这两个事实塌缩成 host 的 `running` 一个,会让刷新后的页面只剩全局 stop,于是文档写明的刷新恢复路径(拉 inventory、把定义装回本页)**没有任何控件可走**。
|
||||
|
||||
**装载成功之后的崩溃,同样属于这一行。** 浏览器半可以装载成功、让 `cordis_run` 答 ok,之后才在 React 渲染它时抛出异常——像误用 `setInterval` 这类错误的教学文案只会落到浏览器控制台,而模型和看着面板的人都不读那里。所以该行会把 runner 记下的、属于本页的最后一次渲染失败就地显示在行内,与装载失败共用同一个位置:一个是「它从来没装上」,另一个是「它装上了、然后抛了」,而一行可以如实地把两者都显示出来。这行文案会点名崩溃的那个座位,并回答随之而来的那个问题——他们的东西还有没有留在屏幕上:遮蔽式座位会让崩溃的条目退场,出厂 UI 随之回来;而 chain 条目会保住自己的位置,它的 UI 可能只是不完整。
|
||||
|
||||
两个面都不把运行态放进组件 state——define 调用结算时卡片会在聊天流里换位置并重挂。事实活在「谁能关闭它、就归谁」的观察量里:浏览器侧 runner 拥有开放请求、编排结果(是它发出 resolve,包括别的页面先应答的情形)与本页的 live set及其渲染失败——它是唯一在监视 `slots.onEntryError` 的一方,也是唯一能把崩溃的条目映射回注册它的那个包的一方——而本包拥有自己读来的清单与折叠过的公告。
|
||||
|
||||
`/client` 导出面是插件体(`apply`/`inject`)加注入面、运行态、端口与事件载荷类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接影响,经由这两个面驱动的 run 与 stop 动词——run 走浏览器侧 runner 的编排,stop 走 `dynamicCordisRunner.stop`,与模型的 `cordis_run` / `cordis_stop` 工具是同一批 host 动词。因此正在运行的定义随后贡献了什么是 runner 的效果,而本包不产生任何模型可见输入:它只渲染已落日志的 call/result 切片与一次 host 清单读取,不加 prompt 内容、不写会话事件,并刻意不为「有人批准 / 拒绝 / 运行 / 停止」留下会话日志痕迹。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无:没有任何 prompt 输入源自这里,应答一次 run 请求既不延长也不改写历史尾部。
|
||||
|
||||
## 已知限制与欠账
|
||||
|
||||
- **已展开的面板看不到「不广播任何东西」的注册表变化** —— `cordis_define`,以及对一个并未在运行的定义执行 undefine,都会改变注册表却不发出下发公告;因此跨过这类变化时,已展开的面板会保留旧行,直到收起再展开(展开即重读)。run 请求是例外,因为它阻塞模型:它既自己渲染出行,也触发一次读取。对一个 host 已不持有的行动手仍然是诚实的:调用会答 `definition-missing`,该行随即转入终态。「展开期间轮询」评估过,代价不值,已否决。
|
||||
- **只有请求、没有清单的行可应答但不可操作** —— 它只提供允许/拒绝,因为 run/stop 开关需要那次读取尚未送达的注册表行。归组与文案不受影响(ask 自带会话、标题与用途)。
|
||||
- **若编排跑在读取之前,该行会消失一次读取的时长** —— 活动的 orchestrating 臂带会话但**刻意不带标题**:用户自发的 run 根本不存在可取标题的 ask,而拿 id 当名字比短暂缺行更难看。所以一个已批准、但注册表读取尚未落地的请求,在读取落地前没有行。实践中这次读取在请求**到达时**就已触发,所以等到有人应答时它几乎总已落地。
|
||||
- **渲染失败是本页自己的读数,而且它来得太晚、赶不上 run 的回执** —— 面板显示的是 runner 在**本页**看到的最后一次崩溃,所以一个在本标签页渲染正常的包,即使正在另一个标签页里崩溃,这里也什么都不显示。它同样不可能出现在 `cordis_run` 的答复里:渲染发生在 run 结算之后,所以模型只能靠主动去问(`cordis_inspect what:"temporary"`)才知道,而不是从它已经发出的那次调用里得知。
|
||||
- **某一页的装载失败对其他页不可见** —— host 以首个装载回报结算一次 dispatch,更晚的回报只记录不动作;因此在另一页确认之后浏览器半才失败的页面,仍会读作运行中。那一页能在自己的行上看到原因(runner 会报),其他页看不到。
|
||||
- **任何页面都可以应答任何请求** —— 审批按设计是框架级的,所以某个标签页里的人可以批准模型为另一个标签页正在看的会话所发起的 run。首个应答生效、其余收敛;收窄「谁有权应答」延后。
|
||||
- **call head 掉出事件窗的卡片会丢掉标签** —— 卡片的 name 与 purpose 取自调用参数,会话长到把它们截断时,卡片只能以自己的 call id 自称。面板不受影响:host 清单携带标签。
|
||||
- **窗口截断会降级「已卸载」这个读数** —— 卡片凭本会话日志里成功的 `cordis_undefine` 判定已卸载;会话长到把那条结果挤出窗口时,该定义只会显示为未运行。
|
||||
89
packages/extensions/ui-cordis/package.json
Normal file
89
packages/extensions/ui-cordis/package.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-cordis",
|
||||
"description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch",
|
||||
"version": "0.0.1-rc.5",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/extensions/ui-cordis"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-cordis-client-runner",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger",
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
42
packages/extensions/ui-cordis/src/client/CordisActionRow.tsx
Normal file
42
packages/extensions/ui-cordis/src/client/CordisActionRow.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/** Localized cards for `cordis_stop` and `cordis_undefine`. */
|
||||
|
||||
import {
|
||||
IconInspectOutline12, IconStopFill16, IconTrashOutline16, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import { cordisActionCard } from './card-model.ts'
|
||||
import css from './CordisRunRow.module.css'
|
||||
|
||||
/** Full action-card props composed by the keyed Tool slot. */
|
||||
export type CordisActionRowProps = ToolCallViewProps & PropsLocale<'cordis'>
|
||||
|
||||
/** Render one Stop or Remove call with Cordis-owned localized copy. */
|
||||
export function CordisActionRow({ callId, toolName, block, inspect, t }: CordisActionRowProps) {
|
||||
const card = cordisActionCard(block)
|
||||
const remove = toolName === 'cordis_undefine'
|
||||
const summary = card.errorSummary ?? card.pluginId ?? callId
|
||||
|
||||
return (
|
||||
<div className={css.card} data-tool={toolName} data-state={card.state}>
|
||||
<div className={css.row}>
|
||||
<span className={css.icon}>
|
||||
{card.state === 'error'
|
||||
? <StateDot state="error" />
|
||||
: card.state === 'stopped'
|
||||
? <StateDot state="warning" />
|
||||
: remove ? <IconTrashOutline16 size={14} /> : <IconStopFill16 size={14} />}
|
||||
</span>
|
||||
<span className={css.title}>{t(remove ? 'row.removeTitle' : 'row.stopTitle')}</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={card.errorSummary === null ? css.summary : css.error}>{summary}</span>
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspect} aria-label="Inspect" onClick={inspect}>
|
||||
<IconInspectOutline12 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{card.output !== null && <pre className={css.output}>{card.output}</pre>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/* Cordis definition card: DisclosureRow chrome (leading/chevron/title come from
|
||||
that primitive) plus this card's own trailing run switch and source body.
|
||||
No running sweep: `cordis_define` only registers a definition in host memory,
|
||||
so the call settles in the same turn it starts and the state dot carries the
|
||||
whole signal. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The shared cordis accent (matches ui-tool's [data-tool^='cordis_'] rows). */
|
||||
.card .title,
|
||||
.card .chevron {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.row {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The purpose absorbs the remaining width and clips first: the name and the
|
||||
switch are the two parts that must survive a narrow row. */
|
||||
.purpose {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.errorSummary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.requestError {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.readout {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Said once in the expanded body: this card records, the panel operates. */
|
||||
.panelHint {
|
||||
margin: 4px 0 2px 4px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* The model's question, shown beside the approve/decline pair. */
|
||||
.approvalPrompt {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* A qualified success (nothing acknowledged the dispatch, or the browser half
|
||||
parked on missing services): informational, so it must not read as the error
|
||||
line beside it. */
|
||||
.notice {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
margin-left: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.switch {
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Terminal definitions (unloaded, or lost to a host restart) stay in the flow
|
||||
as a greyed card: the define call is still in the session log, so removing
|
||||
the row would leave a replay hole. */
|
||||
.card[data-terminal] .title,
|
||||
.card[data-terminal] .name,
|
||||
.card[data-terminal] .purpose,
|
||||
.card[data-terminal] .statusLabel {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.card[data-terminal] .separator {
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.bodyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 4px 0 4px 4px;
|
||||
}
|
||||
|
||||
.sourceTabs {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.sourceTab {
|
||||
position: relative;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.sourceTab:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.sourceTab:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sourceTabActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.sourceTabActive::after {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
left: 10px;
|
||||
height: 2px;
|
||||
border-radius: 1px 1px 0 0;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.sourceTab:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sourcePanel {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sourceCode {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 260px;
|
||||
margin: 4px 0 4px 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: none;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.output[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.inspectButton {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.card:hover .inspectButton,
|
||||
.inspectButton:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inspectButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inspectButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
171
packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
Normal file
171
packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/** Read-only `cordis_define` card with Host and Client source tabs. */
|
||||
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
CodeBlock, DisclosureRow, IconCodeOutline16, IconInspectOutline12, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import { cordisDefineCard, type CordisToolState } from './card-model.ts'
|
||||
import type { CordisCardFace } from './slots.ts'
|
||||
import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts'
|
||||
import type { CordisKey } from './locales.ts'
|
||||
import css from './CordisDefineRow.module.css'
|
||||
|
||||
/** Full card props composed by the keyed Tool slot. */
|
||||
export type CordisDefineRowProps = ToolCallViewProps & InjectFace<CordisCardFace> & PropsLocale<'cordis'>
|
||||
|
||||
type CardReading = CordisVisibleStatus | 'removed'
|
||||
type SourceTab = 'client' | 'host'
|
||||
|
||||
const READING_LABELS = {
|
||||
idle: 'status.idle',
|
||||
'client-pending': 'status.clientPending',
|
||||
running: 'status.running',
|
||||
removed: 'status.removed',
|
||||
} as const satisfies Record<CardReading, CordisKey>
|
||||
|
||||
function stateStatus(state: CordisToolState): CordisKey | null {
|
||||
switch (state) {
|
||||
case 'running': return 'a11y.defining'
|
||||
case 'error': return 'a11y.failed'
|
||||
case 'stopped': return 'a11y.stopped'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function leadingFor(state: CordisToolState): ReactNode {
|
||||
switch (state) {
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconCodeOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one immutable Package definition. */
|
||||
export function CordisDefineRow({
|
||||
callId, block, inspect, useInventory, useLoaded, t,
|
||||
}: CordisDefineRowProps) {
|
||||
const card = cordisDefineCard(block)
|
||||
const inventory = useInventory(snapshot => snapshot)
|
||||
const loaded = useLoaded(snapshot => snapshot)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<SourceTab>(card.clientCode !== null ? 'client' : 'host')
|
||||
const sourcePanelId = useId()
|
||||
|
||||
const row = card.pluginId === null
|
||||
? undefined
|
||||
: inventory.rows.find(candidate => candidate.pluginId === card.pluginId)
|
||||
const reading: CardReading = card.pluginId !== null && inventory.removed.has(card.pluginId)
|
||||
? 'removed'
|
||||
: row !== undefined && card.packageId !== null
|
||||
? cordisVisibleStatus(row, card.packageId, loaded)
|
||||
: 'idle'
|
||||
const name = card.name ?? callId
|
||||
const expandable = card.hostCode !== null || card.clientCode !== null || card.output !== null
|
||||
const open = expanded && expandable
|
||||
const a11yState = stateStatus(card.state)
|
||||
const hasSource = card.clientCode !== null || card.hostCode !== null
|
||||
const activeSource: SourceTab = selectedSource === 'client' && card.clientCode !== null
|
||||
? 'client'
|
||||
: selectedSource === 'host' && card.hostCode !== null
|
||||
? 'host'
|
||||
: card.clientCode !== null ? 'client' : 'host'
|
||||
const activeCode = activeSource === 'client' ? card.clientCode : card.hostCode
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.card}
|
||||
data-tool="cordis_define"
|
||||
data-state={card.state}
|
||||
data-terminal={reading === 'removed' || undefined}
|
||||
data-cordis-plugin-id={card.pluginId ?? undefined}
|
||||
data-cordis-package-id={card.packageId ?? undefined}
|
||||
data-cordis-status={reading}
|
||||
>
|
||||
{a11yState !== null && <span className={css.visuallyHidden}>{t(a11yState)}</span>}
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
titleClassName={css.title}
|
||||
chevronClassName={css.chevron}
|
||||
icon={leadingFor(card.state)}
|
||||
title={t('row.defineTitle')}
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen
|
||||
onToggle={() => { setExpanded(value => !value) }}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={card.errorSummary === null ? css.name : css.errorSummary}>
|
||||
{card.errorSummary ?? name}
|
||||
</span>
|
||||
{card.errorSummary === null && (
|
||||
<span className={css.purpose}>{card.purpose ?? t('purpose.missing')}</span>
|
||||
)}
|
||||
{card.pluginId !== null && (
|
||||
<span className={css.readout}>
|
||||
<span className={css.statusLabel}>{t(READING_LABELS[reading])}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.bodyWrap}>
|
||||
{hasSource && activeCode !== null && (
|
||||
<section className={css.sourceCard}>
|
||||
<div className={css.sourceTabs} role="tablist" aria-label={t('body.source')}>
|
||||
{(['client', 'host'] as const).map((source) => {
|
||||
const available = source === 'client' ? card.clientCode !== null : card.hostCode !== null
|
||||
return (
|
||||
<button
|
||||
key={source}
|
||||
id={`${sourcePanelId}-${source}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls={sourcePanelId}
|
||||
aria-selected={activeSource === source}
|
||||
className={activeSource === source ? `${css.sourceTab} ${css.sourceTabActive}` : css.sourceTab}
|
||||
disabled={!available}
|
||||
onClick={() => { setSelectedSource(source) }}
|
||||
>
|
||||
{t(source === 'client' ? 'body.clientCode' : 'body.hostCode')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
id={sourcePanelId}
|
||||
className={css.sourcePanel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${sourcePanelId}-${activeSource}`}
|
||||
>
|
||||
<CodeBlock
|
||||
code={activeCode}
|
||||
lang="javascript"
|
||||
copyLabel={t('body.copy')}
|
||||
copiedLabel={t('body.copied')}
|
||||
className={css.sourceCode}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{card.output !== null && (
|
||||
<section className={css.codeSection}>
|
||||
<div className={css.sectionLabel}>{t('body.output')}</div>
|
||||
<pre className={css.output} data-error={card.state === 'error' || undefined}>{card.output}</pre>
|
||||
</section>
|
||||
)}
|
||||
{card.pluginId !== null && <div className={css.panelHint}>{t('panel.hint')}</div>}
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspectButton} onClick={inspect}>
|
||||
<IconInspectOutline12 />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
370
packages/extensions/ui-cordis/src/client/CordisPanel.module.css
Normal file
370
packages/extensions/ui-cordis/src/client/CordisPanel.module.css
Normal file
@@ -0,0 +1,370 @@
|
||||
/* Sidebar-foot Cordis action and the fixed list it opens above the footer. */
|
||||
|
||||
.layer {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 49px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.footerButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 49px;
|
||||
padding: 0 8px 0 6px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.badge[data-active] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.badgeLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badgeCount {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.layer.rail {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rail .badge {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rail .footerButtons {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
bottom: 128px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
/* Only `.body` scrolls; the header remains fixed above it. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.note,
|
||||
.readError {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.readError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Session headings replace per-row ownership markers. */
|
||||
.group {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.row[data-cordis-awaiting] {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rowId {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--dsh-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowStatus {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='idle'] .rowStatus {
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='awaiting-approval'] .rowStatus,
|
||||
.row[data-cordis-status='client-pending'] .rowStatus {
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='failed'] .rowStatus {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.row[data-cordis-status='running'] .rowStatus {
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rowDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.versionPicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.versionPicker select {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 7px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rowPurpose {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowError {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.transitionActions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.transitionActions button {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transitionActions button:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.transitionActions button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.activeVersion {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* Matches GoalBar's action controls: compact circular icons whose visible copy
|
||||
lives in a tooltip and accessible name. */
|
||||
.actionButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actionButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.actionButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.doubleCheck {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 17px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.doubleCheck svg {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.doubleCheck svg:first-child {
|
||||
left: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.doubleCheck svg:last-child {
|
||||
left: 5px;
|
||||
}
|
||||
472
packages/extensions/ui-cordis/src/client/CordisPanel.tsx
Normal file
472
packages/extensions/ui-cordis/src/client/CordisPanel.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
/** Frame-wide dynamic Plugin inventory, approvals, versions, and lifecycle actions. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconCordisPluginOutline14, IconPlayOutline16,
|
||||
IconStopFill16, IconTrashOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type { CordisRunActivity } from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CordisInventoryRow } from './dynamic-port.ts'
|
||||
import type { CordisPanelFace } from './slots.ts'
|
||||
import type { CordisKey } from './locales.ts'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId,
|
||||
} from './events.ts'
|
||||
import { cordisVisibleStatus, packageOf, type CordisVisibleStatus } from './status.ts'
|
||||
import css from './CordisPanel.module.css'
|
||||
|
||||
/** Full panel props composed by the sidebar footer-action slot. */
|
||||
export type CordisPanelProps =
|
||||
PropsRuntime<'sidebar.footer.action'> & InjectFace<CordisPanelFace> & PropsLocale<'cordis'>
|
||||
|
||||
type PanelStatus = CordisVisibleStatus | 'awaiting-approval' | 'failed'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
idle: 'status.idle',
|
||||
'awaiting-approval': 'status.awaitingApproval',
|
||||
'client-pending': 'status.clientPending',
|
||||
running: 'status.running',
|
||||
failed: 'status.failed',
|
||||
} as const satisfies Record<PanelStatus, CordisKey>
|
||||
|
||||
const RENDER_FAILURE_LABELS = {
|
||||
abdicated: 'render.failedAbdicated',
|
||||
held: 'render.failedHeld',
|
||||
} as const satisfies Record<'abdicated' | 'held', CordisKey>
|
||||
|
||||
interface RowView {
|
||||
readonly pluginId: CordisDynamicPluginId
|
||||
readonly agentId: SessionId
|
||||
readonly listed?: CordisInventoryRow
|
||||
readonly activity?: CordisRunActivity
|
||||
}
|
||||
|
||||
function selectedPackageIdOf(
|
||||
{ pluginId, listed, activity }: RowView,
|
||||
selected: Readonly<Record<string, CordisDynamicPackageId>>,
|
||||
): CordisDynamicPackageId | undefined {
|
||||
const selectedPackageId = selected[pluginId]
|
||||
if (selectedPackageId !== undefined
|
||||
&& listed?.packages.some(pkg => pkg.packageId === selectedPackageId)) return selectedPackageId
|
||||
return listed?.nextPackageId
|
||||
?? listed?.currentPackageId
|
||||
?? listed?.packages.at(-1)?.packageId
|
||||
?? activity?.packageId
|
||||
}
|
||||
|
||||
function visiblePanelStatus(
|
||||
view: RowView,
|
||||
selectedPackageId: CordisDynamicPackageId | undefined,
|
||||
loaded: Parameters<typeof cordisVisibleStatus>[2],
|
||||
): PanelStatus {
|
||||
const { listed, activity } = view
|
||||
const latest = listed?.latestRun
|
||||
if (activity?.phase === 'awaiting-approval' || latest?.status === 'awaiting-approval') {
|
||||
return 'awaiting-approval'
|
||||
}
|
||||
if (latest?.status === 'failed' && latest.packageId === selectedPackageId) return 'failed'
|
||||
if (listed?.activeRun === undefined) return 'idle'
|
||||
return cordisVisibleStatus(listed, listed.activeRun.packageId, loaded)
|
||||
}
|
||||
|
||||
function blockingFirst(rows: readonly RowView[]): readonly RowView[] {
|
||||
return [
|
||||
...rows.filter(row => row.activity?.phase === 'awaiting-approval'),
|
||||
...rows.filter(row => row.activity?.phase !== 'awaiting-approval'),
|
||||
]
|
||||
}
|
||||
|
||||
function RowAction({ label, children, ...props }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<Tooltip label={label} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.actionButton} aria-label={label} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function DoubleCheckIcon() {
|
||||
return (
|
||||
<span className={css.doubleCheck} aria-hidden>
|
||||
<IconCheckOutline16 size={12} />
|
||||
<IconCheckOutline16 size={12} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render the inventory panel and its unified footer action. */
|
||||
export function CordisPanel({
|
||||
wide,
|
||||
useSessions, useInventory, useActiveRuns, useRunErrors, useLoaded, useRenderFailures,
|
||||
onApprove, onDecline, onRun, onStop, onRemove, onRefresh, t,
|
||||
}: CordisPanelProps) {
|
||||
const inventory = useInventory(snapshot => snapshot)
|
||||
const activeRuns = useActiveRuns(snapshot => snapshot)
|
||||
const errors = useRunErrors(snapshot => snapshot)
|
||||
const loaded = useLoaded(snapshot => snapshot)
|
||||
const renderFailures = useRenderFailures(snapshot => snapshot)
|
||||
const current = useSessions(state => state.current)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Record<string, CordisDynamicPackageId>>({})
|
||||
const [pending, setPending] = useState<ReadonlySet<CordisDynamicPluginId>>(new Set())
|
||||
const [actionErrors, setActionErrors] = useState<ReadonlyMap<CordisDynamicPluginId, string>>(new Map())
|
||||
const visibleRequests = useRef<Set<ApprovalRequestId>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Set<ApprovalRequestId>()
|
||||
for (const activity of activeRuns.values()) {
|
||||
if (activity.phase === 'awaiting-approval') now.add(activity.requestId)
|
||||
}
|
||||
const discovered = [...now].some(requestId => !visibleRequests.current.has(requestId))
|
||||
visibleRequests.current = now
|
||||
if (discovered) setOpen(true)
|
||||
}, [activeRuns])
|
||||
|
||||
useEffect(() => { onRefresh() }, [onRefresh])
|
||||
useEffect(() => { if (open) onRefresh() }, [onRefresh, open])
|
||||
|
||||
const byPlugin = new Map<CordisDynamicPluginId, RowView>()
|
||||
for (const listed of inventory.rows) {
|
||||
const activity = activeRuns.get(listed.pluginId)
|
||||
byPlugin.set(listed.pluginId, {
|
||||
pluginId: listed.pluginId,
|
||||
agentId: activity?.agentId ?? listed.agentId,
|
||||
listed,
|
||||
...activity === undefined ? {} : { activity },
|
||||
})
|
||||
}
|
||||
for (const [pluginId, activity] of activeRuns) {
|
||||
if (byPlugin.has(pluginId)) continue
|
||||
byPlugin.set(pluginId, { pluginId, agentId: activity.agentId, activity })
|
||||
}
|
||||
const all = [...byPlugin.values()]
|
||||
const mine = blockingFirst(all.filter(row => current !== undefined && row.agentId === current))
|
||||
const theirs = blockingFirst(all.filter(row => current === undefined || row.agentId !== current))
|
||||
const approvals = [...activeRuns.values()].filter(activity => activity.phase === 'awaiting-approval').length
|
||||
const running = all.filter(view => visiblePanelStatus(
|
||||
view,
|
||||
selectedPackageIdOf(view, selected),
|
||||
loaded,
|
||||
) === 'running').length
|
||||
|
||||
if (all.length === 0) return null
|
||||
|
||||
const runAction = async (pluginId: CordisDynamicPluginId, action: () => Promise<void | { ok: boolean; message?: string }>) => {
|
||||
if (pending.has(pluginId)) return
|
||||
setPending(currentPending => new Set(currentPending).add(pluginId))
|
||||
setActionErrors((currentErrors) => {
|
||||
const next = new Map(currentErrors)
|
||||
next.delete(pluginId)
|
||||
return next
|
||||
})
|
||||
try {
|
||||
const result = await action()
|
||||
if (result !== undefined && !result.ok) {
|
||||
setActionErrors(currentErrors => new Map(currentErrors).set(pluginId, result.message ?? 'operation failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
setActionErrors(currentErrors => new Map(currentErrors).set(
|
||||
pluginId,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
))
|
||||
} finally {
|
||||
setPending((currentPending) => {
|
||||
const next = new Set(currentPending)
|
||||
next.delete(pluginId)
|
||||
return next
|
||||
})
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const renderRow = (view: RowView) => {
|
||||
const { pluginId, listed, activity } = view
|
||||
const selectedPackageId = selectedPackageIdOf(view, selected)
|
||||
const selectedPackage = listed !== undefined && selectedPackageId !== undefined
|
||||
? packageOf(listed, selectedPackageId)
|
||||
: undefined
|
||||
const activePackage = listed?.activeRun === undefined
|
||||
? undefined
|
||||
: packageOf(listed, listed.activeRun.packageId)
|
||||
const name = selectedPackage?.name
|
||||
?? (activity?.phase === 'awaiting-approval' ? activity.name : pluginId)
|
||||
const purpose = selectedPackage?.purpose
|
||||
?? (activity?.phase === 'awaiting-approval' ? activity.purpose : '')
|
||||
const latest = listed?.latestRun
|
||||
const awaiting = activity?.phase === 'awaiting-approval'
|
||||
? activity.requestId
|
||||
: latest?.status === 'awaiting-approval' ? latest.approvalRequestId : undefined
|
||||
const status = visiblePanelStatus(view, selectedPackageId, loaded)
|
||||
const busy = pending.has(pluginId) || activity?.phase === 'orchestrating'
|
||||
const failure = errors.get(pluginId)
|
||||
const hostFailure = latest?.status === 'failed' ? latest.error : undefined
|
||||
const renderFailure = renderFailures.get(pluginId)
|
||||
const actionError = actionErrors.get(pluginId)
|
||||
const nextPackageId = listed?.nextPackageId !== undefined
|
||||
&& listed.nextPackageId !== listed.currentPackageId ? listed.nextPackageId : undefined
|
||||
const currentPackageId = listed?.currentPackageId
|
||||
const runMode = listed?.currentPackageId !== undefined
|
||||
&& selectedPackageId !== listed.currentPackageId ? 'update' as const : 'run' as const
|
||||
|
||||
return (
|
||||
<li
|
||||
key={pluginId}
|
||||
className={css.row}
|
||||
data-cordis-row={pluginId}
|
||||
data-cordis-status={status}
|
||||
data-cordis-awaiting={awaiting !== undefined || undefined}
|
||||
>
|
||||
<div className={css.rowHead}>
|
||||
<span className={css.rowId}>{pluginId}</span>
|
||||
<span className={css.rowName}>{name}</span>
|
||||
<span className={css.rowStatus}>{t(STATUS_LABELS[status])}</span>
|
||||
</div>
|
||||
{listed !== undefined && listed.packages.length > 1 && selectedPackageId !== undefined && (
|
||||
<label className={css.versionPicker}>
|
||||
<span>{t('panel.version')}</span>
|
||||
<select
|
||||
value={selectedPackageId}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setSelected(currentSelected => ({
|
||||
...currentSelected,
|
||||
[pluginId]: event.target.value as CordisDynamicPackageId,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
{listed.packages.map(pkg => (
|
||||
<option key={pkg.packageId} value={pkg.packageId}>{`${pkg.name} · ${pkg.packageId}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className={css.rowDetail}>
|
||||
<span className={css.rowPurpose}>{purpose}</span>
|
||||
<div className={css.rowActions}>
|
||||
{awaiting !== undefined && (
|
||||
<>
|
||||
<RowAction
|
||||
label={t('action.approveOnce')}
|
||||
data-cordis-approve={awaiting}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, async () => {
|
||||
await onApprove(awaiting, false)
|
||||
setOpen(false)
|
||||
}) }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t('action.approvePlugin')}
|
||||
data-cordis-approve-plugin={awaiting}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, async () => {
|
||||
await onApprove(awaiting, true)
|
||||
setOpen(false)
|
||||
}) }}
|
||||
>
|
||||
<DoubleCheckIcon />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t('action.decline')}
|
||||
data-cordis-decline={awaiting}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, async () => {
|
||||
await onDecline(awaiting)
|
||||
setOpen(false)
|
||||
}) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</RowAction>
|
||||
</>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined
|
||||
&& selectedPackageId !== undefined && listed.activeRun === undefined && (
|
||||
<RowAction
|
||||
label={t('action.run')}
|
||||
data-cordis-switch="run"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: selectedPackageId,
|
||||
mode: runMode,
|
||||
hasClientHalf: selectedPackage?.hasClientHalf === true,
|
||||
})) }}
|
||||
>
|
||||
<IconPlayOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && listed.activeRun !== undefined
|
||||
&& selectedPackageId !== listed.activeRun.packageId && selectedPackage !== undefined && (
|
||||
<RowAction
|
||||
label={t('action.run')}
|
||||
data-cordis-switch="run"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: selectedPackage.packageId,
|
||||
mode: runMode,
|
||||
hasClientHalf: selectedPackage.hasClientHalf,
|
||||
})) }}
|
||||
>
|
||||
<IconPlayOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && listed.activeRun !== undefined && status === 'client-pending'
|
||||
&& activePackage !== undefined && selectedPackageId === listed.activeRun.packageId && (
|
||||
<RowAction
|
||||
label={t('action.run')}
|
||||
data-cordis-switch="run"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: activePackage.packageId,
|
||||
mode: 'run',
|
||||
hasClientHalf: true,
|
||||
})) }}
|
||||
>
|
||||
<IconPlayOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && listed.activeRun !== undefined && (
|
||||
<RowAction
|
||||
label={t('action.stop')}
|
||||
data-cordis-switch="stop"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onStop(listed.agentId, pluginId)) }}
|
||||
>
|
||||
<IconStopFill16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
{awaiting === undefined && listed !== undefined && (
|
||||
<RowAction
|
||||
label={t('action.remove')}
|
||||
data-cordis-remove={pluginId}
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRemove(listed.agentId, pluginId)) }}
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</RowAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{awaiting === undefined && nextPackageId !== undefined && listed !== undefined && (
|
||||
<div className={css.transition}>
|
||||
<span>{currentPackageId === undefined ? '' : t('panel.current', { packageId: currentPackageId })}</span>
|
||||
<span>{t('panel.next', { packageId: nextPackageId })}</span>
|
||||
<div className={css.transitionActions}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: nextPackageId,
|
||||
mode: currentPackageId === undefined ? 'run' : 'update',
|
||||
hasClientHalf: packageOf(listed, nextPackageId)?.hasClientHalf === true,
|
||||
})) }}
|
||||
>{t('action.retry')}</button>
|
||||
{currentPackageId !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => { void runAction(pluginId, () => onRun({
|
||||
agentId: listed.agentId,
|
||||
pluginId,
|
||||
packageId: currentPackageId,
|
||||
mode: 'run',
|
||||
hasClientHalf: packageOf(listed, currentPackageId)?.hasClientHalf === true,
|
||||
})) }}
|
||||
>{t('action.rollback')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failure !== undefined && (
|
||||
<div className={css.rowError} role="alert">{`${failure.message} (${failure.reason})`}</div>
|
||||
)}
|
||||
{failure === undefined && hostFailure !== undefined && (
|
||||
<div className={css.rowError} role="alert">{`${hostFailure.message} (${hostFailure.phase})`}</div>
|
||||
)}
|
||||
{actionError !== undefined && <div className={css.rowError} role="alert">{actionError}</div>}
|
||||
{renderFailure !== undefined && (
|
||||
<div
|
||||
className={css.rowError}
|
||||
role="alert"
|
||||
data-cordis-render-failure={renderFailure.slot}
|
||||
data-cordis-render-abdicated={renderFailure.abdicated || undefined}
|
||||
>
|
||||
{`${t(RENDER_FAILURE_LABELS[renderFailure.abdicated ? 'abdicated' : 'held'], {
|
||||
slot: renderFailure.slot,
|
||||
})} ${renderFailure.message}`}
|
||||
</div>
|
||||
)}
|
||||
{activePackage !== undefined && activePackage.packageId !== selectedPackageId && (
|
||||
<span className={css.activeVersion}>{`${t('status.running')}: ${activePackage.name} · ${activePackage.packageId}`}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wide ? css.layer : `${css.layer} ${css.rail}`}>
|
||||
{open && (
|
||||
<section className={css.panel} data-cordis-panel aria-label={t('panel.title')}>
|
||||
<header className={css.header}>
|
||||
<span className={css.title}>{t('panel.title')}</span>
|
||||
</header>
|
||||
<div className={css.body}>
|
||||
{inventory.error !== undefined && (
|
||||
<p className={css.readError} role="alert">{t('panel.readFailed', { message: inventory.error })}</p>
|
||||
)}
|
||||
{!inventory.read && inventory.error === undefined && <p className={css.note}>{t('panel.loading')}</p>}
|
||||
{inventory.read && all.length === 0 && <p className={css.note}>{t('panel.empty')}</p>}
|
||||
{mine.length > 0 && (
|
||||
<section>
|
||||
<h3 className={css.group}>{t('panel.group.current')}</h3>
|
||||
<ul className={css.rows}>{mine.map(renderRow)}</ul>
|
||||
</section>
|
||||
)}
|
||||
{theirs.length > 0 && (
|
||||
<section>
|
||||
<h3 className={css.group}>{t('panel.group.others')}</h3>
|
||||
<ul className={css.rows}>{theirs.map(renderRow)}</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className={css.footerButtons}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.badge}
|
||||
data-cordis-badge={all.length}
|
||||
data-cordis-approval-badge={approvals}
|
||||
data-active={approvals > 0 || undefined}
|
||||
aria-label={t('panel.plugins.aria')}
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconCordisPluginOutline14 />
|
||||
{wide && (
|
||||
<>
|
||||
<span className={css.badgeLabel}>{t('panel.trigger')}</span>
|
||||
<span className={css.badgeCount}>{t('panel.runningCount', { count: running })}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
packages/extensions/ui-cordis/src/client/CordisRunRow.module.css
Normal file
132
packages/extensions/ui-cordis/src/client/CordisRunRow.module.css
Normal file
@@ -0,0 +1,132 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.summary,
|
||||
.error {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.card[data-cordis-status='awaiting-approval'] .status,
|
||||
.card[data-cordis-status='client-pending'] .status {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.card[data-cordis-status='running'] .status {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.card[data-cordis-status='failed'] .status {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.inspect {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card:hover .inspect,
|
||||
.inspect:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inspect:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.business {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.output {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.business .output {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
135
packages/extensions/ui-cordis/src/client/CordisRunRow.tsx
Normal file
135
packages/extensions/ui-cordis/src/client/CordisRunRow.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/** `cordis_run` card and the host seat for Package-owned interactive UI. */
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
IconCodeOutline16, IconInspectOutline12, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import { cordisRunCard } from './card-model.ts'
|
||||
import { cordisToolViewKey } from './run-card-index.ts'
|
||||
import type { CordisRunCardFace } from './slots.ts'
|
||||
import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts'
|
||||
import type { CordisKey } from './locales.ts'
|
||||
import css from './CordisRunRow.module.css'
|
||||
|
||||
/** Full Run-card props including its declared Package business-view child slot. */
|
||||
export type CordisRunRowProps = ToolCallViewProps
|
||||
& InjectFace<CordisRunCardFace>
|
||||
& PropsRenderSlots<'tool.view.cordis'>
|
||||
& PropsLocale<'cordis'>
|
||||
|
||||
type RunReading = CordisVisibleStatus | 'awaiting-approval' | 'failed' | 'removed' | 'superseded'
|
||||
|
||||
const READING_LABELS = {
|
||||
idle: 'status.idle',
|
||||
'awaiting-approval': 'status.awaitingApproval',
|
||||
failed: 'status.failed',
|
||||
'client-pending': 'status.clientPending',
|
||||
running: 'status.running',
|
||||
removed: 'status.removed',
|
||||
superseded: 'status.superseded',
|
||||
} as const satisfies Record<RunReading, CordisKey>
|
||||
|
||||
/** Render one activation result and, when eligible, its Package-owned view. */
|
||||
export function CordisRunRow({
|
||||
callId, block, inspect, renderSlot, useInventory, useLoaded, useRunCards, useActiveRuns,
|
||||
onObserveRunCard, t,
|
||||
}: CordisRunRowProps) {
|
||||
const card = cordisRunCard(block)
|
||||
const inventory = useInventory(snapshot => snapshot)
|
||||
const loaded = useLoaded(snapshot => snapshot)
|
||||
const latest = useRunCards(snapshot => snapshot)
|
||||
const activeRuns = useActiveRuns(snapshot => snapshot)
|
||||
const key = card.state === 'ok'
|
||||
&& card.pluginId !== null
|
||||
&& card.packageId !== null
|
||||
&& card.pluginRunId !== null
|
||||
&& card.seq !== null
|
||||
? cordisToolViewKey(card.pluginId, card.packageId)
|
||||
: null
|
||||
useEffect(() => {
|
||||
if (key === null || card.seq === null || card.pluginRunId === null) return
|
||||
onObserveRunCard({ key, callId, seq: card.seq, pluginRunId: card.pluginRunId })
|
||||
}, [callId, card.pluginRunId, card.seq, key, onObserveRunCard])
|
||||
|
||||
const row = card.pluginId === null
|
||||
? undefined
|
||||
: inventory.rows.find(candidate => candidate.pluginId === card.pluginId)
|
||||
const pointer = key === null ? undefined : latest.get(key)
|
||||
const superseded = pointer !== undefined && pointer.callId !== callId && pointer.seq >= (card.seq ?? -1)
|
||||
const activity = card.pluginId === null ? undefined : activeRuns.get(card.pluginId)
|
||||
const attempt = card.pluginRunId !== null && row?.latestRun?.pluginRunId === card.pluginRunId
|
||||
? row.latestRun
|
||||
: undefined
|
||||
const awaitingApproval = attempt?.status === 'awaiting-approval' || (card.packageId !== null
|
||||
&& activity?.phase === 'awaiting-approval'
|
||||
&& activity.packageId === card.packageId
|
||||
&& (card.mode === null || activity.mode === card.mode))
|
||||
const reading: RunReading = card.pluginId !== null && inventory.removed.has(card.pluginId)
|
||||
? 'removed'
|
||||
: superseded
|
||||
? 'superseded'
|
||||
: awaitingApproval
|
||||
? 'awaiting-approval'
|
||||
: attempt?.status === 'failed'
|
||||
? 'failed'
|
||||
: row !== undefined && card.packageId !== null
|
||||
? cordisVisibleStatus(row, card.packageId, loaded)
|
||||
: 'idle'
|
||||
const status = t(READING_LABELS[reading])
|
||||
const summary = card.errorSummary
|
||||
?? (card.pluginId === null ? callId : `${card.pluginId}${card.packageId === null ? '' : ` · ${card.packageId}`}`)
|
||||
const showBusiness = reading === 'running' && key !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.card}
|
||||
data-tool="cordis_run"
|
||||
data-state={card.state}
|
||||
data-cordis-plugin-id={card.pluginId ?? undefined}
|
||||
data-cordis-package-id={card.packageId ?? undefined}
|
||||
data-cordis-run-id={card.pluginRunId ?? undefined}
|
||||
data-cordis-status={reading}
|
||||
>
|
||||
<div className={css.row}>
|
||||
<span className={css.icon}>
|
||||
{card.state === 'error'
|
||||
? <StateDot state="error" />
|
||||
: card.state === 'stopped'
|
||||
? <StateDot state="warning" />
|
||||
: <IconCodeOutline16 size={14} />}
|
||||
</span>
|
||||
<span className={css.title}>{t(card.mode === 'update' ? 'row.updateTitle' : 'row.runTitle')}</span>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={card.errorSummary === null ? css.summary : css.error}>{summary}</span>
|
||||
<span className={css.status}>{status}</span>
|
||||
{inspect !== undefined && (
|
||||
<button type="button" className={css.inspect} aria-label="Inspect" onClick={inspect}>
|
||||
<IconInspectOutline12 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{reading === 'removed' && <div className={css.message}>{t('run.removed')}</div>}
|
||||
{reading === 'superseded' && <div className={css.message}>{t('run.superseded')}</div>}
|
||||
{reading === 'failed' && attempt?.error !== undefined && (
|
||||
<div className={css.message}>{attempt.error.message}</div>
|
||||
)}
|
||||
{showBusiness && card.pluginId !== null && card.packageId !== null && card.pluginRunId !== null && (
|
||||
<div className={css.business} data-cordis-business-view={key}>
|
||||
{renderSlot('tool.view.cordis', {
|
||||
pluginId: card.pluginId,
|
||||
packageId: card.packageId,
|
||||
pluginRunId: card.pluginRunId,
|
||||
}, {
|
||||
entryKey: key,
|
||||
fallback: card.output === null ? null : <pre className={css.output}>{card.output}</pre>,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!showBusiness && reading !== 'removed' && reading !== 'superseded' && card.output !== null && (
|
||||
<pre className={css.output}>{card.output}</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
packages/extensions/ui-cordis/src/client/card-model.ts
Normal file
161
packages/extensions/ui-cordis/src/client/card-model.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/** Replay-stable view models for Cordis lifecycle Tool calls. */
|
||||
|
||||
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, CordisDynamicRunMode,
|
||||
} from './events.ts'
|
||||
|
||||
type Block = ToolCallViewProps['block']
|
||||
|
||||
/** Lifecycle of the tool call itself. */
|
||||
export type CordisToolState = 'running' | 'ok' | 'error' | 'stopped'
|
||||
|
||||
/** Frozen `cordis_define` presentation data. */
|
||||
export interface CordisDefineCard {
|
||||
readonly pluginId: CordisDynamicPluginId | null
|
||||
readonly packageId: CordisDynamicPackageId | null
|
||||
readonly name: string | null
|
||||
readonly purpose: string | null
|
||||
readonly hostCode: string | null
|
||||
readonly clientCode: string | null
|
||||
readonly output: string | null
|
||||
readonly errorSummary: string | null
|
||||
readonly state: CordisToolState
|
||||
}
|
||||
|
||||
/** Frozen `cordis_run` presentation data. */
|
||||
export interface CordisRunCard {
|
||||
readonly pluginId: CordisDynamicPluginId | null
|
||||
readonly packageId: CordisDynamicPackageId | null
|
||||
readonly pluginRunId: CordisDynamicPluginRunId | null
|
||||
readonly mode: CordisDynamicRunMode | null
|
||||
readonly seq: number | null
|
||||
readonly output: string | null
|
||||
readonly errorSummary: string | null
|
||||
readonly state: CordisToolState
|
||||
}
|
||||
|
||||
/** Frozen `cordis_stop` or `cordis_undefine` presentation data. */
|
||||
export interface CordisActionCard {
|
||||
readonly pluginId: CordisDynamicPluginId | null
|
||||
readonly output: string | null
|
||||
readonly errorSummary: string | null
|
||||
readonly state: CordisToolState
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
const newline = text.indexOf('\n')
|
||||
return newline === -1 ? text : text.slice(0, newline)
|
||||
}
|
||||
|
||||
function stringAt(source: Record<string, unknown>, key: string): string | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'string' && value !== '' ? value : null
|
||||
}
|
||||
|
||||
function objectAt(source: Record<string, unknown>, key: string): Record<string, unknown> | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : null
|
||||
}
|
||||
|
||||
function parseArgs(argsRaw: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(argsRaw) as unknown
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null
|
||||
} catch {
|
||||
// Running calls can expose a truncated JSON prefix.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resultText(block: Extract<Block, { kind: 'tool-result' }>): string | null {
|
||||
const text = block.content
|
||||
.map(item => item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
|
||||
.join('\n')
|
||||
if (text !== '') return text
|
||||
return block.error === undefined ? null : `${block.error.name}: ${block.error.code}`
|
||||
}
|
||||
|
||||
function stateOf(block: Block): CordisToolState {
|
||||
if (!('kind' in block)) return 'running'
|
||||
if (block.error?.code === 'interrupted') return 'stopped'
|
||||
return block.isError ? 'error' : 'ok'
|
||||
}
|
||||
|
||||
function metaObject(block: Block): Record<string, unknown> | null {
|
||||
if (!('kind' in block) || block.isError || typeof block.meta !== 'object' || block.meta === null) return null
|
||||
return block.meta as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one Define card from its frozen call/result slice.
|
||||
* @param block - active or settled tool-call block.
|
||||
* @returns normalized Define card fields.
|
||||
*/
|
||||
export function cordisDefineCard(block: Block): CordisDefineCard {
|
||||
const settled = 'kind' in block
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const args = parseArgs(argsRaw)
|
||||
const code = args === null ? null : objectAt(args, 'code')
|
||||
const state = stateOf(block)
|
||||
const output = settled ? resultText(block) : null
|
||||
const meta = metaObject(block)
|
||||
const rawName = argsRaw === '' ? null : firstLine(argsRaw)
|
||||
return {
|
||||
pluginId: meta === null ? null : stringAt(meta, 'pluginId') as CordisDynamicPluginId | null,
|
||||
packageId: meta === null ? null : stringAt(meta, 'packageId') as CordisDynamicPackageId | null,
|
||||
name: args === null ? rawName : stringAt(args, 'name') ?? rawName,
|
||||
purpose: args === null ? null : stringAt(args, 'purpose'),
|
||||
hostCode: code === null ? null : stringAt(code, 'host'),
|
||||
clientCode: code === null ? null : stringAt(code, 'client'),
|
||||
output,
|
||||
errorSummary: state === 'error' && output !== null ? firstLine(output) : null,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one Run card and its successful activation metadata.
|
||||
* @param block - active or settled tool-call block.
|
||||
* @returns normalized Run card fields.
|
||||
*/
|
||||
export function cordisRunCard(block: Block): CordisRunCard {
|
||||
const settled = 'kind' in block
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const args = parseArgs(argsRaw)
|
||||
const meta = metaObject(block)
|
||||
const state = stateOf(block)
|
||||
const output = settled ? resultText(block) : null
|
||||
const rawMode = args === null ? null : stringAt(args, 'mode')
|
||||
const argsPluginId = args === null ? null : stringAt(args, 'pluginId')
|
||||
const argsPackageId = args === null ? null : stringAt(args, 'packageId')
|
||||
return {
|
||||
pluginId: (meta === null ? argsPluginId : stringAt(meta, 'pluginId') ?? argsPluginId) as CordisDynamicPluginId | null,
|
||||
packageId: (meta === null ? argsPackageId : stringAt(meta, 'packageId') ?? argsPackageId) as CordisDynamicPackageId | null,
|
||||
pluginRunId: (meta === null ? null : stringAt(meta, 'pluginRunId')) as CordisDynamicPluginRunId | null,
|
||||
mode: rawMode === 'run' || rawMode === 'update' ? rawMode : null,
|
||||
seq: settled ? block.seq : null,
|
||||
output,
|
||||
errorSummary: state === 'error' && output !== null ? firstLine(output) : null,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one Stop or Remove card from its frozen call/result slice.
|
||||
* @param block - active or settled tool-call block.
|
||||
* @returns normalized lifecycle-action card fields.
|
||||
*/
|
||||
export function cordisActionCard(block: Block): CordisActionCard {
|
||||
const settled = 'kind' in block
|
||||
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const args = parseArgs(argsRaw)
|
||||
const state = stateOf(block)
|
||||
const output = settled ? resultText(block) : null
|
||||
return {
|
||||
pluginId: (args === null ? null : stringAt(args, 'pluginId') ?? stringAt(args, 'id')) as CordisDynamicPluginId | null,
|
||||
output,
|
||||
errorSummary: state === 'error' && output !== null ? firstLine(output) : null,
|
||||
state,
|
||||
}
|
||||
}
|
||||
24
packages/extensions/ui-cordis/src/client/dynamic-port.ts
Normal file
24
packages/extensions/ui-cordis/src/client/dynamic-port.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Host operations used directly by the frame-wide Cordis panel. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
CordisDynamicPluginId, DynamicCordisInventoryRow,
|
||||
} from './events.ts'
|
||||
|
||||
/** Result of a panel lifecycle gesture. */
|
||||
export type CordisActionResult =
|
||||
| { readonly ok: true }
|
||||
| { readonly ok: false; readonly message: string }
|
||||
|
||||
/** RPC seam kept outside the React surface. */
|
||||
export interface CordisDynamicPort {
|
||||
/** Stop one Plugin while retaining its immutable Packages. */
|
||||
stop(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
/** Stop and remove one Plugin together with every Package. */
|
||||
remove(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
/** Read the frame-wide Plugin inventory. */
|
||||
inventory(): Promise<readonly DynamicCordisInventoryRow[]>
|
||||
}
|
||||
|
||||
/** One stable Plugin row as the panel receives it. */
|
||||
export type CordisInventoryRow = DynamicCordisInventoryRow
|
||||
17
packages/extensions/ui-cordis/src/client/events.ts
Normal file
17
packages/extensions/ui-cordis/src/client/events.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Client-safe dynamic Cordis vocabulary re-exported through the Remote assembly. */
|
||||
|
||||
// Type-only: merges `ctx.remote` and the forwarded-event key set into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
export type {
|
||||
ApprovalRequestId,
|
||||
CordisDynamicPackageId,
|
||||
CordisDynamicPluginId,
|
||||
CordisDynamicPluginRunId,
|
||||
CordisDynamicRunMode,
|
||||
DynamicCordisInventoryRow,
|
||||
DynamicCordisPackage,
|
||||
DynamicCordisRequestResolved,
|
||||
DynamicCordisRetracted,
|
||||
DynamicCordisRunRequest,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
171
packages/extensions/ui-cordis/src/client/index.ts
Normal file
171
packages/extensions/ui-cordis/src/client/index.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/** Cordis dynamic-plugin cards, inventory panel, business-view host, and `@pluginId` source. */
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { InputTriggerService, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type {} from './events.ts'
|
||||
import { CordisActionRow } from './CordisActionRow.tsx'
|
||||
import { CordisDefineRow } from './CordisDefineRow.tsx'
|
||||
import { CordisRunRow } from './CordisRunRow.tsx'
|
||||
import { CordisPanel } from './CordisPanel.tsx'
|
||||
import { createCordisInventory } from './inventory.ts'
|
||||
import { CordisRunCardRegistry } from './run-card-index.ts'
|
||||
import type { CordisDynamicPort } from './dynamic-port.ts'
|
||||
import type { CordisCardFace, CordisPanelFace, CordisRunCardFace } from './slots.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
|
||||
export type { CordisCardFace, CordisPanelFace, CordisRunCardFace, CordisToolViewOwnerProps } from './slots.ts'
|
||||
export type { CordisActionResult, CordisDynamicPort, CordisInventoryRow } from './dynamic-port.ts'
|
||||
export type { CordisDefineRowProps } from './CordisDefineRow.tsx'
|
||||
export type { CordisActionRowProps } from './CordisActionRow.tsx'
|
||||
export type { CordisRunRowProps } from './CordisRunRow.tsx'
|
||||
export type {
|
||||
CordisRunCardPointer, CordisRunCardStore, CordisToolViewKey,
|
||||
} from './run-card-index.ts'
|
||||
export type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
DynamicCordisInventoryRow, DynamicCordisPackage, DynamicCordisRetracted,
|
||||
} from './events.ts'
|
||||
export type { CordisKey } from './locales.ts'
|
||||
|
||||
/** Required services for the two Tool cards, panel, Remote lifecycle, and Slash source. */
|
||||
export const inject = [
|
||||
'slots', 'locale', 'inputTriggers', 'remote', 'remote.dynamicCordisRunner', 'dynamicCordisRunner',
|
||||
]
|
||||
|
||||
/** Mount every Cordis browser surface over the shared Host inventory. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-cordis: dictionaries')
|
||||
|
||||
const port: CordisDynamicPort = {
|
||||
stop: async (sessionId, pluginId) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.stopFromPanel(sessionId, pluginId)
|
||||
if (!answered.ok) return { ok: false, message: `${answered.error.code}: ${answered.error.message}` }
|
||||
if (answered.value.ok || answered.value.reason === 'not-running') return { ok: true }
|
||||
return { ok: false, message: answered.value.message }
|
||||
},
|
||||
remove: async (sessionId, pluginId) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.undefineFromPanel(sessionId, pluginId)
|
||||
if (!answered.ok) return { ok: false, message: `${answered.error.code}: ${answered.error.message}` }
|
||||
return answered.value.ok ? { ok: true } : { ok: false, message: answered.value.message }
|
||||
},
|
||||
inventory: async () => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.inventory()
|
||||
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
|
||||
return answered.value
|
||||
},
|
||||
}
|
||||
const inventory = createCordisInventory(port, (error) => {
|
||||
console.error('[ui-cordis] reading the Cordis inventory failed:', error)
|
||||
})
|
||||
const runner = ctx.dynamicCordisRunner
|
||||
const loaded = { getSnapshot: () => runner.getSnapshot(), subscribe: (fn: () => void) => runner.subscribe(fn) }
|
||||
const runCards = new CordisRunCardRegistry()
|
||||
|
||||
ctx.effect(() => inventory.subscribe(() => {
|
||||
const snapshot = inventory.getSnapshot()
|
||||
if (snapshot.read) runner.reconcileApprovals(snapshot.rows)
|
||||
}), 'ui-cordis: reconcile pending approvals')
|
||||
|
||||
ctx.remote.$on('cordis/dynamic-package', () => { inventory.refresh() })
|
||||
ctx.remote.$on('cordis/dynamic-retract', () => { inventory.refresh() })
|
||||
ctx.remote.$on('cordis/request-run', (request) => {
|
||||
if (!inventory.getSnapshot().rows.some(row => row.pluginId === request.pluginId)) inventory.refresh()
|
||||
})
|
||||
ctx.remote.$on('cordis/request-run-resolved', () => { inventory.refresh() })
|
||||
ctx.on('connection/reset', () => {
|
||||
inventory.reset()
|
||||
inventory.refresh()
|
||||
})
|
||||
|
||||
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
||||
name: 'sidebar.footer.action',
|
||||
id: 'cordis-panel',
|
||||
locale: NS,
|
||||
inject: (): CordisPanelFace => ({
|
||||
hooks: {
|
||||
inventory,
|
||||
activeRuns: runner.activeRuns,
|
||||
runErrors: runner.lastRunError,
|
||||
loaded,
|
||||
renderFailures: runner.renderFailures,
|
||||
},
|
||||
onApprove: (requestId, approveFutureVersions) => runner.approve(requestId, approveFutureVersions),
|
||||
onDecline: requestId => runner.decline(requestId),
|
||||
onRun: request => runner.startUserRun(request),
|
||||
onStop: async (sessionId, pluginId) => {
|
||||
const result = await port.stop(sessionId, pluginId)
|
||||
inventory.refresh()
|
||||
return result
|
||||
},
|
||||
onRemove: async (sessionId, pluginId) => {
|
||||
const result = await port.remove(sessionId, pluginId)
|
||||
if (result.ok) inventory.retire(pluginId)
|
||||
inventory.refresh()
|
||||
return result
|
||||
},
|
||||
onRefresh: () => { inventory.refresh() },
|
||||
}),
|
||||
}, CordisPanel))
|
||||
|
||||
const cardFace = (): CordisCardFace => ({ hooks: { inventory, loaded } })
|
||||
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
||||
name: 'tool.call.toolview',
|
||||
key: 'cordis_define',
|
||||
locale: NS,
|
||||
inject: cardFace,
|
||||
}, CordisDefineRow))
|
||||
|
||||
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
|
||||
name: 'tool.call.toolview',
|
||||
key: 'cordis_run',
|
||||
locale: NS,
|
||||
children: { 'tool.view.cordis': { kind: 'keyed', scope: 'session' } },
|
||||
inject: (sessionId: SessionId): CordisRunCardFace => {
|
||||
const store = runCards.forSession(sessionId)
|
||||
return {
|
||||
hooks: { inventory, loaded, runCards: store, activeRuns: runner.activeRuns },
|
||||
onObserveRunCard: (pointer) => { store.observe(pointer) },
|
||||
}
|
||||
},
|
||||
}, CordisRunRow))
|
||||
|
||||
ctx.slots.inject('tool.call.toolview', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'tool.call.toolview', key: 'cordis_stop', locale: NS,
|
||||
}, CordisActionRow)
|
||||
yield ctx.slots.register({
|
||||
name: 'tool.call.toolview', key: 'cordis_undefine', locale: NS,
|
||||
}, CordisActionRow)
|
||||
})
|
||||
|
||||
const rowsOf = (sessionId: SessionId, query: string) => inventory.getSnapshot().rows
|
||||
.filter(row => row.agentId === sessionId && String(row.pluginId).includes(query))
|
||||
const source: InputTriggerSource = {
|
||||
trigger: '@',
|
||||
name: 'cordis',
|
||||
order: 1,
|
||||
candidates(session, { query }) {
|
||||
const rows = rowsOf(session.sessionId, query)
|
||||
return Promise.resolve(rows.map((row) => {
|
||||
const packageId = row.nextPackageId ?? row.currentPackageId ?? row.packages.at(-1)?.packageId
|
||||
const pkg = packageId === undefined ? undefined : row.packages.find(candidate => candidate.packageId === packageId)
|
||||
return {
|
||||
name: String(row.pluginId),
|
||||
...pkg === undefined ? {} : { description: pkg.purpose },
|
||||
}
|
||||
}))
|
||||
},
|
||||
warm() { inventory.refresh() },
|
||||
lexicon(session) { return rowsOf(session.sessionId, '').map(row => String(row.pluginId)) },
|
||||
subscribeLexicon(_session, listener) { return inventory.subscribe(listener) },
|
||||
onPick({ candidate }) { return { text: `@${candidate.name} ` } },
|
||||
}
|
||||
const slash = ctx.get('inputTriggers') as InputTriggerService
|
||||
ctx.effect(() => slash.registerSource(source), 'ui-cordis: @pluginId source')
|
||||
|
||||
inventory.refresh()
|
||||
}
|
||||
113
packages/extensions/ui-cordis/src/client/inventory.ts
Normal file
113
packages/extensions/ui-cordis/src/client/inventory.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The host's definition registry as this page last read it, owned by the
|
||||
* plugin's apply closure.
|
||||
*
|
||||
* The panel is a frame-wide surface, so it cannot derive this from any session:
|
||||
* the registry is global and the read is a single global call. The rows are
|
||||
* re-read rather than patched, because the wire announcements
|
||||
* (`cordis/dynamic-package` / `/retract`) carry no labels and a definition
|
||||
* can appear or disappear between them — a patch-in-place cache would drift into
|
||||
* showing definitions the host no longer holds.
|
||||
*
|
||||
* Reads are single-flight: several announcements settling at once, or a badge
|
||||
* opening while a reconnect re-reads, must not multiply the call. Single-flight
|
||||
* alone would be wrong across a reconnect, though — the in-flight read belongs to
|
||||
* the previous connection, so a reset both discards its answer and frees the slot
|
||||
* for a fresh one. Without that, a reconnect either loses its re-read to the old
|
||||
* call or has the old host's rows published on top of it.
|
||||
*/
|
||||
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CordisDynamicPort, CordisInventoryRow } from './dynamic-port.ts'
|
||||
import type { CordisDynamicPluginId } from './events.ts'
|
||||
|
||||
/** What the panel reads: the rows, and whether the first read has happened. */
|
||||
export interface CordisInventorySnapshot {
|
||||
readonly rows: readonly CordisInventoryRow[]
|
||||
/** Plugins explicitly removed through this page, retained for historical cards. */
|
||||
readonly removed: ReadonlySet<CordisDynamicPluginId>
|
||||
/**
|
||||
* False until a read settles. The panel shows a loading line rather than an
|
||||
* empty state, so "nothing defined yet" is never claimed before it is known.
|
||||
*/
|
||||
readonly read: boolean
|
||||
/** Last read failure, so the panel can say why it is empty. */
|
||||
readonly error?: string | undefined
|
||||
}
|
||||
|
||||
/** Inventory source: an observable of the rows plus the read trigger. */
|
||||
export interface CordisInventory extends HostObservable<CordisInventorySnapshot> {
|
||||
/** Read the registry unless a read is already in flight. */
|
||||
refresh(): void
|
||||
/** Record an explicit remove and drop the live row immediately. */
|
||||
retire(pluginId: CordisDynamicPluginId): void
|
||||
/** Drop what was read; the next refresh starts from nothing (a reconnect may be a new host). */
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the inventory source.
|
||||
* @param port - the RPC seam the read goes through.
|
||||
* @param onError - reporter for a failed read (console in production, captured in specs).
|
||||
* @returns the inventory observable and its read trigger.
|
||||
*/
|
||||
export function createCordisInventory(
|
||||
port: CordisDynamicPort,
|
||||
onError: (error: unknown) => void,
|
||||
): CordisInventory {
|
||||
const listeners = new Set<() => void>()
|
||||
let snapshot: CordisInventorySnapshot = { rows: [], removed: new Set(), read: false }
|
||||
let inFlight: Promise<void> | undefined
|
||||
// Bumped by reset; a read whose generation is stale publishes nothing.
|
||||
let generation = 0
|
||||
|
||||
const publish = (next: CordisInventorySnapshot): void => {
|
||||
snapshot = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn) => {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
},
|
||||
refresh: () => {
|
||||
if (inFlight !== undefined) return
|
||||
const issued = generation
|
||||
inFlight = port.inventory().then(
|
||||
(rows) => {
|
||||
if (issued !== generation) return
|
||||
const removed = new Set(snapshot.removed)
|
||||
const live = new Set(rows.map(row => row.pluginId))
|
||||
for (const previous of snapshot.rows) {
|
||||
if (!live.has(previous.pluginId)) removed.add(previous.pluginId)
|
||||
}
|
||||
publish({ rows, removed, read: true })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (issued !== generation) return
|
||||
onError(error)
|
||||
// A failed read keeps whatever was shown and says why: dropping the
|
||||
// rows would turn a transient wire failure into "nothing is defined".
|
||||
publish({
|
||||
rows: snapshot.rows,
|
||||
removed: snapshot.removed,
|
||||
read: snapshot.read,
|
||||
error: error instanceof Error ? error.message : 'reading the cordis inventory failed',
|
||||
})
|
||||
},
|
||||
).then(() => { if (issued === generation) inFlight = undefined })
|
||||
},
|
||||
retire: (pluginId) => {
|
||||
const removed = new Set(snapshot.removed)
|
||||
removed.add(pluginId)
|
||||
publish({ ...snapshot, rows: snapshot.rows.filter(row => row.pluginId !== pluginId), removed })
|
||||
},
|
||||
reset: () => {
|
||||
generation += 1
|
||||
inFlight = undefined
|
||||
publish({ rows: [], removed: snapshot.removed, read: false })
|
||||
},
|
||||
}
|
||||
}
|
||||
119
packages/extensions/ui-cordis/src/client/locales.ts
Normal file
119
packages/extensions/ui-cordis/src/client/locales.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/** Cordis dynamic-plugin UI dictionaries. */
|
||||
|
||||
export const NS = 'cordis'
|
||||
|
||||
/** Simplified Chinese Cordis UI messages. */
|
||||
export const zh = {
|
||||
'row.defineTitle': '注册 Cordis 插件',
|
||||
'row.runTitle': '运行 Cordis 插件',
|
||||
'row.updateTitle': '更新 Cordis 插件',
|
||||
'row.stopTitle': '停止 Cordis 插件',
|
||||
'row.removeTitle': '移除 Cordis 插件',
|
||||
'purpose.missing': '(未填写用途)',
|
||||
'status.idle': '待激活',
|
||||
'status.awaitingApproval': '待审批',
|
||||
'status.failed': '运行失败',
|
||||
'status.clientPending': 'Client 待激活',
|
||||
'status.running': '运行中',
|
||||
'status.removed': '已移除',
|
||||
'status.superseded': '已有更新',
|
||||
'run.removed': '包已不存在',
|
||||
'run.superseded': '已有更新的运行卡片,请查看下方',
|
||||
'panel.hint': '运行控制在左下角设置上方的 Cordis 面板',
|
||||
'panel.plugins.aria': 'Cordis 插件',
|
||||
'panel.approvals.aria': 'Cordis 审批',
|
||||
'panel.trigger': 'Cordis Plugin',
|
||||
'panel.runningCount': '{count} running',
|
||||
'panel.title': 'Cordis 插件',
|
||||
'panel.empty': '还没有定义任何插件',
|
||||
'panel.loading': '读取中…',
|
||||
'panel.readFailed': '读取插件清单失败:{message}',
|
||||
'panel.group.current': '当前会话',
|
||||
'panel.group.others': '其他会话',
|
||||
'panel.version': '版本',
|
||||
'panel.current': '当前:{packageId}',
|
||||
'panel.next': '待切换:{packageId}',
|
||||
'action.approve': '允许',
|
||||
'action.approveOnce': '仅允许此版本',
|
||||
'action.approvePlugin': '允许此插件的后续版本',
|
||||
'action.decline': '拒绝',
|
||||
'action.run': '运行',
|
||||
'action.stop': '停止',
|
||||
'action.remove': '移除',
|
||||
'action.retry': '重试',
|
||||
'action.rollback': '回退',
|
||||
'render.failedAbdicated': '{slot} 渲染失败,已恢复默认界面:',
|
||||
'render.failedHeld': '{slot} 渲染失败:',
|
||||
'a11y.defining': '正在定义插件',
|
||||
'a11y.failed': '定义失败',
|
||||
'a11y.stopped': '定义已中断',
|
||||
'body.source': '插件代码',
|
||||
'body.hostCode': 'Host',
|
||||
'body.clientCode': 'Client',
|
||||
'body.output': '结果',
|
||||
'body.copy': '复制',
|
||||
'body.copied': '已复制',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Translation keys owned by the Cordis UI namespace. */
|
||||
export type CordisKey = keyof typeof zh
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Dynamic Cordis UI copy. */
|
||||
cordis: CordisKey
|
||||
}
|
||||
}
|
||||
|
||||
/** English Cordis UI messages. */
|
||||
export const en = {
|
||||
'row.defineTitle': 'Register Cordis Plugin',
|
||||
'row.runTitle': 'Run Cordis Plugin',
|
||||
'row.updateTitle': 'Update Cordis Plugin',
|
||||
'row.stopTitle': 'Stop Cordis Plugin',
|
||||
'row.removeTitle': 'Remove Cordis Plugin',
|
||||
'purpose.missing': '(no purpose given)',
|
||||
'status.idle': 'Ready',
|
||||
'status.awaitingApproval': 'Awaiting approval',
|
||||
'status.failed': 'Run failed',
|
||||
'status.clientPending': 'Client ready to activate',
|
||||
'status.running': 'Running',
|
||||
'status.removed': 'Removed',
|
||||
'status.superseded': 'Newer run available',
|
||||
'run.removed': 'This package no longer exists',
|
||||
'run.superseded': 'A newer run card is available below',
|
||||
'panel.hint': 'Run controls live in the Cordis panel above Settings',
|
||||
'panel.plugins.aria': 'Cordis plugins',
|
||||
'panel.approvals.aria': 'Cordis approvals',
|
||||
'panel.trigger': 'Cordis Plugin',
|
||||
'panel.runningCount': '{count} running',
|
||||
'panel.title': 'Cordis plugins',
|
||||
'panel.empty': 'No plugins defined yet',
|
||||
'panel.loading': 'Reading…',
|
||||
'panel.readFailed': 'Reading the plugin inventory failed: {message}',
|
||||
'panel.group.current': 'This session',
|
||||
'panel.group.others': 'Other sessions',
|
||||
'panel.version': 'Version',
|
||||
'panel.current': 'Current: {packageId}',
|
||||
'panel.next': 'Next: {packageId}',
|
||||
'action.approve': 'Allow',
|
||||
'action.approveOnce': 'Allow this version only',
|
||||
'action.approvePlugin': 'Allow future versions of this plugin',
|
||||
'action.decline': 'Decline',
|
||||
'action.run': 'Run',
|
||||
'action.stop': 'Stop',
|
||||
'action.remove': 'Remove',
|
||||
'action.retry': 'Retry',
|
||||
'action.rollback': 'Roll back',
|
||||
'render.failedAbdicated': 'Rendering failed in {slot}; the default UI was restored:',
|
||||
'render.failedHeld': 'Rendering failed in {slot}:',
|
||||
'a11y.defining': 'Defining the plugin',
|
||||
'a11y.failed': 'Definition failed',
|
||||
'a11y.stopped': 'Definition interrupted',
|
||||
'body.source': 'Plugin source',
|
||||
'body.hostCode': 'Host',
|
||||
'body.clientCode': 'Client',
|
||||
'body.output': 'Result',
|
||||
'body.copy': 'Copy',
|
||||
'body.copied': 'Copied',
|
||||
} satisfies Record<CordisKey, string>
|
||||
76
packages/extensions/ui-cordis/src/client/run-card-index.ts
Normal file
76
packages/extensions/ui-cordis/src/client/run-card-index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/** Session-local ownership index for Package business views on `cordis_run` cards. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
} from './events.ts'
|
||||
|
||||
/** Stable keyed-slot identity of one Package-owned business view. */
|
||||
export type CordisToolViewKey = `${CordisDynamicPluginId}.${CordisDynamicPackageId}`
|
||||
|
||||
/** One successful tool result competing to host a Package business view. */
|
||||
export interface CordisRunCardPointer {
|
||||
readonly key: CordisToolViewKey
|
||||
readonly callId: string
|
||||
readonly seq: number
|
||||
readonly pluginRunId: CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
/** Per-session observable index consumed by every mounted Run card. */
|
||||
export interface CordisRunCardStore extends HostObservable<ReadonlyMap<CordisToolViewKey, CordisRunCardPointer>> {
|
||||
/** Publish one successful Run result; only a greater log sequence can replace it. */
|
||||
observe(pointer: CordisRunCardPointer): void
|
||||
}
|
||||
|
||||
function createStore(): CordisRunCardStore {
|
||||
const pointers = new Map<CordisToolViewKey, CordisRunCardPointer>()
|
||||
const listeners = new Set<() => void>()
|
||||
let cache: ReadonlyMap<CordisToolViewKey, CordisRunCardPointer> | undefined
|
||||
return {
|
||||
getSnapshot: () => cache ??= new Map(pointers),
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
observe: (pointer) => {
|
||||
const current = pointers.get(pointer.key)
|
||||
if (current !== undefined && current.seq >= pointer.seq) return
|
||||
pointers.set(pointer.key, pointer)
|
||||
cache = undefined
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Page-lifetime registry that gives all cards of one session the same Store. */
|
||||
export class CordisRunCardRegistry {
|
||||
private readonly sessions = new Map<SessionId, CordisRunCardStore>()
|
||||
|
||||
/**
|
||||
* Return the persistent page-local Store for a session.
|
||||
* @param sessionId - session whose cards share supersession state.
|
||||
* @returns the page-local Store retained for that session.
|
||||
*/
|
||||
forSession(sessionId: SessionId): CordisRunCardStore {
|
||||
let store = this.sessions.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = createStore()
|
||||
this.sessions.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Package business-view key shared by registrations and Run cards.
|
||||
* @param pluginId - stable Plugin identity.
|
||||
* @param packageId - immutable Package identity.
|
||||
* @returns the shared business-view key.
|
||||
*/
|
||||
export function cordisToolViewKey(
|
||||
pluginId: CordisDynamicPluginId,
|
||||
packageId: CordisDynamicPackageId,
|
||||
): CordisToolViewKey {
|
||||
return `${pluginId}.${packageId}`
|
||||
}
|
||||
72
packages/extensions/ui-cordis/src/client/slots.ts
Normal file
72
packages/extensions/ui-cordis/src/client/slots.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/** Injected faces and the Package-owned `tool.view.cordis` slot declaration. */
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
CordisRunActivity, CordisRunFailure, CordisUserRunRequest, DynamicCordisLivePackage,
|
||||
DynamicCordisRenderFailure,
|
||||
} from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type { CordisActionResult } from './dynamic-port.ts'
|
||||
import type { CordisInventory } from './inventory.ts'
|
||||
import type { CordisRunCardPointer, CordisRunCardStore } from './run-card-index.ts'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
} from './events.ts'
|
||||
|
||||
/** Owner currency delivered to a dynamic Package's business view. */
|
||||
export interface CordisToolViewOwnerProps {
|
||||
readonly pluginId: CordisDynamicPluginId
|
||||
readonly packageId: CordisDynamicPackageId
|
||||
readonly pluginRunId: CordisDynamicPluginRunId
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* Interactive Package-owned region rendered inside the latest eligible
|
||||
* `cordis_run` card in the conversation flow. Use it for controls and other
|
||||
* UI the user can interact with. Dynamic Client code registers with
|
||||
* `key: 'self'`; the Guard binds that key to the current Plugin and Package.
|
||||
*/
|
||||
'tool.view.cordis': {
|
||||
kind: 'keyed'
|
||||
scope: 'session'
|
||||
owner: CordisToolViewOwnerProps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Live facts used by the read-only Define card. */
|
||||
export interface CordisCardFace {
|
||||
hooks: {
|
||||
inventory: CordisInventory
|
||||
loaded: HostObservable<readonly DynamicCordisLivePackage[]>
|
||||
}
|
||||
}
|
||||
|
||||
/** Live facts used by the Run card and its business-view ownership index. */
|
||||
export interface CordisRunCardFace extends CordisCardFace {
|
||||
hooks: CordisCardFace['hooks'] & {
|
||||
runCards: CordisRunCardStore
|
||||
activeRuns: HostObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>>
|
||||
}
|
||||
/** Publish this successful result into the session's latest-card index. */
|
||||
onObserveRunCard(pointer: CordisRunCardPointer): void
|
||||
}
|
||||
|
||||
/** Frame-wide panel state and lifecycle verbs. */
|
||||
export interface CordisPanelFace {
|
||||
hooks: {
|
||||
inventory: CordisInventory
|
||||
activeRuns: HostObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>>
|
||||
runErrors: HostObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>>
|
||||
renderFailures: HostObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>>
|
||||
loaded: HostObservable<readonly DynamicCordisLivePackage[]>
|
||||
}
|
||||
onApprove(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void>
|
||||
onDecline(requestId: ApprovalRequestId): Promise<void>
|
||||
onRun(request: CordisUserRunRequest): Promise<void>
|
||||
onStop(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
onRemove(sessionId: SessionId, pluginId: CordisDynamicPluginId): Promise<CordisActionResult>
|
||||
onRefresh(): void
|
||||
}
|
||||
45
packages/extensions/ui-cordis/src/client/status.ts
Normal file
45
packages/extensions/ui-cordis/src/client/status.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** Shared status derivation over Host inventory and this page's Client live set. */
|
||||
|
||||
import type { DynamicCordisLivePackage } from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type {
|
||||
CordisDynamicPackageId, DynamicCordisInventoryRow,
|
||||
} from './events.ts'
|
||||
|
||||
/** The three product-visible lifecycle readings. */
|
||||
export type CordisVisibleStatus = 'idle' | 'client-pending' | 'running'
|
||||
|
||||
/**
|
||||
* Locate one immutable Package inside a Plugin row.
|
||||
* @param row - owning Plugin inventory row.
|
||||
* @param packageId - immutable Package identity to locate.
|
||||
* @returns the matching Package metadata, or `undefined` when absent.
|
||||
*/
|
||||
export function packageOf(
|
||||
row: DynamicCordisInventoryRow,
|
||||
packageId: CordisDynamicPackageId,
|
||||
): DynamicCordisInventoryRow['packages'][number] | undefined {
|
||||
return row.packages.find(pkg => pkg.packageId === packageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the visible state of one Package.
|
||||
* @param row - owning Plugin inventory row.
|
||||
* @param packageId - Package being described.
|
||||
* @param loaded - Client activations loaded in this page.
|
||||
* @returns idle, Host-running/Client-pending, or fully running.
|
||||
*/
|
||||
export function cordisVisibleStatus(
|
||||
row: DynamicCordisInventoryRow,
|
||||
packageId: CordisDynamicPackageId,
|
||||
loaded: readonly DynamicCordisLivePackage[],
|
||||
): CordisVisibleStatus {
|
||||
const run = row.activeRun
|
||||
if (run === undefined || run.packageId !== packageId) return 'idle'
|
||||
const pkg = packageOf(row, packageId)
|
||||
if (pkg?.hasClientHalf !== true) return 'running'
|
||||
return loaded.some(live => live.pluginId === row.pluginId
|
||||
&& live.packageId === packageId
|
||||
&& live.pluginRunId === run.pluginRunId)
|
||||
? 'running'
|
||||
: 'client-pending'
|
||||
}
|
||||
6
packages/extensions/ui-cordis/src/css-modules.d.ts
vendored
Normal file
6
packages/extensions/ui-cordis/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/extensions/ui-cordis/src/index.ts
Normal file
9
packages/extensions/ui-cordis/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Cordis dynamic-plugin card, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
33
packages/extensions/ui-cordis/src/invariant.ts
Normal file
33
packages/extensions/ui-cordis/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-cordis`.
|
||||
* @module @deepseek-ai/dsh-client-ui-cordis/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-client-ui-cordis'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-cordis-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single keyed toolview registration whose disposal is
|
||||
* proven by the HMR-safety spec. The one mutable relation this package owns —
|
||||
* the per-definition run-state observable — lives in the browser process, out
|
||||
* of reach of the host invariant service, and the node half emits no cordis
|
||||
* events and holds no cross-plugin state.
|
||||
*/
|
||||
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 */
|
||||
105
packages/extensions/ui-cordis/tests/card-model.client.spec.ts
Normal file
105
packages/extensions/ui-cordis/tests/card-model.client.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Card view model: what a definition card can and cannot derive from the frozen
|
||||
// call/result slice.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { cordisActionCard, cordisDefineCard } from '../src/client/card-model.ts'
|
||||
|
||||
const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return {}","host":"harness.handle(\'now\', () => Date.now())"}}'
|
||||
|
||||
function running(over: Partial<RunningToolCall> = {}): RunningToolCall {
|
||||
return {
|
||||
callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000,
|
||||
callView: null, subCalls: [], ...over,
|
||||
}
|
||||
}
|
||||
|
||||
function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
|
||||
return {
|
||||
kind: 'tool-result', seq: 2, time: 2_000, callId: 'call-1',
|
||||
call: { name: 'cordis_define', argsRaw: ARGS }, callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'defined dyn-1' }], isError: false,
|
||||
meta: { pluginId: 'dyn-1', packageId: 'pkg-1' }, callView: null, resultView: null, subCalls: [], ...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('cordisDefineCard', () => {
|
||||
it('reads name, purpose and both code halves off the call arguments', () => {
|
||||
const card = cordisDefineCard(running())
|
||||
expect(card).toMatchObject({
|
||||
name: 'clock', purpose: '顶栏时钟', clientCode: 'return {}', state: 'running', output: null,
|
||||
})
|
||||
expect(card.hostCode).toContain('harness.handle')
|
||||
// The host mints the id during define, so an unsettled call has none and the
|
||||
// card renders read-only.
|
||||
expect(card.pluginId).toBeNull()
|
||||
expect(card.packageId).toBeNull()
|
||||
})
|
||||
|
||||
it('takes the minted id from the result presentation meta', () => {
|
||||
expect(cordisDefineCard(settled()).pluginId).toBe('dyn-1')
|
||||
expect(cordisDefineCard(settled()).packageId).toBe('pkg-1')
|
||||
expect(cordisDefineCard(settled()).output).toBe('defined dyn-1')
|
||||
expect(cordisDefineCard(settled()).state).toBe('ok')
|
||||
})
|
||||
|
||||
it('renders read-only when the meta carries no usable id', () => {
|
||||
expect(cordisDefineCard(settled({ meta: undefined })).pluginId).toBeNull()
|
||||
expect(cordisDefineCard(settled({ meta: 'dyn-1' })).pluginId).toBeNull()
|
||||
expect(cordisDefineCard(settled({ meta: { pluginId: '' } })).pluginId).toBeNull()
|
||||
expect(cordisDefineCard(settled({ meta: { pluginId: 7 } })).pluginId).toBeNull()
|
||||
})
|
||||
|
||||
it('classifies the define call’s own lifecycle and never operates a failed one', () => {
|
||||
const failed = cordisDefineCard(settled({
|
||||
isError: true, content: [{ type: 'text', text: 'SyntaxError: unexpected token\n at line 3' }],
|
||||
}))
|
||||
expect(failed.state).toBe('error')
|
||||
expect(failed.errorSummary).toBe('SyntaxError: unexpected token')
|
||||
// A definition that failed to register has nothing to run.
|
||||
expect(failed.pluginId).toBeNull()
|
||||
|
||||
expect(cordisDefineCard(settled({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
|
||||
expect(cordisDefineCard(settled({ content: [] })).output).toBeNull()
|
||||
expect(cordisDefineCard(settled({ content: [], error: { name: 'E', code: 'boom' } })).output).toBe('E: boom')
|
||||
// A non-text block has no display text of its own, so the row shows its JSON.
|
||||
expect(cordisDefineCard(settled({ content: [{ type: 'reasoning', text: 'weighing it' }] })).output)
|
||||
.toContain('"type": "reasoning"')
|
||||
})
|
||||
|
||||
it('degrades on a truncated argument stream instead of dropping the row', () => {
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"name":"clo' })).name).toBe('{"name":"clo')
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"name":"clo' })).purpose).toBeNull()
|
||||
expect(cordisDefineCard(running({ argsRaw: '"just a string"' })).name).toBe('"just a string"')
|
||||
})
|
||||
|
||||
it('keeps the raw first line as the name when the arguments carry none', () => {
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"purpose":"顶栏时钟"}' })).name).toBe('{"purpose":"顶栏时钟"}')
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"name":"","purpose":"顶栏时钟"}' })).name).toBe('{"name":"","purpose":"顶栏时钟"}')
|
||||
})
|
||||
|
||||
it('reports an unknown name when the event window cut the call head', () => {
|
||||
// The host definition list answers identity and run state only, so the card
|
||||
// has no label left to fall back on and names its own call instead.
|
||||
const card = cordisDefineCard(settled({ call: null }))
|
||||
expect(card.name).toBeNull()
|
||||
expect(card.purpose).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cordisActionCard', () => {
|
||||
it('keeps the Plugin identity and lifecycle result for Stop and Remove cards', () => {
|
||||
const card = cordisActionCard(settled({
|
||||
call: { name: 'cordis_stop', argsRaw: '{"pluginId":"clock-1"}' },
|
||||
content: [{ type: 'text', text: 'Stopped clock-1.' }],
|
||||
meta: undefined,
|
||||
}))
|
||||
|
||||
expect(card).toEqual({
|
||||
pluginId: 'clock-1',
|
||||
output: 'Stopped clock-1.',
|
||||
errorSummary: null,
|
||||
state: 'ok',
|
||||
})
|
||||
})
|
||||
})
|
||||
142
packages/extensions/ui-cordis/tests/inventory.client.spec.ts
Normal file
142
packages/extensions/ui-cordis/tests/inventory.client.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// The inventory store: how the panel's rows arrive, what a failed read leaves
|
||||
// behind, and why a read is single-flight.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createCordisInventory } from '../src/client/inventory.ts'
|
||||
import type { CordisDynamicPort, CordisInventoryRow } from '../src/client/dynamic-port.ts'
|
||||
|
||||
const ROW = {
|
||||
id: 'dyn-1', name: 'clock', purpose: '顶栏时钟', agentId: 'sess-1', running: true,
|
||||
} as unknown as CordisInventoryRow
|
||||
|
||||
/** A port whose inventory answer the test controls. */
|
||||
function port(answer: () => Promise<readonly CordisInventoryRow[]>): { port: CordisDynamicPort; reads: () => number } {
|
||||
let reads = 0
|
||||
return {
|
||||
port: {
|
||||
inventory: () => { reads += 1; return answer() },
|
||||
stop: () => Promise.reject(new Error('unused')),
|
||||
remove: () => Promise.reject(new Error('unused')),
|
||||
},
|
||||
reads: () => reads,
|
||||
}
|
||||
}
|
||||
|
||||
describe('reading the registry', () => {
|
||||
it('starts unread, then publishes the rows', async () => {
|
||||
const seam = port(() => Promise.resolve([ROW]))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
// Unread is not empty: the panel must not claim "nothing defined" before a
|
||||
// read settles.
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
|
||||
const seen = vi.fn()
|
||||
const off = inventory.subscribe(seen)
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
expect(inventory.getSnapshot().rows).toEqual([ROW])
|
||||
expect(seen).toHaveBeenCalled()
|
||||
|
||||
off()
|
||||
const before = seen.mock.calls.length
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(seam.reads()).toBe(2) })
|
||||
expect(seen.mock.calls.length).toBe(before)
|
||||
})
|
||||
|
||||
it('is single-flight: concurrent triggers read once', async () => {
|
||||
let release: ((rows: readonly CordisInventoryRow[]) => void) | undefined
|
||||
const seam = port(() => new Promise((resolve) => { release = resolve }))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
inventory.refresh()
|
||||
inventory.refresh()
|
||||
expect(seam.reads()).toBe(1)
|
||||
release?.([ROW])
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
// The slot frees once it settles, so the next trigger reads again.
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(seam.reads()).toBe(2) })
|
||||
})
|
||||
|
||||
it('keeps the rows it had when a read fails, and says why', async () => {
|
||||
let fail = false
|
||||
const seam = port(() => (fail ? Promise.reject(new Error('socket closed')) : Promise.resolve([ROW])))
|
||||
const onError = vi.fn()
|
||||
const inventory = createCordisInventory(seam.port, onError)
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
|
||||
fail = true
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().error).toBeDefined() })
|
||||
// Dropping the rows would turn a transient wire failure into "nothing is
|
||||
// defined", which is a different and wrong statement.
|
||||
expect(inventory.getSnapshot().rows).toEqual([ROW])
|
||||
expect(inventory.getSnapshot().read).toBe(true)
|
||||
expect(inventory.getSnapshot().error).toBe('socket closed')
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a non-Error rejection without inventing a message', async () => {
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario.
|
||||
const seam = port(() => Promise.reject('nope'))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().error).toBeDefined() })
|
||||
expect(inventory.getSnapshot().error).toBe('reading the cordis inventory failed')
|
||||
})
|
||||
|
||||
it('forgets everything on reset, because the next host may be a new process', async () => {
|
||||
const seam = port(() => Promise.resolve([ROW]))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
inventory.reset()
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a reconnect while a read is in flight', () => {
|
||||
it('discards the previous connection’s answer and lets the fresh read through', async () => {
|
||||
// Each read gets its own resolver, so the test can settle the stale one only.
|
||||
const releases: ((rows: readonly CordisInventoryRow[]) => void)[] = []
|
||||
const seam = port(() => new Promise((resolve) => { releases.push(resolve) }))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
expect(seam.reads()).toBe(1)
|
||||
|
||||
// The in-flight read belongs to the host we just left; a reset frees the slot
|
||||
// so the fresh read is not swallowed by it.
|
||||
inventory.reset()
|
||||
inventory.refresh()
|
||||
expect(seam.reads()).toBe(2)
|
||||
|
||||
// The stale answer arriving late must not repopulate what reset cleared.
|
||||
releases[0]?.([ROW])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
|
||||
// The fresh read still lands.
|
||||
releases[1]?.([ROW])
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
expect(inventory.getSnapshot().rows).toEqual([ROW])
|
||||
})
|
||||
|
||||
it('swallows a stale read’s failure too, rather than blaming the new connection', async () => {
|
||||
const rejects: ((reason: unknown) => void)[] = []
|
||||
const seam = port(() => new Promise((_resolve, reject) => { rejects.push(reject) }))
|
||||
const onError = vi.fn()
|
||||
const inventory = createCordisInventory(seam.port, onError)
|
||||
inventory.refresh()
|
||||
inventory.reset()
|
||||
|
||||
rejects[0]?.(new Error('socket closed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// The failure belongs to a connection nobody is looking at any more.
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
})
|
||||
})
|
||||
133
packages/extensions/ui-cordis/tests/versioning.client.spec.ts
Normal file
133
packages/extensions/ui-cordis/tests/versioning.client.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DynamicCordisLivePackage } from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
DynamicCordisInventoryRow,
|
||||
} from '../src/client/events.ts'
|
||||
import { cordisDefineCard, cordisRunCard } from '../src/client/card-model.ts'
|
||||
import { CordisRunCardRegistry, cordisToolViewKey } from '../src/client/run-card-index.ts'
|
||||
import { cordisVisibleStatus } from '../src/client/status.ts'
|
||||
|
||||
const PLUGIN = 'clock-1' as CordisDynamicPluginId
|
||||
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
|
||||
const RUN = 'run-1' as CordisDynamicPluginRunId
|
||||
|
||||
const row = (client: boolean): DynamicCordisInventoryRow => ({
|
||||
pluginId: PLUGIN,
|
||||
agentId: 'session-1' as DynamicCordisInventoryRow['agentId'],
|
||||
packages: [{
|
||||
packageId: PACKAGE,
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
hasHostHalf: true,
|
||||
hasClientHalf: client,
|
||||
}],
|
||||
currentPackageId: PACKAGE,
|
||||
activeRun: { packageId: PACKAGE, pluginRunId: RUN },
|
||||
})
|
||||
|
||||
describe('versioned Cordis card models', () => {
|
||||
it('reads symmetric Host and Client source fields from cordis_define', () => {
|
||||
const card = cordisDefineCard({
|
||||
callId: 'call-1',
|
||||
name: 'cordis_define',
|
||||
argsRaw: JSON.stringify({
|
||||
plugin: { kind: 'new', idPrefix: 'clock' },
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
code: { host: 'HOST_CODE', client: 'CLIENT_CODE' },
|
||||
}),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
time: 1,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
expect(card).toMatchObject({
|
||||
pluginId: null,
|
||||
packageId: null,
|
||||
hostCode: 'HOST_CODE',
|
||||
clientCode: 'CLIENT_CODE',
|
||||
state: 'running',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads exact activation metadata from a successful cordis_run result', () => {
|
||||
const card = cordisRunCard({
|
||||
kind: 'tool-result',
|
||||
seq: 9,
|
||||
time: 2,
|
||||
callId: 'call-2',
|
||||
call: { name: 'cordis_run', argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' }) },
|
||||
callTime: 1,
|
||||
content: [{ type: 'text', text: 'running' }],
|
||||
isError: false,
|
||||
meta: { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN },
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
expect(card).toMatchObject({
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: RUN,
|
||||
mode: 'run',
|
||||
seq: 9,
|
||||
state: 'ok',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the target identities while cordis_run waits for approval', () => {
|
||||
const card = cordisRunCard({
|
||||
callId: 'call-3',
|
||||
name: 'cordis_run',
|
||||
argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'update' }),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
time: 1,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
expect(card).toMatchObject({
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: null,
|
||||
mode: 'update',
|
||||
state: 'running',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis run-card ownership', () => {
|
||||
it('keeps the greatest Session log sequence for one Plugin and Package', () => {
|
||||
const store = new CordisRunCardRegistry().forSession('session-1' as DynamicCordisInventoryRow['agentId'])
|
||||
const changed = vi.fn()
|
||||
store.subscribe(changed)
|
||||
const key = cordisToolViewKey(PLUGIN, PACKAGE)
|
||||
|
||||
store.observe({ key, callId: 'new', seq: 20, pluginRunId: RUN })
|
||||
store.observe({ key, callId: 'old', seq: 10, pluginRunId: 'run-0' as CordisDynamicPluginRunId })
|
||||
|
||||
expect(store.getSnapshot().get(key)?.callId).toBe('new')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis visible status', () => {
|
||||
it('distinguishes Host-only running, Client pending, and fully loaded', () => {
|
||||
expect(cordisVisibleStatus(row(false), PACKAGE, [])).toBe('running')
|
||||
expect(cordisVisibleStatus(row(true), PACKAGE, [])).toBe('client-pending')
|
||||
const loaded: DynamicCordisLivePackage[] = [{
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: RUN,
|
||||
name: 'Clock',
|
||||
slots: [],
|
||||
styleCount: 0,
|
||||
}]
|
||||
expect(cordisVisibleStatus(row(true), PACKAGE, loaded)).toBe('running')
|
||||
})
|
||||
})
|
||||
45
packages/extensions/ui-cordis/tsconfig.json
Normal file
45
packages/extensions/ui-cordis/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../client/locale"
|
||||
},
|
||||
{
|
||||
"path": "../cordis-client-runner"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-input-trigger"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-tool"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/extensions/ui-cordis/tsdown.config.ts
Normal file
3
packages/extensions/ui-cordis/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-cordis', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user