Merge branch 'master' into docs/readme-human-polish-2
This commit is contained in:
+6
@@ -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 .agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.md
|
||||
2026-08-13-session-content-search-opt-in.md: 2d14b2def00186c2957dc03ae712fbcb63d2c820
|
||||
2026-08-13-session-content-search-opt-in.zh.md: dbde561c82cebdbf51ad31b1cd22c908c8d0f327
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Session content search ships opt-in through openAt never
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-13-session-content-search-opt-in.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The shipped bundles mounted the SQLite session-query provider with the full-text index live (`openAt: first-search`), so every default deployment carried a derived FTS index and the Web sidebar offered content search. Whether a deployment wants that index — its node:sqlite import, per-search source reconciliation, and derived storage — is a deployment choice, and the product default is to ship without it; the model-facing search tools were already opt-in and unmounted (the [not-shipped-default decision](../feature/2026-08-02-session-search-not-shipped-default.md)).
|
||||
|
||||
Turning the capability off by unmounting the plugin row is not viable. `ApiProxyService` declares `sessionQuery` as a required injection, so without the provider the whole host API gateway stays unloaded and the Web GUI never boots. Session-log export traces subagent descendants through `ctx.sessionQuery.traceSession`, and a subagent fork resolves its Workspace through the same lineage trace — both would need optional-service guards plus a replacement lineage source, roughly tripling the change surface while losing exact reads everywhere.
|
||||
|
||||
## Decision
|
||||
|
||||
Content search is enforced off at the provider. `openAt: 'never'` is a third opening phase on `@deepseek-ai/dsh-session-query-sqlite`: `searchSessions` and `searchEvents` fail with the typed `SESSION_QUERY_SEARCH_DISABLED` code before any request normalization, node:sqlite is never imported or opened, and no source observation or reconciliation runs. Every inherited `ctx.sessionQuery` exact read, filter, and trace keeps working, so session export, fork Workspace inheritance, and title reads are unaffected.
|
||||
|
||||
`SESSION_QUERY_SEARCH_DISABLED` joins the closed `SessionQueryErrorCode` taxonomy, and the `tool-session-query` service boundary maps it to the model-safe message `session search is disabled in this deployment`.
|
||||
|
||||
The base bundle sets `openAt: never` on the `session-query-sqlite` row and the web bundle's restatement keeps it; enabling content search is a one-line `openAt` override (`first-search` or `startup`) in a later patch layer, typically with a durable `path`. The host `session.search` endpoint reports the provider failure through its existing error path, and the Web sidebar keeps its designed degradation: local title/workspace matching plus the content-search-unavailable notice. The CLI compat spec pins the shipped `openAt: never` rows, while the web e2e scaffold keeps content search enabled — its seeded-session scenarios navigate by content search, and those runs are the assembled coverage for the opt-in path.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Unmount the plugin row** (`disabled: true` in the base patch): rejected — the api-gateway's required `sessionQuery` injection keeps the whole host API unloaded, and making that injection optional forces guards plus a header-walk lineage fallback in session export and fork resolution.
|
||||
- **Disable at the consumers** (the host `session.search` endpoint or the sidebar): rejected — enforcement belongs to the operation that makes the decision; opt-in model tools or any other consumer would still reach the index.
|
||||
- **A separate boolean beside `openAt`**: rejected — the opening phase already owns when SQLite starts; `never` extends the same axis instead of adding a second knob that can contradict it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Default deployments run no derived index: no node:sqlite import or experimental-SQLite startup warning, no reconciliation work, no derived database on disk. Sidebar search matches session titles and workspace names only.
|
||||
- Search failures under the default are typed and stable rather than incidental, so callers distinguish a deployment choice from an index fault (`SESSION_QUERY_INDEX_FAILED`).
|
||||
- Re-enabling content search is per-deployment configuration, not a code change, and restores the full FTS behavior unchanged.
|
||||
- Compositions that mount the search tools without overriding `openAt` get the model-safe disabled message on every search call; enabling the tools implies enabling the index.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: 会话内容搜索通过 openAt never 以 opt-in 方式交付
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-13-session-content-search-opt-in.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
交付的 bundle 之前以启用状态挂载 SQLite 会话查询提供方的全文索引(`openAt: first-search`),因此每个默认部署都携带一个派生 FTS 索引,Web 侧边栏提供内容搜索。一个部署是否需要该索引——它的 node:sqlite 导入、每次搜索的来源对账和派生存储——是部署自身的选择,产品默认不携带它交付;面向模型的搜索工具此前已经是 opt-in 且未挂载(见[非默认交付决策](../feature/2026-08-02-session-search-not-shipped-default.md))。
|
||||
|
||||
通过卸载插件行来关闭该能力不可行。`ApiProxyService` 将 `sessionQuery` 声明为必需注入,没有该提供方时整个宿主 API 网关保持未加载,Web GUI 无法启动。会话日志导出通过 `ctx.sessionQuery.traceSession` 追踪子代理后代,子代理分叉也通过同一血缘追踪解析其 Workspace——两者都需要可选服务守卫加一个替代血缘来源,改动面大约扩大三倍,同时使精确读取在所有地方消失。
|
||||
|
||||
## 决策
|
||||
|
||||
内容搜索在提供方处强制关闭。`openAt: 'never'` 是 `@deepseek-ai/dsh-session-query-sqlite` 的第三个打开阶段:`searchSessions` 和 `searchEvents` 在任何请求规范化之前就以类型化的 `SESSION_QUERY_SEARCH_DISABLED` 代码失败,node:sqlite 绝不会被导入或打开,也不运行任何来源观察或对账。`ctx.sessionQuery` 上继承的全部精确读取、过滤和跟踪保持可用,因此会话导出、分叉的 Workspace 继承和标题读取不受影响。
|
||||
|
||||
`SESSION_QUERY_SEARCH_DISABLED` 加入封闭的 `SessionQueryErrorCode` 分类,`tool-session-query` 的服务边界将它映射为模型安全消息 `session search is disabled in this deployment`。
|
||||
|
||||
base bundle 在 `session-query-sqlite` 行上设置 `openAt: never`,web bundle 的重述保持该值;启用内容搜索只需在后续 patch 层用一行覆盖 `openAt`(`first-search` 或 `startup`),通常同时配一个持久 `path`。宿主 `session.search` 端点沿现有错误路径报告提供方失败,Web 侧边栏保持其既有降级:本地标题/工作区匹配加内容搜索不可用提示。CLI 兼容性测试固定交付的 `openAt: never` 行,而 Web e2e 脚手架保持内容搜索启用——其种子会话场景通过内容搜索导航,这些运行也是 opt-in 路径的装配级覆盖。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **卸载插件行**(在 base patch 中 `disabled: true`)——否决:api-gateway 的必需 `sessionQuery` 注入会使整个宿主 API 保持未加载,而把该注入改为可选需要守卫加上会话导出与分叉解析中的 header 遍历血缘回退。
|
||||
- **在消费方处关闭**(宿主 `session.search` 端点或侧边栏)——否决:强制应由做出决定的操作执行;opt-in 的模型工具或任何其他消费方仍会触达索引。
|
||||
- **在 `openAt` 旁增加独立布尔开关**——否决:打开阶段已经拥有"SQLite 何时启动"这一轴;`never` 延伸同一根轴,而不是增加一个可能与之矛盾的第二个旋钮。
|
||||
|
||||
## 结果
|
||||
|
||||
- 默认部署不运行任何派生索引:没有 node:sqlite 导入或实验性 SQLite 启动警告,没有对账工作,磁盘上没有派生数据库。侧边栏搜索只匹配会话标题和工作区名称。
|
||||
- 默认状态下的搜索失败是类型化且稳定的,调用方可以把部署选择与索引故障(`SESSION_QUERY_INDEX_FAILED`)区分开。
|
||||
- 重新启用内容搜索是逐部署配置而非代码改动,并原样恢复完整的 FTS 行为。
|
||||
- 挂载搜索工具但未覆盖 `openAt` 的组合,每次搜索调用都会得到模型安全的已禁用消息;启用工具意味着同时启用索引。
|
||||
+2
-2
@@ -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-08-02-session-search-not-shipped-default.md
|
||||
2026-08-02-session-search-not-shipped-default.md: c1bfd7f8e354a4480c5635619514fe782ea71d2c
|
||||
2026-08-02-session-search-not-shipped-default.zh.md: 9b80c549425c26055700480dd57f1a0a7d01e4a8
|
||||
2026-08-02-session-search-not-shipped-default.md: 60d1f424d5a9029a00b3f195d1f6e6baf2790d1e
|
||||
2026-08-02-session-search-not-shipped-default.zh.md: 221613ae1aef93cd72734e4c328d822a88b64227
|
||||
@@ -12,7 +12,7 @@ The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made
|
||||
|
||||
The shipped TUI, Web, and headless surfaces do not mount `@deepseek-ai/dsh-tool-session-query`, and no shipped agent preset carries it. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies.
|
||||
|
||||
The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed.
|
||||
The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — with its full-text index off by default (`openAt: never`; the [content-search opt-in decision](../architecture/2026-08-13-session-content-search-opt-in.md)), and the Web overlay keeps its in-memory values for deployments that enable content search. Only the model-facing consumer is removed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
交付的 TUI、Web 与无头 surface 均不挂载 `@deepseek-ai/dsh-tool-session-query`,交付的 agent preset 也都不包含它。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。
|
||||
|
||||
`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。
|
||||
`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`;其全文索引默认关闭(`openAt: never`,见[内容搜索 opt-in 决策](../architecture/2026-08-13-session-content-search-opt-in.md)),Web overlay 保留内存索引取值,供启用内容搜索的部署使用。被移除的只有面向模型的消费方。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -21,15 +21,22 @@ import { describe, expect, it } from 'vitest'
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const webDist = join(repoRoot, 'apps/web/dist/index.html')
|
||||
// The web bundle's patch owns the session-query-sqlite lazy-open row.
|
||||
const configPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
// Full-text session search ships off (`openAt: never` on both layers): the
|
||||
// base patch carries the default, and the web restatement must not re-enable it.
|
||||
const baseConfigPath = join(repoRoot, 'packages/bundle/base/cordis.patch.yml')
|
||||
const webConfigPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml')
|
||||
const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
|
||||
|
||||
interface ConfigRow {
|
||||
id?: string
|
||||
disabled?: unknown
|
||||
config?: { openAt?: unknown }
|
||||
}
|
||||
|
||||
interface PatchEntry extends ConfigRow {
|
||||
insert?: ConfigRow[]
|
||||
}
|
||||
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
construct: value => String(value),
|
||||
@@ -92,12 +99,20 @@ function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; cod
|
||||
}
|
||||
|
||||
describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
|
||||
it('boots and disposes the shipped composition without a SQLite startup warning', async () => {
|
||||
it('boots and disposes the shipped composition with full-text search off by default', async () => {
|
||||
expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
|
||||
expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
|
||||
const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[]
|
||||
const searchRow = rows.find(row => row.id === 'session-query-sqlite')
|
||||
expect(searchRow?.config?.openAt).toBe('first-search')
|
||||
const baseRows = (yaml.load(await readFile(baseConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[])
|
||||
.flatMap(entry => entry.insert ?? [entry])
|
||||
const webRows = (yaml.load(await readFile(webConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[])
|
||||
.flatMap(entry => entry.insert ?? [entry])
|
||||
const baseRow = baseRows.find(row => row.id === 'session-query-sqlite')
|
||||
const webRow = webRows.find(row => row.id === 'session-query-sqlite')
|
||||
expect(baseRow?.config?.openAt).toBe('never')
|
||||
expect(baseRow?.disabled).toBeUndefined()
|
||||
// The web restatement keeps the shipped default; opting in is a later layer's override.
|
||||
expect(webRow?.config?.openAt).toBe('never')
|
||||
expect(webRow?.disabled).toBeUndefined()
|
||||
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
|
||||
try {
|
||||
|
||||
@@ -391,6 +391,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
},
|
||||
},
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
// Content search is enabled here although the shipped bundles default it
|
||||
// off (`openAt: never`, pinned by apps/cli/tests/lazy-search-startup):
|
||||
// the seeded-session scenarios navigate by content search, and these e2e
|
||||
// runs are the assembled coverage for the opt-in search path.
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
|
||||
// to an absolute temp root (removed with the workspace at close) so tests
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: 6b60fad7395ba4de28921390641a332ea9667217
|
||||
config-catalog.zh.md: b4378b32b925adce221ca0bac837a6630eb30a24
|
||||
config-catalog.md: 19bfa6d1fb847de4a7207f42dabd67d43f288361
|
||||
config-catalog.zh.md: fda208e8fcd2ff6dd697efed84a4073ecbd5a912
|
||||
@@ -1558,7 +1558,13 @@ 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`. */
|
||||
/**
|
||||
* Open the SQLite module and handle at service activation or the first
|
||||
* search, or `never` to disable full-text search: the inherited exact
|
||||
* reads, filters, and traces stay available, while `searchSessions` and
|
||||
* `searchEvents` fail with `SESSION_QUERY_SEARCH_DISABLED` and SQLite is
|
||||
* never imported or opened. Defaults to `startup`.
|
||||
*/
|
||||
openAt?: OpenAt
|
||||
/** SQLite journal mode. Defaults to `wal`. */
|
||||
journalMode?: JournalMode
|
||||
@@ -1572,8 +1578,8 @@ export interface Config extends SessionQueryConfig {
|
||||
persistedInspectConcurrency?: number
|
||||
}
|
||||
|
||||
/** SQLite module/handle opening phase. */
|
||||
export type OpenAt = 'startup' | 'first-search'
|
||||
/** SQLite module/handle opening phase; `never` disables full-text search entirely. */
|
||||
export type OpenAt = 'startup' | 'first-search' | 'never'
|
||||
|
||||
/** Supported SQLite journal modes. */
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
@@ -1560,7 +1560,13 @@ 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`. */
|
||||
/**
|
||||
* Open the SQLite module and handle at service activation or the first
|
||||
* search, or `never` to disable full-text search: the inherited exact
|
||||
* reads, filters, and traces stay available, while `searchSessions` and
|
||||
* `searchEvents` fail with `SESSION_QUERY_SEARCH_DISABLED` and SQLite is
|
||||
* never imported or opened. Defaults to `startup`.
|
||||
*/
|
||||
openAt?: OpenAt
|
||||
/** SQLite journal mode. Defaults to `wal`. */
|
||||
journalMode?: JournalMode
|
||||
@@ -1574,8 +1580,8 @@ export interface Config extends SessionQueryConfig {
|
||||
persistedInspectConcurrency?: number
|
||||
}
|
||||
|
||||
/** SQLite module/handle opening phase. */
|
||||
export type OpenAt = 'startup' | 'first-search'
|
||||
/** SQLite module/handle opening phase; `never` disables full-text search entirely. */
|
||||
export type OpenAt = 'startup' | 'first-search' | 'never'
|
||||
|
||||
/** Supported SQLite journal modes. */
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
@@ -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 docs/subsystems/session-query.md
|
||||
session-query.md: 647b50a297a434308769ab3c5f1af0af7480b937
|
||||
session-query.zh.md: 7b03f8515501abe5e1ab35d50f6a6a230a96cf0c
|
||||
session-query.md: 6ddde5b8a19ebc65952b5a24003c48f55ff5a244
|
||||
session-query.zh.md: b7d4ab4910c99c9f77c17f0279f2d4fd3c336659
|
||||
@@ -332,7 +332,7 @@ interface SessionEventTraceObservation extends SessionEventTrace {
|
||||
|
||||
## Errors
|
||||
|
||||
The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata.
|
||||
The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, deployment-disabled search, and contradictory source metadata.
|
||||
|
||||
```ts type-equiv
|
||||
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
|
||||
@@ -350,6 +350,7 @@ type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
| 'SESSION_QUERY_SEARCH_DISABLED'
|
||||
| 'SESSION_QUERY_SESSION_NOT_FOUND'
|
||||
| 'SESSION_QUERY_STALE_CURSOR'
|
||||
| 'SESSION_QUERY_SOURCE_CONFLICT'
|
||||
|
||||
@@ -332,7 +332,7 @@ interface SessionEventTraceObservation extends SessionEventTrace {
|
||||
|
||||
## 错误
|
||||
|
||||
封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端故障与矛盾的源元数据。
|
||||
封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端故障、部署关闭搜索与矛盾的源元数据。
|
||||
|
||||
```ts type-equiv
|
||||
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
|
||||
@@ -350,6 +350,7 @@ type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
| 'SESSION_QUERY_SEARCH_DISABLED'
|
||||
| 'SESSION_QUERY_SESSION_NOT_FOUND'
|
||||
| 'SESSION_QUERY_STALE_CURSOR'
|
||||
| 'SESSION_QUERY_SOURCE_CONFLICT'
|
||||
|
||||
@@ -106,13 +106,19 @@
|
||||
- id: attachment-local
|
||||
name: '@deepseek-ai/dsh-attachment-local'
|
||||
|
||||
# Raw configs can supply a process-local path or disable this shared session
|
||||
# capability. The neutral default is process-local and opens only when used.
|
||||
# Full-text session search is opt-in. `openAt: never` keeps
|
||||
# ctx.sessionQuery mounted — exact reads, titles, and lineage traces
|
||||
# (session export, subagent-fork Workspace inheritance) stay available —
|
||||
# while search calls fail with SESSION_QUERY_SEARCH_DISABLED and SQLite is
|
||||
# never opened; the Web sidebar search matches titles and workspace names
|
||||
# only. Deployments enabling content search override `openAt` to
|
||||
# `first-search` or `startup` in a later patch layer (profile
|
||||
# cordis.patch.yml or a --patch overlay), typically with a durable `path`.
|
||||
- id: session-query-sqlite
|
||||
name: '@deepseek-ai/dsh-session-query-sqlite'
|
||||
config:
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
openAt: never
|
||||
|
||||
# Shared projection registry: subagent catalog identity (mode/label) folds
|
||||
# through its registered units, so the `list_agents` surface below fails
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
# Web content search runs on an ephemeral in-memory index. The service
|
||||
# activates at boot, while first-search defers the node:sqlite import and
|
||||
# in-memory handle so Node 22 startup stays quiet until content search
|
||||
# actually uses SQLite. That search then reconciles this boot's sources.
|
||||
# Full-text session search is opt-in (the base row's `openAt: never`). This
|
||||
# restatement keeps the Web values on one ephemeral in-memory index; a
|
||||
# deployment enabling content search overrides `openAt` to `first-search` in a
|
||||
# later patch layer, which defers the node:sqlite import and in-memory handle
|
||||
# to the first search so Node 22 startup stays quiet.
|
||||
- id: session-query-sqlite
|
||||
config:
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
openAt: never
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
|
||||
@@ -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/session-query/session-query-sqlite/README.md
|
||||
README.md: 846f79bd8383a8114a6d1329e7dbe931a67d98d0
|
||||
README.zh.md: e84e521dd5f392dbcede1e1dd957e4ce0a9bc50f
|
||||
README.md: 25257ddbe672d47faf39e1472a1bd882205d8c4e
|
||||
README.zh.md: 584e466a5042334e22797a6c2794cce910844d5a
|
||||
@@ -16,7 +16,7 @@ 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.
|
||||
`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. `openAt: never` turns full-text search off for the deployment: `searchSessions` and `searchEvents` fail with `SESSION_QUERY_SEARCH_DISABLED` before any request normalization, node:sqlite is never imported or opened, and no source observation or reconciliation runs, while every inherited exact read, filter, and trace on `ctx.sessionQuery` keeps working.
|
||||
|
||||
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.
|
||||
|
||||
@@ -27,7 +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. |
|
||||
| `openAt` | `startup` | `startup` opens before service activation completes; `first-search` defers the SQLite module and handle until search; `never` disables full-text search (typed `SESSION_QUERY_SEARCH_DISABLED` failures) while inherited reads stay available. |
|
||||
| `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,7 +16,7 @@
|
||||
|
||||
该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,仅以不修改日志的方式检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`;检查期间接入的活动所有者无法修改其日志,稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该活动所有者脱离后刷新。重复查询以及同一存储未发生变化的重新打开操作不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。
|
||||
|
||||
`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise,在任何搜索前 dispose(资源释放)服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。
|
||||
`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise,在任何搜索前 dispose(资源释放)服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。`openAt: never` 为该部署关闭全文搜索:`searchSessions` 和 `searchEvents` 在任何请求规范化之前就以 `SESSION_QUERY_SEARCH_DISABLED` 失败,node:sqlite 绝不会被导入或打开,也不运行任何来源观察或对账,而 `ctx.sessionQuery` 上继承的全部精确读取、过滤和跟踪保持可用。
|
||||
|
||||
持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时所有者消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| 键 | 默认值 | 约定 |
|
||||
|---|---:|---|
|
||||
| `path` | 必填 | 专用派生索引 SQLite 路径;支持 `:memory:`。在 POSIX 文件系统上,缺失的文件系统路径会以仅所有者可访问的方式创建。 |
|
||||
| `openAt` | `startup` | `startup` 会在服务激活完成前打开;`first-search` 把 SQLite 模块与句柄推迟到搜索时再加载和打开。 |
|
||||
| `openAt` | `startup` | `startup` 会在服务激活完成前打开;`first-search` 把 SQLite 模块与句柄推迟到搜索时再加载和打开;`never` 关闭全文搜索(以类型化的 `SESSION_QUERY_SEARCH_DISABLED` 失败),继承的读取保持可用。 |
|
||||
| `journalMode` | `wal` | `wal`、`delete`、`truncate` 或 `persist`。 |
|
||||
| `defaultLimit` | `20` | 请求省略 `limit` 时的分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 |
|
||||
| `maxLimit` | `100` | 接受的最大请求分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 |
|
||||
|
||||
@@ -82,8 +82,8 @@ 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'
|
||||
/** SQLite module/handle opening phase; `never` disables full-text search entirely. */
|
||||
export type OpenAt = 'startup' | 'first-search' | 'never'
|
||||
|
||||
/** Combined session-query configuration backed by SQLite full-text search. */
|
||||
export interface Config extends SessionQueryConfig {
|
||||
@@ -93,7 +93,13 @@ 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`. */
|
||||
/**
|
||||
* Open the SQLite module and handle at service activation or the first
|
||||
* search, or `never` to disable full-text search: the inherited exact
|
||||
* reads, filters, and traces stay available, while `searchSessions` and
|
||||
* `searchEvents` fail with `SESSION_QUERY_SEARCH_DISABLED` and SQLite is
|
||||
* never imported or opened. Defaults to `startup`.
|
||||
*/
|
||||
openAt?: OpenAt
|
||||
/** SQLite journal mode. Defaults to `wal`. */
|
||||
journalMode?: JournalMode
|
||||
@@ -192,7 +198,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine {
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
path: z.string().required(),
|
||||
openAt: z.union(['startup', 'first-search'] as const).default('startup'),
|
||||
openAt: z.union(['startup', 'first-search', 'never'] 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),
|
||||
@@ -251,6 +257,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine {
|
||||
request: SessionSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionSearchHit>> {
|
||||
this._assertSearchEnabled()
|
||||
const normalized = normalizeSessionRequest(request, this.config)
|
||||
const signal = exec?.signal
|
||||
return this._serialized(signal, async () => {
|
||||
@@ -278,6 +285,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine {
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionEventSearchPage> {
|
||||
this._assertSearchEnabled()
|
||||
const normalized = normalizeEventRequest(request, this.config)
|
||||
const signal = exec?.signal
|
||||
return this._serialized(signal, async () => {
|
||||
@@ -310,6 +318,19 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine {
|
||||
return this._closePromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse full-text calls under `openAt: 'never'` before any request
|
||||
* normalization or SQLite work, so a disabled deployment never imports
|
||||
* node:sqlite, opens the index, or observes sources.
|
||||
*/
|
||||
private _assertSearchEnabled(): void {
|
||||
if (this.config.openAt !== 'never') return
|
||||
throw new SessionQueryError(
|
||||
'session search is disabled: this deployment configures the session-query index with openAt "never"',
|
||||
'SESSION_QUERY_SEARCH_DISABLED',
|
||||
)
|
||||
}
|
||||
|
||||
private async _close(): Promise<void> {
|
||||
this._closed = true
|
||||
await this._tail
|
||||
@@ -991,7 +1012,7 @@ 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']
|
||||
const openPhases: readonly string[] = ['startup', 'first-search', 'never']
|
||||
if (!openPhases.includes(resolved.openAt)) throw invalidConfig('openAt is not supported')
|
||||
assertPageLimit('defaultLimit', resolved.defaultLimit)
|
||||
assertPageLimit('maxLimit', resolved.maxLimit)
|
||||
|
||||
@@ -231,6 +231,36 @@ describe('SQLite session search', () => {
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('refuses search in never mode while inherited reads and traces keep working', async () => {
|
||||
const path = await temporaryPath('never-mode.db')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'never' })
|
||||
const service = ctx.sessionQuery as SqliteSessionQueryEngine
|
||||
expect(service.config.openAt).toBe('never')
|
||||
|
||||
const parent = SessionId('never-parent')
|
||||
const child = SessionId('never-child')
|
||||
ctx.sessions.create(parent, { seed: messageEvents('never opened needle'), meta: { createdAt: 10 } })
|
||||
ctx.sessions.create(child, { meta: { parentSession: parent, createdAt: 20 } })
|
||||
|
||||
await expect(service.searchSessions({ query: 'needle' }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SEARCH_DISABLED'))
|
||||
await expect(service.searchEvents({ sessionId: parent, query: 'needle' }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SEARCH_DISABLED'))
|
||||
|
||||
expect((await service.listSessions()).map(record => record.header.id).sort())
|
||||
.toEqual([child, parent])
|
||||
const lineage = await service.traceSession(parent)
|
||||
expect(lineage.complete).toBe(true)
|
||||
expect(lineage.descendants.map(node => node.session.header.id)).toEqual([child])
|
||||
|
||||
// The disabled index never touches the filesystem, in mount, use, or disposal.
|
||||
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)
|
||||
|
||||
@@ -31,6 +31,7 @@ export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
| 'SESSION_QUERY_SEARCH_DISABLED'
|
||||
| 'SESSION_QUERY_SESSION_NOT_FOUND'
|
||||
| 'SESSION_QUERY_STALE_CURSOR'
|
||||
| 'SESSION_QUERY_SOURCE_CONFLICT'
|
||||
|
||||
@@ -71,6 +71,10 @@ const SAFE_SESSION_QUERY_FAILURES = {
|
||||
code: 'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
message: 'session history storage is unavailable',
|
||||
},
|
||||
SESSION_QUERY_SEARCH_DISABLED: {
|
||||
code: 'SESSION_QUERY_SEARCH_DISABLED',
|
||||
message: 'session search is disabled in this deployment',
|
||||
},
|
||||
SESSION_QUERY_SESSION_NOT_FOUND: {
|
||||
code: 'SESSION_QUERY_SESSION_NOT_FOUND',
|
||||
message: 'session was not found',
|
||||
|
||||
Reference in New Issue
Block a user