fix(web): honor search ownership and cancellation
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: 3cc44ba3652415e9fa32ce67bceefc822c41059b
|
||||
2026-07-27-web-session-search.zh.md: 2b6ea7a60e1b051757852ff331c0b93c48bd2b57
|
||||
2026-07-27-web-session-search.md: 421922d25bc61c1813a515e8439af0c7956b2efb
|
||||
2026-07-27-web-session-search.zh.md: 04035e74fae718dbf65b294b4293d368ecc89cf7
|
||||
|
||||
@@ -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) at `.sessions/session-query.db`. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is 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) at a process-owned `.sessions/session-query-<pid>.db` path. Process scoping preserves the SQLite backend's single-owner contract when multiple CLI or Web processes run from the same directory. Opening the database does not scan logs; the first content query lazily reconciles changed live and persisted sessions. The database is 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`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work. 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`, passes those ids to `ctx.sessionQuery.searchSessions`, and restricts indexed matches to current-surface `user/message`, `assistant/message`, and `steering/message` events. The response is one page of at most 20 session ids and snippets; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. The carrier signal cancels superseded work, including the persistence listing and bounded batches of cold-session metadata stats that build the visibility set. A missing query service or an 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.
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只
|
||||
|
||||
## 决策
|
||||
|
||||
Web 与 headless 共用的组合会在 `.sessions/session-query.db` 挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。
|
||||
Web 与 headless 共用的组合会在由单一进程拥有的 `.sessions/session-query-<pid>.db` 路径挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。当多个 CLI 或 Web 进程从同一目录运行时,进程级隔离可维持 SQLite 后端的单一所有者契约。打开数据库时不会扫描日志;首次内容查询会惰性对齐发生变更的实时会话与持久化会话。该数据库是可丢弃的派生索引,与规范 JSONL 持久化相互独立。
|
||||
|
||||
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。
|
||||
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,将这些 id 传给 `ctx.sessionQuery.searchSessions`,并将索引匹配限制为当前 surface 中的 `user/message`、`assistant/message` 和 `steering/message` 事件。响应只包含一页,最多 20 个会话 id 及其摘要片段;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。载体信号会取消已被取代的工作,包括持久化列表枚举和构建可见集合时分批受限执行的冷会话元数据 stat。查询服务缺失或索引、查询失败仍作为业务错误处理,不会修改规范会话存储。
|
||||
|
||||
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
|
||||
|
||||
|
||||
@@ -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: bb3f4ee98700e4644535d1d3c05d29a9a558275d
|
||||
README.zh.md: 44edea0f5b36598cfda1b61914da0b6622b973d6
|
||||
README.md: c1d76e42a6788f48c4dd01bbf71281e1081a411c
|
||||
README.zh.md: 69bd88ca8fc7086c94017ac7d25ebd55e8585427
|
||||
|
||||
@@ -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 SQLite content index at `.sessions/session-query.db`. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. 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, process-owned SQLite content index at `.sessions/session-query-<pid>.db`. The process-specific path preserves the backend's single-owner contract across parallel invocations. The index is opened without scanning at boot and lazily reconciles changed live and persisted logs on the first session search. 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 字节,并选用首条消息模型标题,且在 `.sessions/session-query.db` 挂载一个可丢弃的 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 字节,并选用首条消息模型标题,且在 `.sessions/session-query-<pid>.db` 挂载一个可丢弃、由单一进程拥有的 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,12 +89,14 @@
|
||||
config:
|
||||
root: './.sessions'
|
||||
|
||||
# Lazy content index for session.search. Opening the database at boot does
|
||||
# not scan logs; the first search reconciles changed live/persisted sessions.
|
||||
# Lazy, process-owned content index for session.search. Opening the database
|
||||
# at boot does not scan logs; the first search reconciles changed
|
||||
# live/persisted sessions. The pid prevents concurrent dsh processes in the
|
||||
# same cwd from sharing one unsupported SQLite owner path.
|
||||
- id: session-query-sqlite
|
||||
name: '@deepseek-ai/dsh-session-query-sqlite'
|
||||
config:
|
||||
path: './.sessions/session-query.db'
|
||||
path: !!js "'./.sessions/session-query-' + process.pid + '.db'"
|
||||
|
||||
- id: storage
|
||||
name: '@deepseek-ai/dsh-storage'
|
||||
|
||||
@@ -41,6 +41,9 @@ const DEFAULT_MAX_MESSAGES = 50
|
||||
/** Product contract: sidebar search returns one bounded page and no cursor. */
|
||||
const SESSION_SEARCH_LIMIT = 20
|
||||
|
||||
/** Bound cold-log stat fan-out so an aborted search stops launching new work. */
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
@@ -170,15 +173,22 @@ function summarize(session: Session, running: boolean): SessionSummary {
|
||||
* updatedAt is the log file's mtime; backends without a per-session file
|
||||
* (locate() undefined) fall back to the header's createdAt.
|
||||
*/
|
||||
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
|
||||
async function summarizeCold(
|
||||
persistence: SessionPersistence,
|
||||
meta: SessionHeader,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionSummary> {
|
||||
signal?.throwIfAborted()
|
||||
let updatedAt = meta.createdAt
|
||||
const location = persistence.locate(meta)
|
||||
signal?.throwIfAborted()
|
||||
if (location !== undefined) {
|
||||
try {
|
||||
updatedAt = (await stat(location.path)).mtimeMs
|
||||
} catch {
|
||||
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
return {
|
||||
sessionId: meta.id,
|
||||
@@ -575,16 +585,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
* Attached sessions come from memory; servable cold sessions merge from
|
||||
* persistence, and the final order is newest-first.
|
||||
*/
|
||||
async function listVisibleSessionSummaries(): Promise<SessionSummary[]> {
|
||||
async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
|
||||
signal?.throwIfAborted()
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
})
|
||||
signal?.throwIfAborted()
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
const cold = (await persistence.list(signal))
|
||||
.filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
signal?.throwIfAborted()
|
||||
for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
|
||||
signal?.throwIfAborted()
|
||||
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
|
||||
items.push(...await Promise.all(
|
||||
batch.map(meta => summarizeCold(persistence, meta, signal)),
|
||||
))
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return items
|
||||
@@ -616,7 +637,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
try {
|
||||
const visible = await listVisibleSessionSummaries()
|
||||
const visible = await listVisibleSessionSummaries(signal)
|
||||
if (isAborted(signal)) return cancelled()
|
||||
if (visible.length === 0) return ok(request, { items: [], hasMore: false })
|
||||
const visibleIds = new Set(visible.map(item => item.sessionId))
|
||||
|
||||
@@ -220,6 +220,40 @@ describe('session.search', () => {
|
||||
expect(response.result.value.items.at(-1)?.sessionId).toBe('visible-19')
|
||||
})
|
||||
|
||||
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
|
||||
const ctx = await baseContext()
|
||||
const controller = new AbortController()
|
||||
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
|
||||
const list = vi.fn((signal?: AbortSignal) => {
|
||||
expect(signal).toBe(controller.signal)
|
||||
return Promise.resolve(cold)
|
||||
})
|
||||
let locateCalls = 0
|
||||
ctx.provide('sessionPersistence', {
|
||||
list,
|
||||
locate: () => {
|
||||
locateCalls++
|
||||
controller.abort()
|
||||
return undefined
|
||||
},
|
||||
} as never)
|
||||
const searchSessions = vi.fn()
|
||||
ctx.provide('sessionQuery', { searchSessions } as never)
|
||||
|
||||
const response = await createApiProxy(ctx, defaults).sessions.search(
|
||||
request('cancel-during-visibility'),
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
})
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
expect(locateCalls).toBe(1)
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps missing composition, query cancellation, and provider failure', async () => {
|
||||
const missingCtx = await baseContext()
|
||||
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
|
||||
|
||||
Reference in New Issue
Block a user