Merge remote-tracking branch 'origin/master' into worktree/align-core-web-rl-prompt
# Conflicts: # apps/cli/reference/README.i18n.yaml # apps/cli/reference/README.md # apps/cli/reference/README.zh.md # apps/cli/src/app-cli-entry.ts # apps/cli/src/dump-config.ts # apps/cli/src/web.ts # apps/cli/tests/built-bin.e2e.ts # apps/cli/tests/web-prompt-context.spec.ts # apps/web/tests/scaffold.ts
This commit is contained in:
@@ -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: dec4d71ca2d323fe05f918dd3bf4709cfa01878e
|
||||
README.zh.md: 9596dfe8bf8d2d6144ffe7820886342707dd3009
|
||||
|
||||
@@ -31,9 +31,10 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`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 |
|
||||
|
||||
@@ -31,9 +31,10 @@
|
||||
| [`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:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
|
||||
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 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。
|
||||
404
packages/bundle/base/cordis.patch.yml
Normal file
404
packages/bundle/base/cordis.patch.yml
Normal file
@@ -0,0 +1,404 @@
|
||||
# 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: 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 store: the live process environment over `$DSH_HOME/.env`
|
||||
# (owner-only file, hot-reloaded). Adapters resolve their key references
|
||||
# through it at each request, so no key is inlined in this file. The web
|
||||
# Models page's key inputs write it through `credentials.set`; nothing hoists
|
||||
# the document into the process environment, which would make every stored key
|
||||
# read as an unrotatable ambient override.
|
||||
- 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
|
||||
|
||||
# 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: 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'
|
||||
|
||||
# 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
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
|
||||
- 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'
|
||||
111
packages/bundle/base/package.json
Normal file
111
packages/bundle/base/package.json
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"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-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-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-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-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: d08fb08e2aca3c4e5ccd733b37fc415d492974ca
|
||||
README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0
|
||||
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), 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 --profile headless "task"`), and fails loud when a task is given to a profile without 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 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "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"
|
||||
}
|
||||
}
|
||||
173
packages/bundle/headless/src/index.ts
Normal file
173
packages/bundle/headless/src/index.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @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 --profile headless "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 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<void>,
|
||||
io: HeadlessIo,
|
||||
): Promise<TurnOutcome> {
|
||||
let started = false
|
||||
let text = ''
|
||||
let reason: string = 'error'
|
||||
void (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
|
||||
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`)
|
||||
}
|
||||
})()
|
||||
await idle
|
||||
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<void>((resolve) => {
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (agent.id === created.sessionId && status === 'idle') resolve()
|
||||
})
|
||||
})
|
||||
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))
|
||||
218
packages/bundle/headless/tests/headless.spec.ts
Normal file
218
packages/bundle/headless/tests/headless.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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 }
|
||||
|
||||
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
|
||||
function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown {
|
||||
return {
|
||||
sessions: {
|
||||
create: (request: RpcShapedRequest) =>
|
||||
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
|
||||
prompt: (request: RpcShapedRequest) => 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* () {
|
||||
for (const event of events) {
|
||||
if (event.type === 'stream/error') {
|
||||
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
|
||||
continue
|
||||
}
|
||||
const { sessionId = 'S1', ...rest } = event
|
||||
yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } = {}): 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)
|
||||
})
|
||||
ctx.provide('apiProxy', scriptedApi(events, options) 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.
|
||||
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' })
|
||||
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
|
||||
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('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' } 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: a835497982e41c29c5dbc287c43b0515a61f77c9
|
||||
README.zh.md: 00d8636e848a7927792dfc8ad3a460015c9e7fef
|
||||
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, 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
|
||||
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
|
||||
# ── 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: session-projection
|
||||
name: '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
- 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: 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'
|
||||
|
||||
|
||||
- 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'
|
||||
86
packages/bundle/web-app/package.json
Normal file
86
packages/bundle/web-app/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "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": "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/cordis/repository-plugin/README.md
|
||||
README.md: 2555876734ddcab7b0bc0f25780010bef0c97a01
|
||||
README.zh.md: 41341f7d7ed92ac071dde274a0328784bc2888d4
|
||||
README.md: 33cd763d7dbe21b72f9e604b7b2e313081cf656f
|
||||
README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b
|
||||
|
||||
@@ -30,7 +30,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
|
||||
|
||||
## Standalone app configuration
|
||||
|
||||
The shipped `dsh` base used by raw-config, Web, and headless modes contains an empty `repository-plugins` row. A Web or headless user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`); a raw-config deployment patches the same row in its explicit overlay:
|
||||
The shipped `dsh-base` bundle every profile starts from contains an empty `repository-plugins` row. A user enables exact GitHub generations by replacing that row's config in a user patch layer — `$DSH_HOME/profiles/<name>/cordis.patch.yml`, or the home-level `$DSH_HOME/cordis.patch.yml` shared by every profile; a `--patch` overlay patches the same row for one run:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
@@ -43,7 +43,7 @@ The shipped `dsh` base used by raw-config, Web, and headless modes contains an e
|
||||
|
||||
Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root.
|
||||
|
||||
Web watches `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless reads the file only at startup, and raw-config mode reads only its explicit overlay. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
|
||||
Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
|
||||
|
||||
## Preparation
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
## 独立应用配置
|
||||
|
||||
随附 `dsh` 中供原始配置、Web 与无头模式使用的基础配置包含一个空 `repository-plugins` 配置项。Web 或无头用户可在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,以启用精确指定的 GitHub generation;原始配置部署则在显式 overlay 中 patch 同一配置项:
|
||||
每个 profile 都以之为起点的随附 `dsh-base` 组合包包含一个空 `repository-plugins` 配置项。用户可在用户 patch 层中替换该配置项的配置来启用精确指定的 GitHub generation:写入 `$DSH_HOME/profiles/<name>/cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml`;`--patch` overlay 则只为单次运行 patch 同一配置项:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。
|
||||
|
||||
Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头模式只在启动时读取该文件,原始配置模式只读取其显式 overlay。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
|
||||
长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
|
||||
|
||||
## 准备阶段
|
||||
|
||||
|
||||
@@ -396,9 +396,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'registerUpgrade(route: WebUpgradeRoute): () => void',
|
||||
jsDoc: '/**\n * Register an exact-path HTTP upgrade route. Duplicate paths throw because\n * one socket can have only one protocol owner.\n * @param route - pathname and handler owning negotiation plus socket use.\n * @returns the disposer removing the route.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'registerFallback(handler: WebRoute[\'handler\']): () => void',
|
||||
jsDoc: '/**\n * Claim the fallback seat: the handler answering every request no named\n * route matches (the SPA dist server in the shipped Web composition). One\n * owner only — a second registration throws, because two fallbacks cannot\n * compose.\n * @param handler - owns the full response lifecycle of unmatched requests.\n * @returns the disposer releasing the seat.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'tapIndex(transform: (html: string) => string): () => void',
|
||||
jsDoc: '/**\n * Register an index.html transform, applied to every index response in\n * registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
|
||||
jsDoc: '/**\n * Register an index.html transform, applied by the fallback owner to every\n * index response ({@link applyIndexTaps}) in registration order.\n * @param transform - pure html-to-html function.\n * @returns the disposer removing the transform.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'applyIndexTaps(html: string): string',
|
||||
jsDoc: '/**\n * Run an index.html body through the registered taps in registration order\n * — called by the fallback owner on every index response it renders.\n * @param html - the raw index.html body.\n * @returns the transformed body.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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/credentials/credentials-local/README.md
|
||||
README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86
|
||||
README.zh.md: a279336f59ca9525ebeb918dd23fb0b7682443d5
|
||||
README.md: 7d541d42efc942310e9a4066a9edadedb608ef0b
|
||||
README.zh.md: 6a3d697551c8607bbab59a81c84eb725bf6191eb
|
||||
|
||||
@@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna
|
||||
|
||||
## Security boundary
|
||||
|
||||
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given.
|
||||
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)), so reaching the value takes a deliberate read of a path the agent was not given.
|
||||
|
||||
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一
|
||||
|
||||
## 安全边界
|
||||
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
|
||||
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。
|
||||
|
||||
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。
|
||||
|
||||
|
||||
@@ -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/host/README.md
|
||||
README.md: 7cd331f113eeec6c0a56f0ebc60554d9647aee75
|
||||
README.zh.md: 07b0e1569e17b9f0465a43f77fa2dbddcb1bae91
|
||||
README.md: 269a27f51c842f13bc11c175916b7be22db72bd2
|
||||
README.zh.md: 559bf785eb45d59a30f676b98c14143c69d57edd
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
|
||||
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`apiproxy/`](apiproxy/README.md) | Shared host API gateway and wire contract | `ctx.apiProxy` |
|
||||
| [`webserver/`](webserver/README.md) | HTTP route carrier | `ctx.httpServer` |
|
||||
| [`frontend-static/`](frontend-static/README.md) | SPA dist server on the webserver fallback seat | consumes `ctx.httpServer` |
|
||||
| [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam | `ctx.directoryPicker` |
|
||||
| [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` |
|
||||
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` |
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),由它提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。
|
||||
dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承载它的普通 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合应用是 [`apps/cli`](../../apps/cli/README.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 [`apps/web`](../../apps/web/)。这些全是**产品**包。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`apiproxy/`](apiproxy/README.md) | 共享宿主 API 网关和协议契约 | `ctx.apiProxy` |
|
||||
| [`webserver/`](webserver/README.md) | HTTP 路由载体 | `ctx.httpServer` |
|
||||
| [`frontend-static/`](frontend-static/README.md) | 占据 webserver 回退席位的 SPA dist 服务器 | 消费 `ctx.httpServer` |
|
||||
| [`directory-picker/`](directory-picker/README.md) | workspace 目录选择 seam | `ctx.directoryPicker` |
|
||||
| [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` |
|
||||
| [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` |
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 00b9ea075af7eb55041d48ceb3a1363e9de90397
|
||||
README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9
|
||||
README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
|
||||
README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。
|
||||
|
||||
## 契约层(`/api`)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* joining the backend's own teardown before the disposer settles.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -43,21 +43,15 @@ afterEach(async () => {
|
||||
fakeBin = undefined
|
||||
})
|
||||
|
||||
/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
||||
/** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
||||
async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
|
||||
const dist = join(root, 'dist')
|
||||
mkdirSync(dist)
|
||||
const distIndex = join(dist, 'index.html')
|
||||
await writeFile(distIndex, '<head></head><body>shell</body>')
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-host-webserver'",
|
||||
' config:',
|
||||
` host: '${bindHost}'`,
|
||||
' port: 0',
|
||||
' portConflict: increment',
|
||||
` distIndex: '${distIndex}'`,
|
||||
`- name: '${AUTO}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
6
packages/host/frontend-static/README.i18n.yaml
Normal file
6
packages/host/frontend-static/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/host/frontend-static/README.md
|
||||
README.md: c3a831abb1060b59e1802d38d5407a29d24e3bb3
|
||||
README.zh.md: d4dc71763280a3c88c73de50f63f2615570c7182
|
||||
19
packages/host/frontend-static/README.md
Normal file
19
packages/host/frontend-static/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `@deepseek-ai/dsh-frontend-static`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
SPA dist server for the Web shell: a function plugin (config `{distIndex}`) that claims the [webserver](../webserver/README.md)'s single fallback seat and serves the built frontend directory with the shell's locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as `application/octet-stream`, and non-GET/HEAD without a matching named route is 405. Every index response runs through the webserver's registered index taps (`applyIndexTaps`), which is how the boot manifest reaches the page. `distIndex` is an assembly fact of the composing application: [`dsh-web-app`](../../bundle/web-app/README.md) resolves it through the frontend package's exports and mounts this plugin; a deployment never hardcodes it.
|
||||
|
||||
The fallback seat is single-owner (a second claim throws) and effect-scoped: disposing the plugin's fiber releases the seat, after which the unclaimed webserver answers 404.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the package serves browser assets; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
|
||||
19
packages/host/frontend-static/README.zh.md
Normal file
19
packages/host/frontend-static/README.zh.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `@deepseek-ai/dsh-frontend-static`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 `application/octet-stream` 提供,GET/HEAD 之外的方法在没有匹配的具名 route 时返回 405。每个 index 响应都会经过 webserver 已注册的 index 转换(`applyIndexTaps`),启动 manifest(元数据清单)就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
|
||||
|
||||
回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束:dispose(资源释放)插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该包只服务浏览器资产;其中没有任何内容会进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **初始 MIME 表很精简**:vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。
|
||||
41
packages/host/frontend-static/package.json
Normal file
41
packages/host/frontend-static/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-frontend-static",
|
||||
"description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
109
packages/host/frontend-static/src/index.ts
Normal file
109
packages/host/frontend-static/src/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-frontend-static — SPA dist server over the webserver
|
||||
* fallback seat: serves the built frontend directory with the semantics the
|
||||
* Web shell locked at step1 — traversal outside the dist root is 403, any
|
||||
* miss falls back to index.html with HTTP 200 (SPA routing), unknown
|
||||
* extensions ship as octet-stream, non-GET/HEAD is 405. Every index response
|
||||
* runs through the webserver's registered index taps (boot-manifest
|
||||
* injection). The dist location is workspace knowledge of the composing
|
||||
* application, so `distIndex` is typically supplied through a `!!js`
|
||||
* expression, never hardcoded by a deployment.
|
||||
* @module @deepseek-ai/dsh-frontend-static
|
||||
*/
|
||||
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'frontend-static'
|
||||
|
||||
/** Service required before the fallback seat can be claimed. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Plugin config: the dist anchor. */
|
||||
export interface Config {
|
||||
/** Absolute path of index.html inside the dist root. */
|
||||
distIndex: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
distIndex: z.string().required(),
|
||||
})
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json',
|
||||
'.map': 'application/json',
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one GET/HEAD static request from the dist root.
|
||||
* @param pathname - decoded URL pathname of the request.
|
||||
* @param res - the node:http response to write.
|
||||
* @param distRoot - absolute dist root directory (resolved by the caller).
|
||||
* @param distIndex - absolute path of index.html inside distRoot.
|
||||
* @param renderIndex - produces the index.html body (index-tap injection) for
|
||||
* `/` and every SPA fallback.
|
||||
*/
|
||||
export async function serveStatic(
|
||||
pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
|
||||
renderIndex: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
const target = resolve(normalize(join(distRoot, pathname)))
|
||||
// Traversal rejection: the target must be distRoot itself (`/`) or stay under
|
||||
// it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/'
|
||||
// suffix would reject every legitimate subpath as traversal.
|
||||
if (target !== distRoot && !target.startsWith(distRoot + sep)) {
|
||||
res.writeHead(403)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const serveIndex = async (): Promise<void> => {
|
||||
const body = await renderIndex()
|
||||
res.writeHead(200, { 'content-type': MIME['.html'] })
|
||||
res.end(body)
|
||||
}
|
||||
if (target === distRoot || target === distIndex) {
|
||||
await serveIndex()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(target)
|
||||
res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing).
|
||||
await serveIndex()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the webserver fallback seat and serve the dist.
|
||||
* @param ctx - plugin context carrying the httpServer service.
|
||||
* @param config - validated {@link Config}.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const distIndex = config.distIndex
|
||||
const distRoot = dirname(distIndex)
|
||||
const renderIndex = async (): Promise<string> =>
|
||||
ctx.httpServer.applyIndexTaps(await readFile(distIndex, 'utf8'))
|
||||
ctx.effect(() => ctx.httpServer.registerFallback(async (req, res) => {
|
||||
// Non-GET/HEAD without a matching named route is 405 (fallback-only
|
||||
// semantics: named routes own their method handling).
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- node:http always sets url on server requests */
|
||||
const rawPath = new URL(req.url ?? '/', 'http://x').pathname
|
||||
await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
|
||||
}), 'frontend-static: fallback seat')
|
||||
}
|
||||
34
packages/host/frontend-static/src/invariant.ts
Normal file
34
packages/host/frontend-static/src/invariant.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-frontend-static`.
|
||||
* @module @deepseek-ai/dsh-frontend-static/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'frontend-static-invariant'
|
||||
/** Service required before the companion can register. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the only owned relation is the single fallback seat,
|
||||
* which cannot be probed from the teardown stream — `internal/plugin` fires
|
||||
* before the disposing fiber's effects run, so the legitimate owner still
|
||||
* holds the seat at notification time and any claim probe would
|
||||
* false-positive on every correct disposal (unlike the webserver companion,
|
||||
* whose reserved-path probes never collide with a live registration). The
|
||||
* seat's register/release symmetry is covered by the package's
|
||||
* real-composition HMR-safety test instead.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
127
packages/host/frontend-static/tests/frontend-static.spec.ts
Normal file
127
packages/host/frontend-static/tests/frontend-static.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* REAL-composition coverage: a test-only cordis.yml booted through the
|
||||
* vendored Loader mounts the webserver and frontend-static rows, and every
|
||||
* assertion observes the served HTTP surface — asset serving, MIME fallback,
|
||||
* SPA index fallback with index taps, traversal rejection, 405 on non-GET/
|
||||
* HEAD, and seat release on fiber disposal (HMR safety).
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import HttpServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as FrontendStatic from '../src/index.ts'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
/** Write a dist fixture and a two-row cordis.yml, then boot it through the real Loader. */
|
||||
async function loadComposition(): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-frontend-static-'))
|
||||
const dist = join(root, 'dist')
|
||||
await mkdir(dist)
|
||||
const distIndex = join(dist, 'index.html')
|
||||
await writeFile(distIndex, '<head></head><body>shell</body>')
|
||||
await writeFile(join(dist, 'app.js'), 'export {}')
|
||||
await writeFile(join(dist, 'blob.bin'), 'BLOB')
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-host-webserver'",
|
||||
' config:',
|
||||
" host: '127.0.0.1'",
|
||||
' port: 0',
|
||||
'- id: frontend',
|
||||
" name: '@deepseek-ai/dsh-frontend-static'",
|
||||
' config:',
|
||||
` distIndex: '${distIndex}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-host-webserver', HttpServer],
|
||||
['@deepseek-ai/dsh-frontend-static', FrontendStatic],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
return context
|
||||
}
|
||||
|
||||
/** GET (by default) one path against the running server; returns status, content-type, and a body prefix. */
|
||||
async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; type: string | null; body: string }> {
|
||||
const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init)
|
||||
return {
|
||||
status: response.status,
|
||||
type: response.headers.get('content-type'),
|
||||
body: (await response.text()).slice(0, 80),
|
||||
}
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
it('serves the dist with SPA fallback, taps, traversal rejection, and method gating', { timeout: 60_000 }, async () => {
|
||||
const loaded = await loadComposition()
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
const server = loaded.httpServer
|
||||
const port = server.port
|
||||
|
||||
// Real asset with its MIME type; a live rebuild is served on the next read.
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' })
|
||||
await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
|
||||
|
||||
// Unknown extension ships as octet-stream.
|
||||
expect(await request(port, '/blob.bin')).toMatchObject({ status: 200, type: 'application/octet-stream', body: 'BLOB' })
|
||||
|
||||
// `/`, the index path, and any miss all render index.html (SPA routing)
|
||||
// through the registered index taps.
|
||||
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
|
||||
for (const path of ['/', '/index.html', '/no/such/route']) {
|
||||
const got = await request(port, path)
|
||||
expect(got.status).toBe(200)
|
||||
expect(got.body).toContain('__T__')
|
||||
expect(got.body).toContain('shell')
|
||||
}
|
||||
untap()
|
||||
expect((await request(port, '/')).body).not.toContain('__T__')
|
||||
|
||||
// Traversal outside the dist root is 403; non-GET/HEAD is 405.
|
||||
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
|
||||
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
|
||||
|
||||
// HMR safety: disposing the frontend row releases the fallback seat (the
|
||||
// unclaimed webserver answers 404) and the seat is claimable again.
|
||||
const frontendEntry = [...loaded.loader.entries()].find(e => e.options.id === 'frontend')
|
||||
expect(frontendEntry).toBeDefined()
|
||||
await frontendEntry!.fiber?.dispose()
|
||||
expect((await request(port, '/no/such/route')).status).toBe(404)
|
||||
expect(() => server.registerFallback(() => {})).not.toThrow()
|
||||
})
|
||||
})
|
||||
27
packages/host/frontend-static/tsconfig.json
Normal file
27
packages/host/frontend-static/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/host/webserver/README.md
|
||||
README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4
|
||||
README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977
|
||||
README.md: b6dccf2f81c9e2f0b9f53264eafe724edb560f07
|
||||
README.zh.md: dbfe420013ed67c48e47048341f020864aeef16a
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
|
||||
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
|
||||
|
||||
The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
|
||||
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
|
||||
|
||||
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
|
||||
|
||||
@@ -21,5 +21,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1.
|
||||
- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships.
|
||||
- **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
|
||||
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
|
||||
|
||||
该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 route。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
|
||||
|
||||
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
|
||||
|
||||
@@ -21,5 +21,4 @@ Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。
|
||||
- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。
|
||||
- **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
|
||||
* server plus the `httpServer` service (HTTP and upgrade route registries,
|
||||
* index transform taps, and static dist fallback). Knows no harness concepts;
|
||||
* feature plugins own every registered protocol. Web shape only — Electron
|
||||
* loads dist over file:// and carries fetch over an IPC bridge. This package
|
||||
* never prints: the URL line belongs to the shell.
|
||||
* index transform taps, and the single fallback seat for everything no route
|
||||
* claims). Knows no harness concepts and serves no files; the composing
|
||||
* application's frontend plugin owns dist serving through the fallback seam.
|
||||
* Web shape only — Electron loads dist over file:// and carries fetch over an
|
||||
* IPC bridge. This package never prints: the URL line belongs to the shell.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { dirname } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { serveStatic } from './static.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -43,28 +41,26 @@ export interface WebUpgradeRoute {
|
||||
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
|
||||
/** Gateway config: the listen address. */
|
||||
export interface Config {
|
||||
/** Listen host; the two supported values are loopback and all-interfaces. */
|
||||
host: '127.0.0.1' | '0.0.0.0'
|
||||
/** Listen port; zero requests an OS-assigned port. */
|
||||
port: number
|
||||
/** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */
|
||||
distIndex: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The web-shape HTTP carrier service. Activation listens immediately (route
|
||||
* registration order carries no request-facing semantics: named routes are
|
||||
* composed to be disjoint, and the static dist fallback answers anything not
|
||||
* yet claimed during the boot window). A listen failure throws out of init —
|
||||
* a FAILED fiber the boot's fail-loud sweep reports.
|
||||
* composed to be disjoint, and the fallback seat answers anything not yet
|
||||
* claimed during the boot window — 404 until its owner registers). A listen
|
||||
* failure throws out of init — a FAILED fiber the boot's fail-loud sweep
|
||||
* reports.
|
||||
*/
|
||||
export class HttpServerService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
|
||||
port: z.natural().max(65535).required(),
|
||||
distIndex: z.string().required(),
|
||||
})
|
||||
|
||||
private readonly exact = new Map<string, WebRoute>()
|
||||
@@ -72,15 +68,12 @@ export class HttpServerService extends Service {
|
||||
private readonly upgrades = new Map<string, WebUpgradeRoute>()
|
||||
private readonly upgradedSockets = new Set<Duplex>()
|
||||
private readonly indexTaps: ((html: string) => string)[] = []
|
||||
private readonly distRoot: string
|
||||
private readonly distIndex: string
|
||||
private fallback: WebRoute['handler'] | undefined
|
||||
private server!: Server
|
||||
private listenedPort!: number
|
||||
|
||||
constructor(ctx: Context, private config: Config) {
|
||||
super(ctx, 'httpServer')
|
||||
this.distIndex = config.distIndex
|
||||
this.distRoot = dirname(config.distIndex)
|
||||
}
|
||||
|
||||
/** The listening port (the OS-assigned value when config.port is 0). */
|
||||
@@ -123,8 +116,24 @@ export class HttpServerService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied to every index response in
|
||||
* registration order.
|
||||
* Claim the fallback seat: the handler answering every request no named
|
||||
* route matches (the SPA dist server in the shipped Web composition). One
|
||||
* owner only — a second registration throws, because two fallbacks cannot
|
||||
* compose.
|
||||
* @param handler - owns the full response lifecycle of unmatched requests.
|
||||
* @returns the disposer releasing the seat.
|
||||
*/
|
||||
registerFallback(handler: WebRoute['handler']): () => void {
|
||||
if (this.fallback !== undefined) {
|
||||
throw new Error('webserver: fallback already registered')
|
||||
}
|
||||
this.fallback = handler
|
||||
return () => { this.fallback = undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an index.html transform, applied by the fallback owner to every
|
||||
* index response ({@link applyIndexTaps}) in registration order.
|
||||
* @param transform - pure html-to-html function.
|
||||
* @returns the disposer removing the transform.
|
||||
*/
|
||||
@@ -147,14 +156,13 @@ export class HttpServerService extends Service {
|
||||
await route.handler(req, res)
|
||||
return
|
||||
}
|
||||
// Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405,
|
||||
// traversal 403, miss falls back to index.html 200 (SPA routing).
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
const fallback = this.fallback
|
||||
if (fallback === undefined) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex())
|
||||
await fallback(req, res)
|
||||
}
|
||||
// Last-resort guard: handle() rejecting would otherwise be an unhandled
|
||||
// rejection killing the process on one malformed request (bad %-escape,
|
||||
@@ -243,11 +251,16 @@ export class HttpServerService extends Service {
|
||||
return best
|
||||
}
|
||||
|
||||
/** Index body: dist index.html through the registered taps in order. */
|
||||
private async renderIndex(): Promise<string> {
|
||||
let html = await readFile(this.distIndex, 'utf8')
|
||||
for (const transform of this.indexTaps) html = transform(html)
|
||||
return html
|
||||
/**
|
||||
* Run an index.html body through the registered taps in registration order
|
||||
* — called by the fallback owner on every index response it renders.
|
||||
* @param html - the raw index.html body.
|
||||
* @returns the transformed body.
|
||||
*/
|
||||
applyIndexTaps(html: string): string {
|
||||
let out = html
|
||||
for (const transform of this.indexTaps) out = transform(out)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Static file serving for the web shell: the starter MIME table and the
|
||||
* request handler with the semantics locked by the step1 acceptance list —
|
||||
* traversal outside the dist root is 403, any miss falls back to index.html
|
||||
* with HTTP 200 (SPA routing), unknown extensions ship as octet-stream.
|
||||
*/
|
||||
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import { extname, join, normalize, resolve, sep } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json',
|
||||
'.map': 'application/json',
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one GET/HEAD static request from the dist root.
|
||||
* @param pathname - decoded URL pathname of the request.
|
||||
* @param res - the node:http response to write.
|
||||
* @param distRoot - absolute dist root directory (resolved by the caller).
|
||||
* @param distIndex - absolute path of index.html inside distRoot.
|
||||
* @param renderIndex - when set, produces the index.html body (boot-manifest
|
||||
* injection) for `/` and every SPA fallback; undefined serves the file verbatim.
|
||||
*/
|
||||
export async function serveStatic(
|
||||
pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
|
||||
renderIndex?: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
const target = resolve(normalize(join(distRoot, pathname)))
|
||||
// Traversal rejection: the target must be distRoot itself (`/`) or stay under
|
||||
// it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/'
|
||||
// suffix would reject every legitimate subpath as traversal.
|
||||
if (target !== distRoot && !target.startsWith(distRoot + sep)) {
|
||||
res.writeHead(403)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const serveIndex = async (): Promise<void> => {
|
||||
const body = renderIndex === undefined ? await readFile(distIndex) : await renderIndex()
|
||||
res.writeHead(200, { 'content-type': MIME['.html'] })
|
||||
res.end(body)
|
||||
}
|
||||
if (target === distRoot || target === distIndex) {
|
||||
await serveIndex()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await readFile(target)
|
||||
res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
|
||||
res.end(body)
|
||||
} catch {
|
||||
// Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing).
|
||||
await serveIndex()
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,10 @@
|
||||
* REAL-composition coverage: a test-only cordis.yml booted through the
|
||||
* vendored Loader mounts the webserver row, and every assertion observes the
|
||||
* user-visible HTTP surface of the running server (routing precedence, index
|
||||
* taps, static-fallback semantics, per-request error containment, teardown).
|
||||
* taps, fallback-seat semantics, per-request error containment, teardown).
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { once } from 'node:events'
|
||||
import { connect } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -28,21 +27,15 @@ afterEach(async () => {
|
||||
root = undefined
|
||||
})
|
||||
|
||||
/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */
|
||||
/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */
|
||||
async function loadComposition(port = 0): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-'))
|
||||
const dist = join(root, 'dist')
|
||||
await mkdir(dist)
|
||||
const distIndex = join(dist, 'index.html')
|
||||
await writeFile(distIndex, '<head></head><body>shell</body>')
|
||||
await writeFile(join(dist, 'app.js'), 'export {}')
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-host-webserver'",
|
||||
' config:',
|
||||
" host: '127.0.0.1'",
|
||||
` port: ${String(port)}`,
|
||||
` distIndex: '${distIndex}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
@@ -96,7 +89,7 @@ describe('real Loader composition', () => {
|
||||
// Real-Loader composition resolves workspace packages through tsx at test
|
||||
// time; first resolution after the host/client program split is slow enough
|
||||
// to trip the default 5s budget on cold caches.
|
||||
it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => {
|
||||
it('serves registered routes, index taps, and the fallback-seat semantics', { timeout: 60_000 }, async () => {
|
||||
const loaded = await loadComposition()
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
@@ -120,21 +113,24 @@ describe('real Loader composition', () => {
|
||||
expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' })
|
||||
expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' })
|
||||
|
||||
// Index taps apply in registration order on `/` and on the SPA fallback;
|
||||
// the disposer removes the transform.
|
||||
// Fallback seat: 404 while unclaimed; the owner answers everything no
|
||||
// named route matches; index taps are the owner's to apply; the seat
|
||||
// admits exactly one owner and the disposer releases it.
|
||||
expect((await request(port, '/no/such/route')).status).toBe(404)
|
||||
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
|
||||
expect((await request(port, '/')).body).toContain('__T__')
|
||||
expect(server.applyIndexTaps('<head></head>')).toContain('__T__')
|
||||
const releaseFallback = server.registerFallback((req, res) => {
|
||||
// Decode like a real static server would — a malformed %-escape throws
|
||||
// here, probing the webserver's per-request error containment.
|
||||
decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
|
||||
res.writeHead(200, { 'content-type': 'text/html' })
|
||||
res.end(server.applyIndexTaps('<head></head><body>shell</body>'))
|
||||
})
|
||||
expect(() => server.registerFallback(() => {})).toThrow(/fallback already registered/)
|
||||
expect((await request(port, '/no/such/route')).body).toContain('__T__')
|
||||
untap()
|
||||
expect((await request(port, '/')).body).not.toContain('__T__')
|
||||
|
||||
// Static fallback semantics: real asset served, traversal 403, non-GET/
|
||||
// HEAD without a matching route 405.
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' })
|
||||
await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
|
||||
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
|
||||
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
|
||||
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
|
||||
expect((await request(port, '/no/such/route')).body).not.toContain('__T__')
|
||||
expect((await request(port, '/no/such/route')).body).toContain('shell')
|
||||
|
||||
// Per-request error containment: a malformed %-escape answers 400 and the
|
||||
// server keeps serving afterwards (no process-level failure path).
|
||||
@@ -148,9 +144,14 @@ describe('real Loader composition', () => {
|
||||
const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } })
|
||||
expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' })
|
||||
disposeOnce()
|
||||
expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
|
||||
expect((await request(port, '/once')).body).toContain('shell') // back to the fallback owner
|
||||
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
|
||||
|
||||
// Releasing the seat restores the unclaimed 404 and registrability.
|
||||
releaseFallback()
|
||||
expect((await request(port, '/no/such/route')).status).toBe(404)
|
||||
expect(() => server.registerFallback(() => {})).not.toThrow()
|
||||
|
||||
// Upgrade routes match exact pathnames, reject duplicate ownership, and
|
||||
// become registrable again after disposal. The accepted socket stays open
|
||||
// so the teardown assertion also covers upgraded-connection ownership.
|
||||
|
||||
@@ -686,7 +686,9 @@ class FaceAnalyzer {
|
||||
const records: ExportRecord[] = []
|
||||
for (const [subpath, target] of targets) {
|
||||
if (target.includes('*') || subpath === './package.json'
|
||||
|| subpath === './typert' || subpath === './client/typert' || target.endsWith('.json')) continue
|
||||
|| subpath === './typert' || subpath === './client/typert'
|
||||
// Data exports (bundle patch lists, JSON manifests) carry no TypeScript API.
|
||||
|| target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue
|
||||
const sourcePath = sourcePathForExport(registration.root, target)
|
||||
const sourceFile = this.sourceFiles.get(realPath(sourcePath))
|
||||
if (sourceFile === undefined) {
|
||||
|
||||
@@ -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/ui/app-boot/README.md
|
||||
README.md: fbdd4c1332a1cc52f15a8ce28264ea16d47fc552
|
||||
README.zh.md: b67fb126ea477acf2e79f5bc1d695a5fc9ca8c82
|
||||
README.md: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b
|
||||
README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a
|
||||
|
||||
@@ -12,10 +12,11 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR |
|
||||
| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer |
|
||||
| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by user patch-layer HMR |
|
||||
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
@@ -29,14 +30,16 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
## Personal config
|
||||
## Profiles
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's Web and headless modes ([`apps/cli`](../../../apps/cli/README.md)); raw config mode and the demo bins boot their named trees without this layer. Two optional files:
|
||||
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path).
|
||||
|
||||
User-level machine-local preferences also live in the Harness home:
|
||||
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the Web settings page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
|
||||
|
||||
Web keeps `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -51,4 +54,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
|
||||
- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook.
|
||||
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
|
||||
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
|
||||
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
|
||||
- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps.
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
@@ -29,14 +30,16 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
## 个人配置
|
||||
## Profile
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 Web 与 headless 模式([`apps/cli`](../../../apps/cli/README.md))使用;原始配置模式与 demo bin 会在不加该层的情况下启动指定的配置树。这里有两个可选文件:
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。
|
||||
|
||||
用户级的机器本地偏好同样位于 Harness home 中:
|
||||
|
||||
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
- **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
|
||||
|
||||
Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -51,4 +54,4 @@ Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches`
|
||||
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。
|
||||
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
|
||||
- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
|
||||
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。
|
||||
- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to
|
||||
* optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to
|
||||
* config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles.
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import type {} from '@cordisjs/plugin-hmr'
|
||||
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -25,6 +25,27 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
composeEntries,
|
||||
DEFAULT_PROFILE_BUNDLES,
|
||||
healProfilesModuleFallback,
|
||||
initProfile,
|
||||
loadProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
PROFILE_TEMPLATES,
|
||||
PROFILES_DIR,
|
||||
readProfileManifest,
|
||||
resolveBundleDir,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
type DshBundleManifest,
|
||||
type DshManifestSection,
|
||||
type DshProfileManifest,
|
||||
type Profile,
|
||||
type ProfileLayer,
|
||||
type ProfileManifest,
|
||||
} from './profile.ts'
|
||||
|
||||
/**
|
||||
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
|
||||
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
|
||||
@@ -65,49 +86,100 @@ export function loadEnv(
|
||||
}
|
||||
}
|
||||
|
||||
/** File inside the Harness home holding the personal loader overlay patches. */
|
||||
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
|
||||
|
||||
const bootstrapIncludes = new WeakMap<Context, Entry>()
|
||||
|
||||
// The include's YAML dialect (`!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time), imported
|
||||
// from the include itself so patch parsing and config dumping can never drift
|
||||
// from what the include mounts. Personal patches share it so they may
|
||||
// from what the include mounts. User patch layers share it so they may
|
||||
// reference `process.env`.
|
||||
const personalPatchesSchema = entryListSchema
|
||||
const userPatchesSchema = entryListSchema
|
||||
|
||||
/** Options for live user patch-layer reconciliation. */
|
||||
export interface UserPatchWatchOptions {
|
||||
/** Diagnostic prefix used by {@link loadOptionalPatches}. */
|
||||
binName: string
|
||||
/** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
|
||||
filename: string
|
||||
/**
|
||||
* Compose the full patch list for a fresh user-layer generation —
|
||||
* the same composition the app booted with, so a reload can interleave the
|
||||
* new user patches between app-owned layers (bundle layers below,
|
||||
* overlay/flag patches above). Identity when omitted: the user layer
|
||||
* is the whole patch list.
|
||||
*/
|
||||
compose?: (userPatches: PatchOptions[]) => PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the optional personal overlay patches (`config.yaml` under the Harness
|
||||
* home). The file is a top-level YAML array of loader patch entries
|
||||
* (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides
|
||||
* and `insert` lists, with `!!js` expressions allowed. A missing file means
|
||||
* "no personal overlay"; an unreadable, unparsable, or non-array file throws —
|
||||
* a present personal config that cannot apply is a misconfiguration and must
|
||||
* fail loud at boot, never be silently skipped.
|
||||
* Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
|
||||
* @param ctx - settled app context containing the root Include and an active HMR service.
|
||||
* @param options - diagnostic, file, and patch-composition inputs.
|
||||
* @returns an asynchronous disposer after the exact-path watcher is ready.
|
||||
* @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
|
||||
*/
|
||||
export async function watchUserPatches(
|
||||
ctx: Context,
|
||||
options: UserPatchWatchOptions,
|
||||
): Promise<() => Promise<void>> {
|
||||
const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options
|
||||
const hmr = ctx.get('hmr')
|
||||
if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
|
||||
const entry = bootstrapIncludes.get(ctx)
|
||||
if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
|
||||
const register = hmr.registerConfig(filename, async () => {
|
||||
// Re-read the include's non-patch options per refresh: a writer that
|
||||
// updates the root Include's other options between refreshes (none exists
|
||||
// today) must not have them silently reverted by a user-layer reload.
|
||||
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
|
||||
const userPatches = loadOptionalPatches(binName, filename) ?? []
|
||||
const patches = compose(userPatches)
|
||||
await entry.update({
|
||||
config: {
|
||||
...includeConfig,
|
||||
patches,
|
||||
},
|
||||
})
|
||||
})
|
||||
try {
|
||||
return await register
|
||||
} catch (error) {
|
||||
// A surface can dispose the whole tree while the watcher is still opening;
|
||||
// the HMR effect registration then fails with INACTIVE_EFFECT. That is the
|
||||
// app exiting exactly as asked, not a watch failure, so return a no-op
|
||||
// disposer instead of crashing.
|
||||
if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an optional patch-list file: a top-level YAML array of loader patch
|
||||
* entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config
|
||||
* overrides and `insert` lists, with `!!js` expressions allowed. A missing
|
||||
* file means "no layer"; an unreadable, unparsable, or non-array file throws —
|
||||
* a present patch file that cannot apply is a misconfiguration and must fail
|
||||
* loud at boot, never be silently skipped.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`).
|
||||
* @param file - absolute path of the patch file.
|
||||
* @returns the parsed patches, or `undefined` when the file does not exist.
|
||||
*/
|
||||
export function loadPersonalPatches(
|
||||
binName: string, dir: string = resolveDshHome(),
|
||||
): PatchOptions[] | undefined {
|
||||
const file = join(dir, PERSONAL_CONFIG_FILENAME)
|
||||
export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(file, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
|
||||
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
|
||||
throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`)
|
||||
}
|
||||
return parsePatchList(binName, file, content, 'personal patches')
|
||||
return parsePatchList(binName, file, content, 'patches')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a
|
||||
* `--config <path>` overlay applied over the shared base. Same file format as
|
||||
* {@link loadPersonalPatches}, but a missing file throws, because the caller
|
||||
* named this file — its absence is a misconfiguration, not "no overlay".
|
||||
* Load a required overlay patch list: a bundle's `cordis.patch.yml` or a
|
||||
* `--patch <path>` overlay. Same file format as {@link loadOptionalPatches},
|
||||
* but a missing file throws, because the caller named this file — its absence
|
||||
* is a misconfiguration, not "no overlay".
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - absolute path of the overlay file.
|
||||
* @returns the parsed patch list.
|
||||
@@ -121,7 +193,6 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
|
||||
}
|
||||
return parsePatchList(binName, file, content, 'overlay')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one loader patch list: a top-level YAML array of
|
||||
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
|
||||
@@ -132,7 +203,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - the source path, quoted in errors.
|
||||
* @param content - the file's text.
|
||||
* @param label - what to call this list in errors (`personal patches`, `overlay`).
|
||||
* @param label - what to call this list in errors (`patches`, `overlay`).
|
||||
* @returns the parsed patch list.
|
||||
*/
|
||||
function parsePatchList(
|
||||
@@ -140,7 +211,7 @@ function parsePatchList(
|
||||
): PatchOptions[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: personalPatchesSchema })
|
||||
parsed = yaml.load(content, { schema: userPatchesSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
|
||||
}
|
||||
@@ -159,7 +230,7 @@ function parsePatchList(
|
||||
export interface ConfigDumpLayer {
|
||||
/** Source name shown in provenance comments (a file basename or path). */
|
||||
label: string
|
||||
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */
|
||||
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
|
||||
patches: PatchOptions[]
|
||||
}
|
||||
|
||||
@@ -290,70 +361,11 @@ function groupedDump(
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** Options for live personal-config reconciliation. */
|
||||
export interface PersonalPatchWatchOptions {
|
||||
/** Diagnostic prefix used by {@link loadPersonalPatches}. */
|
||||
binName: string
|
||||
/** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */
|
||||
dir?: string
|
||||
/**
|
||||
* Compose the full patch list for a fresh personal-overlay generation —
|
||||
* the same composition the app booted with, so a reload can interleave the
|
||||
* new personal patches between app-owned layers (surface overlay below,
|
||||
* profile/flag patches above). Identity when omitted: the personal overlay
|
||||
* is the whole patch list.
|
||||
*/
|
||||
compose?: (personalPatches: PatchOptions[]) => PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include.
|
||||
* @param ctx - settled app context containing the root Include and an active HMR service.
|
||||
* @param options - diagnostic, Harness-home, and patch-composition inputs.
|
||||
* @returns an asynchronous disposer after the exact-path watcher is ready.
|
||||
* @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
|
||||
*/
|
||||
export async function watchPersonalPatches(
|
||||
ctx: Context,
|
||||
options: PersonalPatchWatchOptions,
|
||||
): Promise<() => Promise<void>> {
|
||||
const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options
|
||||
const hmr = ctx.get('hmr')
|
||||
if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`)
|
||||
const entry = bootstrapIncludes.get(ctx)
|
||||
if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`)
|
||||
const filename = join(dir, PERSONAL_CONFIG_FILENAME)
|
||||
const register = hmr.registerConfig(filename, async () => {
|
||||
// Re-read the include's non-patch options per refresh: a writer that
|
||||
// updates the root Include's other options between refreshes (none exists
|
||||
// today) must not have them silently reverted by a personal reload.
|
||||
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
|
||||
const personalPatches = loadPersonalPatches(binName, dir) ?? []
|
||||
const patches = compose(personalPatches)
|
||||
await entry.update({
|
||||
config: {
|
||||
...includeConfig,
|
||||
patches,
|
||||
},
|
||||
})
|
||||
})
|
||||
try {
|
||||
return await register
|
||||
} catch (error) {
|
||||
// A surface can dispose the whole tree while the watcher is still opening;
|
||||
// the HMR effect registration then fails with INACTIVE_EFFECT. That is the
|
||||
// app exiting exactly as asked, not a watch failure, so return a no-op
|
||||
// disposer instead of crashing.
|
||||
if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount and remember the exact root Include entry used by app boot and personal-config HMR.
|
||||
* Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
|
||||
* @param ctx - context carrying an initialized Loader service.
|
||||
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
|
||||
* @param patches - initial app and personal patches, applied in order.
|
||||
* @param patches - initial app and user patches, applied in order.
|
||||
* @returns the created root Include entry, or `undefined` when a surface
|
||||
* disposed the whole tree (taking the Loader service with it) while the
|
||||
* transactional create was still settling entry lifecycle.
|
||||
@@ -599,7 +611,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
* @param patches - optional overlay patches applied over the included tree
|
||||
* (see {@link loadPersonalPatches}); an empty list mounts none.
|
||||
* (see {@link loadOptionalPatches}); an empty list mounts none.
|
||||
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
||||
* @returns the root context once every entry has started, or as soon as a
|
||||
* surface disposed the tree while startup was still in flight.
|
||||
|
||||
388
packages/ui/app-boot/src/profile.ts
Normal file
388
packages/ui/app-boot/src/profile.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Profile discovery, initialization, and patch-layer composition for the
|
||||
* `dsh --profile` launcher family.
|
||||
*
|
||||
* A profile is a directory under `$DSH_HOME/profiles/<name>` holding a
|
||||
* `package.json` (out-of-tree plugin dependencies plus the profile manifest
|
||||
* `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml`
|
||||
* (the user's own patch layer, applied after every bundle layer). Bundles are
|
||||
* npm packages whose manifest declares
|
||||
* `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the tree is
|
||||
* composed by applying each bundle's patch list in `dsh.profile.bundles` order over
|
||||
* an empty entry list, then the profile's own patches, then any launcher
|
||||
* layers (`--patch` files and flag-derived patches).
|
||||
*
|
||||
* Module resolution is two-anchor by construction: a bundle name resolves
|
||||
* first from the dsh installation (the launcher's own package), then from the
|
||||
* profile directory. The Loader's `baseUrl` is the profile directory, whose
|
||||
* `node_modules` pnpm manages for out-of-tree plugins, while the maintained
|
||||
* flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per
|
||||
* package the installation's app and bundles depend on) makes every in-box
|
||||
* plugin Node-resolvable from any profile through the ordinary parent-walk.
|
||||
* @module @deepseek-ai/dsh-app-boot/profile
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import {
|
||||
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { loadOverlayPatches } from './index.ts'
|
||||
|
||||
/** Directory under the Harness home holding every profile. */
|
||||
export const PROFILES_DIR = 'profiles'
|
||||
|
||||
/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */
|
||||
export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
|
||||
|
||||
/** The bundle half of the `dsh` manifest section: what a bundle package exports. */
|
||||
export interface DshBundleManifest {
|
||||
/** The patch layer this bundle exports, relative to its package root. */
|
||||
patch: string
|
||||
}
|
||||
|
||||
/** The profile half of the `dsh` manifest section: what a profile directory composes. */
|
||||
export interface DshProfileManifest {
|
||||
/** Ordered bundle layer list (package names). */
|
||||
bundles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The `dsh`-owned manifest section of a package.json. The nested key names
|
||||
* the manifest kind: a bundle package declares `bundle`, a profile directory
|
||||
* declares `profile`; nothing declares both.
|
||||
*/
|
||||
export interface DshManifestSection {
|
||||
/** Present on bundle packages only. */
|
||||
bundle?: DshBundleManifest
|
||||
/** Present on profile manifests only. */
|
||||
profile?: DshProfileManifest
|
||||
}
|
||||
|
||||
/** The slice of package.json both profiles and bundles use. */
|
||||
export interface ProfileManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
dsh?: DshManifestSection
|
||||
}
|
||||
|
||||
/** One resolved bundle layer of a profile. */
|
||||
export interface ProfileLayer {
|
||||
/** The bundle's package name, as listed in `dsh.profile.bundles`. */
|
||||
packageName: string
|
||||
/** Absolute directory of the resolved bundle package. */
|
||||
packageDir: string
|
||||
/** Absolute path of the bundle's patch file. */
|
||||
patchPath: string
|
||||
/** The parsed patch list. */
|
||||
patches: PatchOptions[]
|
||||
}
|
||||
|
||||
/** A loaded profile: resolved bundle layers plus the user's own patch layer. */
|
||||
export interface Profile {
|
||||
/** The profile name (its directory basename). */
|
||||
name: string
|
||||
/** Absolute profile directory. */
|
||||
dir: string
|
||||
/** Bundle layers in `dsh.profile.bundles` order. */
|
||||
layers: ProfileLayer[]
|
||||
/** Absolute path of the profile's own patch file. */
|
||||
patchPath: string
|
||||
/** The profile's own patches; empty when the file is absent. */
|
||||
patches: PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a profile's directory under the Harness home.
|
||||
* @param name - the profile name (`dsh --profile <name>`).
|
||||
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
||||
* @returns the absolute profile directory (which may not exist yet).
|
||||
*/
|
||||
export function resolveProfileDir(name: string, home: string = resolveDshHome()): string {
|
||||
if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..'
|
||||
// The launcher-maintained flat module fallback lives at this sibling path.
|
||||
|| name === 'node_modules') {
|
||||
throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`)
|
||||
}
|
||||
return join(home, PROFILES_DIR, name)
|
||||
}
|
||||
|
||||
/** The shipped profile templates auto-initialized on first use, by name. */
|
||||
export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
|
||||
web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
|
||||
headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'],
|
||||
}
|
||||
|
||||
/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
|
||||
export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base']
|
||||
|
||||
const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
|
||||
# a top-level YAML array of loader patch entries (id-targeted config
|
||||
# overrides, disables, and insert lists; \`!!js\` expressions allowed).
|
||||
[]
|
||||
`
|
||||
|
||||
// The hoisted linker gives out-of-tree plugins a flat node_modules whose
|
||||
// missing peers (cordis and friends) fall through to the healed
|
||||
// profiles/node_modules installation fallback, so every plugin shares the
|
||||
// installation's single cordis instance instead of a duplicate. pnpm ≥10
|
||||
// reads its settings from pnpm-workspace.yaml, not .npmrc.
|
||||
const PROFILE_PNPM_WORKSPACE = `packages:
|
||||
- .
|
||||
|
||||
nodeLinker: hoisted
|
||||
autoInstallPeers: false
|
||||
`
|
||||
|
||||
/**
|
||||
* Initialize a profile directory: manifest, empty user patch layer, and the
|
||||
* pnpm settings out-of-tree plugins need. Existing files are never touched,
|
||||
* so re-running is a no-op on an initialized profile.
|
||||
* @param dir - the profile directory from {@link resolveProfileDir}.
|
||||
* @param bundles - the initial `dsh.profile.bundles` layer list.
|
||||
*/
|
||||
export function initProfile(dir: string, bundles: readonly string[]): void {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
if (!existsSync(manifestPath)) {
|
||||
const manifest: ProfileManifest & { private: boolean } = {
|
||||
name: `dsh-profile-${basename(dir)}`,
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: [...bundles] } },
|
||||
}
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
|
||||
}
|
||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
|
||||
const workspacePath = join(dir, 'pnpm-workspace.yaml')
|
||||
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE)
|
||||
}
|
||||
|
||||
/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */
|
||||
function ensureSymlink(link: string, target: string): void {
|
||||
let stat
|
||||
try {
|
||||
stat = lstatSync(link)
|
||||
} catch {
|
||||
// Missing link (first run) — created below. Any other lstat failure on a
|
||||
// path we just created the parent of would resurface on symlinkSync.
|
||||
stat = undefined
|
||||
}
|
||||
if (stat !== undefined) {
|
||||
if (!stat.isSymbolicLink()) {
|
||||
throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`)
|
||||
}
|
||||
if (readlinkSync(link) === target) return
|
||||
rmSync(link)
|
||||
}
|
||||
try {
|
||||
symlinkSync(target, link, 'junction')
|
||||
} catch (error) {
|
||||
// Concurrent launches heal the same fallback; losing the race to a
|
||||
// process writing the identical link is success, anything else is not.
|
||||
// The window between the lstat miss above and this write cannot be
|
||||
// staged deterministically from the public surface.
|
||||
/* v8 ignore next 4 */
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST'
|
||||
|| !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one
|
||||
* symlink per package in the dsh app's resolvable dependency CLOSURE (BFS
|
||||
* over `dependencies` from the app manifest), each resolved from its own
|
||||
* real location. Node's parent-directory walk from any profile finds this
|
||||
* directory after the profile's own `node_modules`, so every in-box plugin
|
||||
* resolves without pnpm ever managing it — the exact "bundles come from the
|
||||
* installation" contract. The closure (not just direct dependencies) is
|
||||
* required for out-of-tree plugins: their peer dependencies name seam
|
||||
* packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only
|
||||
* through its implementation packages. Symlinked packages resolve their own
|
||||
* dependencies from their real directories (Node's default
|
||||
* symlink-following), so each package needs only its one flat link.
|
||||
* Idempotent: correct links are kept and moved installations are
|
||||
* re-pointed; a stale link to a vanished package stays until its name is
|
||||
* reused (dangling links are invisible to resolution).
|
||||
* @param installAnchor - absolute path of the dsh app's package.json.
|
||||
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
||||
*/
|
||||
export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void {
|
||||
const profilesDir = join(home, PROFILES_DIR)
|
||||
const modulesDir = join(profilesDir, 'node_modules')
|
||||
mkdirSync(modulesDir, { recursive: true })
|
||||
const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest
|
||||
const links = new Map<string, string>()
|
||||
/* v8 ignore next -- a real app manifest always declares its name */
|
||||
if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor))
|
||||
// BFS over the resolvable dependency graph; the visited set is the link
|
||||
// map itself (first resolution wins, matching Node's own nearest-wins).
|
||||
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
|
||||
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
||||
// Peer dependencies participate: seam packages (dsh-subprocess,
|
||||
// dsh-compact, ...) are peers of their implementations, never plain
|
||||
// dependencies, yet out-of-tree plugins import them directly.
|
||||
/* v8 ignore next -- a real app manifest always declares dependencies */
|
||||
for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) {
|
||||
if (links.has(dep)) continue
|
||||
const dir = packageDirFromAnchor(next.anchor, dep)
|
||||
// A declared-but-uninstalled dependency cannot be a loader-visible
|
||||
// plugin; skip it rather than fail the whole boot.
|
||||
if (dir === undefined) continue
|
||||
links.set(dep, dir)
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest })
|
||||
}
|
||||
}
|
||||
for (const [packageName, target] of links) {
|
||||
const link = join(modulesDir, packageName)
|
||||
mkdirSync(dirname(link), { recursive: true })
|
||||
ensureSymlink(link, target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a profile's manifest.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param dir - the profile directory.
|
||||
* @returns the parsed manifest.
|
||||
*/
|
||||
export function readProfileManifest(binName: string, dir: string): ProfileManifest {
|
||||
const path = join(dir, 'package.json')
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(path, 'utf8')
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`)
|
||||
}
|
||||
// File boundary: the shape check below validates what the parse type asserts.
|
||||
const parsed = JSON.parse(raw) as ProfileManifest | null
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a profile's manifest back (2-space JSON, trailing newline).
|
||||
* @param dir - the profile directory.
|
||||
* @param manifest - the manifest value to persist.
|
||||
*/
|
||||
export function writeProfileManifest(dir: string, manifest: ProfileManifest): void {
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a package's root directory from one anchor without depending on the
|
||||
* package exporting `./package.json` (`require.resolve` would need that):
|
||||
* probe the require resolution paths for a directory holding the named
|
||||
* manifest. This is Node's own node_modules lookup order, so the result
|
||||
* matches what the Loader would import from the same anchor, and
|
||||
* `existsSync` follows the symlinks pnpm's isolated layout uses.
|
||||
*/
|
||||
function packageDirFromAnchor(anchor: string, packageName: string): string | undefined {
|
||||
// resolve.paths returns null only for builtins, which no bundle name is.
|
||||
/* v8 ignore next */
|
||||
for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) {
|
||||
const candidate = join(searchPath, packageName)
|
||||
if (existsSync(join(candidate, 'package.json'))) return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one bundle package's directory: installation anchor first, then the
|
||||
* profile directory. The installation-first order is the contract that
|
||||
* `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from
|
||||
* the same installation as the running dsh, never from a profile-local copy.
|
||||
* Resolution does not require the package to export `./package.json`.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param packageName - the bundle's package name from `dsh.profile.bundles`.
|
||||
* @param installAnchor - absolute path of a file inside the dsh app package (its package.json).
|
||||
* @param profileDir - the profile directory (second anchor).
|
||||
* @returns the bundle package's absolute directory.
|
||||
*/
|
||||
export function resolveBundleDir(
|
||||
binName: string, packageName: string, installAnchor: string, profileDir: string,
|
||||
): string {
|
||||
for (const anchor of [installAnchor, join(profileDir, 'package.json')]) {
|
||||
const dir = packageDirFromAnchor(anchor, packageName)
|
||||
if (dir !== undefined) return dir
|
||||
}
|
||||
throw new Error(
|
||||
`${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; `
|
||||
+ `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
|
||||
* layer and parse the profile's own patch file. A listed bundle without a
|
||||
* `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer
|
||||
* is a misconfiguration, not "no patches".
|
||||
* @param binName - the diagnostic prefix on thrown errors.
|
||||
* @param name - the profile name.
|
||||
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
|
||||
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
||||
* @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a
|
||||
* bundles-only consumer (`--dump-default-config`, a recovery diagnostic)
|
||||
* cannot fail on a broken user layer.
|
||||
* @returns the loaded profile (empty `patches` when the user layer is skipped).
|
||||
*/
|
||||
export function loadProfile(
|
||||
binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),
|
||||
options: { userLayer?: boolean } = {},
|
||||
): Profile {
|
||||
const dir = resolveProfileDir(name, home)
|
||||
if (!existsSync(join(dir, 'package.json'))) {
|
||||
const template = PROFILE_TEMPLATES[name]
|
||||
if (template === undefined) {
|
||||
throw new Error(
|
||||
`${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
|
||||
)
|
||||
}
|
||||
initProfile(dir, template)
|
||||
}
|
||||
const manifest = readProfileManifest(binName, dir)
|
||||
// A hand-written profile manifest may omit the dsh section entirely.
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
const layers = bundles.map((packageName): ProfileLayer => {
|
||||
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
|
||||
const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
|
||||
const declared = bundleManifest.dsh?.bundle?.patch
|
||||
if (declared === undefined) {
|
||||
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
|
||||
}
|
||||
const patchPath = join(packageDir, declared)
|
||||
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
|
||||
})
|
||||
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
|
||||
const patches = options.userLayer !== false && existsSync(patchPath)
|
||||
? loadOverlayPatches(binName, patchPath)
|
||||
: []
|
||||
return { name, dir, layers, patchPath, patches }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose patch layers into the effective entry list over an empty root —
|
||||
* the same single `applyEntryPatches` call the boot include makes, so flag
|
||||
* derivation and config dumps see exactly what mounts.
|
||||
* @param layers - patch lists in application order.
|
||||
* @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them).
|
||||
* @returns the composed entry list.
|
||||
*/
|
||||
export function composeEntries(
|
||||
layers: readonly PatchOptions[][], warn: (message: string) => void = () => {},
|
||||
): EntryOptions[] {
|
||||
return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => {
|
||||
let index = 0
|
||||
warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
|
||||
})
|
||||
}
|
||||
@@ -341,11 +341,11 @@ describe('include refresh with overlay patches', () => {
|
||||
|
||||
describe('include patches layered over one base', () => {
|
||||
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
|
||||
// The surface/`--config`/personal composition: `dsh` includes one shared
|
||||
// base and applies each source as its own patch list at the SAME include
|
||||
// The bundle/user-layer/`--patch` composition: `dsh` includes one root
|
||||
// and applies each source as its own patch list at the SAME include
|
||||
// level, because patches never cross an include boundary. A later layer
|
||||
// must therefore be able to reach a row an earlier layer inserted, or
|
||||
// surface-only rows would be invisible to the user's personal config.
|
||||
// bundle-only rows would be invisible to the user's patch layer.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
@@ -355,30 +355,30 @@ describe('include patches layered over one base', () => {
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
// Layer 1 (a surface overlay): patch a base row and add two of its own.
|
||||
// Layer 1 (a bundle layer): patch a base row and add two of its own.
|
||||
' - id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' value: bundle',
|
||||
' - insert:',
|
||||
' - id: surface-kept',
|
||||
' - id: bundle-kept',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: surface-default',
|
||||
' - id: surface-dropped',
|
||||
' value: bundle-default',
|
||||
' - id: bundle-dropped',
|
||||
' name: ./noop.mjs',
|
||||
// Layer 2 (the user): reconfigure one inserted row and disable the other.
|
||||
' - id: surface-kept',
|
||||
' - id: bundle-kept',
|
||||
' config:',
|
||||
' value: personal',
|
||||
' - id: surface-dropped',
|
||||
' value: user',
|
||||
' - id: bundle-dropped',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
|
||||
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' })
|
||||
expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped')
|
||||
expect(dropped?.options.disabled).toBe(true)
|
||||
expect(dropped?.fiber).toBeUndefined()
|
||||
} finally {
|
||||
|
||||
245
packages/ui/app-boot/tests/profile.spec.ts
Normal file
245
packages/ui/app-boot/tests/profile.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Profile machinery of `dsh-app-boot`: directory resolution and init,
|
||||
* manifest round-trips, two-anchor bundle resolution, patch-layer loading,
|
||||
* empty-root composition, and the installation module-fallback healing.
|
||||
*/
|
||||
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
initProfile,
|
||||
loadProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
PROFILE_TEMPLATES,
|
||||
readProfileManifest,
|
||||
resolveBundleDir,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
|
||||
|
||||
/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
|
||||
function stageInstallation(bundles: Record<string, { patch?: string; deps?: Record<string, string> }>): string {
|
||||
const root = tmp()
|
||||
const appDir = join(root, 'app')
|
||||
mkdirSync(join(appDir, 'node_modules'), { recursive: true })
|
||||
const appDeps: Record<string, string> = {}
|
||||
for (const [name, spec] of Object.entries(bundles)) {
|
||||
appDeps[name] = '0.0.0'
|
||||
const dir = join(appDir, 'node_modules', name)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name,
|
||||
version: '0.0.0',
|
||||
dependencies: spec.deps ?? {},
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
|
||||
}))
|
||||
if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
|
||||
}
|
||||
writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps }))
|
||||
return join(appDir, 'package.json')
|
||||
}
|
||||
|
||||
describe('resolveProfileDir', () => {
|
||||
it('joins the home and rejects traversal-shaped names', () => {
|
||||
const home = tmp()
|
||||
expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui'))
|
||||
for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
|
||||
expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('initProfile', () => {
|
||||
it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => {
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('tui', home)
|
||||
initProfile(dir, ['@deepseek-ai/dsh-base'])
|
||||
const manifest = readProfileManifest('t', dir)
|
||||
expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
|
||||
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
|
||||
// Re-init keeps user edits.
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
|
||||
initProfile(dir, ['other'])
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest round-trip', () => {
|
||||
it('writes and reads back, and fails loud on a broken manifest', () => {
|
||||
const dir = tmp()
|
||||
writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } })
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a'])
|
||||
writeFileSync(join(dir, 'package.json'), '[]')
|
||||
expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
|
||||
expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBundleDir', () => {
|
||||
it('prefers the installation anchor, falls back to the profile, and fails loud', () => {
|
||||
const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } })
|
||||
const profileDir = tmp()
|
||||
mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true })
|
||||
writeFileSync(join(profileDir, 'package.json'), '{}')
|
||||
writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' }))
|
||||
expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box')
|
||||
expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
|
||||
expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
|
||||
})
|
||||
|
||||
it('resolves a package whose exports map omits ./package.json', () => {
|
||||
// Common on npm: an exports map without "./package.json" makes
|
||||
// require.resolve('<pkg>/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED;
|
||||
// resolution must fall through to the paths probe instead of misreporting
|
||||
// the installed package as missing.
|
||||
const anchor = stageInstallation({})
|
||||
const profileDir = tmp()
|
||||
writeFileSync(join(profileDir, 'package.json'), '{}')
|
||||
const dir = join(profileDir, 'node_modules', 'sealed-bundle')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'sealed-bundle',
|
||||
version: '0.0.0',
|
||||
exports: { '.': './index.js' },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}))
|
||||
writeFileSync(join(dir, 'index.js'), '')
|
||||
writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
|
||||
expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadProfile', () => {
|
||||
it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
|
||||
'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
|
||||
})
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['bundle-a', 'bundle-b'])
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n')
|
||||
const profile = loadProfile('t', 'demo', anchor, home)
|
||||
expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
|
||||
expect(profile.patches).toHaveLength(1)
|
||||
const entries = composeEntries([
|
||||
...profile.layers.map(layer => layer.patches),
|
||||
profile.patches,
|
||||
])
|
||||
expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }])
|
||||
// A hand-made profile without the user layer file or dsh section: empty layers, no throw.
|
||||
rmSync(join(dir, PROFILE_PATCH_FILENAME))
|
||||
expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([])
|
||||
writeProfileManifest(dir, { name: 'bare' })
|
||||
const bare = loadProfile('t', 'demo', anchor, home)
|
||||
expect(bare.layers).toEqual([])
|
||||
})
|
||||
|
||||
it('auto-initializes only shipped templates and fails loud otherwise', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
expect(() => loadProfile('t', 'custom', anchor, home))
|
||||
.toThrow('profile "custom" does not exist')
|
||||
// The web template auto-initializes on first load. Bundle resolution
|
||||
// cannot be asserted to fail here: the source-plane test runner resolves
|
||||
// @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
|
||||
expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base')
|
||||
try {
|
||||
loadProfile('t', 'web', anchor, home)
|
||||
} catch {
|
||||
// Resolution failure is the plain-Node outcome for this empty anchor.
|
||||
}
|
||||
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
|
||||
.toEqual([...PROFILE_TEMPLATES.web ?? []])
|
||||
})
|
||||
|
||||
it('fails loud when a listed bundle declares no dsh.bundle', () => {
|
||||
const anchor = stageInstallation({ 'not-a-bundle': {} })
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['not-a-bundle'])
|
||||
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeEntries', () => {
|
||||
it('applies layers over an empty root and reports skipped patches', () => {
|
||||
const warnings: string[] = []
|
||||
const entries = composeEntries([
|
||||
[{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],
|
||||
[{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],
|
||||
], message => warnings.push(message))
|
||||
expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])
|
||||
expect(warnings.join('\n')).toContain('"missing"')
|
||||
// Default warn sink: skipped patches are silently dropped (boot repeats them).
|
||||
expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('healProfilesModuleFallback', () => {
|
||||
it('links the app and bundle dependency surface flat under profiles/node_modules', () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
|
||||
'plain-lib': {},
|
||||
})
|
||||
// An app dependency that is declared but not installed: skipped, not fatal.
|
||||
const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
|
||||
appManifest.dependencies['never-installed'] = '0.0.0'
|
||||
writeFileSync(anchor, JSON.stringify(appManifest))
|
||||
// dep-of-a lives in the installation's node_modules too.
|
||||
const modules = join(anchor, '..', 'node_modules')
|
||||
mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
|
||||
writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
// App deps, the bundle's own deps, and the bundle itself are linked; the
|
||||
// plain library is linked as an app dep (harmless), the app itself too.
|
||||
for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) {
|
||||
expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
|
||||
}
|
||||
// Idempotent, and a moved target is re-pointed.
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
const before = readlinkSync(join(fallback, 'dep-of-a'))
|
||||
expect(before).toContain('dep-of-a')
|
||||
})
|
||||
|
||||
it('throws when a fallback entry is a real directory', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true })
|
||||
expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink')
|
||||
})
|
||||
|
||||
it('replaces a wrong symlink', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
mkdirSync(fallback, { recursive: true })
|
||||
symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
|
||||
})
|
||||
|
||||
it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => {
|
||||
// The EEXIST arm: a second process wrote the link between our lstat miss
|
||||
// and symlinkSync. Simulated by pre-creating the correct link and calling
|
||||
// the internal path through a stale-lstat shim is not possible from
|
||||
// outside, so probe the observable contract: healing twice concurrently
|
||||
// is a no-op, and a foreign REAL directory still fails loud.
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
healProfilesModuleFallback(anchor, home) // second healer sees the correct link
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`)
|
||||
* `config.yaml` overlay loader and `boot()` applying the personal overlay over
|
||||
* a real Loader tree.
|
||||
* User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
|
||||
* (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
|
||||
* a real Loader tree, kept live through transactional HMR.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
@@ -15,14 +15,14 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import {
|
||||
boot,
|
||||
loadPersonalPatches,
|
||||
PERSONAL_CONFIG_FILENAME,
|
||||
watchPersonalPatches,
|
||||
loadOptionalPatches,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
watchUserPatches,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
@@ -34,20 +34,20 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
|
||||
const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))
|
||||
|
||||
describe('loadPersonalPatches', () => {
|
||||
describe('loadOptionalPatches', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DSH_HOME
|
||||
})
|
||||
|
||||
it('returns undefined when no personal patches file exists', () => {
|
||||
expect(loadPersonalPatches(NAME, tmp())).toBeUndefined()
|
||||
it('returns undefined when no user patch file exists', () => {
|
||||
expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [
|
||||
'- id: tui-agent',
|
||||
" name: '@deepseek-ai/dsh-tui-demo'",
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [
|
||||
'- id: agent-loop',
|
||||
" name: '@deepseek-ai/dsh-agent-loop'",
|
||||
' config:',
|
||||
' model: !!js process.env.DSH_SPEC_MODEL',
|
||||
'- insert:',
|
||||
@@ -55,51 +55,44 @@ describe('loadPersonalPatches', () => {
|
||||
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
||||
'',
|
||||
].join('\n'))
|
||||
const patches = loadPersonalPatches(NAME, dir)
|
||||
const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))
|
||||
expect(patches).toHaveLength(2)
|
||||
expect(patches?.[0]).toMatchObject({
|
||||
id: 'tui-agent',
|
||||
id: 'agent-loop',
|
||||
config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
|
||||
})
|
||||
expect(patches?.[1]?.insert).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defaults its directory to the Harness home ($DSH_HOME)', () => {
|
||||
it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n')
|
||||
process.env.DSH_HOME = dir
|
||||
expect(loadPersonalPatches(NAME)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails loud on an unreadable file (a present personal config is never skipped)', () => {
|
||||
const dir = tmp()
|
||||
mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read personal patches `))
|
||||
mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read patches `))
|
||||
})
|
||||
|
||||
it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config:\n a: !!js\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
|
||||
})
|
||||
|
||||
it('fails loud when the file is not a top-level array or an entry is not an object', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow('must be a top-level YAML array of loader patch entries')
|
||||
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n')
|
||||
expect(() => loadPersonalPatches(NAME, dir))
|
||||
.toThrow(`${NAME}: personal patches entry 1 in`)
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(`${NAME}: patches entry 1 in`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with personal patches', () => {
|
||||
describe('boot with user patches', () => {
|
||||
function writeTree(dir: string): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), [
|
||||
'export const name = "noop"',
|
||||
@@ -118,41 +111,41 @@ describe('boot with personal patches', () => {
|
||||
|
||||
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [
|
||||
const userDir = tmp()
|
||||
writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [
|
||||
'- id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC',
|
||||
' value: !!js process.env.DSH_APP_BOOT_USER_SPEC',
|
||||
'- insert:',
|
||||
' - id: personal-extra',
|
||||
' - id: user-extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value'
|
||||
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal))
|
||||
process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value'
|
||||
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
|
||||
// The mounted plugin received the interpolated environment value.
|
||||
expect(noop?.fiber?.config).toEqual({ value: 'personal-value' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true)
|
||||
expect(noop?.fiber?.config).toEqual({ value: 'user-value' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
delete process.env['DSH_APP_BOOT_PERSONAL_SPEC']
|
||||
delete process.env['DSH_APP_BOOT_USER_SPEC']
|
||||
}
|
||||
})
|
||||
|
||||
it('mounts no patch layer for an absent or empty personal overlay', async () => {
|
||||
it('mounts no patch layer for an absent or empty user layer', async () => {
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp()))
|
||||
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
const empty = tmp()
|
||||
writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n')
|
||||
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty))
|
||||
writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n')
|
||||
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
@@ -162,8 +155,8 @@ describe('boot with personal patches', () => {
|
||||
|
||||
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
const filename = join(personal, PERSONAL_CONFIG_FILENAME)
|
||||
const userDir = tmp()
|
||||
const filename = join(userDir, PROFILE_PATCH_FILENAME)
|
||||
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
|
||||
const ctx = await boot(NAME, writeTree(dir), basePatches)
|
||||
await ctx.plugin(Timer)
|
||||
@@ -172,14 +165,14 @@ describe('boot with personal patches', () => {
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failures.push({ filename: failedFilename, error })
|
||||
})
|
||||
const dispose = await watchPersonalPatches(ctx, {
|
||||
const dispose = await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
dir: personal,
|
||||
compose: personalPatches => [...basePatches, ...personalPatches],
|
||||
filename,
|
||||
compose: userPatches => [...basePatches, ...userPatches],
|
||||
})
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied')
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n fail: true\n')
|
||||
await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
|
||||
@@ -199,17 +192,17 @@ describe('boot with personal patches', () => {
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
unlinkSync(filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
|
||||
expect(failures).toHaveLength(2)
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
// Default compose: the personal overlay IS the whole patch list, so a
|
||||
// Default compose: the user layer IS the whole patch list, so a
|
||||
// fresh generation replaces the app-owned layer instead of stacking on it.
|
||||
await dispose()
|
||||
const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
|
||||
const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
|
||||
} finally {
|
||||
await disposeDefault()
|
||||
}
|
||||
@@ -222,7 +215,7 @@ describe('boot with personal patches', () => {
|
||||
it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
|
||||
const dir = tmp()
|
||||
const withoutHmr = await boot(NAME, writeTree(dir))
|
||||
await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service')
|
||||
await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service')
|
||||
await withoutHmr.fiber.dispose()
|
||||
|
||||
const withoutInclude = new Context()
|
||||
@@ -230,7 +223,7 @@ describe('boot with personal patches', () => {
|
||||
await withoutInclude.plugin(Loader)
|
||||
await withoutInclude.plugin(Timer)
|
||||
await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry')
|
||||
await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry')
|
||||
await withoutInclude.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -245,7 +238,7 @@ describe('boot with personal patches', () => {
|
||||
try {
|
||||
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
|
||||
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
|
||||
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() })
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -254,14 +247,14 @@ describe('boot with personal patches', () => {
|
||||
|
||||
it('propagates registration failures other than mid-teardown', async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
const filename = join(tmp(), PROFILE_PATCH_FILENAME)
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
|
||||
// Same personal path registered twice: HMR refuses; not a teardown race.
|
||||
await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered')
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
// Same user-layer path registered twice: HMR refuses; not a teardown race.
|
||||
await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
|
||||
await dispose()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
Reference in New Issue
Block a user