mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): converge search runtime boundaries (round 8)
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 .agents/notes/implemented/feature/2026-07-27-web-session-search.md
|
||||
2026-07-27-web-session-search.md: 2e82c9cb0d453ce6e863f8c436cb793979d17a64
|
||||
2026-07-27-web-session-search.zh.md: 010b29f80098bce518354992f23cd499e63e0b04
|
||||
2026-07-27-web-session-search.md: 8992fdf046c1256ab61278cf5189ba56df8b4ecd
|
||||
2026-07-27-web-session-search.zh.md: 764e9f3363ae321c55e401cc52b35dcba790a0b4
|
||||
|
||||
@@ -10,9 +10,9 @@ The Web sidebar exposes session titles and Workspace membership but cannot retri
|
||||
|
||||
## Decision
|
||||
|
||||
The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with an in-memory database. Each service instance owns one connection-private index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty; the first content query of each invocation lazily reconciles live and persisted sessions. It remains a disposable derived index, separate from canonical JSONL persistence.
|
||||
The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence.
|
||||
|
||||
The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The Host makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider page. A missing query service or an indexing/query failure remains a business error and does not mutate the canonical session store.
|
||||
The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches in pages capped at 20 hits, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. Every hit's session id, best-match session id, surface, event type, and snippet type are revalidated before its snippet leaves the Host, and emitted snippets contain at most 240 Unicode code points. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Those retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store.
|
||||
|
||||
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event.
|
||||
|
||||
@@ -35,8 +35,8 @@ While the first or a later content request is pending, the UI keeps immediate me
|
||||
|
||||
Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search.
|
||||
|
||||
The first content query can take longer because it pays lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective provider stream that has not produced enough authorized results within its first 2,000 hits takes the metadata-only failure path instead of consuming unbounded work.
|
||||
The first content query can take longer because it imports and opens SQLite before paying lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective or repeatedly stale provider attempt that does not complete within 100 calls takes the metadata-only failure path instead of consuming unbounded work.
|
||||
|
||||
## Testing
|
||||
|
||||
Host tests pin request validation, visible-session filtering, event/surface filters, result bounds, provider page and item budgets, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains.
|
||||
Host tests pin request validation, visible-session filtering, event/surface filters, result and snippet bounds, the shared provider-call budget, stale-generation restarts, cursor and cross-page deduplication behavior, continuation-page cancellation, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; a Node 22 compatibility subprocess pins warning-free mount and disposal before the first search. Runtime and UI tests pin stateless delegation, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, row rendering, and navigation semantics. A keyless assembled Web test seeds an unopened persisted conversation, finds it by message content through the lazy SQLite index, captures the sidebar result, opens it, and verifies that the query remains.
|
||||
|
||||
@@ -10,9 +10,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只
|
||||
|
||||
## 决策
|
||||
|
||||
Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。每个服务实例都独占一个连接私有索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动;每次调用的首次内容查询会惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。
|
||||
Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。
|
||||
|
||||
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一页提供方搜索。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。
|
||||
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项(每页最多 20 个命中),并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。每个命中的会话 id、最佳匹配会话 id、surface、事件类型和 snippet 类型都会经过重新校验,其 snippet 才能离开宿主,且发出的 snippet 最多包含 240 个 Unicode 码点。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。这些重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。
|
||||
|
||||
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
|
||||
|
||||
@@ -35,8 +35,8 @@ Web 与 headless 共用的组合会使用内存数据库挂载 [`@deepseek-ai/ds
|
||||
|
||||
无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。
|
||||
|
||||
首次内容查询可能耗时更长,因为它要承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差的提供方结果流在前 2,000 个命中内仍未产生足够多的已授权结果,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。
|
||||
首次内容查询可能耗时更长,因为它要先导入并打开 SQLite,再承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差或反复陈旧的提供方尝试未能在 100 次调用内完成,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。
|
||||
|
||||
## 测试
|
||||
|
||||
宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果边界、提供方页数和单页命中数预算、游标与跨页去重行为、后续页取消及故障映射固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。
|
||||
宿主测试将请求校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享提供方调用预算、陈旧世代重启、游标与跨页去重行为、后续页取消及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;一个 Node 22 兼容性子进程将首次搜索前无警告挂载与处置固定为契约。运行时与 UI 测试将无状态委托、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会播种一段尚未打开的持久化对话,通过惰性 SQLite 索引按消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。
|
||||
|
||||
@@ -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 apps/cli/README.md
|
||||
README.md: 0c4ff8d36a89e234512f42d130774fe3717968b9
|
||||
README.zh.md: 7e116ecb32061060816f27279a5d3b555a584f6b
|
||||
README.md: c3a3cbbdd578b0705a7e6c6d62c52dcd9cf6fa60
|
||||
README.zh.md: b755a82917e9472b6e788667a4f3387abce3397b
|
||||
|
||||
@@ -14,7 +14,7 @@ The TUI surface:
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
|
||||
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content index. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind. The index starts empty and lazily reconciles live and persisted logs on the first session search of each invocation. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
|
||||
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ TUI 界面:
|
||||
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
|
||||
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
|
||||
|
||||
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件。该索引从空状态启动,并在每次调用的首次会话搜索时惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
|
||||
|
||||
|
||||
@@ -89,13 +89,14 @@
|
||||
config:
|
||||
root: './.sessions'
|
||||
|
||||
# Lazy, service-owned content index for session.search. The in-memory database
|
||||
# cannot be shared across processes or leak derived files across invocations;
|
||||
# the first search reconciles changed live/persisted sessions for this boot.
|
||||
# The service activates at boot, while first-search defers the node:sqlite
|
||||
# import and in-memory handle so Node 22 startup stays quiet until content
|
||||
# search actually uses SQLite. That search then reconciles this boot's sources.
|
||||
- id: session-query-sqlite
|
||||
name: '@deepseek-ai/dsh-session-query-sqlite'
|
||||
config:
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
|
||||
- id: storage
|
||||
name: '@deepseek-ai/dsh-storage'
|
||||
|
||||
@@ -1057,6 +1057,8 @@ export interface Config extends SessionQueryConfig {
|
||||
* POSIX filesystems; existing modes are preserved.
|
||||
*/
|
||||
path: string
|
||||
/** Open the SQLite module and handle at service activation or the first search. Defaults to `startup`. */
|
||||
openAt?: OpenAt
|
||||
/** SQLite journal mode. Defaults to `wal`. */
|
||||
journalMode?: JournalMode
|
||||
/** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */
|
||||
@@ -1069,13 +1071,16 @@ export interface Config extends SessionQueryConfig {
|
||||
persistedInspectConcurrency?: number
|
||||
}
|
||||
|
||||
/** SQLite module/handle opening phase. */
|
||||
export type OpenAt = 'startup' | 'first-search'
|
||||
|
||||
/** Supported SQLite journal modes. */
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
```
|
||||
|
||||
Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
|
||||
|
||||
Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts)
|
||||
Source: [`packages/session-query/session-query-sqlite/src/index.ts:79`](../packages/session-query/session-query-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-reference`
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 2f062ba9b927ab62518523731d39fd7c52e07c8d
|
||||
README.zh.md: 72ff415f793b4bdb2068a8240ea42baab79984dc
|
||||
README.md: e61de41a14294b8c1601e5be8cab19fdf780916d
|
||||
README.zh.md: 7e49ad49aaa9356412f90b37237b06ce65776e1c
|
||||
|
||||
@@ -14,7 +14,9 @@ The mux stream projects the latest log-backed title as a validated `session/titl
|
||||
|
||||
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
||||
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches in pages capped at 20 hits, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. It makes at most 100 provider calls (2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that budget fails closed as an `internal` business error. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. The carrier request signal cancels persistence listing, cold-summary collection, and every search page. A deployment without the service, or a failed index/query operation, also returns an `internal` business error so clients can retain metadata-only matches.
|
||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches in pages capped at 20 hits, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Returned snippets contain at most 240 Unicode code points; a malformed non-string provider snippet fails closed instead of crossing the RPC boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||
|
||||
A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot. Stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); an oversized page, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
|
||||
|
||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
||||
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,每页至多 20 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。宿主最多调用提供方 100 次(检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一页搜索。部署若未挂载该服务,或索引/查询操作失败,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
|
||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,每页至多 20 个命中,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。返回的 snippet 最多包含 240 个 Unicode 码点;如果提供方返回格式错误的非字符串 snippet,系统会直接失败,而不会让它越过 RPC 边界。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||
|
||||
陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始。陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果单页命中数超限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
|
||||
@@ -41,8 +41,11 @@ const DEFAULT_MAX_MESSAGES = 50
|
||||
/** Product contract: sidebar search returns one bounded page and no cursor. */
|
||||
const SESSION_SEARCH_LIMIT = 20
|
||||
|
||||
/** Provider work budget: at most 100 pages × 20 hits = 2,000 inspected hits. */
|
||||
const SESSION_SEARCH_PROVIDER_PAGE_LIMIT = 100
|
||||
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
|
||||
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
|
||||
/** Product contract: snippets contain at most 240 Unicode code points. */
|
||||
const SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT = 240
|
||||
|
||||
/** Bound cold-log stat fan-out so an aborted search stops launching new work. */
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
@@ -55,6 +58,28 @@ function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
|
||||
/** Copy at most the product-visible code-point prefix without splitting a surrogate pair. */
|
||||
function boundedSessionSearchSnippet(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('session search provider returned a non-string snippet')
|
||||
}
|
||||
let end = 0
|
||||
for (
|
||||
let count = 0;
|
||||
count < SESSION_SEARCH_SNIPPET_CODE_POINT_LIMIT && end < value.length;
|
||||
count++
|
||||
) {
|
||||
const first = value.charCodeAt(end)
|
||||
const hasSurrogatePair = first >= 0xD800
|
||||
&& first <= 0xDBFF
|
||||
&& end + 1 < value.length
|
||||
&& value.charCodeAt(end + 1) >= 0xDC00
|
||||
&& value.charCodeAt(end + 1) <= 0xDFFF
|
||||
end += hasSurrogatePair ? 2 : 1
|
||||
}
|
||||
return end === value.length ? value : value.slice(0, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
@@ -648,24 +673,42 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const acceptedIds = new Set<SessionId>()
|
||||
const seenCursors = new Set<SessionSearchCursor>()
|
||||
let cursor: SessionSearchCursor | undefined
|
||||
let providerPageCount = 0
|
||||
let providerCallCount = 0
|
||||
while (authorized.length <= SESSION_SEARCH_LIMIT) {
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (providerPageCount >= SESSION_SEARCH_PROVIDER_PAGE_LIMIT) {
|
||||
if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) {
|
||||
throw new Error(
|
||||
`session search provider exceeded the ${SESSION_SEARCH_PROVIDER_PAGE_LIMIT}-page work budget`,
|
||||
`session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`,
|
||||
)
|
||||
}
|
||||
providerPageCount++
|
||||
const page = await sessionQuery.searchSessions({
|
||||
query: request.payload.query,
|
||||
eventFilters: [
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: SESSION_SEARCH_LIMIT,
|
||||
...cursor === undefined ? {} : { cursor },
|
||||
}, { signal })
|
||||
providerCallCount++
|
||||
const requestedCursor = cursor
|
||||
let page
|
||||
try {
|
||||
page = await sessionQuery.searchSessions({
|
||||
query: request.payload.query,
|
||||
eventFilters: [
|
||||
{ kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
|
||||
{ kind: 'surface', values: ['current'] },
|
||||
],
|
||||
limit: SESSION_SEARCH_LIMIT,
|
||||
...requestedCursor === undefined ? {} : { cursor: requestedCursor },
|
||||
}, { signal })
|
||||
} catch (error: unknown) {
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (
|
||||
requestedCursor !== undefined
|
||||
&& error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_STALE_CURSOR'
|
||||
) {
|
||||
authorized.length = 0
|
||||
acceptedIds.clear()
|
||||
seenCursors.clear()
|
||||
cursor = undefined
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (isAborted(signal)) return cancelled()
|
||||
const providerItemCount = page.items.length
|
||||
if (providerItemCount > SESSION_SEARCH_LIMIT) {
|
||||
@@ -691,10 +734,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|
||||
|| acceptedIds.has(hit.header.id)
|
||||
) continue
|
||||
const snippet = boundedSessionSearchSnippet(hit.bestMatch.snippet)
|
||||
acceptedIds.add(hit.header.id)
|
||||
authorized.push({
|
||||
sessionId: hit.header.id,
|
||||
snippet: hit.bestMatch.snippet,
|
||||
snippet,
|
||||
})
|
||||
}
|
||||
const nextCursor = page.nextCursor
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('session.search', () => {
|
||||
expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' })
|
||||
})
|
||||
|
||||
it('fails closed after 100 provider pages with distinct continuation cursors', async () => {
|
||||
it('fails closed after 100 provider calls with distinct continuation cursors', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
let pageNumber = 0
|
||||
@@ -247,10 +247,153 @@ describe('session.search', () => {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.result.error.message).toContain('100-page work budget')
|
||||
expect(response.result.error.message).toContain('100-call work budget')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('restarts a stale continuation from one fresh generation and keeps the visibility snapshot', async () => {
|
||||
const ctx = await baseContext()
|
||||
const oldOnly = hit('old-only', 0)
|
||||
const shared = hit('shared', 1)
|
||||
const freshFirst = hit('fresh-first', 2)
|
||||
const freshLast = hit('fresh-last', 3)
|
||||
for (const item of [oldOnly, shared, freshFirst, freshLast]) {
|
||||
ctx.sessions.create(item.header.id, { meta: item.header })
|
||||
}
|
||||
const late = hit('late-visible', 4)
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
switch (searchSessions.mock.calls.length) {
|
||||
case 1:
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.resolve({
|
||||
items: [oldOnly, shared],
|
||||
nextCursor: 'old-cursor',
|
||||
})
|
||||
case 2:
|
||||
expect(providerRequest.cursor).toBe('old-cursor')
|
||||
ctx.sessions.create(late.header.id, { meta: late.header })
|
||||
return Promise.reject(stale)
|
||||
case 3:
|
||||
expect(providerRequest).not.toHaveProperty('cursor')
|
||||
return Promise.resolve({
|
||||
items: [freshFirst, shared],
|
||||
nextCursor: 'old-cursor',
|
||||
})
|
||||
case 4:
|
||||
expect(providerRequest.cursor).toBe('old-cursor')
|
||||
return Promise.resolve({ items: [freshLast, late] })
|
||||
default:
|
||||
return Promise.reject(new Error('unexpected provider call'))
|
||||
}
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('stale-restart'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [
|
||||
{ sessionId: 'fresh-first', snippet: 'match 2' },
|
||||
{ sessionId: 'shared', snippet: 'match 1' },
|
||||
{ sessionId: 'fresh-last', snippet: 'match 3' },
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('counts continuous stale restarts against the 100-call budget', async () => {
|
||||
const ctx = await baseContext()
|
||||
const partial = hit('partial')
|
||||
ctx.sessions.create(partial.header.id, { meta: partial.header })
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
|
||||
if (searchSessions.mock.calls.length > 100) {
|
||||
return Promise.reject(new Error('provider was called after the shared budget'))
|
||||
}
|
||||
if (providerRequest.cursor !== undefined) return Promise.reject(stale)
|
||||
return Promise.resolve({
|
||||
items: [partial],
|
||||
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
|
||||
})
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('stale-churn'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toContain('100-call work budget')
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
|
||||
it('gives abort priority over a coincident stale continuation failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const controller = new AbortController()
|
||||
const stale = new SessionQueryError(
|
||||
'provider generation changed',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)
|
||||
const searchSessions = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' })
|
||||
.mockImplementationOnce(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(stale)
|
||||
})
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('abort-stale'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not retry a stale first-page failure', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError(
|
||||
'provider generation changed before paging',
|
||||
'SESSION_QUERY_STALE_CURSOR',
|
||||
)))
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('first-page-stale'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
})
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects an oversized provider page before iterating its items', async () => {
|
||||
const ctx = await baseContext()
|
||||
ctx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
@@ -272,6 +415,61 @@ describe('session.search', () => {
|
||||
expect(iterate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
const expected = `${'x'.repeat(239)}😀`
|
||||
const overlong = {
|
||||
...visible,
|
||||
bestMatch: {
|
||||
...visible.bestMatch,
|
||||
snippet: `${expected}${'y'.repeat(10_000)}`,
|
||||
},
|
||||
}
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({ items: [overlong] }),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('bounded-snippet'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [{ sessionId: 'visible', snippet: expected }],
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the provider returns a non-string snippet', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = hit('visible')
|
||||
ctx.sessions.create(visible.header.id, { meta: visible.header })
|
||||
ctx.provide('sessionQuery', {
|
||||
searchSessions: () => Promise.resolve({
|
||||
items: [{
|
||||
...visible,
|
||||
bestMatch: { ...visible.bestMatch, snippet: 42 },
|
||||
}],
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('malformed-snippet'),
|
||||
new AbortController().signal,
|
||||
)
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toContain('non-string snippet')
|
||||
expect(response.result).not.toHaveProperty('value')
|
||||
})
|
||||
|
||||
it('inspects only numerically stored items when a compliant page overrides iteration', async () => {
|
||||
const ctx = await baseContext()
|
||||
const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: ceffb3ac25bc8b5252d6cc40cd6389839dfce1e2
|
||||
README.zh.md: 4e11ae9c9b8012045a7f3bab5d5c45724e553303
|
||||
# pnpm run verify-translation-pairing --write packages/session-query/session-query-sqlite/README.md
|
||||
README.md: 4bf4d979f2d2954cd6280c80bf7f5988d8121fd1
|
||||
README.zh.md: afa45ad364a92e0268cf40c90dd61b163be0e18f
|
||||
|
||||
@@ -16,6 +16,8 @@ All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by def
|
||||
|
||||
The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, non-mutatingly inspects only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Session queries never invoke the persistence backend's crash-repairing `load()`; an owner attaching during inspection cannot mutate its log, and the stable-observation retry makes the result live-preferred. The TEMP live row still records persisted availability, and the durable base refreshes after that live owner detaches. Repeated queries and an unchanged same-store reopen perform no full durable-log inspection; switching stores, or observing new, changed, deleted, or externally load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries.
|
||||
|
||||
`openAt: startup` is the default: service activation imports `node:sqlite`, opens the handle, and fails before publication when the index is invalid. `openAt: first-search` publishes the service as ACTIVE without importing the SQLite module or opening a handle; the first concurrent searches share one readiness promise, and disposal before any search opens nothing. This mode supports compositions that need clean Node 22 startup output by deferring SQLite's experimental warning until the first actual search; it does not suppress a warning at that point. An invalid database likewise fails the first search instead of service activation.
|
||||
|
||||
Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows.
|
||||
|
||||
The database is disposable but reset is guarded: every recognized schema version rejects unknown user tables before mutating journal mode, and only a recognized incompatible schema containing derived tables rebuilds in place. An unrelated or canonical database is refused. Never point `path` at the session-persistence database. On filesystems with POSIX modes, missing directories and databases are created owner-only (`0700` and `0600` before the process umask), and SQLite sidecars inherit the database mode; existing modes are preserved. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned.
|
||||
@@ -25,6 +27,7 @@ The database is disposable but reset is guarded: every recognized schema version
|
||||
| Key | Default | Contract |
|
||||
|---|---:|---|
|
||||
| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. Missing filesystem paths are created owner-only on POSIX filesystems. |
|
||||
| `openAt` | `startup` | `startup` opens before service activation completes; `first-search` defers the SQLite module and handle until search. |
|
||||
| `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. |
|
||||
| `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. |
|
||||
| `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. |
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,以非变更方式只检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`;检查期间附加的 owner 无法修改其日志,稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该实时 owner 脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。
|
||||
|
||||
`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise,在任何搜索前处置服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。
|
||||
|
||||
持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时 owner 消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。
|
||||
|
||||
该数据库可丢弃,但 reset 受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700` 和 `0600`),SQLite sidecar 继承数据库 mode;现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态归连接所有。
|
||||
@@ -25,6 +27,7 @@
|
||||
| 键 | 默认值 | 契约 |
|
||||
|---|---:|---|
|
||||
| `path` | required | 专用派生索引 SQLite 路径;支持 `:memory:`。在 POSIX 文件系统上,缺失的文件系统路径会以仅所有者可访问的方式创建。 |
|
||||
| `openAt` | `startup` | `startup` 会在服务激活完成前打开;`first-search` 把 SQLite 模块与句柄推迟到搜索时再加载和打开。 |
|
||||
| `journalMode` | `wal` | `wal`、`delete`、`truncate` 或 `persist`。 |
|
||||
| `defaultLimit` | `20` | 请求省略 `limit` 时的分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 |
|
||||
| `maxLimit` | `100` | 接受的最大请求分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { Context, Service, type Fiber } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -72,6 +72,9 @@ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240
|
||||
// One transient source change gets a retry; repeated churn fails rather than monopolizing the queue.
|
||||
const STABLE_OBSERVATION_ATTEMPTS = 2
|
||||
|
||||
/** SQLite module/handle opening phase. */
|
||||
export type OpenAt = 'startup' | 'first-search'
|
||||
|
||||
/** Combined session-query configuration backed by SQLite full-text search. */
|
||||
export interface Config extends SessionQueryConfig {
|
||||
/**
|
||||
@@ -80,6 +83,8 @@ export interface Config extends SessionQueryConfig {
|
||||
* POSIX filesystems; existing modes are preserved.
|
||||
*/
|
||||
path: string
|
||||
/** Open the SQLite module and handle at service activation or the first search. Defaults to `startup`. */
|
||||
openAt?: OpenAt
|
||||
/** SQLite journal mode. Defaults to `wal`. */
|
||||
journalMode?: JournalMode
|
||||
/** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */
|
||||
@@ -94,6 +99,7 @@ export interface Config extends SessionQueryConfig {
|
||||
|
||||
interface ResolvedConfig {
|
||||
path: string
|
||||
openAt: OpenAt
|
||||
journalMode: JournalMode
|
||||
defaultLimit: number
|
||||
maxLimit: number
|
||||
@@ -175,6 +181,7 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
path: z.string().required(),
|
||||
openAt: z.union(['startup', 'first-search'] as const).default('startup'),
|
||||
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
|
||||
defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT),
|
||||
maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT),
|
||||
@@ -191,7 +198,7 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
private readonly _instance = randomUUID()
|
||||
private readonly _ready: Promise<void>
|
||||
private _ready: Promise<void> | undefined
|
||||
private _db: DatabaseSync | undefined
|
||||
private _persistenceBinding: PersistenceBinding = { identity: Symbol() }
|
||||
private _lastPersistenceIdentity: symbol | undefined
|
||||
@@ -208,7 +215,6 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
// register `ctx.sessionQuery`; keep that same validated value afterward.
|
||||
super(ctx, config = resolveConfig(config))
|
||||
this.config = config as ResolvedConfig
|
||||
this._ready = this._open()
|
||||
this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
const service = childCtx.sessionPersistence
|
||||
const binding = { identity: Symbol(), service }
|
||||
@@ -225,9 +231,9 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
ctx.effect(() => async () => this.close(), 'sessionQuerySqlite.close')
|
||||
}
|
||||
|
||||
/** Open the index before Cordis publishes this combined service as active. */
|
||||
/** Open eagerly only when activation owns the configured readiness boundary. */
|
||||
protected async [Service.init](): Promise<void> {
|
||||
await this._ensureReady(undefined)
|
||||
if (this.config.openAt === 'startup') await this._ensureReady(undefined)
|
||||
}
|
||||
|
||||
override async searchSessions(
|
||||
@@ -296,10 +302,12 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
private async _close(): Promise<void> {
|
||||
this._closed = true
|
||||
await this._tail
|
||||
try {
|
||||
await this._ready
|
||||
} catch {
|
||||
// Opening already closed a partially-created handle; disposal only waits.
|
||||
if (this._ready !== undefined) {
|
||||
try {
|
||||
await this._ready
|
||||
} catch {
|
||||
// Opening already closed a partially-created handle; disposal only waits.
|
||||
}
|
||||
}
|
||||
this._db?.close()
|
||||
this._db = undefined
|
||||
@@ -315,6 +323,7 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
}
|
||||
|
||||
private async _ensureReady(signal: AbortSignal | undefined): Promise<void> {
|
||||
this._ready ??= this._open()
|
||||
try {
|
||||
await waitWithAbort(this._ready, signal)
|
||||
} catch (error: unknown) {
|
||||
@@ -946,6 +955,7 @@ function invalidCursor(cause: unknown): SessionQueryError {
|
||||
function resolveConfig(config: Config): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = {
|
||||
path: config.path,
|
||||
openAt: config.openAt ?? 'startup',
|
||||
journalMode: config.journalMode ?? 'wal',
|
||||
defaultLimit: config.defaultLimit ?? SESSION_QUERY_SQLITE_DEFAULT_LIMIT,
|
||||
maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT,
|
||||
@@ -957,6 +967,8 @@ function resolveConfig(config: Config): ResolvedConfig {
|
||||
if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) {
|
||||
throw invalidConfig('path must not be blank')
|
||||
}
|
||||
const openPhases: readonly string[] = ['startup', 'first-search']
|
||||
if (!openPhases.includes(resolved.openAt)) throw invalidConfig('openAt is not supported')
|
||||
assertPageLimit('defaultLimit', resolved.defaultLimit)
|
||||
assertPageLimit('maxLimit', resolved.maxLimit)
|
||||
assertPositiveInteger('snippetChars', resolved.snippetChars)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** SQLite schema for the disposable session full-text read model. */
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdir, open } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode)
|
||||
await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
|
||||
await createDatabaseFile(actual)
|
||||
}
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
const db = new DatabaseSync(actual)
|
||||
try {
|
||||
const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Node 22 startup-output smoke for first-search SQLite opening.
|
||||
*
|
||||
* The isolated subprocess omits NODE_OPTIONS so warning suppression cannot
|
||||
* hide a static node:sqlite import.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const root = resolve(import.meta.dirname, '../../../..')
|
||||
|
||||
it('mounts and disposes first-search mode without a SQLite experimental warning', async () => {
|
||||
const script = `
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionQuerySqlite from './packages/session-query/session-query-sqlite/src/index.ts'
|
||||
|
||||
const ctx = new Context()
|
||||
const sessions = await ctx.plugin(SessionStore)
|
||||
const search = await ctx.plugin(SessionQuerySqlite, {
|
||||
path: ':memory:',
|
||||
openAt: 'first-search',
|
||||
})
|
||||
await search.dispose()
|
||||
await sessions.dispose()
|
||||
`
|
||||
const env = { ...process.env }
|
||||
delete env.NODE_OPTIONS
|
||||
const { stderr } = await execFileAsync(process.execPath, [
|
||||
'--import',
|
||||
'tsx',
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
script,
|
||||
], {
|
||||
cwd: root,
|
||||
env,
|
||||
})
|
||||
|
||||
expect(stderr).not.toMatch(/ExperimentalWarning: SQLite/)
|
||||
})
|
||||
@@ -177,16 +177,19 @@ async function liveContext(config: ConstructorParameters<typeof SessionQuerySqli
|
||||
}
|
||||
|
||||
describe('SQLite session search', () => {
|
||||
it('defaults and validates persisted inspection concurrency through its Cordis config', async () => {
|
||||
it('defaults and validates opening policy and persisted inspection concurrency through its Cordis config', async () => {
|
||||
const defaultCtx = await liveContext()
|
||||
expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.openAt).toBe('startup')
|
||||
expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency)
|
||||
.toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY)
|
||||
|
||||
const configuredValue = 2
|
||||
const configured = new SessionQuerySqlite.Config({
|
||||
path: ':memory:',
|
||||
openAt: 'first-search',
|
||||
persistedInspectConcurrency: configuredValue,
|
||||
})
|
||||
expect(configured.openAt).toBe('first-search')
|
||||
expect(configured.persistedInspectConcurrency).toBe(configuredValue)
|
||||
const configuredCtx = await liveContext(configured)
|
||||
expect((configuredCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency)
|
||||
@@ -198,6 +201,72 @@ describe('SQLite session search', () => {
|
||||
persistedInspectConcurrency,
|
||||
})).toThrow()
|
||||
}
|
||||
expect(() => new SessionQuerySqlite.Config({
|
||||
path: ':memory:',
|
||||
openAt: 'later' as never,
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('mounts and disposes first-search mode without opening its database', async () => {
|
||||
const path = await temporaryPath('unopened.db')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const search = await ctx.plugin(SessionQuerySqlite, {
|
||||
path,
|
||||
openAt: 'first-search',
|
||||
})
|
||||
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await search.dispose()
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('opens once on the first search and reuses readiness for later searches', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQuerySqlite, {
|
||||
path: ':memory:',
|
||||
openAt: 'first-search',
|
||||
})
|
||||
const service = ctx.sessionQuery as SessionQuerySqlite
|
||||
const internals = service as unknown as { _open(): Promise<void> }
|
||||
const open = vi.spyOn(internals, '_open')
|
||||
|
||||
await expect(service.searchSessions({ query: 'first' })).resolves.toEqual({ items: [] })
|
||||
await expect(service.searchSessions({ query: 'second' })).resolves.toEqual({ items: [] })
|
||||
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shares one readiness promise across concurrent first searches', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQuerySqlite, {
|
||||
path: ':memory:',
|
||||
openAt: 'first-search',
|
||||
})
|
||||
const service = ctx.sessionQuery as SessionQuerySqlite
|
||||
const internals = service as unknown as { _open(): Promise<void> }
|
||||
const originalOpen = internals._open.bind(internals)
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const open = vi.spyOn(internals, '_open').mockImplementation(async () => {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
await originalOpen()
|
||||
})
|
||||
|
||||
const first = service.searchSessions({ query: 'first' })
|
||||
const second = service.searchSessions({ query: 'second' })
|
||||
await started.promise
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ items: [] },
|
||||
{ items: [] },
|
||||
])
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('searches two-character Unicode61 tokens in live-only sessions', async () => {
|
||||
@@ -522,6 +591,7 @@ describe('SQLite session search', () => {
|
||||
{ path: ':memory:', persistedInspectConcurrency: 0 },
|
||||
{ path: ':memory:', persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ path: ':memory:', defaultLimit: 3, maxLimit: 2 },
|
||||
{ path: ':memory:', openAt: 'later' },
|
||||
{ path: ':memory:', journalMode: 'memory' },
|
||||
]) {
|
||||
const direct = new Context()
|
||||
@@ -1238,6 +1308,30 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
|
||||
}
|
||||
})
|
||||
|
||||
it('defers an invalid database failure only in first-search mode', async () => {
|
||||
const path = await temporaryPath('lazy-invalid.db')
|
||||
const foreign = new DatabaseSync(path)
|
||||
foreign.exec('CREATE TABLE canonical(value TEXT)')
|
||||
foreign.close()
|
||||
|
||||
const lazyCtx = new Context()
|
||||
await lazyCtx.plugin(SessionStore)
|
||||
const lazy = await lazyCtx.plugin(SessionQuerySqlite, {
|
||||
path,
|
||||
openAt: 'first-search',
|
||||
})
|
||||
expect(lazyCtx.sessionQuery).toBeInstanceOf(SessionQuerySqlite)
|
||||
await expect(lazyCtx.sessionQuery.searchSessions({ query: 'needle' }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
|
||||
await lazy.dispose()
|
||||
|
||||
const eagerCtx = new Context()
|
||||
await eagerCtx.plugin(SessionStore)
|
||||
await expect(eagerCtx.plugin(SessionQuerySqlite, { path }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
|
||||
expect(eagerCtx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['sessions', 'events'] as const)(
|
||||
'forwards one exact reconciliation signal through both snapshot lists and persisted inspection for %s search',
|
||||
async (scope) => {
|
||||
|
||||
@@ -261,6 +261,11 @@ function nodeCompatSmokeGates(): Gate[] {
|
||||
'run',
|
||||
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
||||
], { label: 'JSONL Zstandard smoke' }),
|
||||
pnpmExec('session-query-lazy-open-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/session-query/session-query-sqlite/tests/lazy-open.compat.spec.ts',
|
||||
], { label: 'session-query lazy-open smoke' }),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user