Merge branch 'master' into jhz_compact_header
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
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 0d4686a5a233785ca4832ef068a118b484a872fe
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: dcc9213c6b3a088b8b8bce2a442c5232ed5b7d0b
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 43ba5708d1216c37a7ad7e2904df7d2a6baf016d
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b33ff870d745584d2988bb6a7eb1a31e56ec3da
|
||||
|
||||
@@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D
|
||||
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; CI static, pre-push, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;CI 静态检查、pre-push 与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
### 构建管线与产物
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ The implementation needs enough state to preserve real ownership and settlement
|
||||
|
||||
## Decision
|
||||
|
||||
The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; the cooperative prompt-assembly result is authoritative; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race.
|
||||
The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier and shared layer store; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; the cooperative prompt-assembly result is authoritative; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race.
|
||||
|
||||
The design can be skimmed as seven choices:
|
||||
|
||||
| Problem | Authoritative mechanism |
|
||||
|---|---|
|
||||
| Select global plus one agent's registrations | Opaque scope key and routing carrier |
|
||||
| Select global plus one agent's registrations | Opaque scope key, routing carrier, and shared layer store |
|
||||
| Own one live agent or session | One registry entry captured by its disposer |
|
||||
| Coordinate create/resume | One `AgentCreationTransaction` |
|
||||
| Protect durable, queued, model, or wire data | Materialize once at that boundary |
|
||||
@@ -68,11 +68,11 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live
|
||||
|
||||
The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`.
|
||||
|
||||
### Registry reads overlay one exact map
|
||||
### Registry reads overlay one exact layer
|
||||
|
||||
Scope-aware registries store global contributions separately from identity-keyed local contributions. A read resolves the global layer and at most one local layer; it never traverses parentage.
|
||||
Scope-aware registries use `ScopedLayers` to own one eager global aggregate and lazily created identity-keyed aggregates. A read resolves the global layer and at most one exact local layer; it never creates state or traverses parentage. Registration visibility and Cordis effect ownership derive from the same context, and reclamation waits until the concrete layer's complete aggregate is empty ([decision](2026-07-12-scoped-layers-store.md)).
|
||||
|
||||
Each service retains its domain rule. Named prompt values and tools use local shadowing, tool restrictions filter globals before local tools are added, and events select listener audiences rather than registered data. Scope supplies identity and ownership, not a universal merge algorithm.
|
||||
Each service retains its domain rule. Named command and prompt views use the shared insertion-ordered shadow merge; tools keep a richer resolver because restrictions filter globals before local tools are added and the reserved Code Mode transport is inserted separately. Prompt variables and tool guards retain live iteration, while tool-provider membership is materialized per assembly. Scope supplies storage lifecycle and named shadowing, not a universal registry view.
|
||||
|
||||
### Fused dispatch helpers prevent subject drift
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-12-scoped-layers-store.md: b850b6bcbb22401b386b4458b6d5c65a160c85cd
|
||||
2026-07-12-scoped-layers-store.zh.md: 8bfc0a0e8ec1e3de624ff8d9e48b7517833fc025
|
||||
@@ -0,0 +1,126 @@
|
||||
# Agent Note: Shared scoped-layer storage
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-12-scoped-layers-store.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Agent scoping ([decision](2026-07-08-agent-scope-contexts.md), [runtime design](2026-07-12-agent-scope-runtime-design.md)) gives scope-aware registries the same recurring shape: one global registration layer plus one exact agent layer. Seven registration facades use that shape: `tools.register`, `tools.restrict`, and `tools.guard` in `dsh-tools`; `SystemPrompt.section`, `SystemPrompt.tools`, and `SystemPrompt.variable` in `dsh-system-prompt`; and `CommandService.register` in `dsh-commands`.
|
||||
|
||||
Without a shared primitive, each facade repeats the lifecycle choreography around its domain state: derive visibility from the calling context, create a scoped container on demand, attach ownership to the same Cordis fiber, install undo before notifying observers, return Cordis's exact disposer, and reclaim empty scoped state. Separate maps and collection types also leave a service without one object representing a scope's complete contribution.
|
||||
|
||||
The duplicated code carries three non-obvious requirements:
|
||||
|
||||
- Visibility and ownership must come from the same context; accepting them separately permits a registration visible in one scope but disposed with another.
|
||||
- Undo must be collected before a change callback runs, so a throwing callback rolls the mutation back.
|
||||
- The public disposer must be the exact function returned by `ctx.effect()`; wrapping it breaks Cordis's identity-based ordered teardown.
|
||||
|
||||
The shared part is lifecycle and insertion-ordered storage, not registry policy. Tool restrictions, reserved transport handling, prompt evaluation timing, command normalization, exact diagnostics, and callback containment remain different domain contracts.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-scope` provides a key-agnostic `store.ts` implementation module. The package continues to peer on Cordis and `@deepseek-ai/dsh-invariants`, and its invariant companion remains unchanged. The package root exports four storage symbols: `ScopeLayer`, `ScopedLayers`, `NamedEntries`, and `AnonymousEntries`. `EntryValues` remains internal, and `store.ts` is not a package subpath.
|
||||
|
||||
`ScopeLayer` keeps the aggregate concept explicit while requiring only whole-layer emptiness. A service defines one concrete layer whose tables and domain helpers fit that service; `ScopedLayers` owns construction, selection, lifecycle attachment, notification, and aggregate reclamation.
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts ignore-check
|
||||
export interface ScopeLayer {
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class ScopedLayers<L extends ScopeLayer> {
|
||||
constructor(
|
||||
createLayer: (scope: ScopeKey | undefined) => L,
|
||||
onChange: () => void,
|
||||
)
|
||||
|
||||
readonly global: L
|
||||
peek(scope: ScopeKey | undefined): L | undefined
|
||||
|
||||
merge<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V>
|
||||
|
||||
effect(
|
||||
ctx: Context,
|
||||
action: (layer: L) => () => void,
|
||||
options: { label: string; notify?: boolean },
|
||||
): () => void
|
||||
}
|
||||
|
||||
export class NamedEntries<V> {
|
||||
constructor(duplicateError: (name: string) => Error)
|
||||
insert(name: string, value: V): () => void
|
||||
get(name: string): V | undefined
|
||||
has(name: string): boolean
|
||||
keys(): IterableIterator<string>
|
||||
entries(): IterableIterator<[string, V]>
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class AnonymousEntries<V> {
|
||||
append(value: V): () => void
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Storage contract
|
||||
|
||||
- The constructor creates `global` once with `createLayer(undefined)`. A scoped layer is created only by `effect()`; `peek()` and `merge()` never create one, and `peek(undefined)` returns `undefined` because the global layer is already explicit.
|
||||
- `merge()` is the only materialized generic read. It copies named global entries in insertion order, then applies matching scoped entries in their insertion order so same-name entries shadow without moving unrelated names.
|
||||
- `NamedEntries.insert()` checks and inserts atomically, returns an idempotent exact-entry undo, and obtains the registry's exact duplicate diagnostic from the caller-supplied factory. Lookup and iterators retain native `Map` order and stay live within one nonempty table generation; draining the table starts a new generation so an in-flight iterator cannot observe a self-replacement.
|
||||
- `AnonymousEntries.append()` assigns a unique internal key per registration, so equal callbacks or values remain independent. Its iterator is insertion-ordered and uses the same live-generation boundary.
|
||||
- `effect()` derives the key with `scopeOf(ctx)` and attaches the action to that same `ctx.effect()`. It accepts one synchronous action returning one synchronous undo; actions must either return their undo or throw before retaining a contribution. The helper does not normalize the wider Cordis `Effect` union.
|
||||
- `effect()` collects the action's undo before calling `onChange` and returns the exact `ctx.effect()` disposer. Disposal runs the action undo before notification, is idempotent through Cordis, and removes a scoped layer only after its complete `ScopeLayer.isEmpty()` becomes true.
|
||||
- `options.notify` defaults to `true`. The callback's own policy stays authoritative: tool and prompt change callbacks may throw and trigger registration rollback; `CommandService.notifyChange()` contains observer failures; tool guards pass `notify: false`.
|
||||
|
||||
## Registry migrations
|
||||
|
||||
`dsh-tools` defines one `ToolLayer` containing named tools plus anonymous compiled restrictions and guard registrations. `ToolRegistry` retains its private domain resolver for visible definitions, pre-restriction known names, restrictable global names, scoped shadowing, restrictions, and reserved `run_code` insertion. Guard evaluation live-iterates global then scoped registrations: additions to a nonempty generation can run in the current dispatch, while a self-replacement after draining the guard table begins with the next dispatch.
|
||||
|
||||
`dsh-system-prompt` defines one `PromptLayer` containing named sections and variables plus anonymous tool providers. Assembly merges sections before evaluating them, so a shadowed provider is never called. Tool-provider membership is materialized once per assembly. Variable providers live-iterate global then scoped tables: additions to a nonempty generation can run in the current assembly, while a self-replacement after draining the variable table begins with the next assembly.
|
||||
|
||||
`dsh-commands` defines a one-table layer containing `NamedEntries<RegisteredCommand>`. Effective views use `merge()`, while `CommandService` retains definition normalization and freezing, exact duplicate diagnostics, sorted immutable descriptors, direct execution, HMR cleanup, and independently contained `commands/change` observers.
|
||||
|
||||
All seven facades keep validation and diagnostics in their owning registry and continue to return the exact Cordis disposer. The migration changes neither public registry behavior nor model-, human-, wire-, persistence-, or configuration-visible output.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the independent implementations.** This avoids a new library interface but leaves lifecycle ordering, disposer identity, and scope reclamation duplicated across seven facades.
|
||||
|
||||
**One helper per table.** This removes some local code but preserves multiple per-scope maps and cannot reclaim one scope's aggregate contribution correctly.
|
||||
|
||||
**Per-scope registry instances.** Child registries would need delegation for global-plus-scoped views, special subtraction for restrictions, and observer discovery across instances. They would move complexity rather than remove it.
|
||||
|
||||
**Explicit scope parameters on registration methods.** Separate visibility and ownership inputs make mismatched lifetimes representable, while an omitted scope silently becomes global.
|
||||
|
||||
**Accept the complete Cordis `Effect` union.** None of the seven registrations has asynchronous setup, multiple undos, or an independent settlement boundary. General normalization would duplicate Cordis lifecycle machinery without a current consumer.
|
||||
|
||||
**Expose `ScopedLayers.values()`, `ScopedLayers.keys()`, or a global-admission predicate.** Those operations encode consumer-specific live/materialized and filtering policies. Direct table iteration preserves explicit live semantics, `merge()` covers the shared named shadowing operation, and `ToolRegistry` keeps its richer private resolver.
|
||||
|
||||
**Put `values()` on `ScopeLayer` or export `EntryValues`.** A layer aggregates heterogeneous tables and has no coherent value type or iteration policy. `EntryValues` is useful only to share implementation details between the two table classes; making it public would enlarge the interface without giving callers a meaningful layer-wide read.
|
||||
|
||||
**Generate layers from a mapped-type table description.** Three-table and one-table concrete layers are short, inspectable, and free to hold domain helpers. A class generator would add a second construction model and generated runtime shape for little leverage.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Scope-aware registries express one aggregate layer and reuse the same construction, ownership, rollback, notification, and reclamation choreography. Domain-specific validation, diagnostics, filtering, evaluation, and observer policy remain in each registry.
|
||||
- The public read surface stays narrow: direct table iteration preserves explicitly live behavior, while `merge()` is the one shared materialized shadowing operation. A heterogeneous `ScopeLayer` has no layer-wide `values()` contract.
|
||||
- The helper is deliberately synchronous. A future registration that needs asynchronous setup or several independently owned undos must identify its ownership and settlement boundaries before widening this contract.
|
||||
- An action must throw before retaining a contribution or return an undo for everything it retained; the helper cannot repair mutation outside that contract. The provided entry operations are atomic, and migrated registries perform fallible validation before insertion.
|
||||
- A scoped layer remains allocated until every table in its aggregate is empty. Disposing one facade therefore cannot discard sibling contributions owned by the same scope.
|
||||
- The four public symbols become a reusable package contract. Keeping `EntryValues` internal and consumer policy outside the helper limits the compatibility surface.
|
||||
- The migration changes no public registry behavior and no model-, human-, wire-, persistence-, configuration-, or dependency-graph output.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dsh-scope` unit tests cover global construction, lazy scoped construction, non-creating reads, named merge order and shadowing, aggregate reclamation, factory and action failure cleanup, notification ordering and rollback, `notify: false`, effect labels, exact disposer identity, idempotent teardown, caller-owned duplicate errors, independent anonymous duplicates, live iterators, and drained-generation detachment.
|
||||
- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy and self-replacement, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy and self-replacement, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal.
|
||||
- The scoped core-data type-equivalence check ties `ScopeLayer` documentation to its source declaration. Repository documentation, module-graph, build, hygiene, coverage, and built-artifact gates exercise the root export and package boundary.
|
||||
- Existing ACP, headless, and TUI keyless snapshots remain the regression boundary for tool schemas, prompt assembly, and human commands. The implementation does not update any expected transcript.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Agent Note: 共享作用域分层存储
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-12-scoped-layers-store.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandService.register`(位于 `dsh-commands`)。
|
||||
|
||||
如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。
|
||||
|
||||
重复代码承载着三项不明显的要求:
|
||||
|
||||
- 可见性与属主必须来自同一个上下文;若分开接受二者,就能登记出对一个 scope 可见、却随另一个 scope 销毁的贡献。
|
||||
- change 回调运行前必须收集 undo,抛错的回调才能回滚变更。
|
||||
- 公开 disposer 必须就是 `ctx.effect()` 返回的那个函数;包装它会破坏 Cordis 基于身份的有序拆除。
|
||||
|
||||
共享的是生命周期与保持插入顺序的存储,而不是注册表策略。工具限制、保留传输处理、提示词求值时机、命令规范化、精确诊断和回调异常隔离,仍分别属于不同的领域契约。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-scope` 提供与键类型无关的 `store.ts` 实现模块。该包(package)继续将 Cordis 和 `@deepseek-ai/dsh-invariants` 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:`ScopeLayer`、`ScopedLayers`、`NamedEntries` 和 `AnonymousEntries`。`EntryValues` 仍是内部接口,`store.ts` 不是包子路径。
|
||||
|
||||
`ScopeLayer` 保留显式的聚合概念,同时只要求判断整个层是否为空。服务定义一个具体层,使其表结构与领域 helper 适合该服务;`ScopedLayers` 负责构造、选择、生命周期挂接、通知和聚合回收。
|
||||
|
||||
## 公开接口
|
||||
|
||||
```ts ignore-check
|
||||
export interface ScopeLayer {
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class ScopedLayers<L extends ScopeLayer> {
|
||||
constructor(
|
||||
createLayer: (scope: ScopeKey | undefined) => L,
|
||||
onChange: () => void,
|
||||
)
|
||||
|
||||
readonly global: L
|
||||
peek(scope: ScopeKey | undefined): L | undefined
|
||||
|
||||
merge<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V>
|
||||
|
||||
effect(
|
||||
ctx: Context,
|
||||
action: (layer: L) => () => void,
|
||||
options: { label: string; notify?: boolean },
|
||||
): () => void
|
||||
}
|
||||
|
||||
export class NamedEntries<V> {
|
||||
constructor(duplicateError: (name: string) => Error)
|
||||
insert(name: string, value: V): () => void
|
||||
get(name: string): V | undefined
|
||||
has(name: string): boolean
|
||||
keys(): IterableIterator<string>
|
||||
entries(): IterableIterator<[string, V]>
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class AnonymousEntries<V> {
|
||||
append(value: V): () => void
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 存储契约
|
||||
|
||||
- 构造器只创建一次 `global`,调用的是 `createLayer(undefined)`。只有 `effect()` 会创建专属层;`peek()` 和 `merge()` 从不创建专属层,而 `peek(undefined)` 返回 `undefined`,因为全局层已经显式存在。
|
||||
- `merge()` 是唯一会物化结果的通用读取接口。它按插入顺序复制全局命名条目,再按专属条目的插入顺序应用这些条目;同名条目完成遮蔽,但不会移动无关名称。
|
||||
- `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序,并在同一个非空表 generation 内保持活遍历;清空表会开启新的 generation,因此尚未结束的迭代器无法观察到自我替换。
|
||||
- `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器保留插入顺序,并采用同样的 generation 活遍历边界。
|
||||
- `effect()` 通过 `scopeOf(ctx)` 导出键,并把 action 挂到同一个 `ctx.effect()` 上。它只接受一个同步 action,且该 action 只返回一个同步 undo;action 要么返回其 undo,要么必须在保留任何贡献之前抛错。helper 不会规范化更宽泛的 Cordis `Effect` union。
|
||||
- `effect()` 在调用 `onChange` 前收集 action 的 undo,并原样返回 `ctx.effect()` 的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有整个层的 `ScopeLayer.isEmpty()` 变为 true 后,helper 才删除专属层。
|
||||
- `options.notify` 默认为 `true`。回调自身的策略仍具最终效力:工具与提示词的 change 回调可以抛错并触发登记回滚;`CommandService.notifyChange()` 会隔离观察者失败;工具 guard 传入 `notify: false`。
|
||||
|
||||
## 注册表迁移
|
||||
|
||||
`dsh-tools` 定义一个 `ToolLayer`,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。`ToolRegistry` 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 `run_code` 插入。guard 求值会先活遍历全局登记,再活遍历专属登记:向非空 generation 新增的登记可以在当前分发中运行,而 guard 表清空后的自我替换则从下一次分发开始运行。
|
||||
|
||||
`dsh-system-prompt` 定义一个 `PromptLayer`,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方会先活遍历全局表,再活遍历专属表:向非空 generation 新增的提供方可以在当前组装中运行,而变量表清空后的自我替换则从下一次组装开始运行。
|
||||
|
||||
`dsh-commands` 定义一个单表层,其中包含 `NamedEntries<RegisteredCommand>`。生效视图使用 `merge()`;`CommandService` 则保留对定义的规范化与冻结处理、精确重名诊断、经过排序的不可变描述符、直接执行、HMR(热模块替换)清理,以及对各个 `commands/change` 观察者分别隔离失败的行为。
|
||||
|
||||
七个门面都把校验与诊断留在所属注册表中,并继续返回 Cordis 的原始 disposer。迁移既不改变公开注册表行为,也不改变模型可见或人类可见的输出,以及协议、持久化或配置层面的可见输出。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**保留彼此独立的实现。** 这样不必新增库接口,但七个门面仍会重复生命周期顺序、disposer 身份和 scope 回收。
|
||||
|
||||
**每张表一个 helper。** 这能减少一部分局部代码,但会保留多张按 scope 划分的映射,而且无法正确回收某个 scope 的聚合贡献。
|
||||
|
||||
**每 scope 一个注册表实例。** 子注册表需要通过委托获得全局加专属的视图,对 restriction 进行特殊的减法处理,并跨实例发现观察者。这只会转移复杂度,而不会消除复杂度。
|
||||
|
||||
**注册方法上的显式 scope 参数。** 分开的可见性与属主输入让不匹配的生命周期成为可表达状态,而遗漏 scope 则会静默变成全局登记。
|
||||
|
||||
**接受完整的 Cordis `Effect` union。** 七个登记口都没有异步 setup、多份 undo 或独立 settlement 边界。通用规范化会在没有现有消费者需要它时重复 Cordis 的生命周期 machinery。
|
||||
|
||||
**暴露 `ScopedLayers.values()`、`ScopedLayers.keys()` 或全局放行谓词。** 这些操作会编码消费方特有的活遍历或物化策略,以及过滤策略。直接遍历条目表可保留显式的活语义,`merge()` 覆盖共享的命名遮蔽操作,而 `ToolRegistry` 继续保有功能更丰富的私有解析器。
|
||||
|
||||
**把 `values()` 放在 `ScopeLayer` 上,或导出 `EntryValues`。** 一个层会聚合异构表,因而没有一致的值类型或迭代策略。`EntryValues` 只适合在两个表类之间共享实现细节;将其公开只会扩大接口,却不能为调用方提供有意义的整层读取方式。
|
||||
|
||||
**通过 mapped-type 表描述生成层。** 三表与单表具体层都很短、易于检查,并可自由持有领域 helper。类生成器会增加第二种构造模型和生成式运行时形状,收益却很小。
|
||||
|
||||
## 后果
|
||||
|
||||
- 支持作用域的注册表各自通过一个聚合层表达状态,并复用相同的构造、属主、回滚、通知和回收编排。各注册表仍各自保有领域特有的校验、诊断、过滤、求值和观察者策略。
|
||||
- 公开读取接口保持狭窄:直接遍历条目表可保留显式的活语义,`merge()` 是唯一共享的物化遮蔽操作。异构的 `ScopeLayer` 不具备整层 `values()` 契约。
|
||||
- helper 刻意保持同步。未来的登记若需要异步 setup 或多份分别拥有属主的 undo,必须先明确属主与 settlement 边界,再拓宽这项契约。
|
||||
- action 必须在保留贡献前抛错,或者为自己保留的一切返回 undo;helper 无法修复超出这项契约的变更。提供的条目操作是原子的,迁移后的注册表会在插入前执行可能失败的校验。
|
||||
- 专属层会一直保持已分配状态,直到其聚合内的所有表都为空。因此,销毁一个门面不会丢弃同一 scope 拥有的其他贡献。
|
||||
- 四个公开符号构成一项可复用的包契约。将 `EntryValues` 保持为内部接口,并把消费方策略留在 helper 之外,可以限制兼容性范围。
|
||||
- 迁移不改变任何公开注册表行为,也不改变模型、人类、协议、持久化、配置或依赖图层面的任何输出。
|
||||
|
||||
## 验证
|
||||
|
||||
- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记、活迭代器,以及表清空后的 generation 脱离。
|
||||
- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。
|
||||
- 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。
|
||||
- 现有 ACP(Agent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema、提示词组装和人类命令的回归边界。实现不会更新任何预期 transcript(文本记录)。
|
||||
@@ -13,7 +13,7 @@ Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each):
|
||||
1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project extending the root `tsconfig.json`, and compiles it with `tsc -b`. The temp project reuses the source `paths` map and the root project references, so documentation examples see source while vendored code remains checked under its own tsconfig settings. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm.
|
||||
2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of the fully-generated `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` and their `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected.
|
||||
|
||||
Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references.
|
||||
Both run via a shared `doc-sync` package.json script that contributors invoke for relevant documentation changes and CI invokes exhaustively. The [fast local Git hooks](2026-07-22-fast-local-git-hooks.md) decision keeps this surface-selected work out of commit and push hooks.
|
||||
|
||||
**Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the docs/AGENTS.md "one physical line per paragraph" writing rule. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates.
|
||||
|
||||
@@ -24,7 +24,7 @@ Both run via a shared `doc-sync` package.json script that the lefthook pre-push
|
||||
|
||||
## Consequences
|
||||
|
||||
- Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle.
|
||||
- Doc drift in the checkable classes fails `doc-sync` and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle.
|
||||
- Making doc snippets compile costs a few stub imports/`declare`s; the `ignore-check` ratio must stay low or the gate is theater (the ratio guard enforces this).
|
||||
- The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review.
|
||||
- API reports remain available to revisit if the packages are ever published externally.
|
||||
|
||||
@@ -2,24 +2,26 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path.
|
||||
|
||||
## Problem
|
||||
|
||||
This codebase is developed primarily by coding agents. Agents follow enforced gates far more reliably than prose conventions, and "a lot of work" is not a cost argument when agents do the labor. Early evidence: tests that didn't typecheck shipped (vitest doesn't typecheck) and were only caught by a review.
|
||||
|
||||
## Decision
|
||||
|
||||
Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts:
|
||||
Every mechanically checkable AGENTS.md promise gets a command that exits non-zero. CI invokes the exhaustive set, while Git hooks reserve their latency budget for cheap local defects:
|
||||
|
||||
- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary.
|
||||
- ESLint strict-type-checked + @stylistic (the house style, enforced), including file-local duplicated logic checks; vendored code excluded.
|
||||
- jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations.
|
||||
- Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
|
||||
- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations.
|
||||
- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths.
|
||||
- lefthook pre-commit fixes staged lint, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Conventions survive agent turnover; violations fail fast and locally.
|
||||
- Conventions survive agent turnover; cheap commit/push defects fail locally and exhaustive violations fail in CI.
|
||||
- The gates themselves are code to maintain; config changes are reviewed like any change.
|
||||
- 100%-coverage pressure can produce assertion-free tests — mutation testing is the planned counterweight (see [the mutation-testing proposal](../../proposed/testing/2026-06-11-mutation-testing.md)).
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr
|
||||
- Check a target only when it is a **relative path**. Skip scheme-qualified URLs (`https:`, `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path` — no stable base in a checkout), and pure in-page anchors (`#section`). Strip any `#fragment`/`?query`, resolve the path against the linking file's directory, and assert it exists on disk.
|
||||
- Report and never rewrite; exit non-zero on the first broken link found.
|
||||
|
||||
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into the `doc-sync` script that the lefthook pre-push hook and CI both run, so a broken link fails locally before a push — consistent with [mechanical quality gates](2026-06-11-quality-gates.md).
|
||||
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into `doc-sync`, so relevant documentation changes and CI exercise the same broken-link check.
|
||||
|
||||
This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped).
|
||||
|
||||
@@ -26,6 +26,6 @@ This gate checks *existence*, not anchor validity: a link to a real file with a
|
||||
|
||||
## Consequences
|
||||
|
||||
- Renames and moves that orphan a cross-link now fail the pre-push hook and CI instead of waiting for a reader to click a dead link. This made the Agent Note reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle.
|
||||
- Renames and moves that orphan a cross-link fail `doc-sync` and CI instead of waiting for a reader to click a dead link. This made the Agent Note reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle.
|
||||
- One more fast tsx script in the `doc-sync` chain; no new dependency (the mdast/GFM stack is already in devDependencies for `verify-md-wrap`).
|
||||
- The convention this enforces — cross-reference docs by machine-checkable relative link, never by bare prose or a number — is documented in [docs/AGENTS.md](../../../../docs/AGENTS.md) so authors know the gate exists and why.
|
||||
|
||||
@@ -43,4 +43,4 @@ Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don'
|
||||
- Every Agent Note sits under a class folder. A reader can browse one folder to see all simplifications or all testing decisions within a lifecycle.
|
||||
- Two more fast tsx scripts in the `doc-sync` chain; no new dependency (the mdast/GFM stack was already present for `verify-md-wrap`/`verify-md-links`).
|
||||
- Adding a class is a deliberate act: amend the `const` in `scripts/agent-note-tree.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in.
|
||||
- Source-comment doc references are now gated too — a moved or renamed doc that a `.ts` comment cites fails the pre-push hook, closing a drift class `verify-md-links` structurally could not see.
|
||||
- Source-comment doc references are gated too — a moved or renamed doc that a `.ts` comment cites fails `verify-doc-refs` in `doc-sync` and CI, closing a drift class `verify-md-links` structurally could not see.
|
||||
|
||||
@@ -32,7 +32,7 @@ The durability requirement was specific: the doc shows the **literal** current t
|
||||
- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches.
|
||||
- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves.
|
||||
- Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot.
|
||||
- Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates.
|
||||
- Wired into `doc-sync`, so relevant documentation changes run it locally and CI runs it with the other documentation checks.
|
||||
|
||||
### Maintenance is the author's job, with a gate backstop
|
||||
|
||||
@@ -52,7 +52,7 @@ The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and de
|
||||
|
||||
## Consequences
|
||||
|
||||
- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here.
|
||||
- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in `doc-sync` and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here.
|
||||
- The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering.
|
||||
- The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment.
|
||||
- Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist.
|
||||
|
||||
@@ -33,7 +33,7 @@ This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, a tag that contradicts its signature, or an unclassified signature type fails the generator outright.
|
||||
- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in `doc-sync` and CI. A new event with no `@mode` tag, a tag that contradicts its signature, or an unclassified signature type fails the generator outright.
|
||||
- Event and service-method contracts have a single home — the JSDoc at the declaration. The catalog repeats that original JSDoc inside its generated signature block and uses its description portion as entry prose, so thin source documentation yields a thin catalog entry.
|
||||
- The inherited tier is hand-summarized, so a vendor sync that adds/renames a cordis-core event or `ctx` member needs a matching edit to the curated table in `gen-cordis-catalog.ts`. This is the deliberate cost of not walking pinned vendor source; it changes rarely and is called out in the generator.
|
||||
- `verify-event-taxonomy.ts` is deleted and the `docs/architecture.md` event table is gone; anyone who linked to a specific table row now lands on the generated catalog instead.
|
||||
|
||||
@@ -8,7 +8,7 @@ The repository had no single reference for the names, descriptions, and JSON Sch
|
||||
|
||||
## Decision
|
||||
|
||||
Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## <package>` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate.
|
||||
Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## <package>` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so relevant documentation changes and CI exercise the same freshness check.
|
||||
|
||||
### Why boot, not parse (the crux)
|
||||
|
||||
@@ -47,7 +47,7 @@ Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck`
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in the pre-push hook and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright.
|
||||
- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in `doc-sync` and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright.
|
||||
- Tool description prose has a single home — the `defineTool` `description` at the source — and the generated entry is only as good as it, the same forcing function the cordis catalog applies to event JSDoc.
|
||||
- The generator imports and executes workspace packages (the first repo script to do so; the others only read text). It runs under `tsx` via the root `tsconfig` `paths` map, the same unbuilt-source path the demos and tests use, so it needs no build step.
|
||||
- A new capability seam behind a future tool means a new manifest recipe entry (which seams to mount). This is the deliberate hand-written cost called out above; it changes only when a tool package is added.
|
||||
|
||||
@@ -10,7 +10,7 @@ The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-ch
|
||||
|
||||
## Decision
|
||||
|
||||
Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, which both CI and the lefthook pre-push hook already execute, so the gate needs zero new wiring (quality-gates principle: one source of truth).
|
||||
Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, so relevant documentation changes and CI exercise the same gate without separate wiring.
|
||||
|
||||
The contract:
|
||||
|
||||
@@ -32,7 +32,7 @@ Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` dr
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails `doc-sync` and CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically.
|
||||
- The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result.
|
||||
- `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate.
|
||||
|
||||
@@ -28,7 +28,7 @@ This supersedes the hand-copies: the session.md `hook/*` table, the compact READ
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type.
|
||||
- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in `doc-sync` and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type.
|
||||
- Event prose has a single home, the JSDoc at the declaration; the catalog preserves that JSDoc and any nested field comments without flattening or paraphrasing them.
|
||||
- The `SurfaceEventType` union is now structurally load-bearing for docs: renaming an event without updating the union (or vice versa) fails the generator, not just the compiler.
|
||||
- The badge derivation assumes the union stays a closed set of string literals with exactly one owner; a refactor away from that shape must update the generator in the same change.
|
||||
|
||||
@@ -36,7 +36,7 @@ Three exemption families keep the gate from demanding boilerplate, in the spirit
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync`, which pre-push and CI already run. The 203 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync` and CI. The 203 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- Exported functions must annotate return types (universal at adoption, now load-bearing) and use identifier parameters where `@param` must name them.
|
||||
- Seam docs are canonical: an implementation inherits its heritage docs, and behavior notes worth keeping on the implementation are additions, not requirements.
|
||||
- The gate builds a `ts.Program` (~6s) — the one doc gate that pays for type resolution; acceptable inside `doc-sync`, which already compiles doc snippets.
|
||||
|
||||
@@ -32,7 +32,7 @@ The package README `## Config` sections stay. The overlap is accepted deliberate
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in pre-push and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright.
|
||||
- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in `doc-sync` and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright.
|
||||
- Config prose now has a forcing function at the declaration: writing a new config field means writing its JSDoc, which becomes the catalog entry verbatim.
|
||||
- The generator hard-errors on shapes it cannot walk statically — an aliased package-local config import, a schema built by anything other than `object`/`intersect` composition, an unlisted global type name. Introducing such a shape includes teaching the generator (or the shape stays out of the repo), which is the point: the catalog stays the whole truth.
|
||||
- `gen-cordis-catalog.ts` exports its JSDoc/pointer helpers and `LINK_MAP` for reuse, so the two catalogs cross-link types identically and a link-map addition serves both.
|
||||
|
||||
@@ -2,39 +2,30 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands.
|
||||
|
||||
## Problem
|
||||
|
||||
The pre-push hook is the last local checkpoint before a branch leaves the machine, so its wall clock directly shapes whether contributors keep it enabled and trust its signal. Lefthook already runs top-level jobs in parallel, but aggregate jobs such as `pnpm run hygiene` and `pnpm run doc-sync` hide long sequential chains inside one job. The hook can therefore be configured as parallel while still waiting on serial subcommands whose members are independent.
|
||||
|
||||
Flattening those members directly into `lefthook.yml` solves the local hook only. CI has the same scheduling problem, and duplicating a long leaf list in YAML gives future script changes two places to drift.
|
||||
|
||||
`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state.
|
||||
Aggregate jobs such as documentation synchronization hide long sequential chains whose members are read-only and independent. Duplicating their leaf inventory in workflow YAML gives future script changes multiple places to drift, while running package publication checks serially makes one gate consume time proportional to the package count.
|
||||
|
||||
## Decision
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses.
|
||||
[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI and `doc-sync`. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound.
|
||||
|
||||
The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including Agent Note classification and Agent Note format, while the runner schedules independent checks with four active top-level workers by default; `DSH_GATE_CONCURRENCY` overrides that bound.
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
|
||||
|
||||
The build gate makes the hook self-contained from a clean worktree. `publint`, `verify-node-next-types`, and the pre-push form of `doc-typecheck` wait for that build output, while source-only gates continue in parallel.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
|
||||
|
||||
The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain the scheduler mirrors, while `doc-sync` has since moved its member list into the scheduler itself ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)).
|
||||
The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain, while `doc-sync` owns its member list in the scheduler ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep aggregate `hygiene` and `doc-sync` jobs in the hook** - simpler config, but it leaves most of the pre-push wall clock inside serial command chains that lefthook cannot see or schedule.
|
||||
- **Declare one lefthook job per leaf gate** - exposes parallelism through lefthook's native job model, but it makes the hook file carry a long member list that CI cannot reuse.
|
||||
- **Require developers to build before pushing** - avoids one hook gate, but it makes `publint` fail in a clean worktree and turns the final local checkpoint into a convention instead of a runnable check.
|
||||
- **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about.
|
||||
- **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added.
|
||||
- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs.
|
||||
- **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup.
|
||||
- **Declare one CI job per leaf gate** — exposes maximum workflow parallelism but repeats checkout, setup, and install overhead and duplicates the scheduler inventory in YAML.
|
||||
- **Background subcommands inside shell scripts** — parallelizes work but loses per-gate timing, deterministic failure grouping, and straightforward signal handling.
|
||||
- **Declare one `publint` job per package** — exposes maximum package parallelism but creates a hand-maintained package inventory that drifts when packages change.
|
||||
- **Run `publint` with unbounded concurrency** — minimizes elapsed time on small repositories only by gambling with process count, memory pressure, package tarball creation, and readable logs.
|
||||
|
||||
## Consequences
|
||||
|
||||
The hook's critical path becomes the slowest real gate instead of the sum of hidden gate chains. Lefthook reports one `full check` job, and the runner reports per-gate timing inside that job, so a slow local checkpoint still points at the gate that dominates the run.
|
||||
Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. The cost is a custom scheduler with an explicit mode inventory.
|
||||
|
||||
The hook file stays short, and the duplicated member list lives in [scripts/run-gates.ts](../../../../scripts/run-gates.ts), where CI and pre-push can share it. The cost is a custom scheduler script instead of pure lefthook configuration, plus a build in the local pre-push path.
|
||||
|
||||
`publint-all.ts` becomes asynchronous code and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning.
|
||||
`publint-all.ts` is asynchronous and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning.
|
||||
|
||||
@@ -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
|
||||
2026-07-21-doc-sync-through-gate-scheduler.md: b79df2dd7d3515cb0434ac672f7f87c3271d900b
|
||||
2026-07-21-doc-sync-through-gate-scheduler.zh.md: 9395244e3c7700166ad87c49219073210c66bc7e
|
||||
2026-07-21-doc-sync-through-gate-scheduler.md: b7e41ba4aeac8ea03c706acadd481eee26abd5c2
|
||||
2026-07-21-doc-sync-through-gate-scheduler.zh.md: 56699747b1ba97fd90f7d53ab0deebc73ac775ef
|
||||
|
||||
@@ -6,13 +6,13 @@ English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`pnpm run doc-sync` was a `&&` chain of 24 `pnpm run` subcommands. Each link paid a full pnpm wrapper start (workspace resolution, script lookup, tsx boot) before its script ran; measured on a development host, the 24 script bodies together finish in about 34 seconds while the chained form takes around 3 minutes, and the wrapper stall reproduces on local disk, so every developer and CI lane pays it, not just network-filesystem checkouts. The chain also ran serially even though the member gates are read-only and independent, and it silently drifted from [scripts/run-gates.ts](../../../../scripts/run-gates.ts): `verify-cordis-api` joined the chain when the runtime API catalog landed but was never added to `docSyncLeafGates`, so CI and pre-push never enforced that catalog's freshness.
|
||||
`pnpm run doc-sync` was a `&&` chain of 24 `pnpm run` subcommands. Each link paid a full pnpm wrapper start (workspace resolution, script lookup, tsx boot) before its script ran; measured on a development host, the 24 script bodies together finish in about 34 seconds while the chained form takes around 3 minutes, and the wrapper stall reproduces on local disk, so every developer and CI lane pays it, not just network-filesystem checkouts. The chain also ran serially even though the member gates are read-only and independent, and it silently drifted from [scripts/run-gates.ts](../../../../scripts/run-gates.ts): `verify-cordis-api` joined the chain when the runtime API catalog landed but was never added to `docSyncLeafGates`, so CI never enforced that catalog's freshness.
|
||||
|
||||
## Decision
|
||||
|
||||
`doc-sync` in `package.json` now delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — the same way `check:pre-push` and the `check:ci:*` scripts already do ([parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The new `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set; the chain that could drift from it is gone. Like `pre-push`, the mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides.
|
||||
`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides.
|
||||
|
||||
The drift this consolidation surfaced is fixed in the same change: `docSyncLeafGates` gains the missing `verify-cordis-api` leaf, so CI and pre-push now gate the generated runtime API catalog alongside the other generated docs.
|
||||
`docSyncLeafGates` includes `verify-cordis-api`, so relevant local documentation checks and CI gate the generated runtime API catalog alongside the other generated docs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`pnpm run doc-sync` 原本是把 24 个 `pnpm run` 子命令用 `&&` 串起来的链。每一环都要先付一次完整的 pnpm 包装层启动(workspace 解析、脚本查找、tsx 启动)才轮到脚本本体;在开发机上实测,24 个脚本本体合计约 34 秒即可跑完,而链式形态耗时约 3 分钟,且包装层的停顿在本地磁盘上同样复现,因此每位开发者和每条 CI 车道都在付这笔开销,并非只有网络文件系统上的检出受影响。这条链还是串行执行的,尽管各成员门禁只读且相互独立;它也在悄悄偏离 [scripts/run-gates.ts](../../../../scripts/run-gates.ts):运行时 API 目录落地时 `verify-cordis-api` 加入了链,却从未加进 `docSyncLeafGates`,导致 CI 和 pre-push 从未把关该目录的新鲜度。
|
||||
`pnpm run doc-sync` 原本是把 24 个 `pnpm run` 子命令用 `&&` 串起来的链。每一环都要先付一次完整的 pnpm 包装层启动(workspace 解析、脚本查找、tsx 启动)才轮到脚本本体;在开发机上实测,24 个脚本本体合计约 34 秒即可跑完,而链式形态耗时约 3 分钟,且包装层的停顿在本地磁盘上同样复现,因此每位开发者和每条 CI 车道都在付这笔开销,并非只有网络文件系统上的检出受影响。这条链还是串行执行的,尽管各成员门禁只读且相互独立;它也在悄悄偏离 [scripts/run-gates.ts](../../../../scripts/run-gates.ts):运行时 API 目录落地时 `verify-cordis-api` 加入了链,却从未加进 `docSyncLeafGates`,导致 CI 从未把关该目录的新鲜度。
|
||||
|
||||
## 决策
|
||||
|
||||
`package.json` 中的 `doc-sync` 现在委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与 `check:pre-push` 和各 `check:ci:*` 脚本的做法一致([并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。新增的 `doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源;那条可能与之漂移的链不复存在。与 `pre-push` 一样,该模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。
|
||||
`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。
|
||||
|
||||
这次整合暴露出的漂移在同一变更中修复:`docSyncLeafGates` 补上缺失的 `verify-cordis-api` 叶子,CI 和 pre-push 从此与其他生成文档一起把关生成的运行时 API 目录。
|
||||
`docSyncLeafGates` 包含 `verify-cordis-api`,因此相关的本地文档检查与 CI 会同其他生成文档一起把关生成的运行时 API 目录。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-22-fast-local-git-hooks.md: bab47c6479f1a2c01cbfa7152b1d610917fb6175
|
||||
2026-07-22-fast-local-git-hooks.zh.md: 7b279b1a9ad86e09ed5cf7d2470cb61ff17e09b7
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Fast local Git hooks
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-fast-local-git-hooks.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An agent already runs the tests and checks that exercise its change, while commit, push, and CI can each repeat increasingly broad subsets of the same work. A full pre-push suite therefore delays every publication, amplifies unrelated local flakes, and gives no new signal when CI immediately runs the exhaustive matrix again.
|
||||
|
||||
Fast hooks still need to reject cheap, high-confidence defects before work leaves the machine. Staged formatting, whitespace errors, missing vendored-source metadata, and repository type errors fit that boundary; unit suites, snapshots, documentation checks, builds, and package hygiene vary with the changed surface and do not.
|
||||
|
||||
## Decision
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode.
|
||||
|
||||
Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The `check:pre-push` package script and `pre-push` scheduler mode do not exist; [scripts/run-gates.ts](../../../../scripts/run-gates.ts) continues to own CI and `doc-sync` scheduling.
|
||||
|
||||
Agents inspect the outgoing diff and run the narrowest tests and checks that cover its behavior once. CI owns exhaustive coverage, built-artifact checks, and the platform matrix. A complete local rehearsal is reserved for an explicit request, CI diagnosis, or a repository-wide change that cannot be validated credibly by narrower evidence.
|
||||
|
||||
## Supersedes
|
||||
|
||||
This decision supersedes the local-hook portion of [Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) and the hook/CI symmetry in [Mechanical quality gates over prose guidelines](2026-06-11-quality-gates.md). Their CI scheduler, package-gate, and mechanical-enforcement decisions remain in force.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the full pre-push suite and optimize its scheduler** — preserves the earliest exhaustive signal but still repeats agent-selected evidence and CI, while unrelated failures continue blocking publication.
|
||||
- **Remove pre-push entirely** — makes pushes cheapest but loses the fast cross-file guarantee that TypeScript provides after several commits.
|
||||
- **Keep typecheck in pre-commit** — catches type errors earlier but charges every intermediate commit instead of one push; staged lint already covers the commit-local syntax and style boundary.
|
||||
- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the existing auto-fix workflow; Lefthook's `stage_fixed` owns re-staging so the command does not duplicate `git add`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state.
|
||||
|
||||
Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 快速本地 Git 钩子
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-fast-local-git-hooks.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 CI 紧接着再次运行完整矩阵时不会提供新信号。
|
||||
|
||||
快速钩子仍需在工作离开本机之前拦下检查成本低且把握高的缺陷。暂存文件格式问题、空白错误、vendor 源码元数据缺失与仓库类型错误符合这条边界;单元测试套件、快照、文档检查、构建与包(package)的 `hygiene` 检查则随改动范围而异,不符合这条边界。
|
||||
|
||||
## 决策
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。
|
||||
|
||||
两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。`check:pre-push` 包脚本与调度器的 `pre-push` 模式不存在;[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 继续负责 CI 和 `doc-sync` 调度。
|
||||
|
||||
agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小范围测试和检查。CI 负责全量覆盖率门禁、构建产物检查与平台矩阵。只有在明确要求、诊断 CI,或涉及全仓库的改动无法由范围更窄的证据得到可信验证时,才完整运行一遍本地检查矩阵。
|
||||
|
||||
## 取代关系
|
||||
|
||||
本决策取代[并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)中涉及本地钩子的部分,以及[以机械质量门禁代替文字规范](2026-06-11-quality-gates.md)中关于钩子与 CI 对称性的部分。上述记录中关于 CI 调度器、包门禁与机械化强制执行的决策继续有效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **保留全量 pre-push 套件并优化其调度器**——能够最早提供全面信号,但仍会重复 agent 已选取的证据和 CI,且无关失败仍会阻塞推送。
|
||||
- **完全移除 pre-push**——推送成本最低,但会失去 TypeScript 在多个提交之后提供的快速跨文件保证。
|
||||
- **在 pre-commit 中保留类型检查**——更早捕获类型错误,但每次中间提交都要承担开销,而不是只在推送时运行一次;暂存文件 lint 已经覆盖提交本身的语法与风格边界。
|
||||
- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留现有的自动修复工作流;Lefthook 的 `stage_fixed` 负责重新暂存,因此命令无需重复执行 `git add`。
|
||||
|
||||
## 结果
|
||||
|
||||
普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。
|
||||
|
||||
从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。
|
||||
@@ -56,6 +56,8 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
|
||||
|
||||
The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. Superseded PR runs are cancelled, while push and scheduled runs complete for post-merge signal.
|
||||
|
||||
The DeepSeek native `web_search` probe is registered but skipped. The live Anthropic-compatible endpoint can return a successful response without structured source blocks, so its positive-source assertion is not a reliable merge signal; unit coverage still pins response parsing, but CI does not prove the live source-block wire shape.
|
||||
|
||||
## Security
|
||||
|
||||
The repository's first CI secret requires a recorded threat model because access differs between same-repository, fork, and Dependabot pull requests and changes when the repository becomes public.
|
||||
|
||||
@@ -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
|
||||
2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a
|
||||
2026-07-22-cross-platform-test-fixtures.zh.md: 43942ec0468df822d04b39e318010c2b260c734f
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Keep supported-platform tests semantic
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-cross-platform-test-fixtures.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and child-pipe closure or event-loop scheduling does not settle at the same point on every host. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture.
|
||||
|
||||
Treating fixture syntax as product behavior either reports false regressions or encourages production normalization that erases native path semantics.
|
||||
|
||||
## Decision
|
||||
|
||||
Tests of platform-neutral behavior construct absolute paths and `file:` URIs with the host's `node:path` and `node:url` APIs, then assert native absolute output or stable workspace-relative output as the contract requires. Invalid-URI fixtures use encodings rejected by `fileURLToPath()` on every supported platform.
|
||||
|
||||
Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles.
|
||||
|
||||
Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient.
|
||||
|
||||
Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Normalize all paths and URIs to POSIX strings.** This would make assertions uniform but would change correct Windows behavior: external paths are native absolute paths, UNC file URIs are valid, and configured homes resolve through the host path rules.
|
||||
|
||||
**Manipulate child-pipe internals until a write fails.** CRT descriptors and libuv handles have different ownership across hosts and Node versions, so this would test undocumented fixture machinery instead of the connection's write-failure contract.
|
||||
|
||||
**Skip whole files or packages on Windows.** Broad exclusions would hide supported behavior. Only the individual fixture whose state cannot exist on Windows is excluded; the surrounding contract remains covered.
|
||||
|
||||
## Consequences
|
||||
|
||||
Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 让受支持平台的测试聚焦语义
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-cross-platform-test-fixtures.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;子进程管道关闭或事件循环调度在不同宿主上的稳定时点也不一致。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。
|
||||
|
||||
把 fixture 语法当成产品行为,要么会误报回归,要么会促使生产代码引入抹去原生路径语义的归一化。
|
||||
|
||||
## 决策
|
||||
|
||||
测试平台无关行为时,使用宿主的 `node:path` 和 `node:url` API 构造绝对路径与 `file:` URI,再根据契约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。
|
||||
|
||||
传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。
|
||||
|
||||
语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。
|
||||
|
||||
对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为:外部路径是原生绝对路径,UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。
|
||||
|
||||
**操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败契约。
|
||||
|
||||
**在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture;相关契约仍保持覆盖。
|
||||
|
||||
## 后果
|
||||
|
||||
可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,dispose 的调用方仍能观察到该失败。
|
||||
@@ -24,7 +24,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
|
||||
3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [core-data-structures](../../../docs/core-data-structures/core.md) page and any `type-equiv` entry. Internal types need no catalog entry.
|
||||
4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md).
|
||||
5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at its authoritative boundary; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package contract](../../../packages/AGENTS.md)).
|
||||
6. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect.
|
||||
6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect.
|
||||
|
||||
## Manual checks
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ Diff the sibling branch against `origin/master`, not against the current PR bran
|
||||
|
||||
## Validation And PR Hygiene
|
||||
|
||||
For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Before pushing, expect the pre-push hook to run module graph freshness, unit tests, snapshots, doc-sync, and hygiene.
|
||||
For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Select any other evidence from the outgoing diff; the pre-push hook contributes typecheck only.
|
||||
|
||||
When opening or updating a PR, summarize:
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: dsh-pre-push-checks
|
||||
description: Use before pushing, force-pushing, marking ready for review, claiming checks pass, or bypassing a local hook on a deepseek-harness branch, especially after merges, review fixes, package graph changes, docs/catalog updates, snapshots, e2e behavior, or built artifact changes.
|
||||
description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite.
|
||||
---
|
||||
|
||||
# DSH Pre-Push Checks
|
||||
|
||||
Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, and built-bin smoke.
|
||||
Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix.
|
||||
|
||||
## First Steps
|
||||
## Inspect the outgoing change
|
||||
|
||||
1. Confirm the checkout and branch.
|
||||
|
||||
@@ -16,88 +16,80 @@ git status --short --branch
|
||||
git rev-parse --show-toplevel
|
||||
```
|
||||
|
||||
2. Inspect the outgoing diff.
|
||||
2. Inspect the diff against its actual base.
|
||||
|
||||
```sh
|
||||
git diff --stat
|
||||
git diff --name-only origin/$(git branch --show-current)...HEAD
|
||||
```
|
||||
|
||||
If the branch has no upstream or the command is not meaningful for the stack shape, use `git diff --name-only origin/master...HEAD` or the PR base branch.
|
||||
If the branch has no upstream or that range is not meaningful for the stack, compare with the PR base branch. After merging a changed base, reassess which behavior the combined diff can affect and rerun only checks invalidated by the merge.
|
||||
|
||||
3. If the branch was just merged with `master`, or the user says master changed, run the gates after resolving the merge and before pushing or marking ready. Do not present a conflict-resolution commit as ready with only typecheck/lint evidence.
|
||||
## Select relevant evidence
|
||||
|
||||
## Required Baseline
|
||||
There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches.
|
||||
|
||||
Run these before every non-trivial push:
|
||||
- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it.
|
||||
- **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it.
|
||||
- **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output.
|
||||
- **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke.
|
||||
- **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets.
|
||||
|
||||
Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook.
|
||||
|
||||
### Focus unit coverage on the affected source
|
||||
|
||||
Test selection and coverage selection are separate. A Vitest file filter chooses which tests run, while the repository configuration otherwise measures every `packages/*/*/src/**/*.ts` file. When unit coverage is relevant, name both the owning tests and the source files or package whose coverage those tests must prove:
|
||||
|
||||
```sh
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run test:coverage
|
||||
pnpm exec vitest run packages/<group>/<package>/tests/<behavior>.spec.ts \
|
||||
--coverage \
|
||||
--coverage.include='packages/<group>/<package>/src/**/*.ts'
|
||||
```
|
||||
|
||||
Why `test:coverage`, not only `test`: CI enforces per-file 100% coverage. A branch can pass `pnpm run test` and still fail CI.
|
||||
Use an exact source file when the behavior is truly confined to one module. Repeat `--coverage.include` for multiple affected files or packages, and pass every owning test file needed to exercise that scope. The configured per-file 100% thresholds still apply inside the selected source scope.
|
||||
|
||||
## Add Gates By Touched Surface
|
||||
|
||||
Run `pnpm run doc-sync` and `pnpm run verify-module-graph` when the diff touches Markdown docs, package manifests, package imports/exports, generated catalogs, Agent Notes, architecture docs, translation pairs, Mermaid diagrams, or comments that cite docs/packages.
|
||||
|
||||
Run `pnpm run build` and `pnpm run hygiene` when the diff touches any package `package.json`, dependency graph, public exports, build config, declaration surface, bundled runtime path, or code that will be consumed from built `lib/`.
|
||||
|
||||
Run snapshot tests when the diff changes ACP/editor-facing transcript behavior: ACP bridge updates, agent-loop observable output, tool call/result presentation, session log rendering, stdout/stderr protocol output, or snapshot fixtures.
|
||||
When the owning tests are unclear, use Vitest's dependency graph to discover a candidate set, then inspect the selected tests before treating the run as evidence:
|
||||
|
||||
```sh
|
||||
pnpm run test:snapshot
|
||||
pnpm exec vitest related packages/<group>/<package>/src/<changed>.ts \
|
||||
--run \
|
||||
--coverage \
|
||||
--coverage.include='packages/<group>/<package>/src/<changed>.ts'
|
||||
```
|
||||
|
||||
Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change.
|
||||
`vitest related` cannot discover behavior reached only through configuration, dynamic loading, subprocesses, workers, built artifacts, or external providers; select those owning tests explicitly. Do not use `--passWithNoTests`, lower coverage thresholds, or narrow `--coverage.include` merely to hide an uncovered affected file. If a selected package scope fails because one focused test does not cover it, add its other relevant owning tests or narrow the source scope only when the excluded modules cannot be affected by the change.
|
||||
|
||||
```sh
|
||||
DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts
|
||||
```
|
||||
## Full local rehearsal
|
||||
|
||||
Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets.
|
||||
Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate.
|
||||
|
||||
```sh
|
||||
pnpm run test:e2e
|
||||
```
|
||||
## Handle failures
|
||||
|
||||
Run a targeted test first for the changed package, but never use targeted tests as the only push evidence unless the change is test-only and cannot affect shared behavior.
|
||||
|
||||
## Full Local CI Approximation
|
||||
|
||||
Use this before high-risk pushes, after large merges, before asking for review on a major PR, or when prior pushes have caused CI churn. The authoritative command list is the root [AGENTS.md § Run the CI gates locally before marking a PR ready](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready); run that block rather than copying a local variant into this skill. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior.
|
||||
|
||||
## Handling Failures
|
||||
|
||||
If a gate fails, stop and fix or explain the blocker. Do not push and hope CI differs.
|
||||
If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs.
|
||||
|
||||
If a failure looks environment-specific, prove it:
|
||||
|
||||
- Record the exact command, failing test, and platform-specific mismatch.
|
||||
- Confirm the relevant non-platform gates pass.
|
||||
- Prefer fixing the test for cross-platform determinism if the test is part of the required local gate.
|
||||
- Bypass a local hook only when the user explicitly asks to push or agrees, and state exactly which hook failed and why it is not expected to fail on CI.
|
||||
- Confirm the relevant non-platform evidence.
|
||||
- Prefer fixing cross-platform nondeterminism when the check is required.
|
||||
- Bypass a local hook only when the user explicitly asks or agrees, and report exactly what failed and why CI is expected to differ.
|
||||
|
||||
Known pattern to watch for: Linux CI and macOS local behavior can differ for shell utilities such as `sed -i`. Treat this as evidence to investigate, not as automatic permission to bypass.
|
||||
## Push procedure
|
||||
|
||||
## Push Procedure
|
||||
|
||||
1. Local commits may happen before the full gate set, but do not push, mark ready, or claim checks pass until the relevant gates pass or any blocker is explicitly documented.
|
||||
2. Let the normal pre-commit hook run. If it changes files, inspect and commit or amend the change intentionally rather than hiding it.
|
||||
3. Push normally first so the pre-push hook can run.
|
||||
4. If a local hook is bypassed after user approval, use the narrow bypass and say so in the final response.
|
||||
5. After push, verify the remote ref matches local HEAD.
|
||||
1. Run the selected relevant checks once.
|
||||
2. Commit normally and inspect any files changed by the pre-commit fixer before continuing.
|
||||
3. Push normally so the incremental typecheck hook runs.
|
||||
4. Verify the remote ref matches local `HEAD`.
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD origin/$(git branch --show-current)
|
||||
```
|
||||
|
||||
For GitHub PRs, check CI after push:
|
||||
For GitHub PRs, inspect remote CI after the push:
|
||||
|
||||
```sh
|
||||
gh pr checks
|
||||
```
|
||||
|
||||
If checks are pending, say pending. If checks fail, inspect logs before claiming the push is good.
|
||||
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "DSH Pre-Push Checks"
|
||||
short_description: "Run the right DeepSeek Harness gates before push"
|
||||
short_description: "Run the relevant DeepSeek Harness checks before push"
|
||||
default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch."
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@@ -48,7 +48,7 @@ Package groups: [packages/README.md](packages/README.md).
|
||||
```sh
|
||||
pnpm install # pnpm workspaces, node ^22.19 || >=24
|
||||
pnpm run test # vitest unit tests
|
||||
pnpm run test:coverage # THE gating test run: per-file 100% coverage on packages/*/*/src
|
||||
pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src
|
||||
pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY
|
||||
pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t <name>
|
||||
pnpm run test:snapshot:record # re-record expected outputs (needs key)
|
||||
@@ -69,26 +69,13 @@ pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY)
|
||||
|
||||
When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test.
|
||||
|
||||
### Run the CI gates locally before marking a PR ready
|
||||
### Run relevant checks locally
|
||||
|
||||
Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`:
|
||||
Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run.
|
||||
|
||||
```sh
|
||||
set -euo pipefail
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run duplication
|
||||
pnpm run test:coverage
|
||||
pnpm run test:snapshot
|
||||
pnpm run doc-sync
|
||||
pnpm run website:build
|
||||
pnpm run verify-module-graph
|
||||
pnpm run build
|
||||
pnpm run hygiene
|
||||
DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
|
||||
```
|
||||
|
||||
`test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run.
|
||||
- Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior.
|
||||
- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
|
||||
- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
|
||||
|
||||
## Secrets / .env
|
||||
|
||||
@@ -115,12 +102,12 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction.
|
||||
- **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR.
|
||||
- **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)).
|
||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
|
||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
|
||||
- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
|
||||
- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
|
||||
- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
|
||||
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
|
||||
- Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it.
|
||||
- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
|
||||
|
||||
## Defensive patterns
|
||||
|
||||
|
||||
@@ -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
|
||||
architecture.md: 517288c4480295b190050651b6047c608d16cb4c
|
||||
architecture.zh.md: 70a5783d462f3b2d71fc47c0f7768f62141e5cad
|
||||
architecture.md: b3e2db14727c299562f9b061459547d147ec1d70
|
||||
architecture.zh.md: 6fd2a7e161a10ac5f2dcee6859b0d6251671676f
|
||||
|
||||
@@ -14,7 +14,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv
|
||||
|
||||
| ctx key | Package | Role |
|
||||
|---|---|---|
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration primitive (library) |
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration and shared layer storage (library) |
|
||||
| `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions |
|
||||
| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
|
||||
| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
|
||||
@@ -133,7 +133,7 @@ Every session event is turn-enclosed. Reloading preserves an interrupted tail an
|
||||
|
||||
### Agent Scope
|
||||
|
||||
Each agent owns a scoped `agent.ctx`; registrations shadow globals, filter dispatch, and unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, prompt, and command entries while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch, and every scoped contribution unwinds with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
|
||||
## State
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
| ctx 键 | 包 | 职责 |
|
||||
|---|---|---|
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册原语(库) |
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册与共享层存储(库) |
|
||||
| `ctx.sessions` | `dsh-session` | 内存中的事件溯源会话 |
|
||||
| `ctx.systemPrompt` | `dsh-system-prompt` | 有序提示词片段、工具 schema 和提示词变量 |
|
||||
| `ctx.tools` | `dsh-tools` | 工具注册表和[执行流水线](tool-execution-pipeline.md) |
|
||||
@@ -133,7 +133,7 @@ forever:
|
||||
|
||||
### Agent 作用域
|
||||
|
||||
每个 agent 都拥有一个作用域化的 `agent.ctx`;注册项会遮蔽全局项、过滤分派,并在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
|
||||
每个 agent 都拥有一个作用域化的 `agent.ctx`;共享存储会在全局工具、提示词和命令条目之上叠加作用域条目,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派,每项作用域贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
|
||||
|
||||
## 状态
|
||||
|
||||
|
||||
@@ -1493,7 +1493,7 @@ export interface TuiConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:128`](../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:129`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui-demo`
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ A command was registered or unregistered. This is an unfiltered registry notific
|
||||
'commands/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts)
|
||||
Source: [`packages/ui/commands/src/index.ts:103`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `fs/*`
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<Comma
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md)
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts)
|
||||
Source: [`packages/ui/commands/src/index.ts:227`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `ctx.compact` — `CompactService` (abstract seam)
|
||||
|
||||
@@ -1213,7 +1213,7 @@ async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
|
||||
Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:213`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `ctx.tasks` — `TaskService`
|
||||
|
||||
@@ -1456,7 +1456,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Scoped Registration
|
||||
|
||||
The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics.
|
||||
The [scope package](../../packages/core/scope) supplies the identity, carrier, and scoped-layer vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the lifecycle rationale, the [shared-storage Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md) owns the registry-layer decision, and the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics.
|
||||
|
||||
Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts).
|
||||
Sources: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) and [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts).
|
||||
|
||||
## Identity and dispatch carrier
|
||||
|
||||
@@ -39,3 +39,19 @@ interface Scope {
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## Scoped registry layer
|
||||
|
||||
`ScopeLayer` represents one registry's complete contribution at the global or exact-scope level. A concrete layer may aggregate multiple named and anonymous tables; whole-layer emptiness lets `ScopedLayers` reclaim scoped state without discarding a sibling table.
|
||||
|
||||
```ts type-equiv
|
||||
/** One scope's aggregate contribution to a registry. */
|
||||
interface ScopeLayer {
|
||||
/** Whether every table in this layer is empty. */
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`ScopedLayers<L>` owns the eager global layer and lazily created exact-scope layers. Reads do not create layers: `peek(undefined)` means no overlay, while `merge()` materializes insertion-ordered global named entries followed by scoped shadows. Registrations use one context for both visibility and Cordis effect ownership, collect one synchronous undo before optional notification, return Cordis's exact disposer, and reclaim a scoped layer only when its complete `ScopeLayer` is empty.
|
||||
|
||||
`NamedEntries<V>` supplies insertion-ordered lookup and live iteration with caller-owned duplicate errors. `AnonymousEntries<V>` gives every append a unique identity so equal values remain independent. Iteration stays live within one nonempty table generation; draining the table detaches existing iterators from later insertions. Both return idempotent exact-entry undos; the shared `EntryValues` implementation interface is not public.
|
||||
|
||||
@@ -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
|
||||
development.md: f0db7fbcb4a9df98e83d6c1edd5610e5cc4dd517
|
||||
development.zh.md: 62e16479a49d5548e1fbd773dabca5bd741a24fe
|
||||
development.md: 10406cebae1bf83fff663903b1478c9acb8476a1
|
||||
development.zh.md: 50051ffd631518b37c3ad96f5fd3830cf6893ec9
|
||||
|
||||
@@ -35,13 +35,13 @@ pnpm run typecheck
|
||||
|
||||
That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings.
|
||||
|
||||
If you are preparing to push from a fresh clone or worktree, also build once:
|
||||
If a relevant local check consumes built package output, build once first:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs.
|
||||
`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -56,14 +56,14 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
## Git hooks
|
||||
|
||||
lefthook is configured in `lefthook.yml` as an early local checkpoint before review:
|
||||
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
|
||||
|
||||
- `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard.
|
||||
- `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs runtime-closure verification, unit tests, duplication detection, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently.
|
||||
- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
|
||||
- `pre-push` runs only the incremental repository typecheck.
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
|
||||
These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26.
|
||||
The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.
|
||||
|
||||
## CI gates
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@ pnpm run typecheck
|
||||
|
||||
首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。
|
||||
|
||||
如果准备从新克隆或新 worktree 推送,还需要构建一次:
|
||||
如果相关的本地检查需要使用构建后的包产物,请先构建一次:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件。
|
||||
`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -56,14 +56,14 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
|
||||
## Git 钩子
|
||||
|
||||
lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点:
|
||||
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
|
||||
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行 runtime-closure 校验、单元测试、重复代码检查、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene` 与 `pnpm run doc-sync` 的各成员门禁。
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 只运行仓库增量类型检查。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。
|
||||
|
||||
这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。
|
||||
这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。
|
||||
|
||||
## CI 门禁
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
|
||||
@@ -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
|
||||
README.md: bd5d8c08a4c474a13342b6b60800cfe0d31e110b
|
||||
README.zh.md: a53ab8d9d6053b39def34505038504fefc80a3f9
|
||||
README.md: c4ddf44ad2497b4ff371918356ab1ec0698c7049
|
||||
README.zh.md: 4a31af4fdee4db2d0362cf9117a6eef4fea32393
|
||||
|
||||
@@ -21,7 +21,7 @@ This repo's documentation is read by people and agents both inside and outside t
|
||||
|
||||
## The gate: verify-translation-pairing
|
||||
|
||||
`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically:
|
||||
`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:
|
||||
|
||||
1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.
|
||||
2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 门禁:verify-translation-pairing
|
||||
|
||||
`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约:
|
||||
`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:
|
||||
|
||||
1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。
|
||||
2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。
|
||||
|
||||
16
lefthook.yml
16
lefthook.yml
@@ -1,25 +1,23 @@
|
||||
# Git hooks (lefthook). Hooks call the same package.json scripts CI runs —
|
||||
# one source of truth; the hook is just an earlier, faster checkpoint.
|
||||
# Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full
|
||||
# repository-wide gate matrix.
|
||||
# Install: `pnpm exec lefthook install` (runs automatically via postinstall).
|
||||
|
||||
pre-commit:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: lint (staged)
|
||||
glob: '*.{ts,mts,cts,mjs}'
|
||||
exclude:
|
||||
- 'vendor/*/src/**'
|
||||
run: node_modules/.bin/eslint --fix {staged_files} && git add {staged_files}
|
||||
run: node_modules/.bin/eslint --fix {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
- name: typecheck
|
||||
glob: '*.ts'
|
||||
run: pnpm run typecheck
|
||||
- name: whitespace (staged)
|
||||
run: git diff --cached --check
|
||||
|
||||
- name: vendor manifest guard
|
||||
run: scripts/check-vendor-manifest.sh
|
||||
|
||||
pre-push:
|
||||
jobs:
|
||||
- name: full check
|
||||
run: pnpm run check:pre-push
|
||||
- name: typecheck
|
||||
run: node_modules/.bin/tsc -b tsconfig.json --pretty false
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
|
||||
"check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts",
|
||||
"check:node-compat": "tsx scripts/run-gates.ts node-compat",
|
||||
"check:pre-push": "tsx scripts/run-gates.ts pre-push",
|
||||
"knip": "knip --treat-config-hints-as-errors",
|
||||
"publint": "tsx scripts/publint-all.ts",
|
||||
"doc-typecheck": "tsx scripts/doc-typecheck.ts",
|
||||
|
||||
@@ -1143,8 +1143,8 @@ describe('workspace context request injection', () => {
|
||||
})
|
||||
|
||||
it('keeps the direct provider API usable without an operation signal', async () => {
|
||||
const root = '/virtual/no-signal-repo'
|
||||
const home = '/virtual/no-signal-home'
|
||||
const root = resolve('/virtual/no-signal-repo')
|
||||
const home = resolve('/virtual/no-signal-home')
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
|
||||
@@ -12,6 +12,10 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
|
||||
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
|
||||
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
|
||||
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
|
||||
|
||||
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
|
||||
|
||||
@@ -19,6 +23,8 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse
|
||||
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
|
||||
|
||||
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
|
||||
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
|
||||
export type { ScopeLayer } from './store.ts'
|
||||
|
||||
/** An opaque, identity-compared scope key. */
|
||||
export type ScopeKey = object
|
||||
|
||||
|
||||
247
packages/core/scope/src/store.ts
Normal file
247
packages/core/scope/src/store.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Shared insertion-ordered storage and effect ownership for scope-aware registries.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scope
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { scopeOf } from './index.ts'
|
||||
import type { ScopeKey } from './index.ts'
|
||||
|
||||
/** One scope's aggregate contribution to a registry. */
|
||||
export interface ScopeLayer {
|
||||
/** Whether every table in this layer is empty. */
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
/** Internal common read contract for the two entry-table implementations. */
|
||||
interface EntryValues<V> {
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
|
||||
*
|
||||
* Values are borrowed. Iterators are live within one nonempty table
|
||||
* generation; draining the table detaches them from later insertions. Each
|
||||
* successful insertion returns an idempotent undo for that exact entry.
|
||||
*/
|
||||
export class NamedEntries<V> implements EntryValues<V> {
|
||||
private data = new Map<string, V>()
|
||||
|
||||
constructor(
|
||||
private readonly duplicateError: (name: string) => Error,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Insert one unique name.
|
||||
* @param name - name unique within this table.
|
||||
* @param value - borrowed value to retain.
|
||||
* @returns an idempotent undo that removes only this insertion.
|
||||
*/
|
||||
insert(name: string, value: V): () => void {
|
||||
const data = this.data
|
||||
if (data.has(name)) throw this.duplicateError(name)
|
||||
data.set(name, value)
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
data.delete(name)
|
||||
if (data.size === 0 && this.data === data) this.data = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one named value.
|
||||
* @param name - name to resolve.
|
||||
* @returns the retained value, or `undefined` when absent.
|
||||
*/
|
||||
get(name: string): V | undefined {
|
||||
return this.data.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test one name for membership.
|
||||
* @param name - name to test.
|
||||
* @returns whether the table contains that name.
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.data.has(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live names in insertion order.
|
||||
* @returns the native live key iterator.
|
||||
*/
|
||||
keys(): IterableIterator<string> {
|
||||
return this.data.keys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live entries in insertion order.
|
||||
* @returns the native live entry iterator.
|
||||
*/
|
||||
entries(): IterableIterator<[string, V]> {
|
||||
return this.data.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live values in insertion order.
|
||||
* @returns the native live value iterator.
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.data.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this table has no entries.
|
||||
* @returns whether the table is empty.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.data.size === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insertion-ordered anonymous entries with independent registration identity.
|
||||
*
|
||||
* Equal values remain separate registrations. Values are borrowed, and
|
||||
* iterators are live within one nonempty table generation; draining the table
|
||||
* detaches them from later appends.
|
||||
*/
|
||||
export class AnonymousEntries<V> implements EntryValues<V> {
|
||||
private data = new Map<symbol, V>()
|
||||
|
||||
/**
|
||||
* Append one independently owned value.
|
||||
* @param value - borrowed value to retain.
|
||||
* @returns an idempotent undo for this exact append.
|
||||
*/
|
||||
append(value: V): () => void {
|
||||
const data = this.data
|
||||
const key = Symbol()
|
||||
data.set(key, value)
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
data.delete(key)
|
||||
if (data.size === 0 && this.data === data) this.data = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live values in insertion order.
|
||||
* @returns the native live value iterator.
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.data.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this table has no entries.
|
||||
* @returns whether the table is empty.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.data.size === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Own the global and exact-scope layers for one registry.
|
||||
*
|
||||
* Reads never create scoped layers. Registrations derive both visibility and
|
||||
* effect ownership from the supplied Cordis context, collect undo before
|
||||
* notification, and reclaim only a completely empty aggregate layer.
|
||||
*/
|
||||
export class ScopedLayers<L extends ScopeLayer> {
|
||||
/** The eagerly constructed context-global layer. */
|
||||
readonly global: L
|
||||
|
||||
private readonly scoped = new Map<ScopeKey, L>()
|
||||
|
||||
constructor(
|
||||
private readonly createLayer: (scope: ScopeKey | undefined) => L,
|
||||
private readonly onChange: () => void,
|
||||
) {
|
||||
this.global = createLayer(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an existing exact-scope overlay.
|
||||
* @param scope - exact scope key; `undefined` denotes no overlay.
|
||||
* @returns the existing scoped layer, or `undefined` without creating one.
|
||||
*/
|
||||
peek(scope: ScopeKey | undefined): L | undefined {
|
||||
if (scope === undefined) return undefined
|
||||
return this.scoped.get(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize global named entries followed by exact-scope shadows.
|
||||
* @param scope - exact viewing scope, or `undefined` for the global view.
|
||||
* @param pick - select the named table from a layer.
|
||||
* @returns an insertion-ordered effective map.
|
||||
*/
|
||||
merge<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V> {
|
||||
const merged = new Map(pick(this.global).entries())
|
||||
const layer = this.peek(scope)
|
||||
if (layer === undefined) return merged
|
||||
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one synchronous layer mutation to its registration context.
|
||||
* @param ctx - context that determines both scope visibility and effect ownership.
|
||||
* @param action - atomic mutation returning its synchronous undo.
|
||||
* @param options - Cordis effect label and optional change notification.
|
||||
* @returns the exact disposer returned by `ctx.effect()`.
|
||||
*/
|
||||
effect(
|
||||
ctx: Context,
|
||||
action: (layer: L) => () => void,
|
||||
options: { label: string; notify?: boolean },
|
||||
): () => void {
|
||||
const scope = scopeOf(ctx)
|
||||
const notify = options.notify ?? true
|
||||
const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
|
||||
let layer: L
|
||||
let created = false
|
||||
if (scope === undefined) {
|
||||
layer = this.global
|
||||
} else {
|
||||
const existing = this.scoped.get(scope)
|
||||
if (existing === undefined) {
|
||||
layer = this.createLayer(scope)
|
||||
this.scoped.set(scope, layer)
|
||||
created = true
|
||||
} else {
|
||||
layer = existing
|
||||
}
|
||||
}
|
||||
|
||||
let undo: () => void
|
||||
try {
|
||||
undo = action(layer)
|
||||
} catch (error) {
|
||||
if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
|
||||
throw error
|
||||
}
|
||||
|
||||
yield () => {
|
||||
undo()
|
||||
if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
|
||||
if (notify) this.onChange()
|
||||
}
|
||||
if (notify) this.onChange()
|
||||
}.bind(this), options.label)
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
|
||||
return dispose
|
||||
}
|
||||
}
|
||||
289
packages/core/scope/tests/store.spec.ts
Normal file
289
packages/core/scope/tests/store.spec.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
AnonymousEntries,
|
||||
createScope,
|
||||
NamedEntries,
|
||||
ScopedLayers,
|
||||
type Scope,
|
||||
type ScopeKey,
|
||||
type ScopeLayer,
|
||||
} from '@deepseek-ai/dsh-scope'
|
||||
|
||||
class TestLayer implements ScopeLayer {
|
||||
readonly named: NamedEntries<number>
|
||||
readonly anonymous = new AnonymousEntries<string>()
|
||||
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.named = new NamedEntries(name =>
|
||||
new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.named.isEmpty() && this.anonymous.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint one active scope for lifecycle tests. */
|
||||
async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
|
||||
let scope!: Scope
|
||||
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
|
||||
return scope
|
||||
}
|
||||
|
||||
describe('NamedEntries', () => {
|
||||
it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
|
||||
const duplicate = new Error('caller duplicate')
|
||||
const duplicateError = vi.fn(() => duplicate)
|
||||
const entries = new NamedEntries<number>(duplicateError)
|
||||
const undoA = entries.insert('a', 1)
|
||||
const values = entries.values()
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
const undoB = entries.insert('b', 2)
|
||||
|
||||
expect([...values]).toEqual([2])
|
||||
expect([...entries.keys()]).toEqual(['a', 'b'])
|
||||
expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
|
||||
expect(entries.get('a')).toBe(1)
|
||||
expect(entries.get('missing')).toBeUndefined()
|
||||
expect(entries.has('b')).toBe(true)
|
||||
expect(entries.has('missing')).toBe(false)
|
||||
expect(entries.isEmpty()).toBe(false)
|
||||
expect(() => entries.insert('a', 3)).toThrow(duplicate)
|
||||
expect(duplicateError).toHaveBeenCalledWith('a')
|
||||
|
||||
undoA()
|
||||
entries.insert('a', 3)
|
||||
undoA()
|
||||
expect(entries.get('a')).toBe(3)
|
||||
undoB()
|
||||
expect([...entries.entries()]).toEqual([['a', 3]])
|
||||
})
|
||||
|
||||
it('starts a fresh iterator generation after the table drains', () => {
|
||||
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
|
||||
const undo = entries.insert('first', 1)
|
||||
const values = entries.values()
|
||||
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
undo()
|
||||
entries.insert('replacement', 2)
|
||||
|
||||
expect(values.next().done).toBe(true)
|
||||
expect([...entries.values()]).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AnonymousEntries', () => {
|
||||
it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
|
||||
const entries = new AnonymousEntries<object>()
|
||||
const value = {}
|
||||
const undoFirst = entries.append(value)
|
||||
const values = entries.values()
|
||||
expect(values.next()).toEqual({ value, done: false })
|
||||
const undoSecond = entries.append(value)
|
||||
|
||||
expect([...values]).toEqual([value])
|
||||
expect([...entries.values()]).toEqual([value, value])
|
||||
undoFirst()
|
||||
undoFirst()
|
||||
expect([...entries.values()]).toEqual([value])
|
||||
undoSecond()
|
||||
expect(entries.isEmpty()).toBe(true)
|
||||
})
|
||||
|
||||
it('starts a fresh iterator generation after the table drains', () => {
|
||||
const entries = new AnonymousEntries<number>()
|
||||
const undo = entries.append(1)
|
||||
const values = entries.values()
|
||||
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
undo()
|
||||
entries.append(2)
|
||||
|
||||
expect(values.next().done).toBe(true)
|
||||
expect([...entries.values()]).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScopedLayers', () => {
|
||||
it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
|
||||
const created: Array<ScopeKey | undefined> = []
|
||||
const layers = new ScopedLayers(
|
||||
(scope) => {
|
||||
created.push(scope)
|
||||
return new TestLayer(scope)
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
const key = {}
|
||||
layers.global.named.insert('a', 1)
|
||||
layers.global.named.insert('shared', 2)
|
||||
|
||||
expect(created).toEqual([undefined])
|
||||
expect(layers.peek(undefined)).toBeUndefined()
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
|
||||
expect(created).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
const changed = vi.fn()
|
||||
const created: Array<ScopeKey | undefined> = []
|
||||
const layers = new ScopedLayers(
|
||||
(selected) => {
|
||||
created.push(selected)
|
||||
return new TestLayer(selected)
|
||||
},
|
||||
changed,
|
||||
)
|
||||
layers.global.named.insert('a', 1)
|
||||
layers.global.named.insert('shared', 1)
|
||||
const removeNamed = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('shared', 2),
|
||||
{ label: 'test.named', notify: false },
|
||||
)
|
||||
const removeTail = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('c', 3),
|
||||
{ label: 'test.tail', notify: false },
|
||||
)
|
||||
const removeAnonymous = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.anonymous.append('kept'),
|
||||
{ label: 'test.anonymous', notify: false },
|
||||
)
|
||||
|
||||
expect(created).toEqual([undefined, key])
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
removeNamed()
|
||||
expect(layers.peek(key)).toBeDefined()
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
|
||||
removeTail()
|
||||
expect(layers.peek(key)).toBeDefined()
|
||||
removeAnonymous()
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
|
||||
const ctx = new Context()
|
||||
const events: string[] = []
|
||||
const layers = new ScopedLayers(
|
||||
scope => new TestLayer(scope),
|
||||
() => void events.push('notify'),
|
||||
)
|
||||
const dispose = layers.effect(
|
||||
ctx,
|
||||
(layer) => {
|
||||
events.push('action')
|
||||
const undo = layer.named.insert('x', 1)
|
||||
return () => {
|
||||
events.push('undo')
|
||||
undo()
|
||||
}
|
||||
},
|
||||
{ label: 'store.order' },
|
||||
)
|
||||
|
||||
expect(events).toEqual(['action', 'notify'])
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
|
||||
expect(layers.global.isEmpty()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the exact context effect disposer', () => {
|
||||
const rawDispose = vi.fn()
|
||||
const effect = vi.fn(() => rawDispose)
|
||||
const ctx = { effect } as unknown as Context
|
||||
const action = vi.fn(() => vi.fn())
|
||||
const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
|
||||
|
||||
const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
|
||||
|
||||
expect(returned).toBe(rawDispose)
|
||||
expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
let failFactory = true
|
||||
const layers = new ScopedLayers(
|
||||
(selected) => {
|
||||
if (selected !== undefined && failFactory) throw new Error('factory failed')
|
||||
return new TestLayer(selected)
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('never', 1),
|
||||
{ label: 'store.factory', notify: false },
|
||||
)).toThrow('factory failed')
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
|
||||
failFactory = false
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
() => { throw new Error('action failed') },
|
||||
{ label: 'store.action', notify: false },
|
||||
)).toThrow('action failed')
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
|
||||
const dispose = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('kept', 1),
|
||||
{ label: 'store.kept', notify: false },
|
||||
)
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
() => { throw new Error('second action failed') },
|
||||
{ label: 'store.existing-action', notify: false },
|
||||
)).toThrow('second action failed')
|
||||
expect(layers.peek(key)?.named.get('kept')).toBe(1)
|
||||
dispose()
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('rolls back a scoped insertion when notification throws', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
const events: string[] = []
|
||||
let notifications = 0
|
||||
const layers = new ScopedLayers(
|
||||
selected => new TestLayer(selected),
|
||||
() => {
|
||||
events.push('notify')
|
||||
if (++notifications === 1) throw new Error('change failed')
|
||||
},
|
||||
)
|
||||
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
(layer) => {
|
||||
const undo = layer.named.insert('rollback', 1)
|
||||
return () => {
|
||||
events.push('undo')
|
||||
undo()
|
||||
}
|
||||
},
|
||||
{ label: 'store.rollback' },
|
||||
)).toThrow('change failed')
|
||||
|
||||
expect(events).toEqual(['notify', 'undo', 'notify'])
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
await scope.dispose()
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -209,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
return result + text.slice(last)
|
||||
}
|
||||
|
||||
/** One tool-schema provider stored in a prompt layer. */
|
||||
type ToolProvider = (context: AssembleContext) => ToolProviderResult
|
||||
|
||||
/** One prompt-variable provider stored in a prompt layer. */
|
||||
type VariableProvider = (context: AssembleContext) => string | undefined
|
||||
|
||||
/** All prompt registrations owned by one global or scoped layer. */
|
||||
class PromptLayer implements ScopeLayer {
|
||||
readonly sections: NamedEntries<PromptSection>
|
||||
readonly toolProviders = new AnonymousEntries<ToolProvider>()
|
||||
readonly variables: NamedEntries<VariableProvider>
|
||||
|
||||
/**
|
||||
* Create one prompt layer with diagnostics specific to its ownership scope.
|
||||
* @param scope - the scoped owner, or `undefined` for global registrations.
|
||||
*/
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.sections = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${name}" is already registered in this scope`))
|
||||
this.variables = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt variable "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** @returns whether this layer owns no prompt registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.sections.isEmpty()
|
||||
&& this.toolProviders.isEmpty()
|
||||
&& this.variables.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry service for the prompt inputs assembled before each model step. */
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -217,13 +250,10 @@ export class SystemPrompt extends Service {
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
})
|
||||
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
|
||||
private scopedSections = new Map<ScopeKey, PromptSection[]>()
|
||||
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
||||
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new PromptLayer(scope),
|
||||
() => { this.ctx.emit('system-prompt/change') },
|
||||
)
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -255,34 +285,11 @@ export class SystemPrompt extends Service {
|
||||
if (!Number.isFinite(section.order)) {
|
||||
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.sections
|
||||
: this.scopedSections.get(scope) ?? (() => {
|
||||
const created: PromptSection[] = []
|
||||
this.scopedSections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.some(existing => existing.name === section.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${section.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.push(section)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
const index = layer.indexOf(section)
|
||||
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.section()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.sections.insert(section.name, section),
|
||||
{ label: 'systemPrompt.section()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,29 +300,11 @@ export class SystemPrompt extends Service {
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.toolProviders
|
||||
: this.scopedToolProviders.get(scope) ?? (() => {
|
||||
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
this.scopedToolProviders.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
layer.push(provider)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
const index = layer.indexOf(provider)
|
||||
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.tools()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.toolProviders.append(provider),
|
||||
{ label: 'systemPrompt.tools()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,32 +319,11 @@ export class SystemPrompt extends Service {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.variableProviders
|
||||
: this.scopedVariableProviders.get(scope) ?? (() => {
|
||||
const created = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
this.scopedVariableProviders.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.has(name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt variable "${name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(name, provider)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
layer.delete(name)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.variable()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.variables.insert(name, provider),
|
||||
{ label: 'systemPrompt.variable()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,23 +338,19 @@ export class SystemPrompt extends Service {
|
||||
const scope = context.scope
|
||||
// Scoped variables shadow globals.
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
for (const [name, provider] of this.variableProviders) {
|
||||
for (const [name, provider] of this.layers.global.variables.entries()) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
|
||||
for (const [name, provider] of scopedVariables ?? []) {
|
||||
const scopedVariables = this.layers.peek(scope)?.variables
|
||||
for (const [name, provider] of scopedVariables?.entries() ?? []) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
// Scoped sections shadow globals before the stable order sort.
|
||||
const sectionByName = new Map<string, PromptSection>()
|
||||
for (const section of this.sections) sectionByName.set(section.name, section)
|
||||
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
|
||||
sectionByName.set(section.name, section)
|
||||
}
|
||||
const sectionByName = this.layers.merge(scope, layer => layer.sections)
|
||||
// Validate order against pre-restriction names while collecting visible schemas.
|
||||
const providers = [
|
||||
...this.toolProviders,
|
||||
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
|
||||
...this.layers.global.toolProviders.values(),
|
||||
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
|
||||
]
|
||||
const collected: ToolSchema[] = []
|
||||
const knownNames = new Set<string>()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
@@ -63,6 +63,21 @@ describe('scoped sections', () => {
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it('shadows a global section before evaluating either text provider', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const globalText = vi.fn(() => 'global text')
|
||||
const scopedText = vi.fn(() => 'scoped text')
|
||||
ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
|
||||
scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||
|
||||
expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
|
||||
expect(globalText).not.toHaveBeenCalled()
|
||||
expect(scopedText).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('scoped variables', () => {
|
||||
@@ -86,6 +101,28 @@ describe('scoped variables', () => {
|
||||
const again = await mintScope(ctx, 'child2')
|
||||
again.ctx.systemPrompt.variable('v', () => '3')
|
||||
})
|
||||
|
||||
it('defers a scoped variable that replaces the last provider in its generation', async () => {
|
||||
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const key = scopeKeyOf(scope)
|
||||
const calls: string[] = []
|
||||
scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
|
||||
const dispose = scope.ctx.systemPrompt.variable('mode', () => {
|
||||
calls.push('first')
|
||||
dispose()
|
||||
scope.ctx.systemPrompt.variable('mode', () => {
|
||||
calls.push('replacement')
|
||||
return 'replacement'
|
||||
})
|
||||
return 'first'
|
||||
})
|
||||
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
|
||||
expect(calls).toEqual(['first'])
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
|
||||
expect(calls).toEqual(['first', 'replacement'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped tool providers and toolOrder × restriction', () => {
|
||||
|
||||
@@ -157,6 +157,24 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('snapshots tool-provider membership before evaluating an assembly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let added = false
|
||||
ctx.systemPrompt.tools(() => {
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.systemPrompt.tools(() => ({
|
||||
schemas: [{ name: 'late', description: '', parameters: {} }],
|
||||
}))
|
||||
}
|
||||
return { schemas: [{ name: 'first', description: '', parameters: {} }] }
|
||||
})
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
|
||||
})
|
||||
|
||||
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -314,6 +332,24 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
})
|
||||
|
||||
it('live-iterates variables registered by an earlier provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let added = false
|
||||
ctx.systemPrompt.variable('first', () => {
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.systemPrompt.variable('late', () => 'second value')
|
||||
}
|
||||
return 'first value'
|
||||
})
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({
|
||||
first: 'first value',
|
||||
late: 'second value',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a duplicate variable name and an unreferenceable name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
@@ -463,9 +463,40 @@ interface ToolView {
|
||||
*/
|
||||
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
|
||||
/** One guard registration; the wrapper preserves independent duplicate registrations. */
|
||||
interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
/** One scope's complete tool-registry contribution. */
|
||||
class ToolLayer implements ScopeLayer {
|
||||
readonly tools: NamedEntries<ToolDefinition>
|
||||
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
|
||||
readonly guards = new AnonymousEntries<ToolGuard>()
|
||||
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.tools = new NamedEntries(name => new Error(scope === undefined
|
||||
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** Whether every contribution table in this aggregate layer is empty. */
|
||||
isEmpty(): boolean {
|
||||
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
|
||||
}
|
||||
|
||||
/** Whether every compiled restriction in this layer admits a global tool name. */
|
||||
admits(name: string): boolean {
|
||||
for (const filter of this.restrictions.values()) {
|
||||
if ((filter.allow !== undefined && !filter.allow.has(name))
|
||||
|| (filter.deny !== undefined && filter.deny.has(name))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** First monotonic denial from this layer's live guard registrations. */
|
||||
guardReason(exec: ToolExecution): string | undefined {
|
||||
for (const guard of this.guards.values()) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Approval decision plus whether the approval channel reported cancellation. */
|
||||
@@ -509,13 +540,10 @@ export class ToolRegistry extends Service {
|
||||
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
|
||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||
private global = new Map<string, ToolDefinition>()
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Compiled restriction filters, per scope (see {@link restrict}). */
|
||||
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
|
||||
/** Monotonic post-policy guards, split into global and per-agent layers. */
|
||||
private globalGuards = new Set<ToolGuardRegistration>()
|
||||
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new ToolLayer(scope),
|
||||
() => { this.ctx.emit('tools/change') },
|
||||
)
|
||||
private readonly mode: ToolPresentationMode
|
||||
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
||||
private readonly codeTransport: ToolDefinition | undefined
|
||||
@@ -593,7 +621,6 @@ export class ToolRegistry extends Service {
|
||||
* @returns the exact disposer that unregisters the tool.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const name = definition.name
|
||||
const timeoutMs = definition.timeoutMs
|
||||
if (timeoutMs !== undefined
|
||||
@@ -603,26 +630,11 @@ export class ToolRegistry extends Service {
|
||||
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
|
||||
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(name, definition)
|
||||
// Install rollback before notifying listeners.
|
||||
yield () => {
|
||||
layer.delete(name)
|
||||
// Drop empty scope layers.
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.register()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.tools.insert(name, definition),
|
||||
{ label: 'tools.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -655,22 +667,11 @@ export class ToolRegistry extends Service {
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const list = this.restrictions.get(scope) ?? []
|
||||
this.restrictions.set(scope, list)
|
||||
list.push(compiled)
|
||||
yield () => {
|
||||
const index = list.indexOf(compiled)
|
||||
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) list.splice(index, 1)
|
||||
if (list.length === 0) this.restrictions.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.restrict()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.restrictions.append(compiled),
|
||||
{ label: 'tools.restrict()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -684,63 +685,18 @@ export class ToolRegistry extends Service {
|
||||
* @returns the exact disposer that unregisters the guard.
|
||||
*/
|
||||
guard(guard: ToolGuard): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registration = { guard }
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
|
||||
layer.add(registration)
|
||||
yield () => {
|
||||
layer.delete(registration)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
|
||||
}
|
||||
}.bind(this), 'tools.guard()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** The (created-on-demand) scoped layer for `scope`. */
|
||||
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (!layer) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
}
|
||||
|
||||
/** Get or create the guard layer for one agent scope. */
|
||||
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
|
||||
let layer = this.scopedGuards.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Set()
|
||||
this.scopedGuards.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.guards.append(guard),
|
||||
{ label: 'tools.guard()', notify: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** First monotonic denial from the global then matching scoped guard layers. */
|
||||
private guardReason(exec: ToolExecution): string | undefined {
|
||||
for (const { guard } of this.globalGuards) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
|
||||
private admits(scope: ScopeKey | undefined, name: string): boolean {
|
||||
if (scope === undefined) return true
|
||||
const filters = this.restrictions.get(scope)
|
||||
if (!filters) return true
|
||||
return filters.every(filter =>
|
||||
(filter.allow === undefined || filter.allow.has(name))
|
||||
&& (filter.deny === undefined || !filter.deny.has(name)))
|
||||
const globalReason = this.layers.global.guardReason(exec)
|
||||
if (globalReason !== undefined) return globalReason
|
||||
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,18 +708,18 @@ export class ToolRegistry extends Service {
|
||||
* @returns the complete derived view for that scope.
|
||||
*/
|
||||
private view(scope?: ScopeKey): ToolView {
|
||||
const layer = scope === undefined ? undefined : this.scoped.get(scope)
|
||||
const layer = this.layers.peek(scope)
|
||||
const visible = new Map<string, ToolDefinition>()
|
||||
const knownNames = new Set<string>()
|
||||
const restrictableNames = new Set<string>()
|
||||
for (const [name, definition] of this.global) {
|
||||
for (const [name, definition] of this.layers.global.tools.entries()) {
|
||||
knownNames.add(name)
|
||||
restrictableNames.add(name)
|
||||
if (this.admits(scope, name)) visible.set(name, definition)
|
||||
if (layer?.admits(name) ?? true) visible.set(name, definition)
|
||||
}
|
||||
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
|
||||
// and scope-local registrations are never part of the global filter above.
|
||||
for (const [name, definition] of layer ?? []) {
|
||||
for (const [name, definition] of layer?.tools.entries() ?? []) {
|
||||
knownNames.add(name)
|
||||
visible.set(name, definition)
|
||||
}
|
||||
|
||||
@@ -266,6 +266,49 @@ describe('scoped execution dispatch', () => {
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('live-iterates a guard registered by an earlier guard', async () => {
|
||||
const ctx = await mount()
|
||||
const calls: string[] = []
|
||||
let added = false
|
||||
ctx.tools.register(tool('t'))
|
||||
ctx.tools.guard(() => {
|
||||
calls.push('first')
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.tools.guard(() => {
|
||||
calls.push('late')
|
||||
return 'late denial'
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(await run(ctx, 't')).toBe('Error: late denial')
|
||||
expect(calls).toEqual(['first', 'late'])
|
||||
})
|
||||
|
||||
it('defers a scoped guard that replaces the last guard in its generation', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const calls: string[] = []
|
||||
ctx.tools.register(tool('t'))
|
||||
scope.ctx.tools.register(tool('scope_sibling'))
|
||||
const lift = scope.ctx.tools.guard(() => {
|
||||
calls.push('first')
|
||||
lift()
|
||||
scope.ctx.tools.guard(() => {
|
||||
calls.push('replacement')
|
||||
return 'replacement denial'
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(await run(ctx, 't', key)).toBe('ran:t')
|
||||
expect(calls).toEqual(['first'])
|
||||
expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
|
||||
expect(calls).toEqual(['first', 'replacement'])
|
||||
})
|
||||
|
||||
it('shares one token and materialized argument value across the pipeline', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
|
||||
@@ -85,7 +85,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const located = await capture(dir())
|
||||
expect(located.payload.transcript_path).toBe(located.expected)
|
||||
expect((await capture()).payload.transcript_path).toBe('')
|
||||
}, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
|
||||
}, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom.
|
||||
|
||||
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
|
||||
const d = dir()
|
||||
|
||||
@@ -76,7 +76,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const located = await capture(dir())
|
||||
expect(located.payload.transcript_path).toBe(located.expected)
|
||||
expect((await capture()).payload.transcript_path).toBeNull()
|
||||
}, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom.
|
||||
}, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom.
|
||||
|
||||
it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
|
||||
const d = dir()
|
||||
|
||||
@@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
|
||||
## What it does
|
||||
|
||||
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
|
||||
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
|
||||
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
|
||||
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
|
||||
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
|
||||
- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible.
|
||||
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { encodeMessage, MessageDecoder } from './framing.ts'
|
||||
@@ -36,6 +36,132 @@ interface Pending {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one JSON-RPC message to the child stdin.
|
||||
* @param stdin - the spawned server stdin.
|
||||
* @param message - the unencoded JSON-RPC message.
|
||||
* @param done - callback that reports asynchronous stream settlement.
|
||||
*/
|
||||
export type ConnectionWriter = (
|
||||
stdin: Writable,
|
||||
message: unknown,
|
||||
done: (error?: Error | null) => void,
|
||||
) => void
|
||||
|
||||
/** Host operations used to signal a detached process tree. */
|
||||
export interface ProcessTreeOperations {
|
||||
/** Signal a POSIX process group. */
|
||||
readonly signal: (target: number, signal: NodeJS.Signals) => void
|
||||
/** Signal the direct child when POSIX group signaling is unavailable. */
|
||||
readonly killChild: (signal: NodeJS.Signals) => void
|
||||
/** Terminate a Windows process tree by root pid. */
|
||||
readonly taskkill: (pid: number) => void
|
||||
}
|
||||
|
||||
/** Narrow taskkill runner result used by the Windows process-tree adapter. */
|
||||
export interface TaskkillResult {
|
||||
/** Process exit status, or null when spawning failed. */
|
||||
readonly status: number | null
|
||||
/** Spawn failure, when the executable could not run. */
|
||||
readonly error?: Error
|
||||
}
|
||||
|
||||
/** Invoke a command synchronously for the Windows taskkill adapter. */
|
||||
export type TaskkillRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { stdio: 'ignore' },
|
||||
) => TaskkillResult
|
||||
|
||||
/** Invoke the host process-signal primitive for a POSIX process group. */
|
||||
export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
|
||||
|
||||
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
|
||||
|
||||
/** taskkill status for "process not found": the requested process tree is already absent. */
|
||||
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
|
||||
|
||||
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
|
||||
stdin.write(encodeMessage(message), done)
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate one Windows process tree and wait for taskkill to finish.
|
||||
* @param pid - root process id.
|
||||
* @param run - command runner; tests inject results without requiring Windows.
|
||||
*/
|
||||
export function taskkillProcessTree(
|
||||
pid: number,
|
||||
run: TaskkillRunner = spawnSync,
|
||||
): void {
|
||||
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
|
||||
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal one POSIX process group through an injectable host primitive.
|
||||
* @param target - negative process-group id.
|
||||
* @param signal - requested signal.
|
||||
* @param run - host signal runner; tests inject it without touching real processes.
|
||||
*/
|
||||
export function signalProcessGroup(
|
||||
target: number,
|
||||
signal: NodeJS.Signals,
|
||||
run: ProcessSignalRunner = processSignalRunner,
|
||||
): void {
|
||||
run(target, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a process-tree liveness probe reports exit.
|
||||
* @param isAlive - process-tree liveness probe.
|
||||
* @param signal - optional bound for the wait.
|
||||
* @param yieldNow - event-loop yield primitive.
|
||||
* @returns `true` when the tree exited, or `false` when the signal aborted first.
|
||||
*/
|
||||
export async function waitForTreeExit(
|
||||
isAlive: () => boolean,
|
||||
signal?: AbortSignal,
|
||||
yieldNow: () => Promise<unknown> = yieldToEventLoop,
|
||||
): Promise<boolean> {
|
||||
while (isAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await yieldNow()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
|
||||
* child; Windows requires taskkill to reach the full tree.
|
||||
* @param platform - host platform.
|
||||
* @param pid - detached root process id.
|
||||
* @param signal - requested termination signal.
|
||||
* @param operations - host operations.
|
||||
*/
|
||||
export function signalProcessTree(
|
||||
platform: NodeJS.Platform,
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
operations: ProcessTreeOperations,
|
||||
): void {
|
||||
if (platform === 'win32') {
|
||||
operations.taskkill(pid)
|
||||
return
|
||||
}
|
||||
try {
|
||||
operations.signal(-pid, signal)
|
||||
} catch {
|
||||
try {
|
||||
operations.killChild(signal)
|
||||
} catch {
|
||||
// The direct child already exited; teardown remains idempotent.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A live JSON-RPC endpoint bound to one child process. */
|
||||
export class LspConnection {
|
||||
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
|
||||
@@ -50,14 +176,16 @@ export class LspConnection {
|
||||
/**
|
||||
* @param spec - how to launch the server and answer its config requests.
|
||||
* @param onServerRequest - answers a server→client request; rejects to send an error response.
|
||||
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
|
||||
*/
|
||||
constructor(
|
||||
private readonly spec: ConnectionSpec,
|
||||
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
|
||||
private readonly writer: ConnectionWriter = writeConnectionMessage,
|
||||
) {
|
||||
this.decoder = new MessageDecoder(spec.maxMessageBytes)
|
||||
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
|
||||
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
|
||||
// `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
|
||||
// while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
|
||||
this.child = spawn(spec.command, [...spec.args], {
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
@@ -94,6 +222,20 @@ export class LspConnection {
|
||||
return this.stderr.toString('utf8')
|
||||
}
|
||||
|
||||
/** Whether the transport has failed even if the child close event has not arrived yet. */
|
||||
get failed(): boolean {
|
||||
return this.closeReason !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a caught error is this connection's retained fatal transport cause.
|
||||
* @param error - error caught by the instance or provider.
|
||||
* @returns `true` only when this connection produced that exact failure.
|
||||
*/
|
||||
failedWith(error: unknown): boolean {
|
||||
return this.closeReason === error
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and await its result.
|
||||
* @param method - the JSON-RPC method.
|
||||
@@ -147,50 +289,38 @@ export class LspConnection {
|
||||
return this.nextId
|
||||
}
|
||||
|
||||
/** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */
|
||||
/** Request termination of the server's process tree. */
|
||||
terminate(): void {
|
||||
this.signalGroup('SIGTERM')
|
||||
this.signalTree('SIGTERM')
|
||||
}
|
||||
|
||||
/** Send SIGKILL to the server's process group. */
|
||||
/** Force termination of the server's process tree. */
|
||||
kill(): void {
|
||||
this.signalGroup('SIGKILL')
|
||||
this.signalTree('SIGKILL')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the owned process group has no members.
|
||||
* Wait until the owned process tree has exited.
|
||||
* @param signal - optional bound for the wait.
|
||||
* @returns `true` when the group exited, or `false` when the signal aborted first.
|
||||
* @returns `true` when the tree exited, or `false` when the signal aborted first.
|
||||
*/
|
||||
async waitForProcessGroupExit(signal?: AbortSignal): Promise<boolean> {
|
||||
while (this.processGroupAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
return true
|
||||
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
|
||||
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
|
||||
* direct child if the group send fails. Never throws — teardown races process exit.
|
||||
*/
|
||||
private signalGroup(sig: NodeJS.Signals): void {
|
||||
/** Signal the whole process tree. */
|
||||
private signalTree(sig: NodeJS.Signals): void {
|
||||
const pid = this.child.pid
|
||||
if (pid === undefined) return
|
||||
try {
|
||||
process.kill(-pid, sig)
|
||||
} catch {
|
||||
// The group is gone (already exited) or could not be signalled; try the direct child.
|
||||
try {
|
||||
this.child.kill(sig)
|
||||
} catch {
|
||||
// Already dead; nothing to signal.
|
||||
}
|
||||
}
|
||||
signalProcessTree(process.platform, pid, sig, {
|
||||
signal: signalProcessGroup,
|
||||
killChild: this.child.kill.bind(this.child),
|
||||
taskkill: taskkillProcessTree,
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the detached process group still has at least one member. */
|
||||
private processGroupAlive(): boolean {
|
||||
/** Whether the detached tree's root or POSIX process group is still alive. */
|
||||
private processTreeAlive(): boolean {
|
||||
const pid = this.child.pid
|
||||
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
|
||||
if (pid === undefined) return false
|
||||
@@ -218,7 +348,7 @@ export class LspConnection {
|
||||
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
|
||||
// SIGKILL the whole group so helper processes don't outlive the leader.
|
||||
this.fail(asError(error))
|
||||
this.signalGroup('SIGKILL')
|
||||
this.signalTree('SIGKILL')
|
||||
return
|
||||
}
|
||||
for (const message of messages) this.dispatch(message)
|
||||
@@ -293,7 +423,7 @@ export class LspConnection {
|
||||
reject(error)
|
||||
}
|
||||
try {
|
||||
this.child.stdin.write(encodeMessage(message), done)
|
||||
this.writer(this.child.stdin, message, done)
|
||||
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
|
||||
nonconforming Writable implementation throwing synchronously. */
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
|
||||
* of server commands and registers one isolated provider for each entry. Every provider lazily
|
||||
* single-flights one server process per canonical workspace realpath, serves transient-open queries
|
||||
* through it, and evicts a crashed process so a later query can replace it. Providers read sources
|
||||
* through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no
|
||||
* sandbox confinement.
|
||||
* through it, and replaces a selected transport that fails before or during the next read-only
|
||||
* query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
|
||||
* and trust their configured servers — no sandbox confinement.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
|
||||
* unregisters from `ctx.lsp` and tears down every live server.
|
||||
@@ -221,15 +221,23 @@ class LocalLspProvider implements LspProvider {
|
||||
// synchronous get-or-create so every spawned process remains owned by teardown.
|
||||
this.assertActive(signal)
|
||||
let instance = this.instanceFor(workspace)
|
||||
if (instance.dead) {
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
instance = this.instanceFor(workspace)
|
||||
}
|
||||
try {
|
||||
return await instance.query(request, source, signal)
|
||||
} catch (error) {
|
||||
// A selected child can have died while idle or fail during the next write. Queries are
|
||||
// read-only, so replace that transport once and retry transparently.
|
||||
if (!instance.isTransportFailure(error)) throw error
|
||||
await instance.dispose()
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
this.assertActive(signal)
|
||||
instance = this.instanceFor(workspace)
|
||||
return await instance.query(request, source, signal)
|
||||
} finally {
|
||||
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
|
||||
if (instance.dead) this.evictIfCurrent(workspace, instance)
|
||||
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
|
||||
if (instance.dead) {
|
||||
await instance.dispose()
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { abortable, abortError } from './abort.ts'
|
||||
import { LspConnection } from './connection.ts'
|
||||
import type { ConnectionSpec } from './connection.ts'
|
||||
import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
|
||||
import type { HostSource } from './host.ts'
|
||||
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
|
||||
import {
|
||||
@@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec {
|
||||
readonly killGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill a process tree only when graceful termination did not make it exit.
|
||||
* @param treeExited - whether the tree exited within its grace period.
|
||||
* @param forceKill - forceful process-tree termination primitive.
|
||||
*/
|
||||
export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void {
|
||||
if (!treeExited) forceKill()
|
||||
}
|
||||
|
||||
/**
|
||||
* A single initialized server process. Not exported as a provider — the provider single-flights and
|
||||
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
|
||||
@@ -58,9 +67,10 @@ export class LspInstance {
|
||||
|
||||
/**
|
||||
* @param spec - the launch, initialize, and teardown parameters.
|
||||
* @param writer - optional connection writer used by transport conformance tests.
|
||||
*/
|
||||
constructor(private readonly spec: InstanceSpec) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
|
||||
constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
|
||||
this.ready = this.initialize()
|
||||
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
|
||||
// it; queries attach the real handler.
|
||||
@@ -70,7 +80,16 @@ export class LspInstance {
|
||||
|
||||
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
|
||||
get dead(): boolean {
|
||||
return this.processClosed || this.disposed
|
||||
return this.processClosed || this.disposed || this.connection.failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a caught query error came from this instance's transport.
|
||||
* @param error - error caught by the provider.
|
||||
* @returns `true` only for the connection's retained fatal transport cause.
|
||||
*/
|
||||
isTransportFailure(error: unknown): boolean {
|
||||
return this.connection.failedWith(error)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +103,12 @@ export class LspInstance {
|
||||
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
|
||||
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
|
||||
// rather than block on the shared tail forever.
|
||||
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
|
||||
const run = abortable(this.queue, signal)
|
||||
.then(() => this.runQuery(request, source, signal))
|
||||
.catch(async (error: unknown) => {
|
||||
if (this.isTransportFailure(error)) await this.startTeardown()
|
||||
throw error
|
||||
})
|
||||
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
|
||||
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
|
||||
// on the wait does not deserialize the queue.
|
||||
@@ -272,7 +296,7 @@ export class LspInstance {
|
||||
try {
|
||||
await this.gracefulShutdown(shutdownDeadline.signal)
|
||||
} catch {
|
||||
// Graceful shutdown failed or timed out; process-group cleanup below remains authoritative.
|
||||
// Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
|
||||
} finally {
|
||||
shutdownDeadline[Symbol.dispose]()
|
||||
}
|
||||
@@ -286,20 +310,20 @@ export class LspInstance {
|
||||
await abortable(this.connection.closed, signal)
|
||||
}
|
||||
|
||||
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
|
||||
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
|
||||
private async forceTerminate(): Promise<void> {
|
||||
this.connection.terminate()
|
||||
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
|
||||
let groupExited: boolean
|
||||
let treeExited: boolean
|
||||
try {
|
||||
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal)
|
||||
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
|
||||
} finally {
|
||||
graceDeadline[Symbol.dispose]()
|
||||
}
|
||||
if (!groupExited) this.connection.kill()
|
||||
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
|
||||
await Promise.all([
|
||||
this.connection.closed,
|
||||
this.connection.waitForProcessGroupExit(),
|
||||
this.connection.waitForProcessTreeExit(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
|
||||
import {
|
||||
signalProcessGroup,
|
||||
signalProcessTree,
|
||||
taskkillProcessTree,
|
||||
waitForTreeExit,
|
||||
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
import type {
|
||||
ConnectionWriter,
|
||||
ProcessSignalRunner,
|
||||
ProcessTreeOperations,
|
||||
TaskkillRunner,
|
||||
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
@@ -53,6 +65,12 @@ describe('LspConnection', () => {
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
|
||||
})
|
||||
|
||||
it('treats signaling an already-closed child as a teardown race', async () => {
|
||||
const conn = connectScript('')
|
||||
await conn.closed
|
||||
expect(() => { conn.kill() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('answers a server workspace/configuration request from static config', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
@@ -125,7 +143,7 @@ describe('LspConnection', () => {
|
||||
})
|
||||
|
||||
/** Spawn a raw connection running an inline node script as the "server". */
|
||||
function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
|
||||
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
@@ -134,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes,
|
||||
configuration: null,
|
||||
}, () => Promise.resolve(null))
|
||||
}, () => Promise.resolve(null), writer)
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
@@ -209,13 +227,13 @@ describe('LspConnection edge behavior', () => {
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
|
||||
const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
|
||||
})
|
||||
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
|
||||
it('rejects a pending request when child stdin fails but the process stays alive', async () => {
|
||||
const failure = new Error('fixture stdin failure')
|
||||
const writer: ConnectionWriter = (_stdin, _message, done) => {
|
||||
queueMicrotask(() => { done(failure) })
|
||||
}
|
||||
const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/)
|
||||
})
|
||||
|
||||
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
|
||||
@@ -230,6 +248,72 @@ describe('LspConnection edge behavior', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('process-tree signaling', () => {
|
||||
it('forwards POSIX process-group signals through the host runner', () => {
|
||||
const run: ProcessSignalRunner = vi.fn(() => true)
|
||||
signalProcessGroup(-42, 'SIGKILL', run)
|
||||
expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('waits for tree exit and stops when its bound aborts', async () => {
|
||||
const isAlive = vi.fn()
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValue(false)
|
||||
const yieldNow = vi.fn(() => Promise.resolve())
|
||||
await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
|
||||
expect(yieldNow).toHaveBeenCalledOnce()
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
|
||||
const operations = fakeProcessTreeOperations()
|
||||
signalProcessTree('win32', 42, 'SIGTERM', operations)
|
||||
expect(operations.taskkill).toHaveBeenCalledWith(42)
|
||||
expect(operations.signal).not.toHaveBeenCalled()
|
||||
|
||||
signalProcessTree('linux', 42, 'SIGKILL', operations)
|
||||
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
|
||||
const fallback = fakeProcessTreeOperations()
|
||||
vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
|
||||
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
|
||||
expect(fallback.killChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
|
||||
const posixGone = fakeProcessTreeOperations()
|
||||
vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
|
||||
vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
|
||||
expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
|
||||
const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
|
||||
taskkillProcessTree(42, success)
|
||||
expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
|
||||
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
|
||||
|
||||
const spawnFailure = new Error('cannot spawn taskkill')
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
|
||||
})
|
||||
})
|
||||
|
||||
/** Create observable process-tree operations without touching host processes. */
|
||||
function fakeProcessTreeOperations(): ProcessTreeOperations {
|
||||
return {
|
||||
signal: vi.fn(),
|
||||
killChild: vi.fn(),
|
||||
taskkill: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll a predicate until it holds or a deadline elapses. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now()
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
|
||||
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
|
||||
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
@@ -28,7 +26,7 @@
|
||||
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
|
||||
*/
|
||||
|
||||
import { appendFileSync, closeSync } from 'node:fs'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
|
||||
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
|
||||
@@ -40,8 +38,6 @@ const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
|
||||
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
|
||||
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
|
||||
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
|
||||
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
|
||||
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
|
||||
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
|
||||
@@ -146,14 +142,12 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
if (method === 'initialized') {
|
||||
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
|
||||
if (pauseStdinAfterInitialized) process.stdin.pause()
|
||||
if (closeStdinAfterInitialized) closeSync(0)
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
const reply = (): void => {
|
||||
if (closeStdinAfterReply) closeSync(0)
|
||||
if (errorReply) {
|
||||
send({ id, error: { code: -32000, message: 'server refused the request' } })
|
||||
} else {
|
||||
@@ -202,6 +196,6 @@ function send(message: Record<string, unknown>): void {
|
||||
|
||||
// Keep the event loop alive.
|
||||
process.stdin.resume()
|
||||
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
|
||||
if (pauseStdinAfterInitialized) {
|
||||
setInterval(() => {}, 1000)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ describe('readHostSource', () => {
|
||||
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
|
||||
it.skipIf(process.platform === 'win32')('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
const fifo = join(ws, 'pipe.ts')
|
||||
await execFileAsync('mkfifo', [fifo])
|
||||
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
@@ -26,7 +29,11 @@ afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
function makeInstance(
|
||||
env: Record<string, string> = {},
|
||||
overrides: Partial<InstanceSpec> = {},
|
||||
writer?: ConnectionWriter,
|
||||
): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
@@ -39,7 +46,7 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
...overrides,
|
||||
})
|
||||
}, writer)
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
@@ -201,17 +208,25 @@ describe('LspInstance query and abort', () => {
|
||||
})
|
||||
|
||||
it('terminates when stdin fails during the didOpen write', async () => {
|
||||
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
|
||||
// the instance must still become dead so its provider can replace it.
|
||||
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
||||
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
})
|
||||
}, failingWriter('textDocument/didOpen'))
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('awaits process exit before rejecting a request write failure', async () => {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
}, failingWriter('textDocument/definition'))
|
||||
// The pid is observed only to prove the owned subprocess reached quiescence before rejection.
|
||||
const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
|
||||
expect(processAlive(pid)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
|
||||
@@ -228,8 +243,7 @@ describe('LspInstance query and abort', () => {
|
||||
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
|
||||
kind: 'locations',
|
||||
locations: [],
|
||||
@@ -240,6 +254,14 @@ describe('LspInstance query and abort', () => {
|
||||
})
|
||||
|
||||
describe('LspInstance disposal', () => {
|
||||
it('escalates only when the process tree survives its grace period', () => {
|
||||
const forceKill = vi.fn()
|
||||
escalateProcessTree(false, forceKill)
|
||||
expect(forceKill).toHaveBeenCalledOnce()
|
||||
escalateProcessTree(true, forceKill)
|
||||
expect(forceKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a server finish protocol exit before signal escalation', async () => {
|
||||
const marker = join(root, 'graceful-exit.log')
|
||||
const instance = makeInstance({
|
||||
@@ -281,7 +303,7 @@ describe('LspInstance disposal', () => {
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits a surviving process-group helper on every concurrent dispose', async () => {
|
||||
it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
|
||||
const marker = join(root, 'helper.pid')
|
||||
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
|
||||
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
|
||||
@@ -298,6 +320,7 @@ describe('LspInstance disposal', () => {
|
||||
await first
|
||||
} finally {
|
||||
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
|
||||
await waitForProcessExit(helperPid)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -322,6 +345,26 @@ function processAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */
|
||||
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
|
||||
const started = Date.now()
|
||||
while (processAlive(pid)) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Write normally except for one method whose callback receives a deterministic transport error. */
|
||||
function failingWriter(method: string): ConnectionWriter {
|
||||
return (stdin, message, done) => {
|
||||
if ((message as { method?: unknown }).method === method) {
|
||||
queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
|
||||
return
|
||||
}
|
||||
stdin.write(encodeMessage(message), done)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
|
||||
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
|
||||
@@ -122,9 +122,15 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a non-utf-16 position encoding at initialize', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
|
||||
const marker = join(root, 'initialize-rejection-exit.log')
|
||||
const ctx = await mount({
|
||||
LSP_FAKE_ENCODING: 'utf-8',
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_EXIT_MARKER: marker,
|
||||
})
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
@@ -57,7 +57,7 @@ describe('lsp-local provider resolution', () => {
|
||||
await expect(ctx.plugin(LspLocal, config('nope', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: `::${join(root, 'empty')}` },
|
||||
env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -116,7 +116,8 @@ describe('lsp-local provider resolution', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an absolute command that is not executable at load', async () => {
|
||||
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
|
||||
it.skipIf(process.platform === 'win32')('rejects an absolute command that is not executable at load', async () => {
|
||||
const notExe = join(root, 'not-exe.txt')
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-tool-lsp'
|
||||
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const WS = '/home/u/proj'
|
||||
const WS = resolve('/home/u/proj')
|
||||
|
||||
function loc(uri: string, line: number, character = 0): LspLocation {
|
||||
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
|
||||
@@ -52,8 +52,9 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('returns an absolute path for a file: URI outside the workspace', () => {
|
||||
const uri = pathToFileURL('/other/lib/b.ts').href
|
||||
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
|
||||
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
|
||||
const uri = pathToFileURL(outside).href
|
||||
expect(renderUri(uri, WS)).toBe(outside)
|
||||
})
|
||||
|
||||
it('renders the workspace root itself as "."', () => {
|
||||
@@ -72,8 +73,8 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
|
||||
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
|
||||
// An encoded path separator is invalid on every platform and must remain verbatim.
|
||||
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -40,8 +42,11 @@ async function mount(
|
||||
|
||||
let seq = 0
|
||||
const testToolSignal = new AbortController().signal
|
||||
const workspaceRoot = resolve('/virtual/workspace')
|
||||
const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
|
||||
const workspaceAlias = resolve('/virtual/workspace-alias')
|
||||
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: `c-${++seq}` as never,
|
||||
@@ -53,8 +58,8 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
|
||||
const okLocations: LspQueryResult = {
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/ws',
|
||||
locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: workspaceRoot,
|
||||
}
|
||||
|
||||
describe('tool-lsp registration', () => {
|
||||
@@ -107,40 +112,39 @@ describe('tool-lsp execution', () => {
|
||||
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
|
||||
const provider = stubProvider(() => okLocations)
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, workspaceRoot)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(provider.seen[0]).toMatchObject({
|
||||
operation: 'goToDefinition',
|
||||
filePath: 'a.ts',
|
||||
position: { line: 2, character: 4 },
|
||||
workspaceRoot: '/ws',
|
||||
workspaceRoot,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders locations relative to the workspace', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
|
||||
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
|
||||
// location URIs are under. Relativizing against the alias would misclassify the location as
|
||||
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
|
||||
// A symlinked session cwd resolves to the real path that contains the provider's location URIs.
|
||||
// Relativizing against the alias would misclassify the location as external.
|
||||
const provider = stubProvider(() => ({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/real/ws',
|
||||
locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot,
|
||||
}))
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('renders hover content', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
|
||||
})
|
||||
|
||||
@@ -153,14 +157,14 @@ describe('tool-lsp execution', () => {
|
||||
|
||||
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('returns a structured INVALID_ARGS on a bad operation', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
@@ -176,7 +180,7 @@ describe('tool-lsp execution', () => {
|
||||
},
|
||||
}
|
||||
const { ctx } = await mount(provider)
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
|
||||
// undefined); the point is the tool threads it through without throwing.
|
||||
expect(seen).toHaveLength(1)
|
||||
|
||||
@@ -25,7 +25,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
describe('globalConfigDir', () => {
|
||||
it('prefers an explicit DSH_HOME override', () => {
|
||||
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
|
||||
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe(resolve('/custom/dsh'))
|
||||
})
|
||||
|
||||
it('falls back to ~/.dsh when DSH_HOME is unset', () => {
|
||||
|
||||
@@ -215,7 +215,8 @@ describe('cwd resolution', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a config cwd directory without search permission at load', async () => {
|
||||
// Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
|
||||
it.skipIf(process.platform === 'win32')('rejects a config cwd directory without search permission at load', async () => {
|
||||
// statSync().isDirectory() is true for a mode-600 directory, but a
|
||||
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))
|
||||
|
||||
@@ -24,7 +24,7 @@ export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
|
||||
/** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */
|
||||
export type ExampleMode = 'src' | 'lib'
|
||||
|
||||
/** Environment variable selecting the mode; CI and pre-push set it to `lib`, dev leaves it unset (`src`). */
|
||||
/** Environment variable selecting the mode; CI sets it to `lib`, dev leaves it unset (`src`). */
|
||||
export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE'
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
export const name = 'commands'
|
||||
|
||||
@@ -68,6 +68,26 @@ interface RegisteredCommand {
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
/** All command registrations owned by one global or scoped layer. */
|
||||
class CommandLayer implements ScopeLayer {
|
||||
readonly commands: NamedEntries<RegisteredCommand>
|
||||
|
||||
/**
|
||||
* Create one command layer with diagnostics specific to its ownership scope.
|
||||
* @param scope - the scoped owner, or `undefined` for global registrations.
|
||||
*/
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.commands = new NamedEntries(name => new Error(scope === undefined
|
||||
? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** @returns whether this layer owns no command registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.commands.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
@@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult {
|
||||
* globals for that agent.
|
||||
*/
|
||||
export class CommandService extends Service {
|
||||
private readonly global = new Map<string, RegisteredCommand>()
|
||||
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new CommandLayer(scope),
|
||||
() => { this.notifyChange() },
|
||||
)
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
@@ -218,25 +240,12 @@ export class CommandService extends Service {
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registered = normalizeDefinition(definition)
|
||||
const dispose = this.ctx.effect(function* (this: CommandService) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(registered.definition.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${registered.definition.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(registered.definition.name, registered)
|
||||
yield () => {
|
||||
layer.delete(registered.definition.name)
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.notifyChange()
|
||||
}
|
||||
this.notifyChange()
|
||||
}.bind(this), 'commands.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.commands.insert(registered.definition.name, registered),
|
||||
{ label: 'commands.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,19 +294,7 @@ export class CommandService extends Service {
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
private view(agent: Agent): Map<string, RegisteredCommand> {
|
||||
const visible = new Map(this.global)
|
||||
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
|
||||
return visible
|
||||
}
|
||||
|
||||
/** Create the registration layer for one agent scope on demand. */
|
||||
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
return this.layers.merge(agent, layer => layer.commands)
|
||||
}
|
||||
|
||||
/** Notify every registry observer without making UI refresh load-bearing. */
|
||||
|
||||
@@ -94,6 +94,19 @@ describe('CommandService', () => {
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
})
|
||||
|
||||
it('removes a registration when its contributing plugin fiber is disposed', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.commands.register(command('temporary'))
|
||||
}, { inject: ['commands'] }))
|
||||
expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
|
||||
@@ -320,7 +320,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
{ inputTokens: 500, outputTokens: 8 },
|
||||
{ turn: 3, step: 1 },
|
||||
)
|
||||
await tick()
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('final live answer')
|
||||
})
|
||||
|
||||
expect(result.terminal.output).toContain('◒ Working · 8s')
|
||||
expect(result.terminal.output).toContain('esc interrupt')
|
||||
@@ -328,7 +330,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('user context')
|
||||
expect(result.terminal.output).toContain('Prompt blocked')
|
||||
expect(result.terminal.output).toContain('Turn cancelled')
|
||||
expect(result.terminal.output).toContain('final live answer')
|
||||
expect(result.terminal.progress).toContain(true)
|
||||
|
||||
result.session.append('assistant/chunk', {
|
||||
|
||||
@@ -9,17 +9,15 @@ import {
|
||||
} from '@deepseek-ai/dsh-web-search-deepseek'
|
||||
|
||||
/**
|
||||
* Real-API smoke for the DeepSeek search provider. Self-skips without
|
||||
* `$DEEPSEEK_API_KEY`, per the with-key e2e policy in docs/testing.md. This
|
||||
* is the only test that proves DeepSeek's Anthropic-compatible endpoint actually
|
||||
* triggers native `web_search` and returns the structured result blocks the
|
||||
* provider parses — a mock cannot confirm the wire shape is real.
|
||||
* Disabled real-API probe for the DeepSeek search provider. The live endpoint
|
||||
* can complete without structured source blocks, so this is not a reliable
|
||||
* merge signal. Its body remains because mocks cannot confirm the wire shape.
|
||||
*/
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip
|
||||
|
||||
maybe('DeepSeekSearchProvider real API', () => {
|
||||
it('returns citeable sources for a live query via native web_search', async () => {
|
||||
it.skip('returns citeable sources for a live query via native web_search', async () => {
|
||||
const provider = new DeepSeekSearchProvider({
|
||||
apiKey: apiKey!,
|
||||
baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL,
|
||||
|
||||
@@ -17,7 +17,6 @@ type Mode =
|
||||
| 'ci-snapshot'
|
||||
| 'ci-artifacts'
|
||||
| 'node-compat'
|
||||
| 'pre-push'
|
||||
| 'doc-sync'
|
||||
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
|
||||
@@ -87,21 +86,20 @@ function parseMode(raw: string | undefined): Mode {
|
||||
case 'ci-snapshot':
|
||||
case 'ci-artifacts':
|
||||
case 'node-compat':
|
||||
case 'pre-push':
|
||||
case 'doc-sync':
|
||||
return raw
|
||||
default:
|
||||
throw new Error(
|
||||
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | doc-sync, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
|
||||
const available = availableParallelism()
|
||||
// Local modes cap workers: several doc gates each build a full ts.Program,
|
||||
// The local doc mode caps workers: several gates each build a full ts.Program,
|
||||
// so an uncapped default on a large host trades wall clock for memory blowups.
|
||||
const localCap = selectedMode === 'pre-push' || selectedMode === 'doc-sync'
|
||||
const localCap = selectedMode === 'doc-sync'
|
||||
const modeLimit = localCap ? Math.min(4, available) : available
|
||||
return {
|
||||
workers: Math.min(total, modeLimit),
|
||||
@@ -191,21 +189,6 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
]
|
||||
case 'pre-push':
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
snapshotGate(),
|
||||
pnpmScript('build', 'build'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
...docSyncLeafGates({
|
||||
docTypecheckNeeds: ['build'],
|
||||
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
||||
}),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
]
|
||||
case 'doc-sync':
|
||||
return docSyncLeafGates()
|
||||
}
|
||||
@@ -295,8 +278,8 @@ function coverageGate(): Gate {
|
||||
}
|
||||
|
||||
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node,
|
||||
// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather
|
||||
// than the tsx/source path dev uses. It therefore waits on `build`.
|
||||
// plugins via real exports). CI pairs it with `build`, so it exercises what ships rather than
|
||||
// the tsx/source path dev uses and therefore waits on `build`.
|
||||
function snapshotGate(): Gate {
|
||||
return pnpmScript('snapshot', 'test:snapshot', {
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
@@ -321,30 +304,9 @@ function positiveIntArg(envName: string, flag: string): string[] {
|
||||
return [`${flag}=${raw}`]
|
||||
}
|
||||
|
||||
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
||||
function docSyncLeafGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
||||
builtPackageInvariantsGate(options.artifactNeeds),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function docSyncLeafGates(options: {
|
||||
docTypecheckNeeds?: string[]
|
||||
docTypecheckEnv?: Record<string, string | undefined>
|
||||
} = {}): Gate[] {
|
||||
const docTypecheckOptions: Partial<Gate> = {}
|
||||
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
|
||||
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
|
||||
return [
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck'),
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }),
|
||||
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeLayer", "source": "packages/core/scope/src/store.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" },
|
||||
|
||||
Reference in New Issue
Block a user