Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/compaction.i18n.yaml
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/cancel/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	examples/acp-agent/tests/snapshots/error-finish/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-read/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl
#	examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl
#	examples/acp-agent/tests/snapshots/multi-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl
#	examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
#	examples/acp-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl
#	examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl
#	examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl
#	examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/web-fetch/session.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
#	examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl
#	examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/src/client/sessions/request-inspection.ts
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/compact/compact-basic/src/index.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/tmux-context/README.i18n.yaml
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.md
#	packages/core/session/README.zh.md
#	packages/fs/tool-str-replace-editor/tests/tools.spec.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.md
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
#	packages/pty/tool-bash-persistent/tests/tools.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/tests/tui.spec.ts
#	packages/ui/user-approval/src/index.ts
#	packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
_Kerman
2026-07-31 22:16:40 +08:00
1010 changed files with 105572 additions and 5504 deletions

View File

@@ -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: ceffb3ac25bc8b5252d6cc40cd6389839dfce1e2
README.zh.md: 33afe99c3e065e231692e162549aef8e28e542e3
README.md: 4bf4d979f2d2954cd6280c80bf7f5988d8121fd1
README.zh.md: 6eb1f3bca034a974128eb33381b5d3c383b55d12

View File

@@ -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`. |

View File

@@ -16,6 +16,8 @@
该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,仅以不修改日志的方式检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`检查期间接入的活动所有者无法修改其日志稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该活动所有者脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。
`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise在任何搜索前处置服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。
持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时所有者消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。
该数据库虽可丢弃重建,但 reset 操作受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700``0600`SQLite sidecar 继承数据库 mode现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态由连接持有。
@@ -25,6 +27,7 @@
| 键 | 默认值 | 契约 |
|---|---:|---|
| `path` | 必填 | 专用派生索引 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`。 |

View File

@@ -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'
@@ -82,14 +82,19 @@ 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 {
/**
* Dedicated derived-index path; `:memory:` is supported for tests. Missing
* directories and database files are created owner-only on POSIX filesystems;
* existing modes are preserved.
* Dedicated derived-index path; `:memory:` is supported for ephemeral
* indexes. Missing directories and database files are created owner-only on
* 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. */
@@ -104,6 +109,7 @@ export interface Config extends SessionQueryConfig {
interface ResolvedConfig {
path: string
openAt: OpenAt
journalMode: JournalMode
defaultLimit: number
maxLimit: number
@@ -185,6 +191,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),
@@ -201,7 +208,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
@@ -218,7 +225,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 }
@@ -235,9 +241,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(
@@ -306,10 +312,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
@@ -325,6 +333,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) {
@@ -629,6 +638,8 @@ export class SessionQuerySqlite extends SessionQueryService {
offset,
]
assertPortableBindingCount(bindings.length)
// The browser fixture mirrors these rank keys in
// `packages/client/connection/src/client/fixture.ts`; update both together.
return this._requireDb().prepare(`
${selected.sql},
filtered AS (
@@ -956,6 +967,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,
@@ -967,6 +979,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)

View File

@@ -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 }

View File

@@ -1,4 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { DatabaseSync } from 'node:sqlite'
@@ -185,16 +185,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)
@@ -206,6 +209,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 () => {
@@ -230,6 +299,36 @@ describe('SQLite session search', () => {
.resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] })
})
it('excludes assistant reasoning while indexing visible answer text', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('reasoning'))
session.append(
'assistant/message',
{
turn: 1,
step: 1,
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: 'private-chain-marker' },
{ type: 'text', text: 'visible-answer-marker' },
],
source: { provider: 'mock', model: 'mock' },
}),
},
{ surfaceOp: 'append' },
)
await expect(ctx.sessionQuery.searchSessions({ query: 'private-chain-marker' }))
.resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchSessions({ query: 'visible-answer-marker' }))
.resolves.toMatchObject({
items: [{
header: { id: session.id },
bestMatch: { snippet: 'visible-answer-marker' },
}],
})
})
it('searches all surfaces by default and applies metadata before ranking', async () => {
const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 })
const parent = SessionId('parent')
@@ -538,6 +637,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()
@@ -1141,7 +1241,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const staleOwner = await liveContext({ path: stalePath })
await (staleOwner.sessionQuery as SessionQuerySqlite).close()
const stale = new DatabaseSync(stalePath)
stale.exec('PRAGMA user_version = 999')
stale.exec(`PRAGMA user_version = ${SESSION_QUERY_SQLITE_SCHEMA_VERSION - 1}`)
stale.close()
const staleCtx = await liveContext({ path: stalePath })
staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') })
@@ -1259,6 +1359,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) => {