Merge remote-tracking branch 'origin/master' into codex/unify-landlock-release
# Conflicts: # .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml # scripts/check-workspace-constraints.ts
This commit is contained in:
@@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
|
||||
|
||||
Naming notes:
|
||||
|
||||
- **Package tsconfig shape:** extends `tsconfig.base.json` (client: `tsconfig.base.client.json`), `rootDir: src`, `outDir: lib/types`, a `references` entry per workspace dependency plus `support/invariants`; registered in exactly one aggregate — host packages in `tsconfig.host.json`, client in `tsconfig.client.json` ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `support/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)).
|
||||
- `src/types.ts` contains only types — no runtime code.
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`.
|
||||
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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/README.md
|
||||
README.md: 4832fffbc8963b8a7b1f8332e691083195bf94bc
|
||||
README.zh.md: 076b4f877070fcf0ee6b98d2310d1121cbbe63d6
|
||||
README.md: 229feae568ba6e40a9c633696097eff46fd5bc95
|
||||
README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
|
||||
## Hierarchy
|
||||
|
||||
@@ -11,8 +11,10 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
@@ -26,14 +28,15 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Script seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, restricted repository Plugin loading | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类或函数插件;所有贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
|
||||
## 层级结构
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 |
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定表面 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
|
||||
@@ -26,14 +28,15 @@
|
||||
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 |
|
||||
| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 |
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎、面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
|
||||
| [`workflow/`](workflow/README.md) | 脚本 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定表面 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin,以及受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检、临时 Plugin、受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
|
||||
@@ -184,13 +184,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('agent/inbox/claimed', (agent, { message, turn }) => {
|
||||
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
|
||||
})
|
||||
|
||||
ctx.on('agent/error', (agent, turn, _step, error) => {
|
||||
ctx.on('agent/error', ({ agent, turn, error }) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (record === undefined || inflight === undefined || inflight.turn === turn) return
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
let injected = false
|
||||
harness.ctx.on('agent/inbox/inserted', (subject, { message }) => {
|
||||
harness.ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
|
||||
if (subject === agent && message.source.kind === 'user' && !injected) {
|
||||
injected = true
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
@@ -235,7 +235,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] })
|
||||
// A recovery policy: schedule one retry for the failed request.
|
||||
let retried = false
|
||||
harness.ctx.on('agent/request-error', async (_subject) => {
|
||||
harness.ctx.on('agent/request-error', async () => {
|
||||
if (!retried) {
|
||||
retried = true
|
||||
return { kind: 'retry' }
|
||||
@@ -272,7 +272,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
it('cancels a prompt removed before its turn claims it', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
const dispose = harness.ctx.on('agent/inbox/inserted', (agent, { message }) => {
|
||||
const dispose = harness.ctx.on('agent/inbox/inserted', ({ agent, message }) => {
|
||||
if (message.source.kind === 'user') agent.inbox.remove(message.id)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +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/examples/cli-demo/README.md
|
||||
README.md: 6e46ae81421c23806524b0784a976e9f3c8eeab8
|
||||
README.zh.md: b032023fee4bf9d992217cc51731f6356f875daf
|
||||
# pnpm run verify-translation-pairing --write packages/api/README.md
|
||||
README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1
|
||||
README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46
|
||||
17
packages/api/README.md
Normal file
17
packages/api/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/ — Remote API layers
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the TypeRT unary RPC endpoints shared by Host and Client environments.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
|
||||
|
||||
The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypeRTClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts.
|
||||
- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy.
|
||||
17
packages/api/README.zh.md
Normal file
17
packages/api/README.zh.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# api/:Remote API 层
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 TypeRT 一元 RPC endpoint。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` |
|
||||
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
|
||||
|
||||
运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypeRTClientRemote` 契约,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务契约。
|
||||
- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。
|
||||
6
packages/api/gateway/README.i18n.yaml
Normal file
6
packages/api/gateway/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/api/gateway/README.md
|
||||
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
|
||||
README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da
|
||||
39
packages/api/gateway/README.md
Normal file
39
packages/api/gateway/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided TypeRT RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection.
|
||||
|
||||
## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
|
||||
|
||||
`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `GatewayService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-type-meta`](../../typert/type-meta/README.md); `bindTypeRTGateway()` remains available when another base class owns inheritance.
|
||||
|
||||
Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
|
||||
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypeRTLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences.
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
## Client service: `ClientRemote` (ctx key: `remote`)
|
||||
|
||||
`ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.<namespace>` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
|
||||
|
||||
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
|
||||
|
||||
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package dispatches application calls and registers no prompt, tool, or session event.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; invoked business Services own any model-visible result.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypeRTLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers.
|
||||
- SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields.
|
||||
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
|
||||
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
39
packages/api/gateway/README.zh.md
Normal file
39
packages/api/gateway/README.zh.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-api-gateway
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 契约,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。
|
||||
|
||||
## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
|
||||
|
||||
每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-type-meta`](../../typert/type-meta/README.md) 的 `GatewayService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypeRTGateway()`。
|
||||
|
||||
严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
|
||||
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypeRTLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
## Client 服务:`ClientRemote`(ctx key:`remote`)
|
||||
|
||||
`ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.<namespace>` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并通过共享的 `TypeRTClientRemote` 契约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;被调用的业务服务负责产生任何模型可见结果。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypeRTLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。
|
||||
- SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。
|
||||
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
|
||||
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
|
||||
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
66
packages/api/gateway/package.json
Normal file
66
packages/api/gateway/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "TypeRT Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
498
packages/api/gateway/src/client/index.ts
Normal file
498
packages/api/gateway/src/client/index.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Client projection of generated TypeRT Remote descriptors. Contributions
|
||||
* install traced `remote.<namespace>` services; no JavaScript Proxy
|
||||
* participates in method lookup, invocation, or type exposure.
|
||||
*/
|
||||
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientRemote,
|
||||
TypeRTCodec,
|
||||
TypeRTDisposer,
|
||||
TypeRTRemoteContribution,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
interface MountToken {
|
||||
active: boolean
|
||||
readonly abort: AbortController
|
||||
}
|
||||
|
||||
interface ScopedProjection {
|
||||
readonly context: string
|
||||
readonly wire: string
|
||||
readonly codec: TypeRTCodec
|
||||
readonly parameterIndex?: number
|
||||
}
|
||||
|
||||
interface DirectMethod {
|
||||
readonly descriptor: InvocationDescriptor
|
||||
readonly token: MountToken
|
||||
}
|
||||
|
||||
interface ScopedMethod extends DirectMethod {
|
||||
readonly projection: ScopedProjection
|
||||
}
|
||||
|
||||
interface RemoteMethodRecord {
|
||||
direct?: DirectMethod
|
||||
scoped?: ScopedMethod
|
||||
}
|
||||
|
||||
interface BoundContextIdentity {
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
interface RemoteNamespaceHandle {
|
||||
readonly service: RemoteNamespaceService
|
||||
readonly dispose: TypeRTDisposer
|
||||
}
|
||||
|
||||
/** Typed Remote service augmented by generated direct namespaces. */
|
||||
export type ClientRemote = TypeRTClientRemote
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by the Client assembly. */
|
||||
remote: ClientRemote
|
||||
}
|
||||
}
|
||||
|
||||
/** Required Client services: the TypeRT registry and the existing Connection carrier. */
|
||||
export const inject = ['typert', 'connection']
|
||||
|
||||
/**
|
||||
* Install the typed Client Remote service.
|
||||
* @param ctx - Client Cordis root.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
new ClientRemoteService(ctx)
|
||||
}
|
||||
|
||||
class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
|
||||
private mutations = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remote')
|
||||
this.ownerCtx = ctx
|
||||
}
|
||||
|
||||
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
|
||||
const callerCtx = this.ctx
|
||||
const owned = callerCtx.effect(async () => {
|
||||
const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution))
|
||||
return () => this.enqueue(dispose)
|
||||
}, `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`)
|
||||
await owned
|
||||
return async () => { await owned() }
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation, operation)
|
||||
this.mutations = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
private async mountContribution(
|
||||
callerCtx: Context,
|
||||
contribution: TypeRTRemoteContribution,
|
||||
): Promise<TypeRTDisposer> {
|
||||
this.validateContribution(contribution)
|
||||
const disposeRemote = callerCtx.typert.remotes.register(contribution)
|
||||
const installed: TypeRTDisposer[] = []
|
||||
try {
|
||||
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
|
||||
} catch (error) {
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
await disposeRemote()
|
||||
}
|
||||
}
|
||||
|
||||
private validateContribution(contribution: TypeRTRemoteContribution): void {
|
||||
const direct = new Map<string, Set<string>>()
|
||||
const scoped = new Map<string, Set<string>>()
|
||||
const add = (
|
||||
table: Map<string, Set<string>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
kind: 'direct' | 'scoped',
|
||||
): void => {
|
||||
const methods = table.get(descriptor.namespace) ?? new Set<string>()
|
||||
if (methods.has(descriptor.method)) {
|
||||
throw new Error(`client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`)
|
||||
}
|
||||
methods.add(descriptor.method)
|
||||
table.set(descriptor.namespace, methods)
|
||||
const namespace = this.namespaces.get(descriptor.namespace)?.service
|
||||
if (namespace?.has(kind, descriptor.method) === true) {
|
||||
throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`)
|
||||
}
|
||||
}
|
||||
for (const descriptor of contribution.descriptors) {
|
||||
requireStrictDescriptor(descriptor)
|
||||
if (descriptor.invocation.kind === 'direct') add(direct, descriptor, 'direct')
|
||||
if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, 'scoped')
|
||||
}
|
||||
const namespaces = new Set([...direct.keys(), ...scoped.keys()])
|
||||
for (const namespace of namespaces) {
|
||||
const service = this.namespaces.get(namespace)?.service
|
||||
if (service === undefined) {
|
||||
if (namespace in this) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`)
|
||||
}
|
||||
const serviceKey = remoteServiceKey(namespace)
|
||||
const property = this.ownerCtx.reflect.props[serviceKey]
|
||||
if (property?.type === 'accessor' || this.ownerCtx.get(serviceKey) !== undefined) {
|
||||
throw new Error(`client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`)
|
||||
}
|
||||
}
|
||||
for (const method of new Set([...(direct.get(namespace) ?? []), ...(scoped.get(namespace) ?? [])])) {
|
||||
if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method)
|
||||
else service.assertMethodAvailable(method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async install(descriptor: InvocationDescriptor): Promise<TypeRTDisposer> {
|
||||
const token: MountToken = { active: true, abort: new AbortController() }
|
||||
const installed: TypeRTDisposer[] = []
|
||||
try {
|
||||
if (descriptor.invocation.kind === 'direct') {
|
||||
installed.push(await this.installDirect(descriptor, token))
|
||||
}
|
||||
const projection = scopedProjection(descriptor)
|
||||
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
|
||||
} catch (error) {
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
|
||||
if (!token.active) return
|
||||
token.active = false
|
||||
token.abort.abort()
|
||||
for (const dispose of installed.reverse()) await dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installDirect(descriptor, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
namespace.service.remove('direct', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async installScoped(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection,
|
||||
token: MountToken,
|
||||
): Promise<TypeRTDisposer> {
|
||||
const namespace = await this.namespace(descriptor.namespace)
|
||||
try {
|
||||
namespace.service.installScoped(descriptor, projection, token)
|
||||
} catch (error) {
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
namespace.service.remove('scoped', descriptor.method, token)
|
||||
await this.disposeNamespace(descriptor.namespace, namespace)
|
||||
}
|
||||
}
|
||||
|
||||
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
|
||||
let namespace = this.namespaces.get(name)
|
||||
if (namespace !== undefined) return namespace
|
||||
let service: RemoteNamespaceService | undefined
|
||||
const fiber = this.ownerCtx.plugin({
|
||||
name: remoteServiceKey(name),
|
||||
apply: (ctx: Context) => {
|
||||
service = new RemoteNamespaceService(
|
||||
ctx,
|
||||
name,
|
||||
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
|
||||
)
|
||||
},
|
||||
})
|
||||
try {
|
||||
await fiber
|
||||
} catch (error) {
|
||||
await fiber.dispose()
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
|
||||
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
|
||||
namespace = { service, dispose: fiber.dispose }
|
||||
this.namespaces.set(name, namespace)
|
||||
return namespace
|
||||
}
|
||||
|
||||
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
|
||||
if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return
|
||||
this.namespaces.delete(name)
|
||||
await namespace.dispose()
|
||||
}
|
||||
|
||||
private invokeMethod(
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<unknown> {
|
||||
if (scoped !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = binder?.identity(callerCtx)
|
||||
if (identity !== undefined) {
|
||||
return this.invoke(
|
||||
scoped.descriptor,
|
||||
scoped.projection,
|
||||
scoped.token,
|
||||
callerCtx,
|
||||
values,
|
||||
{ value: identity },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (direct !== undefined) {
|
||||
return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values)
|
||||
}
|
||||
if (scoped !== undefined) {
|
||||
return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values)
|
||||
}
|
||||
throw new Error('client api: Remote method is no longer mounted')
|
||||
}
|
||||
|
||||
private async invoke(
|
||||
descriptor: InvocationDescriptor,
|
||||
projection: ScopedProjection | undefined,
|
||||
token: MountToken,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): Promise<unknown> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
|
||||
if (values.length !== expected && !hasCallerSignal) {
|
||||
const contract = descriptor.cancellation === undefined
|
||||
? `${String(expected)} argument(s)`
|
||||
: `${String(expected)} business argument(s) plus an optional AbortSignal`
|
||||
throw new Error(
|
||||
`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
|
||||
)
|
||||
}
|
||||
const args = Object.create(null) as Record<string, unknown>
|
||||
if (projection !== undefined) {
|
||||
const binder = boundIdentity === undefined
|
||||
? this.ownerCtx.typert.contexts.getClient(projection.context)
|
||||
: undefined
|
||||
if (boundIdentity === undefined && binder === undefined) {
|
||||
throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`)
|
||||
}
|
||||
const identity = boundIdentity === undefined
|
||||
? binder?.identity(callerCtx)
|
||||
: boundIdentity.value
|
||||
if (identity === undefined) {
|
||||
throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`)
|
||||
}
|
||||
args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire)
|
||||
}
|
||||
let valueIndex = 0
|
||||
descriptor.parameters.forEach((parameter, parameterIndex) => {
|
||||
if (parameterIndex === projection?.parameterIndex) return
|
||||
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
|
||||
valueIndex += 1
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
|
||||
const signal = callerSignal === undefined
|
||||
? token.abort.signal
|
||||
: AbortSignal.any([token.abort.signal, callerSignal])
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
|
||||
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
|
||||
if (!result.ok) throw remoteFailure(endpoint, result.error)
|
||||
return parse(descriptor.result, result.value, endpoint, 'result')
|
||||
}
|
||||
}
|
||||
|
||||
type InvokeRemote = (
|
||||
direct: DirectMethod | undefined,
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<unknown>
|
||||
|
||||
class RemoteNamespaceService extends Service {
|
||||
private readonly methods = new Map<string, RemoteMethodRecord>()
|
||||
private readonly namespace: string
|
||||
|
||||
static assertMethodAvailable(namespace: string, method: string): void {
|
||||
if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
private readonly invokeRemote: InvokeRemote,
|
||||
) {
|
||||
super(ctx, remoteServiceKey(name))
|
||||
this.namespace = name
|
||||
}
|
||||
|
||||
assertMethodAvailable(method: string): void {
|
||||
RemoteNamespaceService.assertMethodAvailable(this.namespace, method)
|
||||
if (method in this && !this.methods.has(method)) {
|
||||
throw new Error(`client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`)
|
||||
}
|
||||
}
|
||||
|
||||
get empty(): boolean {
|
||||
return this.methods.size === 0
|
||||
}
|
||||
|
||||
has(kind: 'direct' | 'scoped', method: string): boolean {
|
||||
return this.methods.get(method)?.[kind] !== undefined
|
||||
}
|
||||
|
||||
installDirect(descriptor: InvocationDescriptor, token: MountToken): void {
|
||||
this.install(descriptor.method, 'direct', { descriptor, token })
|
||||
}
|
||||
|
||||
installScoped(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
|
||||
this.install(descriptor.method, 'scoped', { descriptor, projection, token })
|
||||
}
|
||||
|
||||
private install(method: string, kind: 'direct', value: DirectMethod): void
|
||||
private install(method: string, kind: 'scoped', value: ScopedMethod): void
|
||||
private install(method: string, kind: 'direct' | 'scoped', value: DirectMethod | ScopedMethod): void {
|
||||
this.assertMethodAvailable(method)
|
||||
let record = this.methods.get(method)
|
||||
const fresh = record === undefined
|
||||
record ??= {}
|
||||
if (fresh) {
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
|
||||
const callerCtx = this.ctx
|
||||
const current = this.methods.get(method)
|
||||
const direct = current?.direct
|
||||
const scoped = current?.scoped
|
||||
return (...args: unknown[]) => {
|
||||
return this.invokeRemote(direct, scoped, callerCtx, args)
|
||||
}
|
||||
},
|
||||
})
|
||||
this.methods.set(method, record)
|
||||
}
|
||||
if (kind === 'direct') record.direct = value
|
||||
else record.scoped = value as ScopedMethod
|
||||
}
|
||||
|
||||
remove(kind: 'direct' | 'scoped', method: string, token: MountToken): void {
|
||||
const record = this.methods.get(method)
|
||||
const current = record?.[kind]
|
||||
/* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */
|
||||
if (record === undefined || current?.token !== token) return
|
||||
if (kind === 'direct') delete record.direct
|
||||
else delete record.scoped
|
||||
if (record.direct !== undefined || record.scoped !== undefined) return
|
||||
this.methods.delete(method)
|
||||
Reflect.deleteProperty(this, method)
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
|
||||
|
||||
function remoteServiceKey(namespace: string): string {
|
||||
return `remote.${namespace}`
|
||||
}
|
||||
|
||||
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
|
||||
return `${descriptor.namespace}/${descriptor.method}`
|
||||
}
|
||||
|
||||
function mountActive(token: MountToken): boolean {
|
||||
return token.active
|
||||
}
|
||||
|
||||
function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined {
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
return {
|
||||
context: descriptor.invocation.context,
|
||||
wire: descriptor.invocation.wire,
|
||||
codec: descriptor.invocation.codec,
|
||||
}
|
||||
}
|
||||
if (descriptor.scope === undefined) return undefined
|
||||
const lookupParameters = descriptor.parameters
|
||||
.map((parameter, index) => ({ parameter, index }))
|
||||
.filter(candidate => candidate.parameter.source === 'lookup')
|
||||
const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined
|
||||
if (selected === undefined
|
||||
|| selected.parameter.wire !== descriptor.scope.wire
|
||||
|| selected.parameter.lookup !== descriptor.scope.context) {
|
||||
throw new Error(
|
||||
`client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
context: descriptor.scope.context,
|
||||
wire: descriptor.scope.wire,
|
||||
codec: selected.parameter.codec,
|
||||
parameterIndex: selected.index,
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictDescriptor(descriptor: InvocationDescriptor): void {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
requireStrictCodec(descriptor.result, endpoint, 'result')
|
||||
for (const parameter of descriptor.parameters) {
|
||||
requireStrictCodec(parameter.codec, endpoint, parameter.wire)
|
||||
}
|
||||
if (descriptor.invocation.kind === 'context') {
|
||||
requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStrictCodec(codec: TypeRTCodec, endpoint: string, field: string): void {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
}
|
||||
|
||||
function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: string): unknown {
|
||||
if (codec.mode !== 'strict') {
|
||||
throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`)
|
||||
}
|
||||
try {
|
||||
return codec.schema.parse(value)
|
||||
} catch (cause) {
|
||||
throw new Error(`client api: ${endpoint} rejected ${JSON.stringify(field)}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFailure(endpoint: string, error: RpcError): Error {
|
||||
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
|
||||
}
|
||||
638
packages/api/gateway/src/index.ts
Normal file
638
packages/api/gateway/src/index.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* Live TypeRT Remote dispatch over Cordis Services and registered providers.
|
||||
* Transport, request correlation, and response envelopes belong to Connection.
|
||||
* @module @deepseek-ai/dsh-api-gateway
|
||||
*/
|
||||
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import {
|
||||
remoteMethods,
|
||||
TypeRTLookupFailure,
|
||||
type InvocationDescriptor,
|
||||
type InvocationParameterDescriptor,
|
||||
type TypeRTCodec,
|
||||
type TypeRTGatewayBinding,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
InvokeRemoteRequest,
|
||||
TypertGateway,
|
||||
TypertGatewayErrorCode,
|
||||
} from './types.ts'
|
||||
|
||||
interface GatewayErrorOptions {
|
||||
readonly cause?: unknown
|
||||
readonly field?: string
|
||||
}
|
||||
|
||||
interface ResolvedBinding {
|
||||
readonly binding: TypeRTGatewayBinding
|
||||
readonly original: object
|
||||
}
|
||||
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
type ConnectionRpcError = Extract<ConnectionRpcResult, { readonly ok: false }>['error']
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
export class TypertGatewayError extends Error {
|
||||
/** Machine-readable failure category. */
|
||||
readonly code: TypertGatewayErrorCode
|
||||
/** Canonical `<namespace>/<method>` endpoint. */
|
||||
readonly endpoint: string
|
||||
/** Affected wire field when the failure is field-specific. */
|
||||
readonly field: string | undefined
|
||||
|
||||
/**
|
||||
* Construct a Gateway failure without embedding boundary values in its message.
|
||||
* @param code - stable failure category.
|
||||
* @param endpoint - canonical Remote endpoint.
|
||||
* @param message - correction-oriented diagnostic without sensitive values.
|
||||
* @param options - optional field and contained cause.
|
||||
*/
|
||||
constructor(
|
||||
code: TypertGatewayErrorCode,
|
||||
endpoint: string,
|
||||
message: string,
|
||||
options: GatewayErrorOptions = {},
|
||||
) {
|
||||
super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause })
|
||||
this.name = 'TypertGatewayError'
|
||||
this.code = code
|
||||
this.endpoint = endpoint
|
||||
this.field = options.field
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve strict generated definitions or conservative SRC markers against
|
||||
* current Cordis Services and TypeRT providers.
|
||||
* @typert service typertGateway
|
||||
*/
|
||||
export class TypertGatewayService extends Service implements TypertGateway {
|
||||
static inject = ['typert']
|
||||
|
||||
private srcClaims: ReadonlySet<string> | undefined
|
||||
|
||||
/**
|
||||
* Register the Gateway against the active TypeRT registry.
|
||||
* @param ctx - owning Host Context with TypeRT registry access.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'typertGateway')
|
||||
ctx.on('internal/service', () => {
|
||||
this.srcClaims = undefined
|
||||
})
|
||||
ctx.inject(['connection'], (connectionCtx) => {
|
||||
connectionCtx.connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => this.claimsEndpoint(endpoint),
|
||||
(endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal),
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private claimsEndpoint(endpoint: string): boolean {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
|
||||
if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true
|
||||
this.srcClaims ??= this.collectSrcClaims()
|
||||
return this.srcClaims.has(endpoint)
|
||||
}
|
||||
|
||||
private collectSrcClaims(): ReadonlySet<string> {
|
||||
const claims = new Set<string>()
|
||||
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
||||
if (definition.type !== 'service') continue
|
||||
const receiver = this.ctx.get(serviceKey) as unknown
|
||||
if (!isObject(receiver)) continue
|
||||
const original = originalOf(receiver)
|
||||
const binding = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (!isObject(binding) || typeof Reflect.get(binding, 'namespace') !== 'string') continue
|
||||
const namespace = Reflect.get(binding, 'namespace') as string
|
||||
for (const candidate of remoteMethods(original)) {
|
||||
claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method))
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke one live Remote method through strict generated reflection or SRC markers.
|
||||
* @param request - decoded endpoint and exact named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
async invoke(request: InvokeRemoteRequest): Promise<unknown> {
|
||||
const endpoint = endpointOf(request.namespace, request.method)
|
||||
const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint)
|
||||
assertExactArguments(request.args, descriptor, endpoint)
|
||||
const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint)
|
||||
const receiver = receiverContext.get(descriptor.service) as unknown
|
||||
if (!isObject(receiver)) {
|
||||
throw new TypertGatewayError(
|
||||
'service-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} is unavailable`,
|
||||
)
|
||||
}
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = await Promise.all(descriptor.parameters.map(parameter =>
|
||||
this.resolveParameter(parameter, request.args, endpoint)))
|
||||
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
|
||||
const implementation = descriptor.implementation ?? descriptor.method
|
||||
const method = Reflect.get(receiver, implementation) as unknown
|
||||
if (typeof method !== 'function') {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(method, receiver, args) as unknown
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
|
||||
}
|
||||
const [namespace, method] = segments as [string, string]
|
||||
if (!isObject(payload)
|
||||
|| !isPlainObject(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 1
|
||||
|| !Object.hasOwn(payload, 'args')
|
||||
|| !isObject(payload.args)
|
||||
|| !isPlainObject(payload.args)) {
|
||||
throw new Error('Remote payload must contain exactly one plain-object args field')
|
||||
}
|
||||
const value = await this.invoke({
|
||||
namespace,
|
||||
method,
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const strict = this.ctx.typert.local.get(endpoint)
|
||||
if (strict !== undefined) return strict
|
||||
if (this.ctx.typert.local.hasSeen(endpoint)) {
|
||||
throw new TypertGatewayError(
|
||||
'definition-unavailable',
|
||||
endpoint,
|
||||
'its strict definition was withdrawn and SRC fallback is forbidden',
|
||||
)
|
||||
}
|
||||
return this.resolveSrcDescriptor(namespace, method, endpoint)
|
||||
}
|
||||
|
||||
private resolveSrcDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor {
|
||||
const candidates: InvocationDescriptor[] = []
|
||||
for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) {
|
||||
if (definition.type !== 'service') continue
|
||||
const receiver = this.ctx.get(serviceKey) as unknown
|
||||
if (!isObject(receiver)) continue
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) continue
|
||||
const binding = readBinding(value, original, serviceKey, endpoint)
|
||||
if (binding.namespace !== namespace) continue
|
||||
const marker = remoteMethods(original).find(candidate => (candidate.exportName ?? candidate.method) === method)
|
||||
if (marker === undefined) continue
|
||||
candidates.push(this.srcDescriptor(binding, marker, method, endpoint))
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint')
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'ambiguous-endpoint',
|
||||
endpoint,
|
||||
`multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`,
|
||||
)
|
||||
}
|
||||
return candidates[0] as InvocationDescriptor
|
||||
}
|
||||
|
||||
private srcDescriptor(
|
||||
binding: TypeRTGatewayBinding,
|
||||
marker: ReturnType<typeof remoteMethods>[number],
|
||||
method: string,
|
||||
endpoint: string,
|
||||
): InvocationDescriptor {
|
||||
const names = methodParameterNames(binding.service, marker.method, endpoint)
|
||||
const signalIndex = names.indexOf('signal')
|
||||
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
'SRC cancellation parameter signal must be the final parameter',
|
||||
{ field: 'signal' },
|
||||
)
|
||||
}
|
||||
const cancellation = signalIndex >= 0
|
||||
? { parameter: 'signal' as const }
|
||||
: undefined
|
||||
const businessNames = cancellation === undefined ? names : names.slice(0, -1)
|
||||
const parameters: InvocationParameterDescriptor[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const name of businessNames) {
|
||||
const matches = this.ctx.typert.lookups.definitions()
|
||||
.filter(definition => definition.parameter === name)
|
||||
if (matches.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`parameter ${JSON.stringify(name)} matches multiple lookup providers`,
|
||||
{ field: name },
|
||||
)
|
||||
}
|
||||
const match = matches[0]
|
||||
const parameter: InvocationParameterDescriptor = match === undefined
|
||||
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
|
||||
: {
|
||||
name,
|
||||
wire: match.wire,
|
||||
source: 'lookup',
|
||||
lookup: match.key,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
if (wires.has(parameter.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`multiple parameters use wire field ${JSON.stringify(parameter.wire)}`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
wires.add(parameter.wire)
|
||||
parameters.push(parameter)
|
||||
}
|
||||
|
||||
let receiver: InvocationDescriptor['invocation'] = { kind: 'direct' }
|
||||
if (marker.invocation.kind === 'context') {
|
||||
const provider = this.ctx.typert.contexts.getHost(marker.invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (wires.has(provider.wire)) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`,
|
||||
{ field: provider.wire },
|
||||
)
|
||||
}
|
||||
receiver = {
|
||||
kind: 'context',
|
||||
context: marker.invocation.context,
|
||||
wire: provider.wire,
|
||||
codec: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `src:${binding.serviceKey}#${endpoint}`,
|
||||
service: binding.serviceKey,
|
||||
namespace: binding.namespace,
|
||||
method,
|
||||
...(marker.method === method ? {} : { implementation: marker.method }),
|
||||
invocation: receiver,
|
||||
parameters,
|
||||
...(cancellation === undefined ? {} : { cancellation }),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveReceiverContext(
|
||||
descriptor: InvocationDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<Context> {
|
||||
if (descriptor.invocation.kind === 'direct') return this.ctx
|
||||
const invocation = descriptor.invocation
|
||||
const provider = this.ctx.typert.contexts.getHost(invocation.context)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-unavailable',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} is unavailable`,
|
||||
)
|
||||
}
|
||||
if (provider.wire !== invocation.wire
|
||||
|| (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire)
|
||||
let context: Context | undefined
|
||||
try {
|
||||
context = await provider.resolve(identity)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'context-failed',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} failed`,
|
||||
{ cause, field: invocation.wire },
|
||||
)
|
||||
}
|
||||
if (context === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'context-not-found',
|
||||
endpoint,
|
||||
`Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`,
|
||||
{ field: invocation.wire },
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
private async resolveParameter(
|
||||
parameter: InvocationParameterDescriptor,
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<unknown> {
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
/* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */
|
||||
if (key === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup parameter ${JSON.stringify(parameter.name)} has no provider key`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
const provider = this.ctx.typert.lookups.get(key)
|
||||
if (provider === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-unavailable',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} is unavailable`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (provider.wire !== parameter.wire
|
||||
|| (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) {
|
||||
throw new TypertGatewayError(
|
||||
'provider-mismatch',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} does not match its strict definition`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
let resolved: unknown
|
||||
try {
|
||||
resolved = await provider.resolve(value)
|
||||
} catch (cause) {
|
||||
if (cause instanceof TypeRTLookupFailure) throw cause
|
||||
throw new TypertGatewayError(
|
||||
'lookup-failed',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} failed`,
|
||||
{ cause, field: parameter.wire },
|
||||
)
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'lookup-not-found',
|
||||
endpoint,
|
||||
`lookup provider ${JSON.stringify(key)} did not resolve the requested identity`,
|
||||
{ field: parameter.wire },
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof TypeRTLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function endpointOf(namespace: string, method: string): string {
|
||||
return `${namespace}/${method}`
|
||||
}
|
||||
|
||||
function validateBinding(
|
||||
receiver: object,
|
||||
serviceKey: string,
|
||||
namespace: string,
|
||||
endpoint: string,
|
||||
): ResolvedBinding {
|
||||
const original = originalOf(receiver)
|
||||
const value = Reflect.get(original, 'typertGateway') as unknown
|
||||
if (value === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has no visible typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
binding: readBinding(value, original, serviceKey, endpoint, namespace),
|
||||
original,
|
||||
}
|
||||
}
|
||||
|
||||
function readBinding(
|
||||
value: unknown,
|
||||
original: object,
|
||||
serviceKey: string,
|
||||
endpoint: string,
|
||||
namespace?: string,
|
||||
): TypeRTGatewayBinding {
|
||||
if (!isObject(value)
|
||||
|| Reflect.get(value, 'service') !== original
|
||||
|| Reflect.get(value, 'serviceKey') !== serviceKey
|
||||
|| typeof Reflect.get(value, 'namespace') !== 'string'
|
||||
|| (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) {
|
||||
throw new TypertGatewayError(
|
||||
'binding-invalid',
|
||||
endpoint,
|
||||
`Service ${JSON.stringify(serviceKey)} has an inconsistent typertGateway binding`,
|
||||
)
|
||||
}
|
||||
return value as unknown as TypeRTGatewayBinding
|
||||
}
|
||||
|
||||
function originalOf(receiver: object): object {
|
||||
const original = Reflect.get(receiver, symbols.original) as unknown
|
||||
return isObject(original) ? original : receiver
|
||||
}
|
||||
|
||||
function methodParameterNames(service: object, method: string, endpoint: string): readonly string[] {
|
||||
let prototype: object | null = Object.getPrototypeOf(service) as object | null
|
||||
let implementation: ((this: object, ...args: never[]) => unknown) | undefined
|
||||
while (prototype !== null) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, method)
|
||||
if (descriptor !== undefined) {
|
||||
if ('value' in descriptor && typeof descriptor.value === 'function') {
|
||||
implementation = descriptor.value as (this: object, ...args: never[]) => unknown
|
||||
}
|
||||
break
|
||||
}
|
||||
prototype = Object.getPrototypeOf(prototype) as object | null
|
||||
}
|
||||
if (implementation === undefined) {
|
||||
throw new TypertGatewayError(
|
||||
'method-unavailable',
|
||||
endpoint,
|
||||
`Remote marker has no prototype method ${JSON.stringify(method)}`,
|
||||
)
|
||||
}
|
||||
const source = Function.prototype.toString.call(implementation)
|
||||
const open = source.indexOf('(')
|
||||
const close = source.indexOf(')', open + 1)
|
||||
/* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */
|
||||
if (open < 0 || close < 0) return invalidSignature(endpoint, method)
|
||||
const body = source.slice(open + 1, close).trim()
|
||||
if (body.length === 0) return []
|
||||
const parts = body.split(',').map(part => part.trim())
|
||||
const names = new Set<string>()
|
||||
for (const part of parts) {
|
||||
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) return invalidSignature(endpoint, method)
|
||||
names.add(part)
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
function invalidSignature(endpoint: string, method: string): never {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
`SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`,
|
||||
)
|
||||
}
|
||||
|
||||
function assertExactArguments(
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
descriptor: InvocationDescriptor,
|
||||
endpoint: string,
|
||||
): void {
|
||||
if (!isPlainObject(args)) {
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object')
|
||||
}
|
||||
const expected = new Set(descriptor.parameters.map(parameter => parameter.wire))
|
||||
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
|
||||
const actual = Reflect.ownKeys(args)
|
||||
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
|
||||
if (extra.length === 0 && missing.length === 0) return
|
||||
const clauses: string[] = []
|
||||
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
|
||||
if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`)
|
||||
throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`)
|
||||
}
|
||||
|
||||
function decode(
|
||||
codec: TypeRTCodec,
|
||||
value: unknown,
|
||||
code: 'input-invalid' | 'result-invalid',
|
||||
endpoint: string,
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') value = codec.schema.parse(value)
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
throw new TypertGatewayError(
|
||||
code,
|
||||
endpoint,
|
||||
code === 'input-invalid'
|
||||
? `wire field ${JSON.stringify(field)} failed boundary validation`
|
||||
: 'business result failed boundary validation',
|
||||
{ cause, field },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertJsonValue(value: unknown, ancestors: Set<object>): void {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return
|
||||
if (typeof value === 'number') {
|
||||
if (Number.isFinite(value)) return
|
||||
throw new TypeError('non-finite number is not JSON-safe')
|
||||
}
|
||||
if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`)
|
||||
if (ancestors.has(value)) throw new TypeError('cyclic value is not JSON-safe')
|
||||
ancestors.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getOwnPropertySymbols(value).length > 0 || Object.keys(value).length !== value.length) {
|
||||
throw new TypeError('sparse or decorated array is not JSON-safe')
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!Object.hasOwn(value, index)) throw new TypeError('sparse array is not JSON-safe')
|
||||
assertJsonValue(value[index], ancestors)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isPlainObject(value)) throw new TypeError('non-plain object is not JSON-safe')
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError('symbol property is not JSON-safe')
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
/* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */
|
||||
if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('non-data property is not JSON-safe')
|
||||
}
|
||||
assertJsonValue(descriptor.value, ancestors)
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: object): value is Record<string, unknown> {
|
||||
if (Array.isArray(value)) return false
|
||||
const prototype = Object.getPrototypeOf(value) as object | null
|
||||
return prototype === null || prototype === Object.prototype
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
||||
}
|
||||
|
||||
export default TypertGatewayService
|
||||
30
packages/api/gateway/src/invariant.ts
Normal file
30
packages/api/gateway/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-api-gateway`.
|
||||
* @module @deepseek-ai/dsh-api-gateway/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-gateway-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
|
||||
* state, while Client methods and descriptors mutate in one owned effect.
|
||||
*/
|
||||
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 */
|
||||
54
packages/api/gateway/src/types.ts
Normal file
54
packages/api/gateway/src/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Carrier-independent TypeRT Gateway request, service, and error contracts.
|
||||
* @module @deepseek-ai/dsh-api-gateway/types
|
||||
*/
|
||||
|
||||
/** One Remote method request after a carrier has decoded its envelope. */
|
||||
export interface InvokeRemoteRequest {
|
||||
/** Remote namespace selected by the generated descriptor. */
|
||||
readonly namespace: string
|
||||
/** Exported Service method name. */
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
||||
export type TypertGatewayErrorCode =
|
||||
| 'ambiguous-endpoint'
|
||||
| 'arguments-invalid'
|
||||
| 'binding-invalid'
|
||||
| 'context-failed'
|
||||
| 'context-not-found'
|
||||
| 'context-unavailable'
|
||||
| 'definition-unavailable'
|
||||
| 'input-invalid'
|
||||
| 'invocation-unavailable'
|
||||
| 'lookup-failed'
|
||||
| 'lookup-not-found'
|
||||
| 'lookup-unavailable'
|
||||
| 'method-unavailable'
|
||||
| 'provider-mismatch'
|
||||
| 'result-invalid'
|
||||
| 'service-unavailable'
|
||||
| 'signature-invalid'
|
||||
|
||||
/** Host dispatcher consumed by Connection adapters. */
|
||||
export interface TypertGateway {
|
||||
/**
|
||||
* Invoke one live Remote method without assuming a carrier or response envelope.
|
||||
* @param request - decoded endpoint and named wire arguments.
|
||||
* @returns the validated business result.
|
||||
* @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity.
|
||||
*/
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host dispatcher for TypeRT Remote calls. */
|
||||
typertGateway: TypertGateway
|
||||
}
|
||||
}
|
||||
573
packages/api/gateway/tests/client.spec.ts
Normal file
573
packages/api/gateway/tests/client.spec.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientRemote,
|
||||
TypeRTContext,
|
||||
TypeRTRemoteScopeApi,
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (
|
||||
agentId: string,
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteScopeMap {
|
||||
'fixture:goals/create': (
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace<'goals'>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type FixtureContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
|
||||
}
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
const renameResultSchema = z.object({ renamed: z.boolean() })
|
||||
|
||||
function directDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
parameters: [{
|
||||
name: 'agent',
|
||||
wire: 'agentId',
|
||||
source: 'lookup',
|
||||
lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}, {
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
|
||||
}],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
function contextDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
context: 'fixture',
|
||||
wire: 'agentId',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
},
|
||||
parameters: [{
|
||||
name: 'request',
|
||||
wire: 'request',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#RenameRequest', schema: requestSchema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#RenameResult', schema: renameResultSchema },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const businessGoals = { owner: 'host business service' }
|
||||
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
const retained = ctx.remote.goals.create
|
||||
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const callerAbort = new AbortController()
|
||||
await expect(ctx.remote.goals.create(
|
||||
'agent-1',
|
||||
{ objective: 'cancel me' },
|
||||
callerAbort.signal,
|
||||
)).resolves.toEqual({ ref: 'goal-1' })
|
||||
const combinedSignal = call.mock.calls.at(-1)?.[3]
|
||||
expect(combinedSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(combinedSignal).not.toBe(callerAbort.signal)
|
||||
const cancellation = new Error('caller cancelled')
|
||||
callerAbort.abort(cancellation)
|
||||
expect(combinedSignal?.aborted).toBe(true)
|
||||
expect(combinedSignal?.reason).toBe(cancellation)
|
||||
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBe(businessGoals)
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
disposeBusinessGoals()
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-2' } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const weak: InvocationDescriptor = {
|
||||
...directDescriptor(),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }))
|
||||
.rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/conflict',
|
||||
descriptors: [{ ...directDescriptor(), namespace: '$mount' }],
|
||||
})).rejects.toThrow('conflicts with the Remote service')
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-duplicates',
|
||||
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
|
||||
})).rejects.toThrow('repeats direct method')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-duplicates',
|
||||
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
|
||||
})).rejects.toThrow('repeats scoped method')
|
||||
|
||||
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
|
||||
})).rejects.toThrow('direct method goals/create is already mounted')
|
||||
await disposeDirect()
|
||||
|
||||
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
|
||||
})).rejects.toThrow('scoped method goals/rename is already mounted')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-method-conflict',
|
||||
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
const scopedService = ctx.get('remote.goals') as unknown as object
|
||||
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-own-property-conflict',
|
||||
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
Reflect.deleteProperty(scopedService, 'custom')
|
||||
await disposeScoped()
|
||||
|
||||
const disposeRemoteTypert = ctx.reflect.provide('remote.typert', { owner: 'fixture' })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/context-property-conflict',
|
||||
descriptors: [{ ...context, namespace: 'typert' }],
|
||||
})).rejects.toThrow('conflicts with an existing Remote namespace')
|
||||
await disposeRemoteTypert()
|
||||
|
||||
const disposeMultipleScoped = await ctx.remote.$mount({
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await disposeMultipleScoped()
|
||||
})
|
||||
|
||||
it('rolls back earlier descriptors when a later descriptor fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'archive') throw new Error('fixture later-descriptor failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/failing-batch', descriptors: [first, second] }))
|
||||
.rejects.toThrow('fixture later-descriptor failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('rolls back a direct projection when its scoped projection fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const disposeContext = await ctx.remote.$mount({
|
||||
package: '@fixture/context-anchor',
|
||||
descriptors: [contextDescriptor()],
|
||||
})
|
||||
const namespace = ctx.get('remote.goals') as unknown as {
|
||||
installScoped: (...args: unknown[]) => void
|
||||
readonly create?: unknown
|
||||
}
|
||||
const installScoped = vi.spyOn(namespace, 'installScoped').mockImplementation(() => {
|
||||
throw new Error('fixture scoped projection failure')
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-projection-failure',
|
||||
descriptors: [directDescriptor()],
|
||||
})).rejects.toThrow('fixture scoped projection failure')
|
||||
} finally {
|
||||
installScoped.mockRestore()
|
||||
}
|
||||
|
||||
expect(namespace.create).toBeUndefined()
|
||||
await disposeContext()
|
||||
})
|
||||
|
||||
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-parameter',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: direct.parameters.map((parameter, index) => index === 0
|
||||
? { ...parameter, codec: { mode: 'src-json' } }
|
||||
: parameter),
|
||||
}],
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/weak-context',
|
||||
descriptors: [{
|
||||
...context,
|
||||
invocation: { ...context.invocation, codec: { mode: 'src-json' } },
|
||||
} as InvocationDescriptor],
|
||||
})).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/malformed-scope',
|
||||
descriptors: [{ ...direct, scope: { context: 'fixture', wire: 'missingId' } }],
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/ambiguous-scope',
|
||||
descriptors: [{
|
||||
...direct,
|
||||
parameters: [...direct.parameters, {
|
||||
name: 'other', wire: 'otherId', source: 'lookup', lookup: 'fixture',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#AgentId', schema: idSchema },
|
||||
}],
|
||||
}],
|
||||
})).rejects.toThrow('scope must select its only lookup parameter')
|
||||
})
|
||||
|
||||
it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = await ctx.remote.$mount({
|
||||
package: '@fixture/goals',
|
||||
descriptors: [descriptor, contextDescriptor()],
|
||||
})
|
||||
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const goals = (ctx as FixtureContext).remote.goals
|
||||
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
|
||||
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
|
||||
.rejects.toThrow('got 4')
|
||||
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
|
||||
.rejects.toThrow('no Client Context binder')
|
||||
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
|
||||
|
||||
ctx.set('connection', undefined)
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('withdraws a pending invocation and preserves a direct namespace until its last method leaves', async () => {
|
||||
let resolveCall!: (result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>) => void
|
||||
const pending = new Promise<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>((resolve) => {
|
||||
resolveCall = resolve
|
||||
})
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>().mockReturnValue(pending)
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
|
||||
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
||||
await dispose()
|
||||
resolveCall({ ok: true, value: { ref: 'goal-1' } })
|
||||
|
||||
await expect(invocation).rejects.toThrow('withdrawn during invocation')
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails a method obtained from a withdrawn namespace getter', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
const namespace = ctx.get('remote.goals') as unknown as object
|
||||
const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(getWithdrawn).toBeTypeOf('function')
|
||||
const withdrawn = getWithdrawn?.() as (...args: unknown[]) => Promise<unknown>
|
||||
expect(() => withdrawn('agent-1', { objective: 'ship' }))
|
||||
.toThrow('Remote method is no longer mounted')
|
||||
})
|
||||
|
||||
it('preserves a __proto__ wire parameter as an own named argument', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const { scope: _scope, ...base } = directDescriptor()
|
||||
const descriptor: InvocationDescriptor = {
|
||||
...base,
|
||||
id: '@fixture/goals#goals/prototype',
|
||||
method: 'prototype',
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: '__proto__',
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#PrototypeValue', schema: z.string() },
|
||||
}],
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
|
||||
|
||||
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
|
||||
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
|
||||
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
|
||||
expect(Object.getPrototypeOf(payload.args)).toBeNull()
|
||||
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
|
||||
expect(payload.args.__proto__).toBe('wire-value')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rolls back Remote registration when namespace Service startup fails', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === Service.tracker) throw new Error('fixture namespace startup failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
.rejects.toThrow('fixture namespace startup failure')
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh direct namespace when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'create') throw new Error('fixture direct method installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-failure',
|
||||
descriptors: [directDescriptor()],
|
||||
})).rejects.toThrow('fixture direct method installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-retry',
|
||||
descriptors: [directDescriptor()],
|
||||
})
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('withdraws a fresh scoped Service when its first method fails to install', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const defineProperty = Object.defineProperty
|
||||
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
|
||||
if (key === 'rename') throw new Error('fixture scoped installation failure')
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/scoped-failure', descriptors: [contextDescriptor()] }))
|
||||
.rejects.toThrow('fixture scoped installation failure')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
|
||||
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('remote.goals')).toBeDefined()
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
const replacement = { owner: 'replacement' }
|
||||
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
|
||||
expect(ctx.get('remote.goals')).toBe(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
})
|
||||
1317
packages/api/gateway/tests/gateway.spec.ts
Normal file
1317
packages/api/gateway/tests/gateway.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
27
packages/api/gateway/tsconfig.json
Normal file
27
packages/api/gateway/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/api/gateway/tsdown.config.ts
Normal file
3
packages/api/gateway/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-api-gateway', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
6
packages/api/remotes/README.i18n.yaml
Normal file
6
packages/api/remotes/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/api/remotes/README.md
|
||||
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
|
||||
README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575
|
||||
33
packages/api/remotes/README.md
Normal file
33
packages/api/remotes/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
|
||||
|
||||
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
|
||||
|
||||
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway.
|
||||
|
||||
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
|
||||
|
||||
## Build boundary
|
||||
|
||||
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
|
||||
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
|
||||
|
||||
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct effect; mounted Host capabilities own any model-visible behavior they trigger.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime.
|
||||
- Additional capabilities require an explicit `/remote` value import and mount in this assembly.
|
||||
- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`.
|
||||
33
packages/api/remotes/README.zh.md
Normal file
33
packages/api/remotes/README.zh.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-api-remotes
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
|
||||
|
||||
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
|
||||
|
||||
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 契约,均可复用其 Client face。
|
||||
|
||||
## 构建边界
|
||||
|
||||
仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
|
||||
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
|
||||
|
||||
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;其触发的任何模型可见行为均由已挂载的 Host 能力负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。
|
||||
- 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。
|
||||
- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。
|
||||
64
packages/api/remotes/package.json
Normal file
64
packages/api/remotes/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
194
packages/api/remotes/src/agent-lookup.ts
Normal file
194
packages/api/remotes/src/agent-lookup.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/** Host BFF policy for resolving Remote Agent and Session identities. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
/** Caller-facing failures preserved by the Gateway's RPC adapter. */
|
||||
export type ApiRemoteLookupError =
|
||||
| { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } }
|
||||
| { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } }
|
||||
| { readonly code: 'internal'; readonly message: string; readonly details: Record<never, never> }
|
||||
|
||||
/** Result of resolving one session identity to its live Agent. */
|
||||
export type ApiRemoteAgentResult =
|
||||
| { readonly agent: Agent }
|
||||
| { readonly error: ApiRemoteLookupError }
|
||||
|
||||
/** Resume configuration supplied by the owning Host composition. */
|
||||
export interface ApiRemoteAgentOptions {
|
||||
/** Read the per-Agent defaults when a cold identity must resume. */
|
||||
readonly agentOptions?: () => AgentOptions
|
||||
/** Host-specific Agent-scope composition completed before publication. */
|
||||
readonly setup?: AgentSetup
|
||||
}
|
||||
|
||||
/** Cold identity absent from the durable session store. */
|
||||
export class ApiRemoteSessionNotFound extends Error {}
|
||||
|
||||
/** Session identity whose lifecycle belongs to subagent routing. */
|
||||
export class ApiRemoteSubagentSessionOwnership extends Error {
|
||||
/**
|
||||
* Construct the ownership fence.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
*/
|
||||
constructor(readonly sessionId: SessionId) {
|
||||
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether generic Host routing must leave an identity to subagent routing.
|
||||
* @param ctx - Host Context carrying the live Agent registry.
|
||||
* @param session - attached or live Session metadata.
|
||||
* @param agent - live Agent when one is registered.
|
||||
* @returns whether generic Remote and legacy API calls must reject the identity.
|
||||
*/
|
||||
export function hasApiRemoteSubagentOwner(
|
||||
ctx: Context,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): boolean {
|
||||
if (session.header.origin === 'subagent') return true
|
||||
const parentId = session.header.parentSession
|
||||
if (parentId === undefined || agent === undefined) return false
|
||||
const parent = ctx.agents.get(parentId)
|
||||
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable caller-facing ownership rejection.
|
||||
* @param sessionId - identity reserved to subagent routing.
|
||||
* @returns the existing `agent-busy` RPC shape.
|
||||
*/
|
||||
export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError {
|
||||
return {
|
||||
code: 'agent-busy',
|
||||
message: `session "${sessionId}" is owned by subagent routing`,
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one cold served session without repairing, resuming, or publishing it.
|
||||
* @param ctx - Host Context carrying the optional persistence provider.
|
||||
* @param sessionId - durable identity to inspect.
|
||||
* @returns detached metadata and events for a servable session.
|
||||
* @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session.
|
||||
*/
|
||||
export async function inspectApiRemoteSession(
|
||||
ctx: Context,
|
||||
sessionId: SessionId,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
const meta = (await persistence.list()).find(candidate => candidate.id === sessionId)
|
||||
if (meta === undefined || meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
const inspected = await persistence.inspect(sessionId)
|
||||
if (inspected.meta.cwd === undefined) {
|
||||
throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
return { meta: inspected.meta, events: [...inspected.events] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Host's shared Agent resolver and configure Agent/Session TypeRT lookups.
|
||||
* Live Agents are reused, ordinary cold sessions resume once per identity, and
|
||||
* subagent-owned identities retain the legacy `agent-busy` fence.
|
||||
* @param ctx - owning Host Context.
|
||||
* @param options - defaults and Agent-scope setup used only for cold resume.
|
||||
* @returns resolver shared by legacy API Proxy methods and TypeRT lookups.
|
||||
*/
|
||||
export function createApiRemoteAgentResolver(
|
||||
ctx: Context,
|
||||
options: ApiRemoteAgentOptions,
|
||||
): (sessionId: SessionId) => Promise<ApiRemoteAgentResult> {
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
|
||||
const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => {
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live === undefined) return undefined
|
||||
if (hasApiRemoteSubagentOwner(ctx, live.session, live)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return { agent: live }
|
||||
}
|
||||
|
||||
const agentFor = async (sessionId: SessionId): Promise<ApiRemoteAgentResult> => {
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
let resume = resumes.get(sessionId)
|
||||
if (resume === undefined) {
|
||||
resume = (async () => {
|
||||
try {
|
||||
const inspected = await inspectApiRemoteSession(ctx, sessionId)
|
||||
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
const publishedAgent = ctx.agents.get(sessionId)
|
||||
if (publishedSession !== undefined
|
||||
&& hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
|
||||
...options.setup === undefined ? {} : { setup: options.setup },
|
||||
})
|
||||
return handle.agent
|
||||
} finally {
|
||||
resumes.delete(sessionId)
|
||||
}
|
||||
})()
|
||||
resumes.set(sessionId, resume)
|
||||
}
|
||||
try {
|
||||
return { agent: await resume }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiRemoteSessionNotFound) {
|
||||
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
||||
}
|
||||
if (error instanceof ApiRemoteSubagentSessionOwnership) {
|
||||
return { error: apiRemoteSubagentOwnershipError(error.sessionId) }
|
||||
}
|
||||
const fenced = fencedLiveAgent(sessionId)
|
||||
if (fenced !== undefined) return fenced
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) {
|
||||
return { error: apiRemoteSubagentOwnershipError(sessionId) }
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: `resume failed for session "${sessionId}": ${String(error)}`,
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.inject(['typert'], (typeCtx) => {
|
||||
const resolveAgent = async (sessionId: SessionId): Promise<Agent> => {
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) throw new TypeRTLookupFailure(found.error)
|
||||
return found.agent
|
||||
}
|
||||
typeCtx.typert.lookups.configure('agent', resolveAgent)
|
||||
typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session)
|
||||
typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx)
|
||||
})
|
||||
|
||||
return agentFor
|
||||
}
|
||||
27
packages/api/remotes/src/client/index.ts
Normal file
27
packages/api/remotes/src/client/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by this Client assembly. */
|
||||
remote: TypeRTClientRemote
|
||||
}
|
||||
}
|
||||
|
||||
/** Required service: the typed Client Remote contribution mount. */
|
||||
export const inject = ['remote']
|
||||
|
||||
/**
|
||||
* Mount the Host capabilities explicitly selected for this Client assembly.
|
||||
* @param ctx - Client Cordis root carrying the typed API service.
|
||||
* @returns disposer after every selected Remote namespace is ready.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
return await ctx.remote.$mount(goalsRemote)
|
||||
}
|
||||
18
packages/api/remotes/src/index.ts
Normal file
18
packages/api/remotes/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||
|
||||
export {
|
||||
ApiRemoteSessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership,
|
||||
apiRemoteSubagentOwnershipError,
|
||||
createApiRemoteAgentResolver,
|
||||
hasApiRemoteSubagentOwner,
|
||||
inspectApiRemoteSession,
|
||||
} from './agent-lookup.ts'
|
||||
export type {
|
||||
ApiRemoteAgentOptions,
|
||||
ApiRemoteAgentResult,
|
||||
ApiRemoteLookupError,
|
||||
} from './agent-lookup.ts'
|
||||
|
||||
/** Host plugin body; the selected contributions mount only in Client environments. */
|
||||
export function apply(): void {}
|
||||
24
packages/api/remotes/src/invariant.ts
Normal file
24
packages/api/remotes/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-remotes-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */
|
||||
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 */
|
||||
154
packages/api/remotes/tests/agent-lookup.spec.ts
Normal file
154
packages/api/remotes/tests/agent-lookup.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes'
|
||||
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
|
||||
function header(id: SessionId): SessionHeader {
|
||||
return { version: 0, id, createdAt: 1, cwd: '/proj' }
|
||||
}
|
||||
|
||||
async function createContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function provideSession(
|
||||
ctx: Context,
|
||||
meta: SessionHeader,
|
||||
inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>,
|
||||
): void {
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
}
|
||||
|
||||
function stubAgent(ctx: Context, session: Session): Agent {
|
||||
return { id: session.id, session, status: 'idle', ctx } as Agent
|
||||
}
|
||||
|
||||
describe('API Remote Agent resolver races', () => {
|
||||
it('maps an inspected session without a cwd to session-not-found', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('missing-after-inspect')
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => Promise.resolve({
|
||||
meta: { ...meta, cwd: undefined } as unknown as SessionHeader,
|
||||
events: [],
|
||||
}))
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resumes through a concurrently attached ordinary Session without optional defaults', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('ordinary-attach-race')
|
||||
const meta = header(sessionId)
|
||||
let published: Session | undefined
|
||||
provideSession(ctx, meta, () => {
|
||||
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
if (published === undefined) throw new Error('Session was not published')
|
||||
return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() }
|
||||
})
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ agent: { id: sessionId } })
|
||||
expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a subagent Session published after durable inspection', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('owned-attach-race')
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => {
|
||||
ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reclassifies failed resumes after a live or attached subagent wins publication', async () => {
|
||||
for (const winner of ['agent', 'session'] as const) {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid(`owned-${winner}-resume-race`)
|
||||
const meta = header(sessionId)
|
||||
provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] }))
|
||||
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session))
|
||||
throw new Error('session id already published')
|
||||
})
|
||||
|
||||
const result = await createApiRemoteAgentResolver(ctx, {})(sessionId)
|
||||
|
||||
expect(result).toMatchObject({ error: { code: 'agent-busy' } })
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the shared cold-resume policy for the Agent Host Context', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('context-cold-resume')
|
||||
const meta = header(sessionId)
|
||||
let published: Session | undefined
|
||||
provideSession(ctx, meta, () => {
|
||||
published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } })
|
||||
return Promise.resolve({ meta, events: [] })
|
||||
})
|
||||
const agentCtx = ctx.extend()
|
||||
vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
|
||||
if (published === undefined) throw new Error('Session was not published')
|
||||
return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() }
|
||||
})
|
||||
const defaultProvider = ctx.typert.contexts.getHost('agent')
|
||||
createApiRemoteAgentResolver(ctx, {})
|
||||
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
|
||||
const provider = ctx.typert.contexts.getHost('agent')
|
||||
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
|
||||
|
||||
await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the subagent ownership fence to the Agent Host Context', async () => {
|
||||
const ctx = await createContext()
|
||||
const sessionId = sid('context-owned-subagent')
|
||||
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } })
|
||||
ctx.agents.register(stubAgent(ctx.extend(), session))
|
||||
const defaultProvider = ctx.typert.contexts.getHost('agent')
|
||||
createApiRemoteAgentResolver(ctx, {})
|
||||
await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) })
|
||||
const provider = ctx.typert.contexts.getHost('agent')
|
||||
if (provider === undefined) throw new Error('Agent Host Context provider was not mounted')
|
||||
|
||||
const resolution = provider.resolve(sessionId)
|
||||
await expect(resolution).rejects.toBeInstanceOf(TypeRTLookupFailure)
|
||||
await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
224
packages/api/remotes/tests/built-lib.e2e.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Built-artifact smoke for the first generated Remote: plain Node boots the
|
||||
* Host and Browser bundle handoffs, then crosses the shared `/api` HTTP route.
|
||||
*/
|
||||
|
||||
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const root = resolve(packageDir, '../../..')
|
||||
const artifact = (path: string): string => join(root, path)
|
||||
const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
|
||||
|
||||
const requiredArtifacts = [
|
||||
'packages/client/connection/lib/client.js',
|
||||
'packages/client/connection/lib/index.js',
|
||||
'packages/api/remotes/lib/client.js',
|
||||
'packages/core/agent/lib/index.js',
|
||||
'packages/core/session/lib/index.js',
|
||||
'packages/goal/goal/lib/index.js',
|
||||
'packages/goal/goal/lib/typert.host.js',
|
||||
'packages/api/gateway/lib/client.js',
|
||||
'packages/api/gateway/lib/index.js',
|
||||
'packages/typert/registry/lib/client.js',
|
||||
'packages/typert/registry/lib/index.js',
|
||||
].every(path => existsSync(artifact(path)))
|
||||
|
||||
describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
it('runs root and Agent-scoped calls through generated bundles and real HTTP', async () => {
|
||||
const urls = Object.fromEntries(Object.entries({
|
||||
agent: 'packages/core/agent/lib/index.js',
|
||||
apiGatewayClient: 'packages/api/gateway/lib/client.js',
|
||||
apiGatewayHost: 'packages/api/gateway/lib/index.js',
|
||||
connectionClient: 'packages/client/connection/lib/client.js',
|
||||
connectionHost: 'packages/client/connection/lib/index.js',
|
||||
goal: 'packages/goal/goal/lib/index.js',
|
||||
goalTypert: 'packages/goal/goal/lib/typert.host.js',
|
||||
registryClient: 'packages/typert/registry/lib/client.js',
|
||||
registryHost: 'packages/typert/registry/lib/index.js',
|
||||
remotesClient: 'packages/api/remotes/lib/client.js',
|
||||
session: 'packages/core/session/lib/index.js',
|
||||
}).map(([key, path]) => [key, artifactUrl(path)]))
|
||||
const script = `
|
||||
import { createServer } from 'node:http'
|
||||
import * as cordis from 'cordis'
|
||||
|
||||
const urls = ${JSON.stringify(urls)}
|
||||
const { Context } = cordis
|
||||
const { default: AgentRegistry } = await import(urls.agent)
|
||||
const connectionHost = await import(urls.connectionHost)
|
||||
const { default: TypertGatewayService } = await import(urls.apiGatewayHost)
|
||||
const { default: GoalService } = await import(urls.goal)
|
||||
const { TYPERT } = await import(urls.goalTypert)
|
||||
const { default: TypertRegistry } = await import(urls.registryHost)
|
||||
const { Session, SessionId } = await import(urls.session)
|
||||
|
||||
const routes = []
|
||||
const host = new Context()
|
||||
host.provide('httpServer', {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex() { return () => {} },
|
||||
port: 0,
|
||||
})
|
||||
await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply })
|
||||
await host.plugin(TypertRegistry)
|
||||
await host.plugin(AgentRegistry)
|
||||
await host.plugin(TypertGatewayService)
|
||||
await host.plugin(GoalService)
|
||||
host.typert.register(TYPERT)
|
||||
|
||||
const makeAgent = rawId => {
|
||||
const session = new Session(SessionId(rawId))
|
||||
return {
|
||||
id: session.id,
|
||||
options: {},
|
||||
session,
|
||||
ctx: host.extend(),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
send() {},
|
||||
updateInbox() { return 'not-found' },
|
||||
followup() {},
|
||||
steer() { return { outcome: Promise.resolve({ status: 'rejected' }) } },
|
||||
inject(input) { session.append('user/message', input, { surfaceOp: 'append' }) },
|
||||
reserveTurnAdmission() {},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
}
|
||||
const rootAgent = makeAgent('built-root-agent')
|
||||
const scopedAgent = makeAgent('built-scoped-agent')
|
||||
host.agents.register(rootAgent)
|
||||
host.agents.register(scopedAgent)
|
||||
|
||||
if (routes.length !== 1 || routes[0].path !== '/api') {
|
||||
throw new Error('Connection did not register exactly one /api route')
|
||||
}
|
||||
const server = createServer((request, response) => { void routes[0].handler(request, response) })
|
||||
await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address')
|
||||
const origin = 'http://127.0.0.1:' + String(address.port)
|
||||
|
||||
const handoffs = new Map()
|
||||
globalThis.window = {
|
||||
__ModuleLoader__: {
|
||||
load(handoff) { handoffs.set(handoff.id, handoff) },
|
||||
},
|
||||
}
|
||||
globalThis.location = { hostname: '127.0.0.1', origin, search: '' }
|
||||
await import(urls.registryClient)
|
||||
await import(urls.connectionClient)
|
||||
await import(urls.apiGatewayClient)
|
||||
await import(urls.remotesClient)
|
||||
|
||||
const instantiate = id => {
|
||||
const handoff = handoffs.get(id)
|
||||
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
|
||||
return handoff.factory(specifier => {
|
||||
if (specifier === 'cordis') return cordis
|
||||
throw new Error('unexpected Client external ' + specifier)
|
||||
})
|
||||
}
|
||||
const client = new Context()
|
||||
for (const id of [
|
||||
'@deepseek-ai/dsh-typert-registry',
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-api-gateway',
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
]) {
|
||||
const plugin = instantiate(id)
|
||||
await client.plugin({ inject: plugin.inject, apply: plugin.apply })
|
||||
}
|
||||
client.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => candidate.builtAgentId,
|
||||
})
|
||||
|
||||
let invalidRejected = false
|
||||
try {
|
||||
await client.remote.goals.create(rootAgent.id, { objective: 1 })
|
||||
} catch {
|
||||
invalidRejected = true
|
||||
}
|
||||
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
|
||||
const rootEdit = await client.remote.goals.edit(
|
||||
rootAgent.id,
|
||||
rootResult.ref,
|
||||
{ objective: 'edited root goal' },
|
||||
)
|
||||
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
|
||||
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
|
||||
const result = {
|
||||
invalidRejected,
|
||||
rootResult,
|
||||
rootEdit,
|
||||
scopedResult,
|
||||
rootGoal: host.goals.get(rootAgent)?.objective,
|
||||
scopedGoal: host.goals.get(scopedAgent)?.objective,
|
||||
rootEvents: rootAgent.session.events.length,
|
||||
scopedEvents: scopedAgent.session.events.length,
|
||||
}
|
||||
|
||||
await client.fiber.dispose()
|
||||
await new Promise((resolveClose, rejectClose) => server.close(error => {
|
||||
if (error === undefined) resolveClose()
|
||||
else rejectClose(error)
|
||||
}))
|
||||
await host.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
`
|
||||
|
||||
const result = await runPlainNode(script)
|
||||
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
|
||||
const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
|
||||
invalidRejected: boolean
|
||||
rootResult: { ref: { id: string; revision: number } }
|
||||
rootEdit: { objective: string; revision: number }
|
||||
scopedResult: { ref: { id: string; revision: number } }
|
||||
rootGoal: string
|
||||
scopedGoal: string
|
||||
rootEvents: number
|
||||
scopedEvents: number
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
invalidRejected: true,
|
||||
rootResult: { ref: { revision: 1 } },
|
||||
rootEdit: { objective: 'edited root goal', revision: 2 },
|
||||
scopedResult: { ref: { revision: 1 } },
|
||||
rootGoal: 'edited root goal',
|
||||
scopedGoal: 'scoped goal',
|
||||
rootEvents: 2,
|
||||
scopedEvents: 1,
|
||||
})
|
||||
expect(output.rootResult.ref.id).toMatch(/^goal-/)
|
||||
expect(output.scopedResult.ref.id).toMatch(/^goal-/)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
/** Execute one ESM script without tsx or a TypeScript loader. */
|
||||
function runPlainNode(script: string): Promise<{
|
||||
readonly exitCode: number | null
|
||||
readonly stdout: string
|
||||
readonly stderr: string
|
||||
}> {
|
||||
return new Promise((resolveRun) => {
|
||||
execFile(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
timeout: 55_000,
|
||||
}, (error, stdout, stderr) => {
|
||||
resolveRun({
|
||||
exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
22
packages/api/remotes/tsconfig.client.json
Normal file
22
packages/api/remotes/tsconfig.client.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
packages/api/remotes/tsconfig.host.json
Normal file
36
packages/api/remotes/tsconfig.host.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/agent-lookup.ts",
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/registry"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
packages/api/remotes/tsconfig.json
Normal file
11
packages/api/remotes/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
packages/api/remotes/tsdown.config.ts
Normal file
7
packages/api/remotes/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle(
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
{ hostPhase: true },
|
||||
)
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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/bash/bash-local/README.md
|
||||
README.md: bb87ad6fe021e3144cef4adced3d798bf3d94d67
|
||||
README.zh.md: d2f8c9091072bbf3d75909f6826432601001ab88
|
||||
README.md: cb40cb8fa40d95d5b4589b7c804f450a2bf38c8e
|
||||
README.zh.md: bd4f73babdb47ff92e87e20eb7d60657ed515ec4
|
||||
|
||||
@@ -23,7 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
## Behavior
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
## 行为
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。
|
||||
|
||||
@@ -14,7 +14,7 @@ import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
@@ -48,7 +48,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
@@ -102,6 +102,9 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
|
||||
@@ -70,6 +71,8 @@ describe('LocalBashExecutor.run', () => {
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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/bash/pwsh-local/README.md
|
||||
README.md: 2914c46ab91dd9555dab04551e52321f6eac05bf
|
||||
README.zh.md: ce9696b276a2e60acf116d7124cd5cd256d7ebde
|
||||
README.md: 3e38ea3830cb651a80eaee744a42f68891767358
|
||||
README.zh.md: 8d32ce865d299bac37704e3e8730a7faa63ee108
|
||||
|
||||
@@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
|
||||
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
|
||||
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
|
||||
|
||||
@@ -18,7 +18,7 @@ import z from 'schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolvePwshPath } from './resolve.ts'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
|
||||
@@ -62,7 +62,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
|
||||
maxSpillBytes?: number
|
||||
/** Grace period for kill escalation and for inherited pipes after shell exit. */
|
||||
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
|
||||
graceMs?: number
|
||||
/**
|
||||
* Explicit pwsh executable. When omitted, well-known Windows install
|
||||
@@ -129,6 +129,9 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
this.pwshPath = resolvePwshPath(this.config.pwshPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPa
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SubprocessService from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
|
||||
@@ -187,6 +188,8 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
|
||||
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
|
||||
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
|
||||
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
|
||||
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
|
||||
const { bash } = await setup()
|
||||
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
|
||||
|
||||
@@ -48,7 +48,7 @@ afterEach(() => {
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
|
||||
6
packages/bundle/README.i18n.yaml
Normal file
6
packages/bundle/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/bundle/README.md
|
||||
README.md: 4759170435a80e85731446cef21d24fff2abed66
|
||||
README.zh.md: 1ef610a1b7b3c591c9a900e04f2d8096b0b086b9
|
||||
13
packages/bundle/README.md
Normal file
13
packages/bundle/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# bundle/ — profile plugin bundles
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../ui/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) |
|
||||
| [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows |
|
||||
| [`headless/`](headless/README.md) | One-shot task mode over base + web-app | mounts `headless-runner` |
|
||||
|
||||
In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile <name> add <package>`.
|
||||
13
packages/bundle/README.zh.md
Normal file
13
packages/bundle/README.zh.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# bundle/ — profile 插件组合包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 契约](../ui/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`base/`](base/README.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) |
|
||||
| [`web-app/`](web-app/README.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 |
|
||||
| [`headless/`](headless/README.md) | 叠加在 base + web-app 之上的一次性任务模式 | 挂载 `headless-runner` |
|
||||
|
||||
内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile <name> add <package>` 安装进 profile。
|
||||
6
packages/bundle/base/README.i18n.yaml
Normal file
6
packages/bundle/base/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/bundle/base/README.md
|
||||
README.md: 301d397d4c87687b382665cf63af47ab5e3f85be
|
||||
README.zh.md: f007bc817b6cbad84725fe8abe72549cf67d8cd7
|
||||
19
packages/bundle/base/README.md
Normal file
19
packages/bundle/base/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `@deepseek-ai/dsh-base`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
|
||||
|
||||
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the inserted rows: this bundle selects the shipped persona-less prompt base, tool set, and DeepSeek adapter that mode bundles specialize, and contributes no model-visible text of its own.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None directly; each inserted row's package owns its effect.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer.
|
||||
19
packages/bundle/base/README.zh.md
Normal file
19
packages/bundle/base/README.zh.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `@deepseek-ai/dsh-base`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。
|
||||
|
||||
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过插入的行间接产生影响:该组合包选定了随发行版交付的无 persona 提示词基座、工具集合与 DeepSeek 适配器,供各模式组合包进一步特化;它自身不贡献任何模型可见文本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;每条插入行的影响归其所属的包负责。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。
|
||||
421
packages/bundle/base/cordis.patch.yml
Normal file
421
packages/bundle/base/cordis.patch.yml
Normal file
@@ -0,0 +1,421 @@
|
||||
# The dsh-base bundle patch: the shared core of every dsh profile, applied as
|
||||
# ONE insert over the empty profile root. Later bundle patches and the user's
|
||||
# profile cordis.patch.yml address these rows by id, with the last write
|
||||
# winning per row.
|
||||
#
|
||||
# A patch replaces the targeted row's whole `config` rather than merging into
|
||||
# it, so a row whose value differs by mode does NOT live here: it belongs to
|
||||
# each mode bundle, keeping any single row down to one bundle layer plus the
|
||||
# user's. Mode-specific rows appear below only with shared plugin identity and
|
||||
# neutral defaults; each mode bundle restates its complete configuration.
|
||||
#
|
||||
# Row order carries no load semantics (activation is service-availability
|
||||
# driven); the grouping is for readers.
|
||||
|
||||
- insert:
|
||||
- id: timer
|
||||
name: '@cordisjs/plugin-timer'
|
||||
|
||||
- id: hmr
|
||||
name: '@cordisjs/plugin-hmr'
|
||||
config:
|
||||
root: ['.']
|
||||
|
||||
# The profile's cordis.patch.yml replaces this row's config to select exact GitHub
|
||||
# repository Plugin generations. The app registers the DSH-owned runtime even
|
||||
# when the list is empty so a later personal-config edit can load
|
||||
# transactionally; one-shot headless runs consume the startup value only.
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm'
|
||||
|
||||
- id: session
|
||||
name: '@deepseek-ai/dsh-session'
|
||||
|
||||
- id: typert
|
||||
name: '@deepseek-ai/dsh-typert-registry'
|
||||
|
||||
- id: typert-loader
|
||||
name: '@deepseek-ai/dsh-typert-loader'
|
||||
|
||||
- id: typert-gateway
|
||||
name: '@deepseek-ai/dsh-api-gateway'
|
||||
|
||||
- id: session-title
|
||||
name: '@deepseek-ai/dsh-session-title'
|
||||
config:
|
||||
fallbackMaxWords: 5
|
||||
fallbackMaxBytes: 40
|
||||
maxTitleBytes: 80
|
||||
|
||||
- id: session-title-llm
|
||||
name: '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
config:
|
||||
targetWords: 5
|
||||
targetCjkCharacters: 10
|
||||
maxInputBytes: 4096
|
||||
maxOutputTokens: 64
|
||||
timeoutMs: 60000
|
||||
|
||||
- id: user-interaction
|
||||
name: '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
- id: agent
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
- id: tasks
|
||||
name: '@deepseek-ai/dsh-tasks-local'
|
||||
|
||||
- id: llm-retry
|
||||
name: '@deepseek-ai/dsh-llm-retry'
|
||||
|
||||
# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a
|
||||
# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries
|
||||
# below without a restart, and is what the web Models page writes.
|
||||
- id: settings
|
||||
name: '@deepseek-ai/dsh-settings-local'
|
||||
|
||||
# Credential sources: inherited environment over the managed
|
||||
# `$DSH_HOME/.credentials.yaml`, with project and user `.env` fallbacks.
|
||||
# Adapters resolve references per request; the Models page writes only the
|
||||
# managed document, which is never materialized into the process environment.
|
||||
- id: credentials
|
||||
name: '@deepseek-ai/dsh-credentials-local'
|
||||
|
||||
# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra
|
||||
# models in the picker) until a `llm-pi-ai:` settings section supplies provider
|
||||
# profiles — then those routes register live, keys resolving per request
|
||||
# through their apiKeyEnv references, and drop again when the section empties.
|
||||
# Supplying those profiles is exactly what the web Models page does. Which
|
||||
# adapters exist is composition; which providers run is the user's settings
|
||||
# document.
|
||||
- id: llm-pi-ai
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
|
||||
- id: session-persistence-jsonl
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js dshHomePath('sessions')
|
||||
|
||||
# Raw configs can supply a process-local path or disable this shared session
|
||||
# capability. The neutral default is process-local and opens only when used.
|
||||
- id: session-query-sqlite
|
||||
name: '@deepseek-ai/dsh-session-query-sqlite'
|
||||
config:
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
|
||||
# Shared projection registry: subagent catalog identity (mode/label) folds
|
||||
# through its registered units, so the `list_agents` surface below fails
|
||||
# loud without it; web layers reuse this same mount for list rows.
|
||||
- id: session-projection
|
||||
name: '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
# Session telemetry, on for every dsh mode: mirrors every session-log
|
||||
# event (assistant/chunk projected to first-of-step) plus ops markers onto
|
||||
# OTLP/HTTP log records, streaming on the batch processor's cadence
|
||||
# (10s/batch here) — not at exit; a crash loses at most the last unexported
|
||||
# interval. No telemetry/record redaction rule is mounted yet, so exports
|
||||
# are the raw captured copy; the deployment stance, env seams, and
|
||||
# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
|
||||
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
|
||||
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
|
||||
# process out (the launchers patch the row disabled; config cannot disable
|
||||
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
|
||||
# random UUID; delete the file to reset the identity) as the Resource's
|
||||
# user.id. The exporter/processor values normally bound the shutdown drain
|
||||
# to ~1s against an unreachable collector: exporter.timeoutMillis is both
|
||||
# the per-attempt socket timeout and the retry deadline (1s effectively
|
||||
# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
|
||||
# (both explicit) makes the drain a single batch. The SDK awaits
|
||||
# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
|
||||
# shutdownTimeoutMillis is the load-bearing outer bound when a transport
|
||||
# promise never settles. Every CLI exit path drains it by disposing the root
|
||||
# on SIGINT/SIGTERM.
|
||||
- id: telemetry-otel
|
||||
name: '@deepseek-ai/dsh-session-telemetry-otel'
|
||||
config:
|
||||
shutdownTimeoutMillis: 3000
|
||||
exporter:
|
||||
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
|
||||
compression: gzip
|
||||
timeoutMillis: 1000
|
||||
processor:
|
||||
scheduledDelayMillis: 10000
|
||||
maxQueueSize: 2048
|
||||
maxExportBatchSize: 2048
|
||||
exportTimeoutMillis: 1500
|
||||
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
# Every shipped CLI mode starts with the same file-effect boundary.
|
||||
# The environment remains an explicit deployment override; otherwise fresh
|
||||
# sessions pin workspace-write + ask through the permission service below.
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
|
||||
- id: approval
|
||||
name: '@deepseek-ai/dsh-user-approval'
|
||||
config:
|
||||
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
|
||||
|
||||
- id: permission
|
||||
name: '@deepseek-ai/dsh-permission'
|
||||
config:
|
||||
presets:
|
||||
read-only:
|
||||
sandbox: read-only
|
||||
approval: ask
|
||||
workspace-write:
|
||||
sandbox: workspace-write
|
||||
approval: ask
|
||||
danger-full-access:
|
||||
sandbox: danger-full-access
|
||||
approval: never
|
||||
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
- id: fs-policy
|
||||
name: '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
- id: tool-fs-search
|
||||
name: '@deepseek-ai/dsh-tool-fs-search'
|
||||
config:
|
||||
sampleOverCapGlobResults: false
|
||||
|
||||
- id: workspace-context
|
||||
name: '@deepseek-ai/dsh-workspace-context'
|
||||
config:
|
||||
maxBytes: 65536
|
||||
|
||||
- id: skill
|
||||
name: '@deepseek-ai/dsh-skill'
|
||||
|
||||
- id: skill-local
|
||||
name: '@deepseek-ai/dsh-skill-local'
|
||||
|
||||
- id: tool-skill
|
||||
name: '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
- id: commands
|
||||
name: '@deepseek-ai/dsh-commands'
|
||||
|
||||
- id: command-feedback
|
||||
name: '@deepseek-ai/dsh-command-feedback'
|
||||
|
||||
- id: goal
|
||||
name: '@deepseek-ai/dsh-goal'
|
||||
|
||||
- id: goal-session
|
||||
name: '@deepseek-ai/dsh-goal-session'
|
||||
|
||||
- id: command-goal
|
||||
name: '@deepseek-ai/dsh-command-goal'
|
||||
|
||||
- id: plan-mode
|
||||
name: '@deepseek-ai/dsh-plan-mode'
|
||||
config:
|
||||
section: |
|
||||
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
||||
|
||||
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
||||
|
||||
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
|
||||
|
||||
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
|
||||
|
||||
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
|
||||
|
||||
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
|
||||
|
||||
- id: token-meter
|
||||
name: '@deepseek-ai/dsh-token-meter'
|
||||
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
# Human `/compact`: one useful reduction below the automatic threshold. Backend
|
||||
# independent, so it follows whichever compaction service this leaf mounts.
|
||||
- id: command-compact
|
||||
name: '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
- id: subagent
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
|
||||
- id: subagent-spawn
|
||||
name: '@deepseek-ai/dsh-subagent-spawn'
|
||||
config:
|
||||
providerName: spawn
|
||||
|
||||
- id: subagent-fork
|
||||
name: '@deepseek-ai/dsh-subagent-fork'
|
||||
config:
|
||||
providerName: fork
|
||||
|
||||
# Continuable background children are selected per delegation tool. The
|
||||
# separately loaded follow-up tool registers the one global `send_message`.
|
||||
- id: tool-subagent-control
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control'
|
||||
|
||||
- id: tool-subagent-list-agents
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
backgroundMode: continuable
|
||||
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
# Optional direct-child return channel; absent from roots and one-shot agents.
|
||||
- id: tool-subagent-report
|
||||
name: '@deepseek-ai/dsh-tool-subagent-report'
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
- id: timeout-policy
|
||||
name: '@deepseek-ai/dsh-timeout-policy'
|
||||
|
||||
- id: spill-local
|
||||
name: '@deepseek-ai/dsh-spill-local'
|
||||
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
|
||||
# Durability checkpoints before each model request and top-level dispatch.
|
||||
- id: session-checkpoint-policy
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
# Compacts oversized tool results before the broader conversation compactor
|
||||
# runs, preserving the model-visible result within the configured budget.
|
||||
- id: tool-result-prune
|
||||
name: '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
config:
|
||||
thresholdChars: 8192
|
||||
headChars: 4096
|
||||
tailChars: 1024
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
config:
|
||||
allowParallelInProgress: true
|
||||
|
||||
# Persisted same-session goals reach the model and the slash menu here; the
|
||||
# domain, driver, and `/goal` command are above.
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
# Fresh-agent Ralph iteration over a build-time-fixed script.
|
||||
- id: tool-ralph
|
||||
name: '@deepseek-ai/dsh-tool-ralph'
|
||||
config:
|
||||
subagentProvider: spawn
|
||||
maxRounds: 64
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# Consecutive-repeat reminders on the tool chain.
|
||||
- id: repeat-tool-guard
|
||||
name: '@deepseek-ai/dsh-repeat-tool-guard'
|
||||
config:
|
||||
thresholds: [3, 5, 8]
|
||||
argumentsPreviewChars: 500
|
||||
|
||||
# Every mode enables the stable web_search model surface. DeepSeek search
|
||||
# resolves the same DEEPSEEK_API_KEY credential the Models page manages for
|
||||
# chat, at each search; its Messages endpoint is separate from the
|
||||
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
|
||||
# disabled and no fetch provider is mounted: that provider defers SSRF
|
||||
# protection and the model would choose the request target. Search is a full
|
||||
# auxiliary model request with server-side retrieval, so this shipped DeepSeek
|
||||
# route gets 60s while the provider-neutral tool default remains 30s.
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: deepseek-official
|
||||
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetch: false
|
||||
searchTimeoutMs: 60000
|
||||
|
||||
# ── rows every mode mounts, whose values each overlay may state ──────────────
|
||||
|
||||
# The tool registry. Presentation mode is a deployment choice; omitting it here
|
||||
# keeps the schema default (native).
|
||||
- id: tools
|
||||
name: '@deepseek-ai/dsh-tools'
|
||||
|
||||
# The deployment persona is a deployment choice; plan-mode and tool plugins own
|
||||
# their own prompt sections.
|
||||
- id: system-prompt
|
||||
name: '@deepseek-ai/dsh-system-prompt'
|
||||
config:
|
||||
persona: ''
|
||||
|
||||
# Agents created at startup. The base stays empty; raw overlays may create
|
||||
# agents, while Web creates sessions on client request.
|
||||
- id: agent-loop
|
||||
name: '@deepseek-ai/dsh-agent-loop'
|
||||
config:
|
||||
agents: []
|
||||
|
||||
# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; an
|
||||
# overlay can pin another workspace.
|
||||
- id: fs-sandbox
|
||||
name: '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per
|
||||
# request from the `llm-deepseek:` settings section over this entry, with the
|
||||
# key coming from the credential store below. Thinking defaults are a deployment
|
||||
# choice.
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
116
packages/bundle/base/package.json
Normal file
116
packages/bundle/base/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-base",
|
||||
"description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@cordisjs/plugin-hmr": "workspace:*",
|
||||
"@cordisjs/plugin-timer": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
9
packages/bundle/base/src/index.ts
Normal file
9
packages/bundle/base/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-base — the shared dsh core as a profile bundle. The
|
||||
* package's substance is `cordis.patch.yml`, declared by the `dsh.bundle.patch`
|
||||
* manifest field and resolved by the profile composer through that field;
|
||||
* this module carries no runtime API.
|
||||
* @module @deepseek-ai/dsh-base
|
||||
*/
|
||||
|
||||
export {}
|
||||
28
packages/bundle/base/src/invariant.ts
Normal file
28
packages/bundle/base/src/invariant.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-base`.
|
||||
* @module @deepseek-ai/dsh-base/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-base'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'base-bundle-invariant'
|
||||
/** Service required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
// No runtime invariant: the package is a static patch-list carrier (a YAML
|
||||
// document of loader rows owned by other packages); it mounts no service,
|
||||
// emits no events, and owns no mutable relation to check. Each inserted row's
|
||||
// own package carries that row's invariants.
|
||||
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))
|
||||
25
packages/bundle/base/tests/base.spec.ts
Normal file
25
packages/bundle/base/tests/base.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The bundle's substance is its patch file: the `dsh.bundle.patch` manifest
|
||||
* field must name a real, parseable patch list.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
|
||||
describe('dsh-base bundle', () => {
|
||||
it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } }
|
||||
expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
|
||||
const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema })
|
||||
expect(Array.isArray(parsed)).toBe(true)
|
||||
// The base layer is one insert list over the empty profile root.
|
||||
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? [])
|
||||
expect(rows.length).toBeGreaterThan(50)
|
||||
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
|
||||
})
|
||||
})
|
||||
18
packages/bundle/base/tsconfig.json
Normal file
18
packages/bundle/base/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/bundle/headless/README.i18n.yaml
Normal file
6
packages/bundle/headless/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/bundle/headless/README.md
|
||||
README.md: 661b377817482d22f58f22b573075722646729a2
|
||||
README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7
|
||||
18
packages/bundle/headless/README.md
Normal file
18
packages/bundle/headless/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# `@deepseek-ai/dsh-headless`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the runner submits the task as an ordinary user message over the shared composition; prompts and tools belong to the base/web bundles.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; the runner adds nothing to the request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One turn only** — the runner anchors on the first message-triggered turn and exits at its end; queued follow-ups and multi-turn tasks are out of scope.
|
||||
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the seam.
|
||||
18
packages/bundle/headless/README.zh.md
Normal file
18
packages/bundle/headless/README.zh.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# `@deepseek-ai/dsh-headless`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。runner 把任务作为普通用户消息经共享组合提交;提示词与工具归 base/web 组合包所有。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;runner 不向请求前缀添加任何内容。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **只运行一个轮次**:runner 锚定第一个由消息触发的轮次,并在其结束时退出;排队的后续消息与多轮任务不在范围内。
|
||||
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时大声失败,直到宿主提供该 seam。
|
||||
21
packages/bundle/headless/cordis.patch.yml
Normal file
21
packages/bundle/headless/cordis.patch.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
|
||||
# dsh-web-app. The web composition stays mounted (the session is observable
|
||||
# in a browser while it runs); this layer silences the URL line and the
|
||||
# GUI-orientation surface context (this user is not in the GUI), moves the
|
||||
# webserver to an OS-assigned port so parallel headless runs never collide,
|
||||
# and mounts the one-shot runner. The launcher patches the runner's `task`.
|
||||
|
||||
- id: webserver
|
||||
config:
|
||||
host: 127.0.0.1
|
||||
port: 0
|
||||
|
||||
- id: web-runtime
|
||||
config:
|
||||
mode: production
|
||||
printUrl: false
|
||||
surfaceContext: false
|
||||
|
||||
- insert:
|
||||
- id: headless-runner
|
||||
name: '@deepseek-ai/dsh-headless'
|
||||
54
packages/bundle/headless/package.json
Normal file
54
packages/bundle/headless/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-headless",
|
||||
"description": "The dsh one-shot bundle: a patch layer over dsh-base + dsh-web-app plus the runner plugin driving one task turn through the in-process API carrier",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
182
packages/bundle/headless/src/index.ts
Normal file
182
packages/bundle/headless/src/index.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-headless — the one-shot headless bundle: the bundle patch
|
||||
* (`cordis.patch.yml`) rides over dsh-base + dsh-web-app (the headless
|
||||
* session is web-observable while it runs — same composition), and this
|
||||
* runner plugin drives one task through the in-process API carrier
|
||||
* (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
|
||||
* chain — serialization, zod, SSE framing — really runs), prints the final
|
||||
* assistant text at agent quiescence, and exits (completed → 0, else 1). The
|
||||
* task text arrives as launcher-patched config (`dsh run "task"`).
|
||||
* @module @deepseek-ai/dsh-headless
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
// Empty type imports carry the httpServer and agent/status Context merges used below.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
// Empty type import carries the loader Context merge for the settlement await.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'headless-runner'
|
||||
|
||||
/** Services required before the one-shot turn can start. */
|
||||
export const inject = ['apiProxy', 'httpServer']
|
||||
|
||||
/** Plugin config: the task, patched in by the launcher. */
|
||||
export interface Config {
|
||||
/** The prompt text for the single turn. */
|
||||
task: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
task: z.string().required(),
|
||||
})
|
||||
|
||||
/** Outcome of one headless run: aggregated final text plus the last turn-end reason kind. */
|
||||
interface TurnOutcome {
|
||||
text: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-facing effects of one run, injectable for tests: output
|
||||
* streams and the exit request (the launcher wires it to its bounded
|
||||
* shutdown controller).
|
||||
*/
|
||||
export interface HeadlessIo {
|
||||
stdout: { write(chunk: string): unknown }
|
||||
stderr: { write(chunk: string): unknown }
|
||||
/** Request process exit with `code` after the tree disposes. */
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
/** Host seam: the launcher provides the exit wiring before the tree mounts. */
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Process-facing effects for the one-shot headless runner. */
|
||||
headlessIo?: HeadlessIo
|
||||
}
|
||||
}
|
||||
|
||||
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1. */
|
||||
async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
|
||||
if (response.result.ok) return response.result.value
|
||||
const { code, message } = response.result.error
|
||||
io.stderr.write(`dsh: ${code}: ${message}\n`)
|
||||
io.exit(1)
|
||||
// Exit is asynchronous (bounded tree disposal); park this turn forever so
|
||||
// no further request rides a session that is already being torn down.
|
||||
return new Promise<never>(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume mux frames until the agent reaches idle, per the one-shot CLI
|
||||
* idle-to-idle contract: the stream opens immediately before the prompt, and
|
||||
* its first observed turn/start begins the task. Text is the last committed
|
||||
* assistant message of the whole interval (steering or injected work may run
|
||||
* further turns before quiescence), and the outcome reason is the final
|
||||
* turn/end's kind. Idleness is signalled out of band by the caller's
|
||||
* `agent/status` subscription; the stream itself carries no status frame.
|
||||
* @param frames - the mux stream opened before the prompt.
|
||||
* @param sessionId - the headless session.
|
||||
* @param idle - resolves to the final session-event sequence when the agent reaches quiescence.
|
||||
* @param io - process-facing effects for stream diagnostics.
|
||||
* @returns the aggregated outcome.
|
||||
*/
|
||||
async function consumeUntilIdle(
|
||||
frames: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
sessionId: SessionId,
|
||||
idle: Promise<number>,
|
||||
io: HeadlessIo,
|
||||
): Promise<TurnOutcome> {
|
||||
let started = false
|
||||
let text = ''
|
||||
let reason: string = 'error'
|
||||
let observedSeq = -1
|
||||
let resolveProgress: (() => void) | undefined
|
||||
const streamDone = (async () => {
|
||||
try {
|
||||
for await (const frame of frames) {
|
||||
const payload = frame.payload
|
||||
if (payload.type === 'stream/error') return
|
||||
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
|
||||
const event = payload.event
|
||||
observedSeq = event.seq
|
||||
resolveProgress?.()
|
||||
resolveProgress = undefined
|
||||
if (event.type === 'turn/start') {
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (!started) continue
|
||||
if (event.type === 'assistant/message') {
|
||||
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
if (joined !== '') text = joined
|
||||
}
|
||||
if (event.type === 'turn/end') reason = event.data.reason.kind
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
|
||||
}
|
||||
})()
|
||||
const streamEnded = streamDone.then(() => 'ended' as const)
|
||||
const idleSeq = await idle
|
||||
while (observedSeq < idleSeq) {
|
||||
const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } })
|
||||
if (await Promise.race([progress, streamEnded]) === 'ended') break
|
||||
}
|
||||
return { text, reason }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one headless task to quiescence and request exit (completed → 0, else 1).
|
||||
* @param ctx - plugin context carrying apiProxy, httpServer, and the launcher's headlessIo.
|
||||
* @param config - validated {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const io = ctx.headlessIo
|
||||
if (io === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
|
||||
}
|
||||
// Fire-and-forget by design: the run outlives plugin activation, and every
|
||||
// failure path inside ends in io.exit, not a rejection.
|
||||
void (async () => {
|
||||
// The Loader mounts sibling rows concurrently and this plugin's inject
|
||||
// gate covers only apiProxy/httpServer; prompting before the agent loop,
|
||||
// adapters, and tools settle would fail the turn on a half-mounted tree.
|
||||
// The old launcher ran strictly after settled boot — preserve that.
|
||||
// A tree disposed mid-settlement (early SIGTERM) has nothing to run.
|
||||
await ctx.get('loader')?.await()
|
||||
if (ctx.get('httpServer') === undefined) return
|
||||
// The headless session is web-observable while it runs (same composition).
|
||||
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
|
||||
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
||||
const created = await unwrap(await api.sessions.create({}), io)
|
||||
// Open the stream before prompting so no frame is lost. The quiescence
|
||||
// anchor below is an in-process ctx subscription, so a remote-carrier
|
||||
// port of this runner must replace it with a wire-visible idle signal.
|
||||
const abort = new AbortController()
|
||||
const frames = api.events.mux({}, abort.signal)
|
||||
const idle = new Promise<number>((resolve) => {
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
|
||||
})
|
||||
})
|
||||
const done = consumeUntilIdle(frames, created.sessionId, idle, io)
|
||||
await unwrap(await api.sessions.prompt({
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: config.task }],
|
||||
}), io)
|
||||
const outcome = await done
|
||||
io.stdout.write(outcome.text + '\n')
|
||||
abort.abort()
|
||||
io.exit(outcome.reason === 'completed' ? 0 : 1)
|
||||
})()
|
||||
}
|
||||
30
packages/bundle/headless/src/invariant.ts
Normal file
30
packages/bundle/headless/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-headless`.
|
||||
* @module @deepseek-ai/dsh-headless/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-headless'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'headless-invariant'
|
||||
/** Service required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the runner is a one-shot driver over the API carrier
|
||||
* whose observable contract (final text on stdout, exit code by turn-end
|
||||
* reason) is process-level and owned by the launcher e2e; it registers
|
||||
* nothing and holds no mutable relation to audit inside the tree.
|
||||
*/
|
||||
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))
|
||||
258
packages/bundle/headless/tests/headless.spec.ts
Normal file
258
packages/bundle/headless/tests/headless.spec.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* One-shot runner behavior over a scripted in-process API: idle-to-idle
|
||||
* aggregation (last text of the whole interval), exit-code mapping by the
|
||||
* final turn-end reason, stream-error and RPC-error paths, and the
|
||||
* launcher-owned `ctx.headlessIo` requirement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { apply, Config, type HeadlessIo } from '../src/index.ts'
|
||||
|
||||
interface ScriptedEvent { type: string; seq?: number; time?: number; sessionId?: string; data: Record<string, unknown> }
|
||||
|
||||
let nextSeq = 0
|
||||
/** Stamp the envelope fields the wire schema requires. */
|
||||
function stamped(event: ScriptedEvent): ScriptedEvent {
|
||||
nextSeq += 1
|
||||
return { seq: nextSeq, time: nextSeq, ...event }
|
||||
}
|
||||
|
||||
interface RpcShapedRequest { rpcId: string }
|
||||
|
||||
interface ScriptedApiOptions {
|
||||
promptFails?: boolean
|
||||
framesAfterPrompt?: boolean
|
||||
onPrompt?: () => void
|
||||
}
|
||||
|
||||
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
|
||||
function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown {
|
||||
let releaseFrames = (): void => {}
|
||||
const framesReady = options.framesAfterPrompt === true
|
||||
? new Promise<void>((resolve) => { releaseFrames = resolve })
|
||||
: Promise.resolve()
|
||||
const prepared = events.map((event) => {
|
||||
if (event.type === 'stream/error') return { streamError: true } as const
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
return { streamError: false, sessionId, event: stamped(rest) } as const
|
||||
})
|
||||
return {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) => {
|
||||
releaseFrames()
|
||||
options.onPrompt?.()
|
||||
return Promise.resolve(options.promptFails === true
|
||||
// A code from the closed wire union: the carrier schema rejects invented codes.
|
||||
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
|
||||
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } })
|
||||
},
|
||||
},
|
||||
events: {
|
||||
mux: async function* () {
|
||||
await framesReady
|
||||
for (const item of prepared) {
|
||||
if (item.streamError) {
|
||||
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
|
||||
continue
|
||||
}
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the runner against a scripted API, emit the idle transition after the
|
||||
* scripted frames drain, and wait for its exit request.
|
||||
*/
|
||||
async function run(
|
||||
events: ScriptedEvent[],
|
||||
options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {},
|
||||
): Promise<{ code: number; out: string; err: string }> {
|
||||
const ctx = new Context()
|
||||
let out = ''
|
||||
let err = ''
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
const io: HeadlessIo = {
|
||||
stdout: { write: (chunk: string) => { out += chunk; return true } },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
}
|
||||
ctx.provide('headlessIo', io)
|
||||
})
|
||||
const emitIdle = (): void => {
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
}
|
||||
ctx.provide('apiProxy', scriptedApi(events, {
|
||||
...options.promptFails === undefined ? {} : { promptFails: options.promptFails },
|
||||
...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt },
|
||||
...options.idleInPrompt === true ? { onPrompt: emitIdle } : {},
|
||||
}) as never)
|
||||
ctx.provide('httpServer', { port: 12345 } as never)
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
// Quiescence is out of band: give the scripted stream a beat to drain, then
|
||||
// flip the agent idle exactly as the loop would. Foreign agents and
|
||||
// non-idle transitions must not settle the run.
|
||||
if (options.idleInPrompt !== true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
|
||||
emitIdle()
|
||||
}
|
||||
const code = await exited
|
||||
await ctx.fiber.dispose()
|
||||
return { code, out, err }
|
||||
}
|
||||
|
||||
const startupTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 0, trigger: { kind: 'startup' } } }
|
||||
const messageTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } }
|
||||
const text = (turn: number, value: string): ScriptedEvent => ({
|
||||
type: 'assistant/message',
|
||||
data: { turn, message: { content: [{ type: 'text', text: value }] } },
|
||||
})
|
||||
const end = (turn: number, reason: string): ScriptedEvent => ({ type: 'turn/end', data: { turn, reason: { kind: reason } } })
|
||||
|
||||
describe('headless runner', () => {
|
||||
it('aggregates to quiescence: last text wins across turns, final turn-end reason maps to exit 0', async () => {
|
||||
const { code, out, err } = await run([
|
||||
// Frames before the first turn/start are outside the task interval.
|
||||
{ type: 'assistant/message', data: { turn: 0, message: { content: [{ type: 'text', text: 'pre-task noise' }] } } },
|
||||
startupTurn,
|
||||
// Off-session, non-text, and text-empty frames never affect the aggregate.
|
||||
{ type: 'assistant/message', sessionId: 'OTHER', data: { turn: 1, message: { content: [{ type: 'text', text: 'other session' }] } } },
|
||||
{ type: 'assistant/message', data: { turn: 1, message: { content: [{ type: 'tool_call', text: 'ignored' }] } } },
|
||||
text(0, 'draft'),
|
||||
end(0, 'completed'),
|
||||
messageTurn,
|
||||
text(1, 'final answer'),
|
||||
end(1, 'completed'),
|
||||
])
|
||||
expect(code).toBe(0)
|
||||
expect(out).toBe('final answer\n')
|
||||
expect(err).toContain('observing at http://127.0.0.1:12345')
|
||||
})
|
||||
|
||||
it('consumes through the idle sequence when queued frames arrive after the status transition', async () => {
|
||||
const { code, out } = await run(
|
||||
[messageTurn, text(1, 'race-free answer'), end(1, 'completed')],
|
||||
{ framesAfterPrompt: true, idleInPrompt: true },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(out).toBe('race-free answer\n')
|
||||
})
|
||||
|
||||
it('exits 1 when the final turn ends for any other reason', async () => {
|
||||
const { code } = await run([messageTurn, end(1, 'aborted')])
|
||||
expect(code).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 1 when no turn ever starts (idle without work)', async () => {
|
||||
const { code, out } = await run([])
|
||||
expect(code).toBe(1)
|
||||
expect(out).toBe('\n')
|
||||
})
|
||||
|
||||
it('keeps the error outcome after a stream error ends the frame consumer early', async () => {
|
||||
const { code } = await run([messageTurn, { type: 'stream/error', data: {} }, end(1, 'completed')])
|
||||
// The consumer stopped at the stream error; the completed turn-end after
|
||||
// it is never observed, so the reason stays 'error'.
|
||||
expect(code).toBe(1)
|
||||
})
|
||||
|
||||
it('prints an RPC business error and exits 1 without waiting for idle', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
})
|
||||
ctx.provide('apiProxy', scriptedApi([messageTurn, end(1, 'completed')], { promptFails: true }) as never)
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
expect(await exited).toBe(1)
|
||||
expect(err).toContain('agent-busy')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reports the stream-failed diagnostic when the event channel dies, still settling at idle', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
})
|
||||
ctx.provide('apiProxy', {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }),
|
||||
},
|
||||
events: {
|
||||
// Synchronous throw: the SSE response never forms, so the client-side
|
||||
// iterable rejects — the runner's own catch path, not a carrier frame.
|
||||
mux: () => { throw new Error('channel exploded') },
|
||||
},
|
||||
} as never)
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
|
||||
expect(await exited).toBe(1)
|
||||
expect(err).toContain('event stream failed')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for Loader settlement and abandons the run when the tree died during it', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
let exited = false
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: () => { exited = true },
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('apiProxy', scriptedApi([]) as never)
|
||||
// The webserver is provided by a child fiber whose disposal (early
|
||||
// SIGTERM during the boot window) removes the service; settlement
|
||||
// resolves only afterwards, and the runner must abandon rather than
|
||||
// crash on the torn-down port read.
|
||||
const webserverFiber = ctx.plugin((childCtx: Context) => {
|
||||
childCtx.provide('httpServer', { port: 1 } as never)
|
||||
})
|
||||
await webserverFiber
|
||||
let release: () => void
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
ctx.provide('loader', { await: () => settlement } as never)
|
||||
apply(ctx, { task: 't' })
|
||||
await webserverFiber.dispose()
|
||||
release!()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(err).toBe('')
|
||||
expect(exited).toBe(false)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud without the launcher-owned headlessIo seam', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('apiProxy', scriptedApi([]) as never)
|
||||
ctx.provide('httpServer', { port: 1 } as never)
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
|
||||
})
|
||||
|
||||
it('validates config: the task is required', () => {
|
||||
expect(() => new Config({ } as never)).toThrow()
|
||||
expect(new Config({ task: 'x' })).toEqual({ task: 'x' })
|
||||
})
|
||||
})
|
||||
36
packages/bundle/headless/tsconfig.json
Normal file
36
packages/bundle/headless/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/bundle/web-app/README.i18n.yaml
Normal file
6
packages/bundle/web-app/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/bundle/web-app/README.md
|
||||
README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b
|
||||
README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170
|
||||
26
packages/bundle/web-app/README.md
Normal file
26
packages/bundle/web-app/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# `@deepseek-ai/dsh-web-app`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Web-surface prompt section and bash runtime variables
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When `surfaceContext` is true, the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither the section nor the variables are registered.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One prompt paragraph per session plus two managed-environment variable lines; constant per process.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The frontend dist must be built** — `require.resolve` of the dist fails loud at activation with a build hint; there is no source-serving fallback.
|
||||
- **`lanAddresses` is a boot-time snapshot** — interface changes after boot are not re-advertised; the printed LAN URL always matches the configured trust fence.
|
||||
26
packages/bundle/web-app/README.zh.md
Normal file
26
packages/bundle/web-app/README.zh.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# `@deepseek-ai/dsh-web-app`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Web 表层提示词段落与 bash 运行时变量
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新契约,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个会话一段提示词,外加两行受管环境变量;每个进程内保持恒定。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **前端 dist 必须已构建**:对 dist 的 `require.resolve` 在激活时大声失败并给出构建提示;没有从源码直接服务的回退路径。
|
||||
- **`lanAddresses` 是启动期快照**:启动后的网卡变化不会重新公告;打印的 LAN URL 始终与配置的信任栅栏一致。
|
||||
194
packages/bundle/web-app/cordis.patch.yml
Normal file
194
packages/bundle/web-app/cordis.patch.yml
Normal file
@@ -0,0 +1,194 @@
|
||||
# The dsh-web-app bundle patch: the browser surface over the dsh-base layer.
|
||||
# Applied after dsh-base's insert; rows here override base rows by id, with
|
||||
# the profile's own cordis.patch.yml and any --patch overlays still to come.
|
||||
#
|
||||
# A patch replaces the targeted row's whole `config`, so each row below
|
||||
# restates every key it owns. The `dsh web` launcher alias turns --host/--port/
|
||||
# --dev/--workspace-root/--trusted-host into further patches over these rows
|
||||
# (`--dev` inserts the dsh-client-hmr row).
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
- id: system-prompt
|
||||
config:
|
||||
persona: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
# Web content search runs on an ephemeral in-memory index. The service
|
||||
# activates at boot, while first-search defers the node:sqlite import and
|
||||
# in-memory handle so Node 22 startup stays quiet until content search
|
||||
# actually uses SQLite. That search then reconciles this boot's sources.
|
||||
- id: session-query-sqlite
|
||||
config:
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
# TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh
|
||||
# process into Code Mode while per-session tool-mode selection is being
|
||||
# designed; unset keeps the schema default (native). Remove the env seam
|
||||
# once the web UI owns the choice per session.
|
||||
mode: !!js process.env.DSH_TOOLS_MODE
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
|
||||
- id: storage
|
||||
name: '@deepseek-ai/dsh-storage'
|
||||
|
||||
- id: storage-json
|
||||
name: '@deepseek-ai/dsh-storage-json'
|
||||
config:
|
||||
root: !!js dshHomePath('storages')
|
||||
|
||||
- id: storage-domain
|
||||
name: '@deepseek-ai/dsh-storage-domain'
|
||||
config:
|
||||
backend: json
|
||||
|
||||
- id: workspace
|
||||
name: '@deepseek-ai/dsh-workspace'
|
||||
|
||||
- id: session-projection-cache
|
||||
name: '@deepseek-ai/dsh-session-projection-cache'
|
||||
config:
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
|
||||
# Resolve bind host, SSH launch, and display once at boot, then mount the
|
||||
# matching dual-face directory picker. Mount -native or -browse directly in
|
||||
# an overlay to pin the interaction.
|
||||
- id: directory-picker
|
||||
name: '@deepseek-ai/dsh-host-directory-picker-auto'
|
||||
|
||||
# The API gateway: the transport-agnostic dispatch face every client shape
|
||||
# shares. provider/model are the host default routing — the profile json's
|
||||
# mapping target (user config overrides these engineering defaults).
|
||||
- id: api-gateway
|
||||
name: '@deepseek-ai/dsh-host-apiproxy'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
|
||||
# ── layer 2: transport/service ──────────────────────────────────────────────
|
||||
|
||||
# Plain route-registration carrier; host and port arrive as `dsh web`
|
||||
# flag patches over these defaults. The dist is served by the web-runtime
|
||||
# row below through the fallback seat.
|
||||
- id: webserver
|
||||
name: '@deepseek-ai/dsh-host-webserver'
|
||||
config:
|
||||
host: 127.0.0.1
|
||||
port: 3080
|
||||
|
||||
# Web glue owned by this bundle: resolves the built frontend dist (an
|
||||
# assembly fact of dsh-web-app, never user config), mounts the
|
||||
# frontend-static fallback owner, registers the web-surface prompt
|
||||
# section and bash runtime variables, and prints the URL line. `dsh web`
|
||||
# patches mode/lanAddresses over these defaults; complete-prompt overlays
|
||||
# set surfaceContext false to suppress every model- and shell-visible Web
|
||||
# runtime contribution.
|
||||
- id: web-runtime
|
||||
name: '@deepseek-ai/dsh-web-app'
|
||||
config:
|
||||
mode: production
|
||||
printUrl: true
|
||||
surfaceContext: true
|
||||
|
||||
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
|
||||
|
||||
# Dual-face: node half scans this very tree for dshClient rows, composes
|
||||
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
|
||||
# module table the shell kernel constructs before cordis exists (§4.7 —
|
||||
# adopted as a plugin entry by the kernel, never fetched).
|
||||
- id: modules
|
||||
name: '@deepseek-ai/dsh-client-modules'
|
||||
|
||||
# Owns both ends of the web transport: node half binds the gateway to the
|
||||
# webserver under /api; browser half is the fetch/SSE client.
|
||||
- id: connection
|
||||
name: '@deepseek-ai/dsh-client-connection'
|
||||
|
||||
- id: api-remotes
|
||||
name: '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
- id: client-runtime
|
||||
name: '@deepseek-ai/dsh-client-runtime'
|
||||
|
||||
- id: ui-theme
|
||||
name: '@deepseek-ai/dsh-client-ui-theme'
|
||||
|
||||
- id: locale
|
||||
name: '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
- id: ui-layout
|
||||
name: '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
- id: ui-sidebar
|
||||
name: '@deepseek-ai/dsh-client-ui-sidebar'
|
||||
|
||||
- id: ui-settings
|
||||
name: '@deepseek-ai/dsh-client-ui-settings'
|
||||
|
||||
- id: ui-settings-general
|
||||
name: '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
- id: ui-models
|
||||
name: '@deepseek-ai/dsh-client-ui-models'
|
||||
|
||||
- id: ui-conversation
|
||||
name: '@deepseek-ai/dsh-client-ui-conversation'
|
||||
|
||||
# Turn tail: the produced-files row under each closing assistant message.
|
||||
# Remove this entry to turn the surface off; the tail hole renders empty.
|
||||
- id: ui-deliverables
|
||||
name: '@deepseek-ai/dsh-client-ui-deliverables'
|
||||
|
||||
|
||||
- id: ui-workspace
|
||||
name: '@deepseek-ai/dsh-client-ui-workspace'
|
||||
|
||||
# Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over
|
||||
# it (ui-command), and the two reference sources (ui-skill / ui-subagent).
|
||||
- id: ui-slash
|
||||
name: '@deepseek-ai/dsh-client-ui-slash'
|
||||
|
||||
- id: ui-command
|
||||
name: '@deepseek-ai/dsh-client-ui-command'
|
||||
|
||||
- id: ui-skill
|
||||
name: '@deepseek-ai/dsh-client-ui-skill'
|
||||
|
||||
- id: ui-subagent
|
||||
name: '@deepseek-ai/dsh-client-ui-subagent'
|
||||
|
||||
# Goal surface: GoalBar in the input dock over the goal session projection.
|
||||
- id: ui-goal
|
||||
name: '@deepseek-ai/dsh-client-ui-goal'
|
||||
|
||||
# Model selection: the /model popupSelect + composer seat over session.models.
|
||||
- id: ui-model
|
||||
name: '@deepseek-ai/dsh-client-ui-model'
|
||||
|
||||
- id: ui-permission
|
||||
name: '@deepseek-ai/dsh-client-ui-permission'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
|
||||
- id: ui-question
|
||||
name: '@deepseek-ai/dsh-client-ui-question'
|
||||
|
||||
- id: ui-trajectory
|
||||
name: '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
87
packages/bundle/web-app/package.json
Normal file
87
packages/bundle/web-app/package.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-app",
|
||||
"description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"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"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend-static": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
157
packages/bundle/web-app/src/index.ts
Normal file
157
packages/bundle/web-app/src/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-web-app — the browser-surface bundle's runtime glue plugin
|
||||
* plus the bundle patch (`cordis.patch.yml`, declared by the `dsh.bundle.patch`
|
||||
* manifest field). The plugin owns what used to be launcher code: it resolves
|
||||
* the built frontend dist (workspace knowledge of this bundle, never user
|
||||
* config), mounts the `frontend-static` fallback owner over it, registers the
|
||||
* web-surface prompt section and the bash-visible web runtime variables, and
|
||||
* prints the URL line when configured to. Flag-derived values (`mode`,
|
||||
* `lanAddresses`, `printUrl`) arrive as launcher patches over this row.
|
||||
* @module @deepseek-ai/dsh-web-app
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static'
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'web-app'
|
||||
|
||||
/** Services required before the web runtime can mount. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
|
||||
export type WebMode = 'production' | 'development'
|
||||
|
||||
/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */
|
||||
export interface Config {
|
||||
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
|
||||
mode: WebMode
|
||||
/** Print the URL line on activation; a headless layer over this bundle turns it off. */
|
||||
printUrl: boolean
|
||||
/**
|
||||
* Register the model-visible surface context (the `app:web-surface` prompt
|
||||
* section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
|
||||
* layer turns it off: its user is not interacting through the GUI, so the
|
||||
* orientation text would be false.
|
||||
*/
|
||||
surfaceContext: boolean
|
||||
/**
|
||||
* LAN IPv4 addresses sampled once by the launcher when the effective bind
|
||||
* is all-interfaces — the exact snapshot the /api trust fence was
|
||||
* configured with, so the printed LAN URL can never name an address the
|
||||
* fence rejects. Empty on a loopback bind.
|
||||
*/
|
||||
lanAddresses: string[]
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
||||
printUrl: z.boolean().default(true),
|
||||
surfaceContext: z.boolean().default(true),
|
||||
lanAddresses: z.array(String).default([]),
|
||||
})
|
||||
|
||||
/** Environment variable naming the canonical local URL of this Web GUI. */
|
||||
const DSH_WEB_URL = 'DSH_WEB_URL' as const
|
||||
/** Environment variable naming the Web runtime mode. */
|
||||
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
|
||||
|
||||
// Display-only mirror of the webserver schema's loopback host: the address the
|
||||
// local URL always prints. Not a source of truth — the schema is.
|
||||
const LOOPBACK_HOST = '127.0.0.1'
|
||||
|
||||
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
|
||||
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
|
||||
const updateContract = mode === 'development'
|
||||
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
|
||||
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
|
||||
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
|
||||
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
|
||||
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
|
||||
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
|
||||
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
|
||||
+ 'The browser provides no implicit DOM, route, or screenshot context. '
|
||||
+ updateContract
|
||||
+ 'Starting another server does not update this GUI. '
|
||||
+ 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. '
|
||||
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.'
|
||||
}
|
||||
|
||||
/** Resolve the canonical loopback URL from the active Web server. */
|
||||
function localWebUrl(ctx: Context): string {
|
||||
const port = ctx.get('httpServer')?.port
|
||||
if (port === undefined) throw new Error('web-app: httpServer service missing while resolving Web runtime')
|
||||
return `http://${LOOPBACK_HOST}:${String(port)}`
|
||||
}
|
||||
|
||||
/** Dist location is workspace knowledge of this bundle: resolved through the frontend package exports, not configured. */
|
||||
function resolveDistIndex(): string {
|
||||
const require = createRequire(import.meta.url)
|
||||
try {
|
||||
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
|
||||
} catch {
|
||||
/* v8 ignore next 2 -- reachable only on a checkout without a built dist; the test tree builds it */
|
||||
throw new Error('web-app: frontend dist not built; run pnpm run build from the repository root first')
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: hosts with no built frontend dist substitute the resolver; production never touches this. */
|
||||
export const internals: { resolveDistIndex: () => string } = { resolveDistIndex }
|
||||
|
||||
/**
|
||||
* Mount the Web runtime: dist serving, surface prompt, bash runtime
|
||||
* variables, and the URL line.
|
||||
* @param ctx - plugin context carrying the httpServer service.
|
||||
* @param config - validated {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||
if (config.surfaceContext) {
|
||||
ctx.inject(['systemPrompt'], (promptCtx) => {
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
|
||||
})
|
||||
})
|
||||
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
||||
runtimeCtx.bashEnv.register({
|
||||
name: 'web-runtime',
|
||||
variables: {
|
||||
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
|
||||
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
|
||||
},
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
|
||||
})
|
||||
})
|
||||
}
|
||||
if (config.printUrl) {
|
||||
// The URL line is a readiness signal: supervisors (and the keyless CLI
|
||||
// smoke) RPC as soon as they observe it, so it must not print while
|
||||
// sibling rows (the /api route owner) are still mounting. Await Loader
|
||||
// settlement first; a hand-built tree without a Loader prints at once.
|
||||
const printUrl = (): void => {
|
||||
// The launcher's boot-time LAN snapshot, not a fresh sample: the printed
|
||||
// LAN URL must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = config.lanAddresses[0]
|
||||
const port = ctx.httpServer.port
|
||||
console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`)
|
||||
}
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) printUrl()
|
||||
else {
|
||||
void loader.await().then(() => {
|
||||
// The tree can be disposed while settlement was in flight (early
|
||||
// SIGTERM); a URL line for a dead server would only mislead, and
|
||||
// reading the torn-down port would turn a clean shutdown into a crash.
|
||||
if (ctx.get('httpServer') !== undefined) printUrl()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
30
packages/bundle/web-app/src/invariant.ts
Normal file
30
packages/bundle/web-app/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-web-app`.
|
||||
* @module @deepseek-ai/dsh-web-app/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-web-app'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'web-app-invariant'
|
||||
/** Service required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: every contribution (frontend-static child plugin,
|
||||
* prompt section, bashEnv registration) is registry-disposed with the fiber,
|
||||
* and each owning registry's package carries that relation's invariant; the
|
||||
* package holds no mutable state of its own to audit.
|
||||
*/
|
||||
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))
|
||||
197
packages/bundle/web-app/tests/web-app.spec.ts
Normal file
197
packages/bundle/web-app/tests/web-app.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Web runtime glue behavior: dist resolution through the bundle's own seam,
|
||||
* the frontend-static child claiming the fallback seat, the web-surface
|
||||
* prompt section and bash runtime variables, and URL-line printing with the
|
||||
* launcher's LAN snapshot.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { apply, Config, internals } from '../src/index.ts'
|
||||
|
||||
let dist: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
internals.resolveDistIndex = originalResolve
|
||||
if (dist !== undefined) rmSync(dist, { recursive: true, force: true })
|
||||
dist = undefined
|
||||
})
|
||||
|
||||
const originalResolve = internals.resolveDistIndex
|
||||
|
||||
/** Stage a dist fixture and point the bundle's resolver at it. */
|
||||
function stageDist(): string {
|
||||
dist = mkdtempSync(join(tmpdir(), 'dsh-web-app-'))
|
||||
mkdirSync(join(dist, 'dist'))
|
||||
const index = join(dist, 'dist', 'index.html')
|
||||
writeFileSync(index, '<head></head><body>shell</body>')
|
||||
internals.resolveDistIndex = () => index
|
||||
return index
|
||||
}
|
||||
|
||||
/** A fake httpServer capturing the fallback seat and index taps. */
|
||||
function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } {
|
||||
let fallback: unknown
|
||||
const server = {
|
||||
port: 4567,
|
||||
registerFallback: (handler: unknown) => {
|
||||
fallback = handler
|
||||
return () => { fallback = undefined }
|
||||
},
|
||||
applyIndexTaps: (html: string) => html,
|
||||
} as unknown as HttpServerService
|
||||
return { server, seat: () => fallback }
|
||||
}
|
||||
|
||||
interface BashContribution {
|
||||
name: string
|
||||
variables: Record<string, { description: string }>
|
||||
resolve: () => Record<string, string>
|
||||
}
|
||||
|
||||
describe('web-app runtime glue', () => {
|
||||
it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
const { server, seat } = fakeHttpServer()
|
||||
ctx.provide('httpServer', server)
|
||||
const contributions: BashContribution[] = []
|
||||
ctx.provide('bashEnv', {
|
||||
register: (contribution: BashContribution) => {
|
||||
contributions.push(contribution)
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
// Settle the injected registrations.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(seat()).toBeDefined() // frontend-static claimed the fallback
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)')
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
|
||||
expect(section?.text).toContain('http://127.0.0.1:4567')
|
||||
expect(section?.text).toContain('--dev')
|
||||
const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
|
||||
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('stays quiet in production mode with printUrl off and reports the production update contract', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
|
||||
.toContain('without `--dev`')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const contributions: BashContribution[] = []
|
||||
ctx.provide('bashEnv', {
|
||||
register: (contribution: BashContribution) => {
|
||||
contributions.push(contribution)
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
|
||||
expect(contributions).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defers the URL line until Loader settlement and drops it when the server is gone', async () => {
|
||||
stageDist()
|
||||
// Settlement path: the line waits for loader.await() so supervisors can
|
||||
// RPC immediately after observing it.
|
||||
const settled = new Context()
|
||||
settled.provide('httpServer', fakeHttpServer().server)
|
||||
let release: () => void
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
settled.provide('loader', { await: () => settlement } as never)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
release!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await settled.fiber.dispose()
|
||||
|
||||
// Torn-down path: settlement resolves after the webserver is gone — no
|
||||
// line, no crash.
|
||||
log.mockClear()
|
||||
const torn = new Context()
|
||||
const child = torn.plugin((childCtx: Context) => {
|
||||
childCtx.provide('httpServer', fakeHttpServer().server)
|
||||
})
|
||||
await child
|
||||
let releaseTorn: () => void
|
||||
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
||||
torn.provide('loader', { await: () => tornSettlement } as never)
|
||||
apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
|
||||
await child.dispose() // the httpServer service goes away
|
||||
releaseTorn!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
await torn.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud when the prompt section resolves against a portless webserver', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
// A webserver whose bound port is gone (torn down mid-request): the
|
||||
// section must throw, never render a URL with an undefined port.
|
||||
const { server } = fakeHttpServer()
|
||||
Object.defineProperty(server, 'port', { get: () => undefined })
|
||||
ctx.provide('httpServer', server)
|
||||
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
|
||||
// The production resolver (not the test seam). A built checkout resolves
|
||||
// the frontend package's index.html; a dist-less one (the CI coverage
|
||||
// lane runs before any build) must fail with the build hint, never a
|
||||
// silent fallback.
|
||||
try {
|
||||
expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain('frontend dist not built')
|
||||
}
|
||||
})
|
||||
})
|
||||
33
packages/bundle/web-app/tsconfig.json
Normal file
33
packages/bundle/web-app/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../host/frontend-static"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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/client/connection/README.md
|
||||
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
|
||||
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
|
||||
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
|
||||
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { isLoopbackHostname } from './loopback-hostname.ts'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
/** The request facts the fence reads from either HTTP representation. */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
headers: IncomingHttpHeaders | Headers
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
|
||||
if (headers instanceof Headers) return headers.get(name) ?? undefined
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
@@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
|
||||
|
||||
/**
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param request - Node HTTP or Fetch request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ export type {
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
|
||||
@@ -35,10 +35,12 @@ import type {
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(crypto.randomUUID()), payload }
|
||||
return { rpcId: RpcId(randomUuid()), payload }
|
||||
}
|
||||
|
||||
function text(t: string): ContentBlock[] {
|
||||
@@ -474,11 +476,14 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip.
|
||||
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
const fixtureTodos = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
@@ -531,7 +536,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -686,9 +691,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
|
||||
/**
|
||||
* Fixture parallel of the plan unit's double-event fold: `command/run`
|
||||
* records named `plan` set the wanted target (`off` → false, else true);
|
||||
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
|
||||
* boundary (the fixture's step/start parallel).
|
||||
* records named `plan` with recorded input set the wanted target (`off` →
|
||||
* false, else true); `plan/mode` commits and clears it. `wanted` is exposed
|
||||
* for the prompt boundary (the fixture's step/start parallel).
|
||||
*/
|
||||
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
|
||||
let active = false
|
||||
@@ -697,7 +702,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo
|
||||
const item = event as unknown as { type: string; data?: Record<string, unknown> }
|
||||
if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
|
||||
const args = item.data['args']
|
||||
wanted = (typeof args === 'string' ? args : '').trim() !== 'off'
|
||||
if (typeof args !== 'string') continue
|
||||
wanted = args.trim() !== 'off'
|
||||
} else if (item.type === 'plan/mode') {
|
||||
active = item.data?.['active'] === true
|
||||
wanted = null
|
||||
@@ -1004,9 +1010,11 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
// The plan unit advances on its two folded event kinds.
|
||||
// The plan unit advances on its two folded event kinds when the command
|
||||
// lifecycle contains the input that represents a plan selection.
|
||||
const commandData = event as unknown as { data: { name?: string; args?: unknown } }
|
||||
if (type === 'plan/mode' || (type === 'command/run'
|
||||
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
|
||||
&& commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
@@ -1322,6 +1330,16 @@ class FxInbox<F> implements StreamConn<F> {
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return createFixtureWorld(options).api
|
||||
}
|
||||
|
||||
interface FixtureWorld {
|
||||
readonly api: ApiProxy
|
||||
readonly rpc: ClientConnectionRpc
|
||||
}
|
||||
|
||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
@@ -1500,31 +1518,141 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return backscanGoal(log) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
const fxMutateGoal = (
|
||||
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
|
||||
ref: { id: string; revision: number },
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
|
||||
const missing = requireSession(request)
|
||||
type FxGoalRef = { id: string; revision: number }
|
||||
type FxGoalView = FxGoalProjection['goal'] & {
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
activation: 'armed' | 'disarmed'
|
||||
}
|
||||
|
||||
const goalFailure = <T>(message: string): RpcResult<T> => ({
|
||||
ok: false,
|
||||
error: { code: 'internal', message, details: {} },
|
||||
})
|
||||
|
||||
const requireGoalSession = (id: SessionId): RpcResult<never> | undefined => (
|
||||
summaryOf(id) === undefined
|
||||
? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } }
|
||||
: undefined
|
||||
)
|
||||
|
||||
const goalView = (projection: FxGoalProjection): FxGoalView => ({
|
||||
...projection.goal,
|
||||
roundsStarted: projection.roundsStarted,
|
||||
createdAt: projection.createdAt,
|
||||
updatedAt: projection.updatedAt,
|
||||
activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed',
|
||||
})
|
||||
|
||||
/** Canonical fixture implementation of the generated Goal Remote contract. */
|
||||
const goalRemotes = {
|
||||
create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return goalFailure(`goal "${current.goal.id}" already exists`)
|
||||
}
|
||||
const now = Date.now()
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: {
|
||||
id: `fx-goal-${logOf(id).length}`,
|
||||
revision: 1,
|
||||
objective: request.objective,
|
||||
phase: 'active',
|
||||
maxGoalRounds: request.maxGoalRounds ?? 256,
|
||||
},
|
||||
roundsStarted: 0, createdAt: now, updatedAt: now,
|
||||
})
|
||||
return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } }
|
||||
},
|
||||
edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.objective === undefined ? {} : { objective: request.objective },
|
||||
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds },
|
||||
}))
|
||||
},
|
||||
pause(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
resume(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
complete(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
))
|
||||
},
|
||||
clear(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalRef> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 }
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(),
|
||||
})
|
||||
return { ok: true, value: tombstone }
|
||||
},
|
||||
}
|
||||
|
||||
/** Resolve one current goal revision for a canonical Remote mutation. */
|
||||
function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalProjection> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
return goalFailure('stale or missing goal revision')
|
||||
}
|
||||
return { ok: true, value: current }
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path behind the canonical Remote verbs. */
|
||||
function mutateGoal(
|
||||
id: SessionId,
|
||||
ref: FxGoalRef,
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): RpcResult<FxGoalView> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const goal = next(current)
|
||||
if (goal === undefined) {
|
||||
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
|
||||
return goalFailure(`invalid goal transition from "${current.goal.phase}"`)
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1,
|
||||
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
|
||||
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
return { ok: true, value: goalView(projection) }
|
||||
}
|
||||
|
||||
const mapGoalResult = <T, U>(result: RpcResult<T>, map: (value: T) => U): RpcResult<U> => (
|
||||
result.ok ? { ok: true, value: map(result.value) } : result
|
||||
)
|
||||
|
||||
const goalRefResult = (result: RpcResult<FxGoalView>): RpcResult<{ ref: { id: never; revision: number } }> => (
|
||||
mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } }))
|
||||
)
|
||||
|
||||
const legacyGoalResponse = <P, T>(request: RpcRequest<P>, result: RpcResult<T>): Promise<RpcResponse<T>> => (
|
||||
Promise.resolve({ rpcId: request.rpcId, result })
|
||||
)
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
@@ -1770,7 +1898,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
replays.set(id, { timer: setTimeout(tick, 80), finish })
|
||||
}
|
||||
|
||||
return {
|
||||
const api: ApiProxy = {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
search: (request, signal) => {
|
||||
@@ -1972,6 +2100,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
// The fixture's routes all serve; a surface exercising the blocked
|
||||
// posture drives it through its own stub.
|
||||
routable: true,
|
||||
groups: fixtureModelGroups(),
|
||||
failures: [],
|
||||
}),
|
||||
@@ -2318,66 +2449,51 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
|
||||
{ name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
// Mutation-only mirror of the host handlers: each verb CAS-checks the
|
||||
// projected current goal, appends the whole-value change (the mux
|
||||
// stream and projection frame ride the shared append path), and
|
||||
// acknowledges with the new ref only.
|
||||
create: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
},
|
||||
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
pause: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
)),
|
||||
resume: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
)),
|
||||
complete: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
)),
|
||||
clear: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear',
|
||||
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { cleared: true as const })
|
||||
},
|
||||
// Compatibility face only: old API Proxy payloads and acknowledgements
|
||||
// adapt to the canonical fixture Remote implementation above.
|
||||
create: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.create(request.payload.sessionId, {
|
||||
objective: request.payload.objective,
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
}),
|
||||
value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }),
|
||||
),
|
||||
),
|
||||
edit: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, {
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
),
|
||||
pause: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
resume: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
complete: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
clear: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.clear(request.payload.sessionId, request.payload.ref),
|
||||
() => ({ cleared: true as const }),
|
||||
),
|
||||
),
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
@@ -2497,11 +2613,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
providers: request => ok(request, {
|
||||
providers: [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false },
|
||||
// One hand-declared route, so a surface reading this fixture meets
|
||||
// the tagged shape rather than only the shipped one.
|
||||
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
|
||||
],
|
||||
}),
|
||||
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
|
||||
// The fixture endpoint is imaginary, so the interrogation answers the
|
||||
// catalog it already serves — enough for a surface to exercise adopting
|
||||
// candidates without a reachable provider.
|
||||
discoverModels: request => ok(request, {
|
||||
models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))),
|
||||
}),
|
||||
},
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// Same routing discipline as the host: rpcId first, then the payload's
|
||||
@@ -2529,6 +2654,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
}
|
||||
|
||||
const rpc: ClientConnectionRpc = {
|
||||
call(channel, endpoint, payload) {
|
||||
if (channel !== '/api') {
|
||||
return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`))
|
||||
}
|
||||
const args = (payload as {
|
||||
args: {
|
||||
agentId: SessionId
|
||||
ref?: { id: string; revision: number }
|
||||
request?: { objective?: string; maxGoalRounds?: number }
|
||||
}
|
||||
}).args
|
||||
const sessionId = args.agentId
|
||||
switch (endpoint) {
|
||||
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
|
||||
objective: args.request?.objective as string,
|
||||
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
|
||||
}))
|
||||
case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {}))
|
||||
case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef))
|
||||
default:
|
||||
return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`))
|
||||
}
|
||||
},
|
||||
}
|
||||
return { api, rpc }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2540,10 +2695,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api: ApiProxy
|
||||
/** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.api = createFixtureApi(fixtureOptionsFromLocation())
|
||||
const world = createFixtureWorld(fixtureOptionsFromLocation())
|
||||
this.api = world.api
|
||||
this.rpc = world.rpc
|
||||
}
|
||||
|
||||
protected doFetch(): Promise<Response> {
|
||||
@@ -2619,6 +2778,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'credentials.unset': return this.api.credentials.unset(request)
|
||||
case 'llm.providers': return this.api.llm.providers(request)
|
||||
case 'llm.models': return this.api.llm.models(request)
|
||||
case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { createWebConnectionRpc } from './rpc.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
@@ -25,7 +27,7 @@ export type {
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
} from './api.ts'
|
||||
export {
|
||||
RpcId,
|
||||
@@ -36,6 +38,7 @@ export {
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -69,11 +74,14 @@ export interface ConnectionHandle {
|
||||
export function apply(ctx: Context): void {
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
const fixtureClient = fixture ? new FixtureApiClient() : undefined
|
||||
const api: IApiClient = fixtureClient ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
rpc,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
14
packages/client/connection/src/client/random-uuid.ts
Normal file
14
packages/client/connection/src/client/random-uuid.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Browser-safe UUID generation for client-side wire correlation. */
|
||||
|
||||
/**
|
||||
* Generate an RFC 4122 version 4 UUID without requiring a secure context.
|
||||
* @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
|
||||
*/
|
||||
export function randomUuid(): string {
|
||||
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
|
||||
view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
63
packages/client/connection/src/client/rpc.ts
Normal file
63
packages/client/connection/src/client/rpc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Browser caller for generic Connection unary RPC channels. */
|
||||
|
||||
import {
|
||||
RpcId,
|
||||
serverResponseSchema,
|
||||
type ClientRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/**
|
||||
* Create the browser-backed generic RPC caller.
|
||||
* @returns caller that owns request correlation and response-envelope validation.
|
||||
*/
|
||||
export function createWebConnectionRpc(): ClientConnectionRpc {
|
||||
return {
|
||||
async call(channel, endpoint, payload, signal) {
|
||||
assertTarget(channel, endpoint)
|
||||
const rpcId = RpcId(randomUuid())
|
||||
const message: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId,
|
||||
method: endpoint,
|
||||
payload,
|
||||
}
|
||||
const response = await globalThis.fetch(
|
||||
new URL(`${channel}/${endpoint}`, resolveBase()),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
...signal === undefined ? {} : { signal },
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
|
||||
}
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
if (full.rpcId !== rpcId) {
|
||||
throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
|
||||
}
|
||||
return full.result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBase(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
||||
}
|
||||
|
||||
function assertTarget(channel: string, endpoint: string): void {
|
||||
const segments = endpoint.split('/')
|
||||
if (!CHANNEL_PATTERN.test(channel)
|
||||
|| segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
/** Transport-independent request handler consumed by the Host HTTP bridge. */
|
||||
export interface FetchHandler {
|
||||
/**
|
||||
* Handle one standard Fetch request.
|
||||
* @param request - request produced by the active transport bridge.
|
||||
* @returns complete or streaming Fetch response.
|
||||
*/
|
||||
fetch(request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge one node:http request to the fetch-shaped handler (client close
|
||||
* aborts; SSE bodies stream out chunk by chunk).
|
||||
@@ -12,7 +22,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
* @param res - node:http response the bridge writes and owns to completion.
|
||||
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
||||
*/
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
|
||||
@@ -7,15 +7,26 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { HostConnectionService } from './rpc-host.ts'
|
||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||
|
||||
export type {
|
||||
ConnectionRpcAuthority,
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
export { HostConnectionService } from './rpc-host.ts'
|
||||
|
||||
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
@@ -44,10 +55,15 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
* reconnaissance no anonymous caller should have. `trustedHosts` is a
|
||||
* DNS-rebinding fence, explicitly not authentication, so the whole
|
||||
* configuration plane stays loopback-same-origin until a real authentication
|
||||
* layer exists. The model catalog (`llm.providers`, `llm.models`) is
|
||||
* deliberately NOT here: it carries provider ids, display names, and model
|
||||
* lists — no endpoints, keys, or key state — and a LAN client's model picker
|
||||
* legitimately needs it.
|
||||
* layer exists. `llm.discoverModels` belongs to that plane on both counts: it
|
||||
* carries a draft credential, and it makes the HOST issue a GET to a URL the
|
||||
* caller chose and reports back the status or the parsed body — an anonymous
|
||||
* LAN caller would have a probe for whatever the host can reach and the
|
||||
* browser cannot.
|
||||
*
|
||||
* The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here:
|
||||
* it carries provider ids, display names, and model lists — no endpoints,
|
||||
* keys, or key state — and a LAN client's model picker legitimately needs it.
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
'host.pickDirectory',
|
||||
@@ -60,6 +76,7 @@ const PRIVILEGED_METHODS = new Set([
|
||||
'credentials.describe',
|
||||
'credentials.set',
|
||||
'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -77,49 +94,61 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
||||
const connection = new HostConnectionService(ctx, trustedHosts)
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
if (method !== undefined
|
||||
&& PRIVILEGED_METHODS.has(method)
|
||||
&& !isTrustedApiRequest(request, [])) {
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
return new Response('upgrade required', {
|
||||
status: 426,
|
||||
headers: { connection: 'Upgrade', upgrade: 'websocket' },
|
||||
})
|
||||
}
|
||||
const apiProxy = ctx.get('apiProxy')
|
||||
if (apiProxy === undefined) return new Response('not found', { status: 404 })
|
||||
return toFetchHandler(apiProxy).fetch(request)
|
||||
},
|
||||
})
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
|
||||
? isTrustedApiRequest(req, [])
|
||||
: isTrustedApiRequest(req, trustedHosts)
|
||||
if (!allowed) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
ctx.inject(['apiProxy'], (apiCtx) => {
|
||||
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
})
|
||||
}
|
||||
|
||||
224
packages/client/connection/src/rpc-host.ts
Normal file
224
packages/client/connection/src/rpc-host.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
type RpcError,
|
||||
type RpcErrorDetailsMap,
|
||||
type RpcId as RpcIdType,
|
||||
type ServerResponse as RpcServerResponse,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { bridge, type FetchHandler } from './http-bridge.ts'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import type {
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
|
||||
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
interface ConnectionRpcInterceptor {
|
||||
readonly matches: ConnectionRpcEndpointMatcher
|
||||
readonly fetchHandler: FetchHandler
|
||||
readonly options: ConnectionRpcHandlerOptions
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host Connection transport and RPC registrations. */
|
||||
connection: HostConnectionHandle
|
||||
}
|
||||
}
|
||||
|
||||
/** Host Connection service whose channel registrations belong to the caller fiber. */
|
||||
export class HostConnectionService extends Service implements HostConnectionHandle {
|
||||
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
|
||||
|
||||
/**
|
||||
* Provide the Host half over the active HTTP server.
|
||||
* @param ctx - owning Connection plugin context.
|
||||
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
|
||||
super(ctx, 'connection')
|
||||
}
|
||||
|
||||
/** Generic channel registry scoped to the Context reading this service. */
|
||||
get rpc(): HostConnectionRpc {
|
||||
const owner = this.ctx
|
||||
return {
|
||||
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
|
||||
intercept: (channel, matches, handler, options) =>
|
||||
this.registerInterceptor(owner, channel, matches, handler, options),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one shared-channel Fetch handler from its interceptor and fallback.
|
||||
* @param channel - shared channel mounted by Connection.
|
||||
* @param fallback - handler for endpoints not claimed by the interceptor.
|
||||
* @returns Fetch handler that selects exactly one target for each request.
|
||||
*/
|
||||
createSharedFetchHandler(
|
||||
channel: '/api',
|
||||
fallback: FetchHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
fetch: (request) => {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
const interceptor = this.interceptors.get(channel)
|
||||
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
|
||||
return fallback.fetch(request)
|
||||
}
|
||||
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
|
||||
return Promise.resolve(new Response('forbidden', { status: 403 }))
|
||||
}
|
||||
return interceptor.fetchHandler.fetch(request)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private register(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
assertChannel(channel)
|
||||
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
|
||||
const fetchHandler = rpcFetchHandler(channel, handler)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: channel,
|
||||
handler: async (req, res) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
return owner.effect(
|
||||
() => owner.httpServer.register(route),
|
||||
`client-connection: ${channel} rpc channel`,
|
||||
)
|
||||
}
|
||||
|
||||
private registerInterceptor(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
if (channel !== API_PATH) {
|
||||
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
const interceptor: ConnectionRpcInterceptor = {
|
||||
matches,
|
||||
fetchHandler: rpcFetchHandler(channel, handler),
|
||||
options,
|
||||
}
|
||||
return owner.effect(() => {
|
||||
if (this.interceptors.has(channel)) {
|
||||
throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
|
||||
}
|
||||
this.interceptors.set(channel, interceptor)
|
||||
return () => {
|
||||
this.interceptors.delete(channel)
|
||||
}
|
||||
}, `client-connection: ${channel} rpc interceptor`)
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFetchHandler(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
if (request.method !== 'POST' || endpoint === undefined) {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
|
||||
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (mediaType !== 'application/json') {
|
||||
return new Response('content type must be application/json', { status: 415 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return new Response('body is not JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const envelope = clientRequestSchema.safeParse(body)
|
||||
if (!envelope.success) {
|
||||
return invalidEnvelopeResponse(body, envelope.error.issues)
|
||||
}
|
||||
const message: ClientRequest = envelope.data
|
||||
if (message.method !== endpoint) {
|
||||
return errorResponse(message.rpcId, {
|
||||
code: 'bad-request',
|
||||
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
||||
details: { issues: [] },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(endpoint, message.payload, request.signal)
|
||||
return fullResponse(message.rpcId, result)
|
||||
} catch (error) {
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response {
|
||||
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
|
||||
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
|
||||
return errorResponse(rpcId, {
|
||||
code: 'bad-request',
|
||||
message: 'invalid client-request message',
|
||||
details: { issues },
|
||||
})
|
||||
}
|
||||
|
||||
function endpointFromPath(channel: string, pathname: string): string | undefined {
|
||||
if (!pathname.startsWith(`${channel}/`)) return undefined
|
||||
const endpoint = pathname.slice(channel.length + 1)
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
return undefined
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
|
||||
return fullResponse(rpcId, { ok: false, error })
|
||||
}
|
||||
|
||||
function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
|
||||
const body: RpcServerResponse = { type: 'server-response', rpcId, result }
|
||||
return Response.json(body)
|
||||
}
|
||||
|
||||
function assertChannel(channel: string): void {
|
||||
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
|
||||
throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
}
|
||||
77
packages/client/connection/src/rpc.ts
Normal file
77
packages/client/connection/src/rpc.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
||||
|
||||
import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/** Trust fence applied before a Host RPC channel reaches its handler. */
|
||||
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
|
||||
|
||||
/** Registration policy for one logical RPC channel. */
|
||||
export interface ConnectionRpcHandlerOptions {
|
||||
/** Browser authority accepted by every endpoint in this channel. */
|
||||
readonly authority: ConnectionRpcAuthority
|
||||
}
|
||||
|
||||
/** Handler invoked after Connection has decoded the transport envelope. */
|
||||
export type ConnectionRpcHandler = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<RpcResult<unknown>>
|
||||
|
||||
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
|
||||
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
|
||||
|
||||
/** Host registry for logical RPC channels carried by the current transport. */
|
||||
export interface HostConnectionRpc {
|
||||
/**
|
||||
* Register one absolute channel prefix and its trust policy.
|
||||
* @param channel - absolute logical channel such as `/rpc`.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - channel trust policy.
|
||||
* @returns asynchronous disposer removing the channel and its physical route.
|
||||
*/
|
||||
handle(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
|
||||
/**
|
||||
* Intercept owned endpoints on the shared `/api` channel before its fallback.
|
||||
* @param channel - reserved shared channel; currently `/api`.
|
||||
* @param matches - synchronous endpoint ownership test.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - trust policy for every endpoint claimed by this interceptor.
|
||||
* @returns asynchronous disposer removing the interceptor.
|
||||
*/
|
||||
intercept(
|
||||
channel: '/api',
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
}
|
||||
|
||||
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
|
||||
export interface HostConnectionHandle {
|
||||
/** Generic RPC channel registry. */
|
||||
readonly rpc: HostConnectionRpc
|
||||
}
|
||||
|
||||
/** Client caller for logical RPC channels carried by the current transport. */
|
||||
export interface ClientConnectionRpc {
|
||||
/**
|
||||
* Call one endpoint through an already registered logical channel.
|
||||
* @param channel - absolute logical channel such as `/api`.
|
||||
* @param endpoint - channel-relative endpoint such as `goals/create`.
|
||||
* @param payload - channel-owned request payload.
|
||||
* @param signal - optional caller cancellation.
|
||||
* @returns the existing RPC success/error result; correlation stays inside Connection.
|
||||
*/
|
||||
call(
|
||||
channel: string,
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResult<unknown>>
|
||||
}
|
||||
@@ -203,4 +203,119 @@ describe('connection client apply', () => {
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
|
||||
it('carries RPC calls without requiring secure-context randomUUID', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues(bytes: Uint8Array) {
|
||||
return bytes.fill(0)
|
||||
},
|
||||
})
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: { url: string; body: unknown }[] = []
|
||||
globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
|
||||
const body = JSON.parse(init.body) as { rpcId: string }
|
||||
seen.push({ url, body })
|
||||
return Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: body.rpcId,
|
||||
result: { ok: true, value: { ref: 'goal-1' } },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
|
||||
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
|
||||
expect(seen[0]?.body).toMatchObject({
|
||||
type: 'client-request',
|
||||
rpcId: '00000000-0000-4000-8000-000000000000',
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('validates generic RPC transport failures, correlation, and targets', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'harness.example', search: '', origin: 'https://harness.example',
|
||||
}
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const abort = new AbortController()
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
|
||||
.rejects.toThrow('HTTP 503')
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
new URL('https://harness.example/api/goals/create'),
|
||||
expect.objectContaining({ signal: abort.signal }),
|
||||
)
|
||||
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: 'different-rpc',
|
||||
result: { ok: true, value: null },
|
||||
}))
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
|
||||
const fetch = vi.mocked(globalThis.fetch)
|
||||
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
|
||||
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
|
||||
for (const [channel, endpoint] of [
|
||||
['api2', 'goals/create'],
|
||||
['/api/path', 'goals/create'],
|
||||
['/api', ''],
|
||||
['/api', '.'],
|
||||
['/api', '..'],
|
||||
['/api', 'goals//create'],
|
||||
['/api', 'goals/create?unsafe'],
|
||||
] as const) {
|
||||
await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
|
||||
}
|
||||
})
|
||||
|
||||
it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const created = await handle.rpc.call('/api', 'goals/create', {
|
||||
args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
|
||||
})
|
||||
expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
|
||||
if (!created.ok) throw new Error('fixture Goal create failed')
|
||||
const ref = (created.value as { ref: { id: string; revision: number } }).ref
|
||||
const edited = await handle.rpc.call('/api', 'goals/edit', {
|
||||
args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
|
||||
})
|
||||
expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
|
||||
const editedRef = { id: ref.id, revision: 2 }
|
||||
const paused = await handle.rpc.call('/api', 'goals/pause', {
|
||||
args: { agentId: 'fx-alpha', ref: editedRef },
|
||||
})
|
||||
expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
|
||||
const resumed = await handle.rpc.call('/api', 'goals/resume', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
|
||||
})
|
||||
expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
|
||||
const completed = await handle.rpc.call('/api', 'goals/complete', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
|
||||
})
|
||||
expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
|
||||
await expect(handle.rpc.call('/api', 'goals/clear', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
|
||||
})).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
|
||||
await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
|
||||
await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
|
||||
.rejects.toThrow(/endpoint.*unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,6 +59,7 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
@@ -162,6 +163,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
@@ -197,6 +199,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
|
||||
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
|
||||
@@ -242,6 +242,10 @@ describe('createFixtureApi', () => {
|
||||
const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time)
|
||||
expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0)
|
||||
expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0)
|
||||
// The sample is a parallel plan: this fixture chooses the parallel policy,
|
||||
// so the surfaces fed from here face more than one active item.
|
||||
const snapshot = events[todoAt] as { data: { todos: { status: string }[] } }
|
||||
expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => {
|
||||
let carrierSignal: AbortSignal | undefined
|
||||
const pending = bridge(request, response, {
|
||||
fetch: async (input) => {
|
||||
const fetchRequest = input as Request
|
||||
const fetchRequest = input
|
||||
carrierSignal = fetchRequest.signal
|
||||
resolveStarted()
|
||||
if (!fetchRequest.signal.aborted) {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
@@ -17,6 +18,9 @@ function fakeHttpServer(
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
@@ -36,15 +40,32 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
|
||||
return request
|
||||
}
|
||||
|
||||
/** JSON POST carrying a complete client-request envelope. */
|
||||
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Raw POST for malformed-body and media-type boundary cases. */
|
||||
function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const chunks: Buffer[] = []
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
|
||||
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
|
||||
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
@@ -129,13 +150,15 @@ describe('connection node half', () => {
|
||||
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
|
||||
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
|
||||
// The privileged set: native dialogs plus the whole settings/credential
|
||||
// configuration plane, reads included. The same declared authority reaches
|
||||
// configuration plane, reads included, plus the one method that makes the
|
||||
// host fetch a caller-chosen URL. The same declared authority reaches
|
||||
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -171,6 +194,211 @@ describe('connection node half', () => {
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
}, { authority: 'trusted-host' })
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')
|
||||
expect(route).toBeDefined()
|
||||
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-dedicated'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
const result = fakeResponse()
|
||||
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
|
||||
expect(result.state.status).toBe(200)
|
||||
expect(JSON.parse(String(result.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-dedicated',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
|
||||
authority: 'trusted-host',
|
||||
})).toThrow(/duplicate route/)
|
||||
await remove()
|
||||
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
},
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('already has an interceptor')
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/rpc' as '/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('invalid shared RPC channel')
|
||||
const route = routes.find(candidate => candidate.path === API_PATH)!
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-shared'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
|
||||
const claimed = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
|
||||
expect(JSON.parse(String(claimed.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-shared',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const unclaimed = fakeResponse()
|
||||
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
|
||||
expect(unclaimed.state.status).toBe(404)
|
||||
|
||||
await remove()
|
||||
const withdrawn = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
|
||||
expect(withdrawn.state.status).toBe(404)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const removeLoopback = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'loopback' },
|
||||
)
|
||||
const loopbackOnly = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
|
||||
expect(loopbackOnly.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
|
||||
if (endpoint === 'fail') throw new Error('handler broke')
|
||||
return { ok: true, value: null }
|
||||
}, {
|
||||
authority: 'trusted-host',
|
||||
})
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')!
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
|
||||
const methodMismatch = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
|
||||
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
|
||||
}), methodMismatch.response)
|
||||
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
|
||||
rpcId: 'rpc-bad',
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
|
||||
for (const [request, status] of [
|
||||
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
|
||||
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(request, response.response)
|
||||
expect(response.state.status).toBe(status)
|
||||
}
|
||||
|
||||
for (const [body, rpcId] of [
|
||||
[{ rpcId: 'retained-id' }, 'retained-id'],
|
||||
[{ rpcId: 42 }, 'invalid-request'],
|
||||
[null, 'invalid-request'],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
|
||||
expect(JSON.parse(String(response.state.body))).toMatchObject({
|
||||
rpcId,
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
}
|
||||
|
||||
const failed = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
|
||||
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
|
||||
}), failed.response)
|
||||
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
|
||||
|
||||
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
|
||||
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})
|
||||
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
|
||||
const publicResponse = fakeResponse()
|
||||
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
|
||||
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
|
||||
}), publicResponse.response)
|
||||
expect(publicResponse.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await remove()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
@@ -221,6 +449,9 @@ describe('connection node half over a real HTTP server', () => {
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-typert-registry"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
@@ -47,11 +48,15 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-type-meta": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
|
||||
export type AgentContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'agent'>
|
||||
}
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.client.scope')
|
||||
@@ -29,7 +35,7 @@ export interface AgentScopeHandle {
|
||||
* through it (passing it as the dispatch subject routes to this agent's
|
||||
* tagged listeners plus every untagged one).
|
||||
*/
|
||||
ctx: Context
|
||||
ctx: AgentContext
|
||||
/** Backing fiber (dispose tears down every scope-owned registration). */
|
||||
fiber: Fiber
|
||||
}
|
||||
@@ -48,15 +54,16 @@ function agentScope(): void {}
|
||||
*/
|
||||
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
|
||||
const fiber = ctx.plugin(agentScope)
|
||||
const scoped = fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}) as AgentContext
|
||||
return {
|
||||
fiber,
|
||||
ctx: fiber.ctx.extend({
|
||||
[kScope]: key,
|
||||
[CordisContext.filter](listenerCtx: Context): boolean {
|
||||
const tag = scopeOf(listenerCtx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}),
|
||||
ctx: scoped,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AgentContext } from '../agents/scope.ts'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
import type { SessionFace } from './session.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
export type { AgentContext } from '../agents/scope.ts'
|
||||
|
||||
/** The sessions-service face injected as `ctx.sessions`. */
|
||||
export interface ISessions {
|
||||
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
|
||||
@@ -95,7 +98,7 @@ export interface ISessions {
|
||||
* @param id - session id.
|
||||
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
|
||||
*/
|
||||
scope(id: SessionId): Context | undefined
|
||||
scope(id: SessionId): AgentContext | undefined
|
||||
/**
|
||||
* Read the Agent scope tag off a context (service-method seam: fetch
|
||||
* bundles must reach scope resolution through ctx.sessions).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
|
||||
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
@@ -26,7 +27,7 @@ export type { ISession, ProjectionsFace, SessionFace } from './contract/session.
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.ts'
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export type { AgentContext, ISessions } from './contract/sessions.ts'
|
||||
export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
@@ -75,6 +76,13 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
export type ClientContext = Context
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTContextMap {
|
||||
/** Client Agent scope identity; the agent and session share one wire id. */
|
||||
agent: TypeRTContext<SessionId>
|
||||
}
|
||||
}
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
@@ -170,8 +178,8 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
/** Required services: the wire handle and Client TypeRT registry. */
|
||||
export const inject = ['connection', 'typert']
|
||||
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
@@ -180,6 +188,9 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
ctx.typert.contexts.registerClient('agent', {
|
||||
identity: candidate => sessions.scopeOf(candidate),
|
||||
})
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
|
||||
@@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView {
|
||||
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
|
||||
case 'plugin':
|
||||
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
|
||||
// A user-explicit skill invocation names the skill it injected.
|
||||
case 'skill-invocation':
|
||||
return { role: 'inject', label: readString(record, 'name') ?? kind }
|
||||
// Documented default arm of the merge-extensible source map: an unknown
|
||||
// producer still identifies itself by its own durable kind.
|
||||
default:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user