From aa1dc0e2c75df10e309a5af455fcf02086482e91 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 10 Jul 2026 16:51:19 +0800 Subject: [PATCH 01/11] feat(session-query): checkpoint build round 1 --- docs/architecture.md | 3 +- docs/capability-seams.md | 10 +- docs/config-catalog.md | 20 + docs/cordis-catalog/events.md | 24 +- docs/cordis-catalog/services.md | 23 +- docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 7 + docs/rfc/INDEX.md | 2 + .../2026-07-10-session-query-service.md | 61 ++ ...026-07-10-sqlite-session-query-provider.md | 50 ++ packages/README.md | 3 +- .../cordis/tool-cordis/src/api-catalog.ts | 128 ++++ packages/core/session/README.md | 7 +- packages/core/session/src/index.ts | 19 +- packages/core/session/src/surface.ts | 191 +++-- packages/core/session/tests/session.spec.ts | 37 + packages/core/session/tests/surface.spec.ts | 30 +- .../session-persistence/README.md | 2 + .../session-persistence/src/coordinator.ts | 60 +- .../session-persistence/src/index.ts | 23 + .../tests/coordinator-contract.ts | 68 ++ packages/session-query/README.md | 9 + .../session-query/session-query/README.md | 46 ++ .../session-query/session-query/package.json | 44 ++ .../session-query/session-query/src/config.ts | 29 + .../session-query/session-query/src/corpus.ts | 221 ++++++ .../session-query/src/extraction.ts | 258 +++++++ .../session-query/src/filters.ts | 123 +++ .../session-query/session-query/src/index.ts | 225 ++++++ .../session-query/src/provider.ts | 328 ++++++++ .../session-query/src/tracing.ts | 158 ++++ .../session-query/session-query/src/types.ts | 292 ++++++++ .../session-query/tests/session-query.spec.ts | 704 ++++++++++++++++++ .../session-query/session-query/tsconfig.json | 30 + pnpm-lock.yaml | 19 + scripts/gen-doc-graphs.ts | 12 +- scripts/gen-module-graph.ts | 1 + tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 40 files changed, 3174 insertions(+), 102 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-10-session-query-service.md create mode 100644 docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md create mode 100644 packages/session-query/README.md create mode 100644 packages/session-query/session-query/README.md create mode 100644 packages/session-query/session-query/package.json create mode 100644 packages/session-query/session-query/src/config.ts create mode 100644 packages/session-query/session-query/src/corpus.ts create mode 100644 packages/session-query/session-query/src/extraction.ts create mode 100644 packages/session-query/session-query/src/filters.ts create mode 100644 packages/session-query/session-query/src/index.ts create mode 100644 packages/session-query/session-query/src/provider.ts create mode 100644 packages/session-query/session-query/src/tracing.ts create mode 100644 packages/session-query/session-query/src/types.ts create mode 100644 packages/session-query/session-query/tests/session-query.spec.ts create mode 100644 packages/session-query/session-query/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index 01ae6ef7cb..8a9cb0a4f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,6 +33,7 @@ Composition is preferred over inheritance. `packages/core/` is a repository grou | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live/persisted session retrieval and search-provider coordination | ## Event @@ -156,4 +157,4 @@ The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeleton - Exact event and service signatures in [events](cordis-catalog/events.md) - [services](cordis-catalog/services.md) catalogs - package contracts in the [package map](../packages/README.md) -- [RFCs](rfc/README.md) \ No newline at end of file +- [RFCs](rfc/README.md) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1c24a2916e..dc2f0a05bc 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -18,12 +18,14 @@ flowchart LR svc_sessions["ctx.sessions
In-memory session store"] pkg_agent["agent"] pkg_session_persistence["session-persistence"] + pkg_session_query["session-query"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_acp["acp"] + svc_sessionQuery["ctx.sessionQuery
Session retrieval read model"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -90,6 +92,7 @@ flowchart LR pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence + pkg_session_query --> svc_sessionQuery pkg_stdio_agent --> svc_userInteraction pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents @@ -122,10 +125,12 @@ flowchart LR svc_llm --> pkg_compact_basic svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop + svc_sessionPersistence --> pkg_session_query svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence + svc_sessions --> pkg_session_query svc_sessions --> pkg_subagent_inprocess svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop @@ -152,8 +157,9 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 772924df9c..d008878b6a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -483,6 +483,26 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:50`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +## `@deepseek-ai/dsh-session-query` + +Requires: `sessions` + +```ts config-catalog +/** Configuration for the provider-neutral session-query service. */ +export interface Config { + /** Explicit provider id; omitted auto-selects exactly one usable provider. */ + searchProvider?: string + /** Default search result page size. Defaults to 20. */ + defaultLimit?: number + /** Maximum accepted search page size. Defaults to 100. */ + maxLimit?: number + /** Maximum accepted raw read context on either side. Defaults to 50. */ + readWindowMax?: number +} +``` + +Source: [`packages/session-query/session-query/src/config.ts:17`](../packages/session-query/session-query/src/config.ts) + ## `@deepseek-ai/dsh-stdio-agent` ```ts config-catalog diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a9dfb7e05c..42be61a24e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -237,7 +237,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -247,7 +247,27 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:65`](../../packages/core/session/src/index.ts) + +### `session/persisted` — parallel + +A persistence backend committed a canonical session-log change. This is an observe-only notification for derived read models: the durable write has already succeeded, and listener failures are contained rather than propagated into append, load, flush, or teardown. + +```ts cordis-catalog +'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void +``` + +Source: [`packages/session-persistence/session-persistence/src/index.ts:50`](../../packages/session-persistence/session-persistence/src/index.ts) + +### `session/removed` — parallel + +A session left the live store. The header is snapshotted after the store entry is removed; listener failures are contained and cannot break the owning fiber's teardown. + +```ts cordis-catalog +'session/removed'(header: SessionHeader): Promise | void +``` + +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 79902ba2fc..04d07f5795 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -167,7 +167,26 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:125`](../../packages/session-persistence/session-persistence/src/index.ts) + +## `ctx.sessionQuery` — `SessionQueryService` + +Session-history retrieval and provider coordination service. + +```ts cordis-catalog +listSessions(): Promise +async listEvents(sessionId: SessionId): Promise +async readEvent(request: SessionEventReadRequest): Promise +async traceSession(sessionId: SessionId): Promise +async traceEvent(sessionId: SessionId, seq: number): Promise +registerSearchProvider(provider: SessionSearchProvider): () => void +registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void +registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void +searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise> +searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise> +``` + +Source: [`packages/session-query/session-query/src/index.ts:59`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -185,7 +204,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:405`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:413`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index acbe700965..23e3844375 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -24,8 +24,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | +| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 68c680c914..6e479d2111 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -75,6 +75,9 @@ flowchart TD pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] end + subgraph group_session_query["packages/session-query"] + pkg_session_query["session-query"] + end subgraph group_support["packages/support"] pkg_acp_snapshot["acp-snapshot"] pkg_invariants["invariants"] @@ -145,6 +148,9 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence pkg_invariants --> pkg_agent pkg_invariants --> pkg_llm pkg_invariants --> pkg_session @@ -297,6 +303,7 @@ flowchart TD | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ba2b9d88dc..4c77469f63 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -12,6 +12,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | | [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | +| [SQLite FTS5 session-query provider](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | ### Simplification @@ -67,6 +68,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | +| [Provider-neutral session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md new file mode 100644 index 0000000000..eeb3a13fc7 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -0,0 +1,61 @@ +# RFC: Provider-neutral session query service + +Status: implemented + +## Problem + +Session logs contain the harness's durable working memory, but the existing services expose them only as live objects or backend-specific persisted records. Consumers that want history search, compacted-event recall, lineage inspection, or another agent's status otherwise have to choose a storage backend, duplicate live-versus-persisted precedence, and reconstruct surface provenance independently. Live state also advances between persistence checkpoints, so treating durable storage as the only query source makes current-turn reads stale. + +Search is only one operation in that read model. Metadata filtering must compose without another database round trip, event and session lineage need deterministic graph semantics, and an event read must return exact canonical content rather than a search snippet. Folding all of those responsibilities into one SQLite package would make storage technology the public API and would prevent live-only deployments from using the non-search capabilities. + +## Decision + +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a trusted provider-neutral read model over one logical corpus: live `SessionStore` entries plus an optional, dynamically mounted `SessionPersistence` service. Matching ids resolve to one record. Live events take precedence because they include appends after the latest checkpoint; the record still exposes independent `live` and `persisted` flags. The service compares immutable headers and fails with a typed source-conflict error when the two sources cannot represent the same session. + +The service owns source observation, reconciliation, precedence, cloning, filters, tracing, extraction, and provider selection. It exposes lightweight session and event records, bounded exact-event reads, complete known session lineage, event surface/provenance traces, and two full-text scopes. A search backend owns only indexing, ranking, snippets, cursors, and backend-specific query validation. + +Persistence is optional. Live-only reads and provider synchronization work without it. Unmounting persistence hides the provider's durable base rather than deleting derived cache rows, so remounting can reuse fingerprints. An installed but unreadable backend fails cross-session operations; a read of a known live session remains independent of that failure. + +## Surface and lineage semantics + +`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seq range. Session-query derives `current`, `shadowed`, and `log-only` classifications and replacement chains from that result, so query and model-history derivation cannot disagree about positional replacement semantics. + +Event traces accept any raw event. They return direct `sourceEventSeqs` references, reverse references, nodes directly shadowed by a replacement, its immediate replacer, and the transitive replacement chain toward the current surface. Related content is deliberately not embedded; exact content remains the job of the bounded event read. + +Session traces walk parents nearest-first. A complete chain reports its root; a partial corpus reports the first unresolved parent id. Descendants form a complete known tree ordered by creation time and id. A cycle connected to the target is an invalid lineage error rather than a truncated result. + +## Filters and public records + +Serializable discriminated filter specs cover session identity, cwd, creation time, parent/root, availability, event seq/time/type, and surface status. Alternatives within one spec are OR; specs in a supplied array are AND. The exported generic transforms are pure, preserve order and item identity, and work on base records or richer hits. Search requests accept the same specs before ranking. Applying a transform to one materialized page never triggers a refill. + +Public records are intentionally small. `SessionRecord` carries a cloned header and source flags. `SessionEventRecord` carries session id, seq, type, time, and surface status. Search adds a plain snippet to event hits and exactly one best event to session hits; numeric provider scores remain private. Search pages default to 20 and reject limits above 100. Exact event reads default to no neighbors and cap each side with the configurable `readWindowMax`, default 50. + +## Lifecycle notifications + +Two observe-only Cordis notifications keep derived read models current without joining the write transaction. `session/removed` fires after a live entry leaves `SessionStore`. `session/persisted` fires only after an ordinary append or load-time repair commits and carries the affected seq range. Both snapshot their payloads and contain synchronous dispatch errors and rejected listeners, so observers cannot fail session teardown or durability. + +A persistence load preserves an existing live owner in coordinator state. HMR adoption of a torn durable prefix truncates only the uncommitted fragment while the live session remains authoritative; it does not publish a repair notification or synthesize an interrupted turn mid-turn. A later real append produces the ordinary committed notification. + +## Provider and extractor contracts + +A selected search provider receives separate persisted-base and live-override operations. Persisted reconciliation begins inactive, compares the provider inventory with SHA-256 fingerprints over canonicalized header/events and relevant extractor versions, replaces only changed sessions, removes proven-stale rows, and then activates the base. Live snapshots always replace the matching override; removal reveals an active persisted base. Search waits for relevant queued reconciliation, with corpus scope for session search and target scope for a live event search. A failed update stays retryable and fails affected searches with a typed derived-index error without affecting canonical writes. Caller cancellation stops waiting and reaches provider query work through `AbortSignal`. + +Core extractors cover semantic messages, reasoning, tools, todos, blocked prompts, context and steering, and error/status detail. Chunks, request headers, and structural events add no document. Declaration-merged event and content-block owners can install one effect-scoped extractor per type with a stable version; unknown types stay non-searchable. + +## Security boundary + +The service is context-wide trusted infrastructure, not an authorization layer. A model-facing history tool or human UI applies explicit caller/session scope before invoking cross-session operations. This decision exposes no unscoped model tool and changes no transcript or snapshot surface. + +## Alternatives considered + +- **Put all query behavior in a SQLite implementation** — rejected because filters, exact reads, source precedence, lineage, and surface provenance are storage-independent, and live-only deployments still need them. It would also let backend details become the public service contract. +- **Query only persisted sessions** — rejected because persistence checkpoints occur at turn boundaries; a current live session would be stale precisely when an agent inspects its latest work. +- **Mirror every live append into persistence before querying** — rejected because query observation must not add durability latency or change the turn checkpoint contract. The live override is an ephemeral derived layer. +- **Express every chained filter as SQL** — rejected because post-filters operate over already materialized pages and must preserve item identity and caller-chosen composition. Serializable pure transforms also remain usable without a search provider. +- **Make session-query part of the compaction capability** — rejected because retrieval reads all session structure and has consumers beyond recall; compaction is one producer of replacement provenance, not the owner of the read model. + +## Consequences + +Consumers gain one coherent API for current and durable history, deterministic traces, and backend-neutral search. Derived index failures and optional persistence are isolated from canonical session writes, and unchanged persisted sessions can reuse provider rows across restarts. + +The service carries non-trivial reconciliation state and performs canonical log loads to validate fingerprints. Cross-session search intentionally waits for whole-corpus synchronization, and live precedence means providers must implement a two-layer model. Authorization remains the responsibility of future consumers. Full-text search is unavailable until an implementation package registers a provider; that implementation is intentionally outside this decision's package. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md new file mode 100644 index 0000000000..08c1c6fc75 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -0,0 +1,50 @@ +# RFC: SQLite FTS5 session-query provider + +Status: proposed + +## Problem + +The provider-neutral session-query service defines full-text scopes and synchronization but deliberately ships no index. A first backend must search semantic event documents across large persisted histories without rebuilding unchanged sessions at every process start, while keeping unflushed live overrides current and disposable. It also needs deterministic ranking and pagination semantics strong enough for model tools and UI clients to continue a result set safely. + +Using the canonical session-persistence database directly would couple two failure domains and schemas: query rows are derived and rebuildable, while session logs are authoritative. A query schema reset, corrupt index, or experimental tokenizer must never endanger durable conversation history. + +## Proposal + +Add an `@deepseek-ai/dsh-session-query-sqlite` implementation in a separate phase-two pull request after the provider-neutral phase is complete. It will register one `SessionSearchProvider` on `ctx.sessionQuery` and own a separate derived SQLite database. Persisted event documents survive provider restarts; live overrides remain connection-local and disappear when the provider closes. + +The provider will use SQLite FTS5 with the trigram tokenizer. A query splits on whitespace and requires every term. Terms shorter than three characters fail with a typed provider error rather than silently changing matching semantics. Each searchable event is one document, including current, shadowed, and log-only states by default. Event search ranks documents within one session; session search groups by session and ranks it by exactly one strongest matching event. Ties are deterministic, public hits contain plain-text snippets, and numeric FTS scores remain internal. + +## Storage and reconciliation + +The database path, journal mode, page/result limits, and snippet length are validated configuration. Durable tables store provider schema version, persisted-session fingerprints, lightweight session metadata, event metadata, text, and the FTS virtual table. A provider-schema mismatch is the exceptional full reset; ordinary startup calls `persistedInventory()` and lets the service replace only new or changed sessions and remove canonical deletions. + +The live layer uses temporary or connection-local tables with the same searchable shape. A live snapshot shadows every persisted document for that session. Removing the override reveals the active persisted base. `setPersistedActive(false)` excludes durable rows from results without deleting their fingerprint cache. Reopening the database proves that persisted rows remain and live rows do not. + +## Query and cursor semantics + +Search request filters compile to parameterized metadata predicates before FTS ranking. Query terms are escaped as data, never interpolated into FTS syntax. Snippets are plain text with bounded length and no provider-specific markup contract. + +Opaque cursors bind to the normalized request shape and a generation. Session-search cursors bind to the global logical-corpus generation. Event-search cursors bind only to the target session generation. A relevant change makes the cursor stale and produces a typed error; unrelated session changes do not invalidate an inner-session cursor. Stable tie fields are encoded after rank so resumed pages neither duplicate nor skip hits. + +Provider update operations are transactional. An index write failure leaves the prior committed generation queryable only after the owning service has successfully retried the dirty update; affected searches fail rather than returning a knowingly stale page. Abort signals interrupt waits and SQLite query work where the runtime permits. + +## Alternatives considered + +- **Use the session-persistence SQLite database and add FTS tables there** — rejected because derived-index schema churn, resets, and corruption recovery must not share the authoritative log's transaction or failure boundary. +- **Persist live overrides immediately** — rejected because live events are not canonical until the existing persistence checkpoint commits. Ephemeral overlay rows preserve read-your-writes without inventing a second durability path. +- **Use the default FTS5 unicode tokenizer** — rejected for the first backend because substring-oriented history recall is a core use case. Trigram search gives predictable mid-token matching at the accepted cost of rejecting sub-three-character terms. +- **Return raw BM25 scores** — rejected because scores are provider-specific and unstable across corpus changes. Ranking is observable; numeric scale is not part of the service API. +- **Keep cursors valid across index changes** — rejected because rank and grouping can move after a relevant write, making continued pages duplicate or omit hits. + +## Acceptance criteria + +- Restart tests prove an unchanged persisted fingerprint performs no FTS replacement, while new, changed, and deleted sessions reconcile correctly. +- Reopening proves persisted rows survive, live rows disappear, removing a live override reveals its persisted base, and the provider works with no persistence service. +- Tests cover both search scopes, all metadata filters, surface defaults, snippets, AND-term escaping, short-term rejection, deterministic ties, pagination, request-bound cursors, scoped stale generations, cancellation, and recovery after a failed index update. +- A provider-schema mismatch resets only the derived database. Normal source changes never trigger a full reset. +- A keyless end-to-end restart test combines a real persistence backend with the real SQLite query provider. +- The implementation, package wiring, and tests land only in the separate phase-two pull request; phase one contains this proposal but no SQLite query code. + +## Risks + +Trigram indexes use more space than word-token indexes, and loading canonical logs to recompute fingerprints still has startup I/O cost even when FTS replacement is skipped. FTS5 ranking and snippet behavior can differ across SQLite runtime versions, so deterministic tie fields and provider-owned snippet tests must pin only the contract the package controls. A global generation makes cross-session cursors conservative: any corpus change invalidates them. The separate derived database adds configuration and lifecycle work, but it preserves the authoritative store's safety boundary. diff --git a/packages/README.md b/packages/README.md index a656812253..8bc5c07637 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,6 +1,6 @@ # Packages -Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin, declares its ctx key/events through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) (subtree) and the root [AGENTS.md](../AGENTS.md) § Conventions. +Harness packages use the `@deepseek-ai/dsh-*` scope and Cordis plugin model. Authoring conventions live in [packages/AGENTS.md](AGENTS.md) and the root [AGENTS.md](../AGENTS.md) § Conventions. ## Hierarchy @@ -23,6 +23,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, filters, tracing, and full-text provider seam | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 68fd63ae25..7b3cd1297f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -135,6 +135,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract list(): Promise', ], }, + { + key: 'sessionQuery', + summary: 'Session-history retrieval and provider coordination service.', + methods: [ + 'listSessions(): Promise', + 'async listEvents(sessionId: SessionId): Promise', + 'async readEvent(request: SessionEventReadRequest): Promise', + 'async traceSession(sessionId: SessionId): Promise', + 'async traceEvent(sessionId: SessionId, seq: number): Promise', + 'registerSearchProvider(provider: SessionSearchProvider): () => void', + 'registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void', + 'registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void', + 'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise>', + 'searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise>', + ], + }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', @@ -321,6 +337,18 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'session/flush\'(session: Session): Promise | void', summary: 'Awaited durability checkpoint.', }, + { + name: 'session/persisted', + mode: 'parallel', + signature: '\'session/persisted\'(header: SessionHeader, change: SessionPersistedChange): Promise | void', + summary: 'A persistence backend committed a canonical session-log change.', + }, + { + name: 'session/removed', + mode: 'parallel', + signature: '\'session/removed\'(header: SessionHeader): Promise | void', + summary: 'A session left the live store.', + }, { name: 'subagent/end', mode: 'emit', @@ -677,6 +705,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n}', }, + { + name: 'SessionContentTextExtractor', + declaration: 'export interface SessionContentTextExtractor {\n version: string;\n extract(block: ContentBlockMap[K]): readonly string[];\n}', + }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -685,10 +717,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventMap', declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: E /* …truncated — full shape in source */', }, + { + name: 'SessionEventReadRequest', + declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: number;\n before?: number;\n after?: number;\n}', + }, + { + name: 'SessionEventRecord', + declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', + }, + { + name: 'SessionEventResultFilter', + declaration: 'export type SessionEventResultFilter = {\n kind: \'seq\';\n range: SessionQueryRange;\n} | {\n kind: \'time\';\n range: SessionQueryRange;\n} | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n};', + }, + { + name: 'SessionEventSearchHit', + declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', + }, + { + name: 'SessionEventSearchRequest', + declaration: 'export interface SessionEventSearchRequest extends SessionSearchPageRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventResultFilter[];\n}', + }, + { + name: 'SessionEventSurface', + declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', + }, + { + name: 'SessionEventTextExtractor', + declaration: 'export interface SessionEventTextExtractor {\n version: string;\n extract(event: SessionEvent): readonly string[];\n}', + }, + { + name: 'SessionEventTrace', + declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n shadowedBy?: number;\n replacementChain: number[];\n shadows: number[];\n references: number[];\n referencedBy: number[];\n}', + }, { name: 'SessionEventType', declaration: 'export type SessionEventType = keyof SessionEventMap;', }, + { + name: 'SessionEventWindow', + declaration: 'export interface SessionEventWindow {\n session: SessionRecord;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', + }, { name: 'SessionForkSource', declaration: 'export type SessionForkSource = Session | SessionId;', @@ -701,6 +769,66 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionIndexDocument', + declaration: 'export interface SessionIndexDocument extends SessionEventRecord {\n text: string;\n}', + }, + { + name: 'SessionIndexSnapshot', + declaration: 'export interface SessionIndexSnapshot {\n session: SessionRecord;\n fingerprint: string;\n documents: readonly SessionIndexDocument[];\n}', + }, + { + name: 'SessionLineageNode', + declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n children: SessionLineageNode[];\n}', + }, + { + name: 'SessionLineageTrace', + declaration: 'export interface SessionLineageTrace {\n target: SessionRecord;\n parents: SessionRecord[];\n root?: SessionRecord;\n unresolvedParentId?: SessionId;\n children: SessionLineageNode[];\n}', + }, + { + name: 'SessionPersistedIndexEntry', + declaration: 'export interface SessionPersistedIndexEntry {\n sessionId: SessionId;\n fingerprint: string;\n}', + }, + { + name: 'SessionQueryExecContext', + declaration: 'export interface SessionQueryExecContext {\n readonly signal?: AbortSignal;\n}', + }, + { + name: 'SessionQueryRange', + declaration: 'export interface SessionQueryRange {\n from?: number;\n to?: number;\n}', + }, + { + name: 'SessionRecord', + declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', + }, + { + name: 'SessionResultFilter', + declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | {\n kind: \'created-at\';\n range: SessionQueryRange;\n} | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly (\'live\' | \'persisted\')[];\n};', + }, + { + name: 'SessionSearchHit', + declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}', + }, + { + name: 'SessionSearchPage', + declaration: 'export interface SessionSearchPage {\n providerId: string;\n items: readonly T[];\n nextCursor?: string;\n}', + }, + { + name: 'SessionSearchPageRequest', + declaration: 'export interface SessionSearchPageRequest {\n limit?: number;\n cursor?: string;\n}', + }, + { + name: 'SessionSearchProvider', + declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise>;\n}', + }, + { + name: 'SessionSearchProviderStatus', + declaration: 'export type SessionSearchProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'misconfigured\' | \'unavailable\';\n};', + }, + { + name: 'SessionSearchRequest', + declaration: 'export interface SessionSearchRequest extends SessionSearchPageRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventResultFilter[];\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};', diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 1cc393e172..89d2ffdf03 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -25,11 +25,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Events -| Event | Mode | Purpose | -|---|---|---| -| `session/created` | emit | A session was created | -| `session/event` | emit | An event was appended (sync, fire-and-forget) | -| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) | +The generated [Cordis event catalog](../../../docs/cordis-catalog/events.md) is the signature reference. `session/removed` is an observe-only notification emitted with a cloned header after the entry leaves the store; listener failures cannot fail owner teardown. ### Class: `Session` @@ -47,6 +43,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. +- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges. `SurfaceManager` shares the same transitions while retaining its incremental cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cd9e1828b2..88cb198c49 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -20,8 +20,8 @@ export * from './types.ts' export { isJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' -export type { SurfaceNode } from './surface.ts' -export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts' +export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { isToolPairingBalanced } from './tool-pairing.ts' export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts' @@ -37,6 +37,14 @@ declare module 'cordis' { * @mode emit */ 'session/created'(session: Session): void + /** + * A session left the live store. The header is snapshotted after the store + * entry is removed; listener failures are contained and cannot break the + * owning fiber's teardown. + * @param header - immutable identity and lineage of the removed session. + * @mode parallel + */ + 'session/removed'(header: SessionHeader): Promise | void /** * An event was appended to a session log (sync, fire-and-forget). This is * the per-append feed a UI or invariant plugin tails. @@ -501,8 +509,15 @@ export class SessionStore extends Service { session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } this.store.set(session.id, session) return () => { + if (this.store.get(session.id) !== session) return session.onAppend = undefined this.store.delete(session.id) + const header = structuredClone(session.header) + void Promise.resolve() + .then(() => this.ctx.parallel('session/removed', header)) + .catch((error: unknown) => { + this.ctx.logger.warn(`session store: session/removed listener failed for "${session.id}": ${String(error)}`) + }) } } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 7219856bdb..60c3633a1b 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -61,6 +61,125 @@ export interface SurfaceNode { next: number | null } +/** One replacement operation observed while folding a session surface. */ +export interface SurfaceFoldReplacement { + /** Seq of the event that replaced the prior surface range. */ + seq: number + /** Declared inclusive start seq of the replaced surface range. */ + start: number + /** Declared inclusive end seq of the replaced surface range. */ + end: number + /** Actual surface nodes removed by the operation, in surface order. */ + shadowedSeqs: number[] +} + +/** Complete result of replaying the surface operations in a session log. */ +export interface SurfaceFoldResult { + /** Current surface nodes in linked-list order. */ + nodes: SurfaceNode[] + /** Replacement operations in event order. */ + replacements: SurfaceFoldReplacement[] +} + +/** Mutable state shared by the incremental manager and the full-log fold. */ +interface SurfaceFoldState { + nodes: SurfaceNode[] + nodeBySeq: Map + replacements: SurfaceFoldReplacement[] + replaceGeneration: number +} + +/** Create an empty surface fold state. */ +function createFoldState(replaceGeneration = 0): SurfaceFoldState { + return { + nodes: [], + nodeBySeq: new Map(), + replacements: [], + replaceGeneration, + } +} + +/** Apply one event to a surface fold state. */ +function applySurfaceEvent(state: SurfaceFoldState, event: SessionEvent): void { + if (!isSurfaceEvent(event)) return + + if (event.surfaceOp === 'append') { + const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined + const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null } + if (tail) tail.next = event.seq + state.nodes.push(node) + state.nodeBySeq.set(event.seq, node) + return + } + + const shadowedSeqs = replaceSurface(state, event.seq, event.surfaceOp) + state.replacements.push({ + seq: event.seq, + start: event.surfaceOp.start, + end: event.surfaceOp.end, + shadowedSeqs, + }) +} + +/** Apply one positional replacement and return the nodes it removed. */ +function replaceSurface( + state: SurfaceFoldState, + newSeq: number, + op: Extract, +): number[] { + const startNode = state.nodeBySeq.get(op.start) + if (!startNode) { + throw new Error(`surface replace: start seq ${op.start} not found in surface`) + } + const endNode = state.nodeBySeq.get(op.end) + if (!endNode) { + throw new Error(`surface replace: end seq ${op.end} not found in surface`) + } + const startIdx = state.nodes.indexOf(startNode) + const endIdx = state.nodes.indexOf(endNode) + if (startIdx > endIdx) { + throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) + } + + const removed = state.nodes.splice(startIdx, endIdx - startIdx + 1) + for (const node of removed) state.nodeBySeq.delete(node.seq) + + const prevNode = startIdx > 0 ? state.nodes[startIdx - 1] : undefined + const nextNode = startIdx < state.nodes.length ? state.nodes[startIdx] : undefined + const newNode: SurfaceNode = { + seq: newSeq, + prev: prevNode?.seq ?? null, + next: nextNode?.seq ?? null, + } + if (prevNode) prevNode.next = newSeq + if (nextNode) nextNode.prev = newSeq + state.nodes.splice(startIdx, 0, newNode) + state.nodeBySeq.set(newSeq, newNode) + state.replaceGeneration += 1 + return removed.map(node => node.seq) +} + +/** + * Replay a complete session log through the canonical surface fold. + * + * The returned arrays and nodes are detached snapshots. The incremental + * {@link SurfaceManager} uses the same transition functions, so query read + * models cannot disagree with `deriveMessages()` about replacement ranges. + * @param events - session events in contiguous seq order. + * @returns the current surface and every positional replacement. + */ +export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { + const state = createFoldState() + for (const event of events) applySurfaceEvent(state, event) + return { + nodes: state.nodes.map(node => ({ ...node })), + replacements: state.replacements.map(replacement => ({ + ...replacement, + shadowedSeqs: [...replacement.shadowedSeqs], + })), + } +} + /** * Maintains a cached linked list of surface nodes, rebuilt lazily from * `surfaceOp` markers in the event log. Because the log is append-only, it @@ -69,16 +188,11 @@ export interface SurfaceNode { * whole log. */ export class SurfaceManager { - /** Surface nodes in linked-list order (head to tail). Empty until first access. */ - private _nodes: SurfaceNode[] = [] - /** Map from event seq → node. */ - private _nodeBySeq = new Map() + /** Incremental state shared with the complete surface fold. */ + private _state = createFoldState() /** The last processed seq. -1 forces a full rebuild on first access. */ private _lastProcessedSeq = -1 - /** Rewrite generation — see {@link replaceGeneration}. */ - private _replaceGeneration = 0 - constructor(private log: readonly SessionEvent[]) {} /** @@ -88,11 +202,9 @@ export class SurfaceManager { */ invalidate(): void { this._lastProcessedSeq = -1 - this._nodes = [] - this._nodeBySeq.clear() // A wholesale rebuild is a rewrite: bump the generation so incremental // consumers (the session's derived-message cache) discard their view. - this._replaceGeneration += 1 + this._state = createFoldState(this._state.replaceGeneration + 1) } /** @@ -106,13 +218,13 @@ export class SurfaceManager { */ get replaceGeneration(): number { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - return this._replaceGeneration + return this._state.replaceGeneration } /** The surface nodes in linked-list order (head to tail). */ get nodes(): readonly SurfaceNode[] { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - return this._nodes + return this._state.nodes } /** @@ -124,61 +236,8 @@ export class SurfaceManager { // Index is bounded by i < this.log.length — never undefined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const event = this.log[i]! - // isSurfaceEvent checks event.type first (is it a surface-eligible type?) - // then checks that surfaceOp is present. Only after both pass do we treat - // it as a SurfaceEvent with mandatory surfaceOp. - if (!isSurfaceEvent(event)) continue - - if (event.surfaceOp === 'append') { - const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined - const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null } - if (tail) tail.next = event.seq - this._nodes.push(node) - this._nodeBySeq.set(event.seq, node) - } else { - this._replace(event.seq, event.surfaceOp) - } + applySurfaceEvent(this._state, event) } this._lastProcessedSeq = this.log.length - 1 } - - /** Apply a replace operation to the in-progress surface. */ - private _replace( - newSeq: number, - op: Extract, - ): void { - const startNode = this._nodeBySeq.get(op.start) - if (!startNode) { - throw new Error(`surface replace: start seq ${op.start} not found in surface`) - } - const endNode = this._nodeBySeq.get(op.end) - if (!endNode) { - throw new Error(`surface replace: end seq ${op.end} not found in surface`) - } - const startIdx = this._nodes.indexOf(startNode) - const endIdx = this._nodes.indexOf(endNode) - if (startIdx > endIdx) { - throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) - } - - // Remove shadowed nodes from `[startIdx, endIdx]` inclusive. - const count = endIdx - startIdx + 1 - const removed = this._nodes.splice(startIdx, count) - for (const r of removed) this._nodeBySeq.delete(r.seq) - - // Insert the new node where the removed range was. - const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined - const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined - - const newNode: SurfaceNode = { - seq: newSeq, - prev: prevNode?.seq ?? null, - next: nextNode?.seq ?? null, - } - if (prevNode) prevNode.next = newSeq - if (nextNode) nextNode.prev = newSeq - this._nodes.splice(startIdx, 0, newNode) - this._nodeBySeq.set(newSeq, newNode) - this._replaceGeneration += 1 - } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f338b635f3..e51ed923df 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -350,6 +350,43 @@ describe('SessionStore', () => { expect(observed).toBe(0) }) + it('announces a cloned header only after the session leaves the store', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const observations: Array<{ id: string; live: boolean }> = [] + ctx.on('session/removed', (header) => { + observations.push({ id: header.id, live: ctx.sessions.get(header.id) !== undefined }) + header.createdAt = -1 + }) + const session = ctx.sessions.prepare(SessionId('removed'), { meta: { createdAt: 7 } }) + const detach = ctx.sessions.enter(session) + + detach() + await Promise.resolve() + await Promise.resolve() + + expect(observations).toEqual([{ id: 'removed', live: false }]) + expect(session.header.createdAt).toBe(7) + // A repeated disposer cannot remove or announce a later same-id owner. + const replacement = ctx.sessions.create(SessionId('removed')) + detach() + expect(ctx.sessions.get(replacement.id)).toBe(replacement) + expect(observations).toHaveLength(1) + }) + + it('contains rejected session/removed listeners during teardown', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) + const session = ctx.sessions.prepare(SessionId('contained')) + const detach = ctx.sessions.enter(session) + + expect(detach).not.toThrow() + await Promise.resolve() + await Promise.resolve() + expect(ctx.sessions.get(session.id)).toBeUndefined() + }) + it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 257828e466..e127b4a4b3 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import { Session, SessionId, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' /** Build a minimal session with turn boundaries and a single user message. */ @@ -14,6 +14,34 @@ function surfaceSession(): Session { } describe('SurfaceManager', () => { + it('shares exact nodes and nested replacement ranges with foldSurface', () => { + const s = new Session(SessionId('shared-fold')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }) + s.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary 2' }] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] }) + + const folded = foldSurface(s.events) + expect(folded.nodes).toEqual(s.surface.nodes) + expect(folded.replacements).toEqual([ + { seq: 2, start: 0, end: 0, shadowedSeqs: [0] }, + { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] }, + ]) + folded.nodes[0]!.next = 99 + folded.replacements[0]!.shadowedSeqs.push(99) + expect(s.surface.nodes).toEqual([{ seq: 3, prev: null, next: null }]) + expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0]) + }) + + it('foldSurface reports the same invalid replacement failures as the incremental manager', () => { + const s = new Session(SessionId('shared-fold-invalid')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] }) + + expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/) + expect(() => s.surface.nodes).toThrow(/start seq 42 not found/) + }) + it('rebuilds a linked list from surfaceOp: append markers', () => { const s = surfaceSession() const nodes = s.surface.nodes diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 8bd3fed568..557b968609 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -26,6 +26,8 @@ The two first-party backends were byte-identical (or same-algorithm) for ALL of `PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). +After an append or load-time repair commits, the coordinator emits the observe-only `session/persisted` notification described in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Its snapshotted header and seq range let derived read models invalidate safely; synchronous dispatch failures and rejected listeners are contained and never fail durability. Truncate-only HMR adoption emits no repair notification while the live session still owns the open turn. + The `PersistenceBackend` hooks (the only seam between the coordinator and storage): | Hook | Role | diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 0180999842..b04387dab1 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -27,7 +27,7 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { assertSerializable, seedCoversPrefix } from './index.ts' +import { assertSerializable, seedCoversPrefix, type SessionPersistedChange } from './index.ts' /** * A stored session's durable prefix as read back from a backend: its @@ -229,13 +229,13 @@ export class PersistenceCoordinator { // event inside it — before the op runs would otherwise have those changes // persisted. The clone is taken synchronously (at call time). const batch = events.map(e => structuredClone(e)) - return this.serialize(id, () => this.appendCore(id, batch)) + return this.serialize(id, () => this._appendCore(id, batch)) } - private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { + private async _appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { if (events.length === 0) return let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) // calls loadCore, not load + if (state === undefined) state = await this.adopt(id) // calls _loadCore, not load // Contiguity contract: each event's seq must continue the stored log. for (const [i, event] of events.entries()) { @@ -247,8 +247,14 @@ export class PersistenceCoordinator { await this.backend.appendBatch(state.meta, events, state.materialized) // The durable write is the transaction: mark materialized + advance the // cursor as soon as it commits (uniform across backends). + const fromSeq = state.cursor state.materialized = true state.cursor += events.length + this._notifyPersisted(state.meta, { + kind: 'append', + fromSeq, + toSeq: state.cursor - 1, + }) } /** @@ -259,10 +265,10 @@ export class PersistenceCoordinator { * @returns the header plus the event log, ending on a balanced `turn/end`. */ load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.serialize(id, () => this.loadCore(id)) + return this.serialize(id, () => this._loadCore(id)) } - private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + private async _loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) const { meta, events, tornMarker } = stored @@ -281,10 +287,21 @@ export class PersistenceCoordinator { // there is no state-path ordering dependency (uniform across backends). if (tornMarker !== undefined || closers.length > 0) { await this.backend.commitRepair(meta, tornMarker, closers) + this._notifyPersisted(meta, { + kind: 'repair', + fromSeq: events.length, + toSeq: balanced.length - 1, + }) } - // The state keeps its OWN copy of the meta; the returned value is separate so - // a consumer mutating loaded.meta cannot corrupt the backend's metadata. - this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) + const owner = this.states.get(id)?.owner + // The state keeps its OWN copy of the meta; preserve a live owner already + // bound to the id so a read-side load cannot downgrade adoption state. + this.states.set(id, { + meta: { ...meta }, + cursor: balanced.length, + materialized: true, + ...owner !== undefined ? { owner } : {}, + }) return { meta, events: balanced } } @@ -314,11 +331,11 @@ export class PersistenceCoordinator { /** Build a state for a session discovered in storage but not yet in memory. */ private async adopt(id: SessionId): Promise { - // loadCore (NOT load) — adopt runs inside an already-serialized op, so + // _loadCore (NOT load) — adopt runs inside an already-serialized op, so // re-entering the chain via the public load() would deadlock. - await this.loadCore(id) + await this._loadCore(id) const state = this.states.get(id) - /* v8 ignore next -- loadCore always sets the state for the id */ + /* v8 ignore next -- _loadCore always sets the state for the id */ if (!state) throw new Error(`failed to adopt session "${id}"`) return state } @@ -475,7 +492,7 @@ export class PersistenceCoordinator { // resume. const live = await this.backend.loadLive(id, session.header.cwd) if (live !== undefined) { - // Do NOT route through loadCore(): that crash-repairs open turns as + // Do NOT route through _loadCore(): that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. await this.serialize(id, () => this.adoptLivePrefix(session, seed, live)) @@ -515,7 +532,7 @@ export class PersistenceCoordinator { owner: session, }) const suffix = seed.slice(events.length) - if (suffix.length > 0) await this.appendCore(session.header.id, suffix) + if (suffix.length > 0) await this._appendCore(session.header.id, suffix) } private async flush(session: Session): Promise { @@ -546,9 +563,20 @@ export class PersistenceCoordinator { /* v8 ignore next -- state is always set by the awaited init before flush */ const cursor = state?.cursor ?? 0 const fresh = batch.filter(e => e.seq >= cursor) - // appendCore (NOT the serialized append) — drain already runs inside the + // _appendCore (NOT the serialized append) — drain already runs inside the // per-session chain, so re-entering via append() would deadlock. - if (fresh.length > 0) await this.appendCore(session.header.id, fresh) + if (fresh.length > 0) await this._appendCore(session.header.id, fresh) buffer.splice(0, batch.length) } + + /** Notify derived read models after source data commits. */ + private _notifyPersisted(meta: SessionHeader, change: SessionPersistedChange): void { + const header = structuredClone(meta) + const snapshot = structuredClone(change) + void Promise.resolve() + .then(() => this.ctx.parallel('session/persisted', header, snapshot)) + .catch((error: unknown) => { + this.ctx.logger.warn(`${this.backend.name}: session/persisted listener failed after ${change.kind} for "${meta.id}": ${String(error)}`) + }) + } } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 1588ed2526..ea98f70e9f 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -36,6 +36,29 @@ declare module 'cordis' { interface Context { sessionPersistence: SessionPersistence } + + interface Events { + /** + * A persistence backend committed a canonical session-log change. This is + * an observe-only notification for derived read models: the durable write + * has already succeeded, and listener failures are contained rather than + * propagated into append, load, flush, or teardown. + * @param header - snapshotted persisted session metadata. + * @param change - committed seq range and whether it was an append or repair. + * @mode parallel + */ + 'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void + } +} + +/** A committed persisted-log change observed by derived read models. */ +export interface SessionPersistedChange { + /** Whether ordinary append or load-time repair committed the change. */ + kind: 'append' | 'repair' + /** First seq affected by the commit. */ + fromSeq: number + /** Last seq appended; less than `fromSeq` when repair only removed a torn fragment. */ + toSeq: number } /** diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 42583c4fe3..6d95fbb4b2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -31,6 +31,7 @@ import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '../src/index.ts' +import type { SessionPersistedChange } from '../src/index.ts' import { meta, oneTurnLog, appendLog } from './contract.ts' /** @@ -123,6 +124,40 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('announces committed append and repair ranges without coupling listener failures to writes', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] + ctx.on('session/persisted', (header, change) => { + observed.push({ headerId: header.id, change: structuredClone(change) }) + header.createdAt = -1 + return Promise.reject(new Error('derived read model failed')) + }) + try { + const m = meta('notifications', WORK) + await ctx.sessionPersistence.create(m) + await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined() + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + ]) + await expect(ctx.sessionPersistence.load(m.id)).resolves.toMatchObject({ meta: { createdAt: m.createdAt } }) + await Promise.resolve() + await Promise.resolve() + + expect(observed).toEqual([ + { headerId: m.id, change: { kind: 'append', fromSeq: 0, toSeq: 5 } }, + { headerId: m.id, change: { kind: 'append', fromSeq: 6, toSeq: 7 } }, + { headerId: m.id, change: { kind: 'repair', fromSeq: 8, toSeq: 9 } }, + ]) + expect((await ctx.sessionPersistence.load(m.id)).meta.createdAt).toBe(m.createdAt) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + await fix.cleanup() + } + }) + it('round-trips the seed boundary (seedLength) through persistence', async () => { // A forked child records how many leading events were inherited via the // seed; the boundary must survive a reload (so a resume/replay can tell the @@ -368,6 +403,10 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Crash-tail a torn fragment past the (open) committed turn, then reload. await first.dispose() if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK) + const repairs: SessionPersistedChange[] = [] + ctx.on('session/persisted', (_header, change) => { + if (change.kind === 'repair') repairs.push(structuredClone(change)) + }) const second = await fix.mount(ctx) // The live session is still the authority: it appends the REAL step/turn // end. Adoption must truncate the torn tail but NOT synthesize closers. @@ -378,6 +417,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + expect(repairs).toEqual([]) await second.dispose() } finally { await ctx.fiber.dispose() @@ -385,6 +425,34 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('a query-side load preserves the existing live owner binding', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + let session!: Session + const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('load-owner'), { meta: { cwd: WORK } }) + send(session, oneTurnLog()) + }, { inject: ['sessions'] })) + try { + await ctx.parallel('session/flush', session) + const loaded = await ctx.sessionPersistence.load(session.id) + await liveFiber.dispose() + + let replacement!: Session + await ctx.plugin(Object.assign((inner: Context) => { + replacement = inner.sessions.create(session.id, { + seed: loaded.events, + meta: { cwd: WORK, createdAt: loaded.meta.createdAt }, + }) + }, { inject: ['sessions'] })) + await expect(inits(ctx.sessionPersistence).get(replacement)).rejects.toThrow(/different live session|id collision/) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + await fix.cleanup() + } + }) + // --- collision / id reuse --- it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => { diff --git a/packages/session-query/README.md b/packages/session-query/README.md new file mode 100644 index 0000000000..15e5b13295 --- /dev/null +++ b/packages/session-query/README.md @@ -0,0 +1,9 @@ +# session-query/ — session retrieval capability family + +Trusted read-model infrastructure over live and durable session logs. The interface package owns `ctx.sessionQuery`, logical-corpus resolution, filters, traces, text extractors, and the full-text provider contract. A search backend is a separate implementation package; a model tool or UI remains a separate consumer. + +| Package | Role | ctx key | +|---|---|---| +| [`session-query/`](session-query/README.md) | Retrieval service and provider contract | `ctx.sessionQuery` | + +The family is independent of the [compaction capability](../compact/README.md): it reads compaction provenance from the canonical session log but does not participate in compaction policy or execution. The provider-neutral decision is recorded in the [session-query RFC](../../docs/rfc/implemented/feature/2026-07-10-session-query-service.md); the first proposed backend is specified separately in the [SQLite provider RFC](../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md new file mode 100644 index 0000000000..ab06959ea5 --- /dev/null +++ b/packages/session-query/session-query/README.md @@ -0,0 +1,46 @@ +# @deepseek-ai/dsh-session-query + +Provider-neutral session-history retrieval (`ctx.sessionQuery`). The service presents live `ctx.sessions` state and, when mounted, `ctx.sessionPersistence` state as one logical corpus. A matching id produces one record: live events win, while independent `live` and `persisted` flags report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT` instead of silently merging unrelated histories. + +This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. + +## Reads and traces + +- `listSessions()` returns cloned lightweight records in deterministic newest-first order. +- `listEvents(sessionId)` classifies each raw event as `current`, `shadowed`, or `log-only` using the shared `dsh-session` surface fold. +- `readEvent(request)` returns the cloned target and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax` (default 50). +- `traceSession(sessionId)` returns nearest-first parents, a known root or explicit unresolved parent id, and the complete deterministic descendant tree. A connected lineage cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. +- `traceEvent(sessionId, seq)` returns direct provenance references and reverse references, direct shadows, the immediate replacer, and the transitive replacement chain toward the current surface node. Related nodes stay seq links; callers use `readEvent()` for content. + +An installed persistence backend is optional and may mount or unmount dynamically. Cross-session operations fail with `SESSION_QUERY_PERSISTENCE_FAILED` while installed persistence is unreadable. A read targeting a known live session never depends on persistence health. Provider-side persisted rows are deactivated rather than deleted when persistence is absent. + +## Filters + +`filterSessionResults()` and `filterEventResults()` are pure generic transforms over records or richer hits. Each discriminated filter is serializable. Values within one filter are OR alternatives; filters in the supplied array are an AND chain. The functions preserve order and item identity and return a fresh array. + +Session filters cover id, exact cwd, inclusive creation time, parent id/root, and live/persisted availability. Event filters cover inclusive seq/time, event type, and surface status. Search requests accept the same specs as pre-ranking filters. Applying the pure functions to a materialized provider page is a post-filter: it never fetches replacement hits to refill the page. + +## Full-text providers + +`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. + +The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. + +Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative. + +## Text extractors + +Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`. + +Extractor registrations are unique per discriminant and effect-scoped. Their stable versions participate in fingerprints, so changing extraction semantics invalidates only sessions whose indexed source uses that extractor. + +## Configuration + +| Key | Default | Contract | +|---|---:|---| +| `searchProvider` | omitted | Explicit provider id; omission requires exactly one available provider. | +| `defaultLimit` | `20` | Search page size when the request omits `limit`. | +| `maxLimit` | `100` | Maximum accepted search page size; must be at least `defaultLimit`. | +| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | + +The package ships no full-text backend and no model-facing tool. The proposed SQLite implementation is a later, independent phase described in the [SQLite provider RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json new file mode 100644 index 0000000000..a3a3a2839a --- /dev/null +++ b/packages/session-query/session-query/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-session-query", + "description": "Provider-neutral live and persisted session retrieval service (ctx.sessionQuery)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts new file mode 100644 index 0000000000..6f8443aeb1 --- /dev/null +++ b/packages/session-query/session-query/src/config.ts @@ -0,0 +1,29 @@ +/** + * Public configuration, defaults, and typed failures for session-query. + * + * @module @deepseek-ai/dsh-session-query/config + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** Default page size for provider-backed search. */ +export const SESSION_QUERY_DEFAULT_LIMIT = 20 +/** Maximum page size accepted by provider-backed search. */ +export const SESSION_QUERY_MAX_LIMIT = 100 +/** Default maximum `before`/`after` raw-event window. */ +export const SESSION_QUERY_READ_WINDOW_MAX = 50 + +/** Configuration for the provider-neutral session-query service. */ +export interface Config { + /** Explicit provider id; omitted auto-selects exactly one usable provider. */ + searchProvider?: string + /** Default search result page size. Defaults to 20. */ + defaultLimit?: number + /** Maximum accepted search page size. Defaults to 100. */ + maxLimit?: number + /** Maximum accepted raw read context on either side. Defaults to 50. */ + readWindowMax?: number +} + +/** Typed session-query failure with a stable machine-routable code. */ +export class SessionQueryError extends HarnessError {} diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts new file mode 100644 index 0000000000..980e11e416 --- /dev/null +++ b/packages/session-query/session-query/src/corpus.ts @@ -0,0 +1,221 @@ +/** Live/persisted logical-corpus resolution for session-query. */ + +import type { Context } from 'cordis' +import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import type { SessionRecord } from './types.ts' +import type { LoadedSession } from './extraction.ts' +import { canonicalJson } from './extraction.ts' +import { SessionQueryError } from './config.ts' + +interface PersistenceBinding { + token: symbol + service: SessionPersistence + headers: Map + error?: unknown + refreshing: Promise | undefined +} + +/** Active persistence view used by provider reconciliation. */ +export interface PersistenceView { + /** Canonical headers in deterministic creation order. */ + headers: SessionHeader[] + /** Load one canonical persisted source. */ + load(id: SessionId): Promise +} + +/** Resolves one live-preferred corpus while containing optional persistence lifecycle. */ +export class SessionCorpus { + private _persistence: PersistenceBinding | undefined + + constructor( + private readonly _ctx: Context, + private readonly _onPersistenceChange: (active: boolean) => void, + ) { + _ctx.effect(() => { + const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { + this._attachPersistence(childCtx, childCtx.sessionPersistence) + }) + return () => void fiber.dispose() + }, 'sessionQuery.optionalPersistence') + } + + /** + * List the complete logical corpus with live precedence and cloned headers. + * @returns logical records in deterministic newest-first order. + */ + async listSessions(): Promise { + const binding = await this._ensurePersistence() + const records = new Map() + if (binding !== undefined) { + for (const header of binding.headers.values()) { + records.set(header.id, { header: structuredClone(header), live: false, persisted: true }) + } + } + for (const session of this._ctx.sessions.list()) { + const persisted = binding?.headers.get(session.id) + if (persisted !== undefined) this._assertCompatibleHeaders(session.header, persisted) + records.set(session.id, { + header: structuredClone(session.header), + live: true, + persisted: persisted !== undefined, + }) + } + return [...records.values()].sort(compareSessions) + } + + /** + * Load one logical source, preferring a detached live snapshot. + * @param sessionId - session to resolve. + * @returns detached live-preferred metadata and events. + */ + async loadLogical(sessionId: SessionId): Promise { + const live = this._ctx.sessions.get(sessionId) + if (live !== undefined) return this.snapshotLive(live) + const binding = await this._ensurePersistence() + if (binding === undefined || !binding.headers.has(sessionId)) { + throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } + return this._loadPersisted(binding, sessionId) + } + + /** + * Return a detached live source with current availability flags. + * @param session - live session to snapshot. + * @returns detached metadata and events. + */ + snapshotLive(session: Session): LoadedSession { + const persistedHeader = this._persistence?.headers.get(session.id) + if (persistedHeader !== undefined) this._assertCompatibleHeaders(session.header, persistedHeader) + return { + record: { + header: structuredClone(session.header), + live: true, + persisted: persistedHeader !== undefined, + }, + events: session.events.map(event => structuredClone(event)), + } + } + + /** + * Get one live session without consulting persistence. + * @param sessionId - live id to resolve. + * @returns current store object, or undefined. + */ + getLive(sessionId: SessionId): Session | undefined { + return this._ctx.sessions.get(sessionId) + } + + /** + * List live sessions in store order. + * @returns fresh array of current store objects. + */ + listLive(): Session[] { + return this._ctx.sessions.list() + } + + /** + * Resolve an authoritative persisted view. + * @returns cloned headers and loader, or undefined while unmounted. + */ + async persistenceView(): Promise { + const binding = await this._ensurePersistence() + if (binding === undefined) return undefined + return { + headers: [...binding.headers.values()].map(header => structuredClone(header)).sort(compareHeadersAscending), + load: id => this._loadPersisted(binding, id), + } + } + + private _attachPersistence(ctx: Context, service: SessionPersistence): void { + const binding: PersistenceBinding = { + token: Symbol('session-query-persistence'), + service, + headers: new Map(), + refreshing: undefined, + } + this._persistence = binding + this._onPersistenceChange(true) + void this._refreshPersistence(binding) + ctx.on('session/persisted', (header) => { + /* v8 ignore next -- a stale notification can race optional-service disposal */ + if (this._persistence?.token !== binding.token) return + binding.headers.set(header.id, structuredClone(header)) + this._onPersistenceChange(true) + }) + ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') + } + + private _detachPersistence(binding: PersistenceBinding): void { + /* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */ + if (this._persistence?.token !== binding.token) return + this._persistence = undefined + this._onPersistenceChange(false) + } + + private _refreshPersistence(binding: PersistenceBinding): Promise { + if (binding.refreshing !== undefined) return binding.refreshing + const refresh = binding.service.list().then((headers) => { + /* v8 ignore next -- a list completion can race optional-service disposal */ + if (this._persistence?.token !== binding.token) return + binding.headers = new Map(headers.map(header => [header.id, structuredClone(header)])) + binding.error = undefined + this._onPersistenceChange(true) + }).catch((error: unknown) => { + /* v8 ignore next -- a failed list can race optional-service disposal */ + if (this._persistence?.token !== binding.token) return + binding.error = error + }).finally(() => { + /* v8 ignore next -- a newer refresh may already own the slot */ + if (binding.refreshing === refresh) binding.refreshing = undefined + }) + binding.refreshing = refresh + return refresh + } + + private async _ensurePersistence(): Promise { + const binding = this._persistence + if (binding === undefined) return undefined + await this._refreshPersistence(binding) + if (binding.error !== undefined) { + const cause = binding.error + throw new SessionQueryError(`session persistence listing failed: ${errorMessage(cause)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause }) + } + return binding + } + + private async _loadPersisted(binding: PersistenceBinding, sessionId: SessionId): Promise { + try { + const loaded = await binding.service.load(sessionId) + const listed = binding.headers.get(sessionId) + /* v8 ignore else -- every internal persisted load starts from a listed header */ + if (listed !== undefined) this._assertCompatibleHeaders(loaded.meta, listed) + return { + record: { header: structuredClone(loaded.meta), live: false, persisted: true }, + events: loaded.events.map(event => structuredClone(event)), + } + } catch (error: unknown) { + if (error instanceof SessionQueryError) throw error + throw new SessionQueryError(`failed to load session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error }) + } + } + + private _assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { + if (canonicalJson(a) !== canonicalJson(b)) { + throw new SessionQueryError(`live and persisted headers conflict for session "${a.id}"`, 'SESSION_QUERY_SOURCE_CONFLICT') + } + } +} + +function compareSessions(a: SessionRecord, b: SessionRecord): number { + return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id) +} + +function compareHeadersAscending(a: SessionHeader, b: SessionHeader): number { + return a.createdAt - b.createdAt || a.id.localeCompare(b.id) +} + +function errorMessage(error: unknown): string { + /* v8 ignore next -- persistence service contracts reject Error instances */ + return error instanceof Error ? error.message : 'unknown error' +} diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts new file mode 100644 index 0000000000..58db38c8ef --- /dev/null +++ b/packages/session-query/session-query/src/extraction.ts @@ -0,0 +1,258 @@ +/** Semantic text extraction and stable provider snapshot fingerprints. */ + +import { createHash } from 'node:crypto' +import type { Context } from 'cordis' +import type { ContentBlock, ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionEventType } from '@deepseek-ai/dsh-session' +import type { + SessionContentTextExtractor, + SessionEventTextExtractor, + SessionIndexDocument, + SessionIndexSnapshot, + SessionRecord, +} from './types.ts' +import { SessionQueryError } from './config.ts' +import { eventRecords } from './tracing.ts' + +/** Canonical session source consumed by extraction and provider reconciliation. */ +export interface LoadedSession { + /** Logical source metadata. */ + record: SessionRecord + /** Detached canonical events. */ + events: SessionEvent[] +} + +interface StoredEventExtractor { + version: string + extract(event: SessionEvent): readonly string[] +} + +interface StoredContentExtractor { + version: string + extract(block: ContentBlock): readonly string[] +} + +/** Owns core/custom semantic extractors and builds versioned index snapshots. */ +export class SessionTextExtractors { + private readonly _eventExtractors = new Map() + private readonly _contentExtractors = new Map() + + constructor(private readonly _onChange: () => void) { + this._installCoreExtractors() + } + + /** + * Register one effect-scoped event extractor. + * @param ctx - contributing caller context. + * @param type - event discriminant. + * @param extractor - versioned semantic extractor. + * @returns disposer for the registration. + */ + registerEvent( + ctx: Context, + type: K, + extractor: SessionEventTextExtractor, + ): () => void { + this._validateVersion(type, extractor.version) + if (this._eventExtractors.has(type)) { + throw new SessionQueryError(`session event text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') + } + const stored: StoredEventExtractor = { + version: extractor.version, + extract: event => extractor.extract(event as SessionEvent), + } + const dispose = ctx.effect(function* (this: SessionTextExtractors) { + this._eventExtractors.set(type, stored) + this._onChange() + yield () => { + this._eventExtractors.delete(type) + this._onChange() + } + }.bind(this), `sessionQuery.eventExtractor(${type})`) + return () => void dispose() + } + + /** + * Register one effect-scoped content-block extractor. + * @param ctx - contributing caller context. + * @param type - content-block discriminant. + * @param extractor - versioned semantic extractor. + * @returns disposer for the registration. + */ + registerContent( + ctx: Context, + type: K, + extractor: SessionContentTextExtractor, + ): () => void { + this._validateVersion(type, extractor.version) + if (this._contentExtractors.has(type)) { + throw new SessionQueryError(`session content text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') + } + const stored: StoredContentExtractor = { + version: extractor.version, + extract: block => extractor.extract(block as ContentBlockMap[K]), + } + const dispose = ctx.effect(function* (this: SessionTextExtractors) { + this._contentExtractors.set(type, stored) + this._onChange() + yield () => { + this._contentExtractors.delete(type) + this._onChange() + } + }.bind(this), `sessionQuery.contentExtractor(${type})`) + return () => void dispose() + } + + /** + * Build one provider-neutral snapshot and SHA-256 source/version fingerprint. + * @param loaded - detached canonical source. + * @returns lightweight documents and stable fingerprint. + */ + buildSnapshot(loaded: LoadedSession): SessionIndexSnapshot { + const records = eventRecords(loaded.record.header.id, loaded.events) + const documents: SessionIndexDocument[] = [] + const eventVersions = new Set() + const blockVersions = new Set() + for (const event of loaded.events) { + const extractor = this._eventExtractors.get(event.type) + if (extractor === undefined) continue + eventVersions.add(`${event.type}@${extractor.version}`) + collectBlockVersions(event.data, this._contentExtractors, blockVersions) + const text = normalizeText(extractor.extract(event)) + if (text.length === 0) continue + // The event record array parallels the contiguous log. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + documents.push({ ...records[event.seq]!, text }) + } + const fingerprint = createHash('sha256').update(canonicalJson({ + header: loaded.record.header, + events: loaded.events, + eventExtractors: [...eventVersions].sort(), + contentExtractors: [...blockVersions].sort(), + })).digest('hex') + return { + session: cloneRecord(loaded.record), + fingerprint, + documents, + } + } + + private _installCoreExtractors(): void { + this._contentExtractors.set('text', { version: '1', extract: block => [(block as ContentBlockMap['text']).text] }) + this._contentExtractors.set('reasoning', { version: '1', extract: block => [(block as ContentBlockMap['reasoning']).text] }) + this._contentExtractors.set('tool-call', { + version: '1', + extract: (block) => { + const call = block as ContentBlockMap['tool-call'] + return [call.name, call.arguments] + }, + }) + this._contentExtractors.set('tool-result', { + version: '1', + extract: block => this._extractBlocks((block as ContentBlockMap['tool-result']).content), + }) + for (const type of ['user/message', 'assistant/message', 'context/message', 'steering/message'] as const) { + this._eventExtractors.set(type, { + version: '1', + extract: event => this._extractBlocks((event as SessionEvent).data.content), + }) + } + this._eventExtractors.set('prompt/blocked', { + version: '1', + extract: (event) => { + const data = (event as SessionEvent<'prompt/blocked'>).data + return [...this._extractBlocks(data.content), data.reason] + }, + }) + this._eventExtractors.set('tool/call', { + version: '1', + extract: (event) => { + const data = (event as SessionEvent<'tool/call'>).data + return [data.name, data.arguments] + }, + }) + this._eventExtractors.set('tool/result', { + version: '1', + extract: (event) => { + const data = (event as SessionEvent<'tool/result'>).data + return [...this._extractBlocks(data.content), data.error?.name ?? '', data.error?.code ?? ''] + }, + }) + this._eventExtractors.set('todo/write', { + version: '1', + extract: event => (event as SessionEvent<'todo/write'>).data.todos.map(todo => `${todo.status} ${todo.content}`), + }) + this._eventExtractors.set('turn/end', { + version: '1', + extract: (event) => { + const reason = (event as SessionEvent<'turn/end'>).data.reason + switch (reason.kind) { + case 'error': return ['error', reason.message, reason.code ?? ''] + case 'aborted': return ['aborted', reason.reason ?? ''] + case 'rejected': return ['rejected', reason.reason] + case 'disposed': return ['disposed'] + case 'max-tokens': return ['max-tokens'] + case 'interrupted': return ['interrupted'] + case 'completed': return [] + // TurnEndReasonMap is merge-extensible; unknown variants contribute no text. + /* v8 ignore next -- only an external declaration-merged reason can reach this fallback */ + default: return [] + } + }, + }) + } + + private _extractBlocks(blocks: readonly ContentBlock[]): string[] { + const fragments: string[] = [] + for (const block of blocks) { + const extractor = this._contentExtractors.get(block.type) + if (extractor !== undefined) fragments.push(...extractor.extract(block)) + } + return fragments + } + + private _validateVersion(type: string, version: string): void { + if (version.trim().length === 0) { + throw new SessionQueryError(`session-query extractor "${type}" requires a non-blank version`, 'SESSION_QUERY_INVALID_EXTRACTOR') + } + } +} + +/** + * Encode canonical JSON with recursively sorted object keys. + * @param value - JSON-compatible source value. + * @returns deterministic JSON text. + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + const object = value as Record + return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}` +} + +function normalizeText(fragments: readonly string[]): string { + return fragments.map(fragment => fragment.trim()).filter(Boolean).join('\n') +} + +function collectBlockVersions( + value: unknown, + extractors: ReadonlyMap, + versions: Set, +): void { + if (Array.isArray(value)) { + for (const item of value) collectBlockVersions(item, extractors, versions) + return + } + if (value === null || typeof value !== 'object') return + const object = value as Record + if (typeof object.type === 'string') { + const type = object.type as ContentBlockType + const extractor = extractors.get(type) + if (extractor !== undefined) versions.add(`${type}@${extractor.version}`) + } + for (const nested of Object.values(object)) collectBlockVersions(nested, extractors, versions) +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts new file mode 100644 index 0000000000..296361cfbf --- /dev/null +++ b/packages/session-query/session-query/src/filters.ts @@ -0,0 +1,123 @@ +/** Pure serializable session-query result filters. */ + +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { + SessionEventRecord, + SessionEventResultFilter, + SessionQueryRange, + SessionRecord, + SessionResultFilter, +} from './types.ts' +import { SessionQueryError } from './config.ts' + +const AVAILABILITIES = ['live', 'persisted'] as const +const SURFACE_STATES = ['current', 'shadowed', 'log-only'] as const + +/** + * Apply an ordered AND-chain of session filters while preserving item order + * and the concrete generic item type. + * @param results - session records or richer session search hits. + * @param filters - serializable filters applied in order. + * @returns a fresh filtered array. + */ +export function filterSessionResults( + results: readonly T[], + filters: readonly SessionResultFilter[], +): T[] { + for (const filter of filters) validateSessionFilter(filter) + return results.filter(result => filters.every(filter => matchesSessionFilter(result, filter))) +} + +/** + * Apply an ordered AND-chain of event filters while preserving item order and + * the concrete generic item type. + * @param results - event records or richer event search hits. + * @param filters - serializable filters applied in order. + * @returns a fresh filtered array. + */ +export function filterEventResults( + results: readonly T[], + filters: readonly SessionEventResultFilter[], +): T[] { + for (const filter of filters) validateEventFilter(filter) + return results.filter(result => filters.every(filter => matchesEventFilter(result, filter))) +} + +function matchesSessionFilter(record: SessionRecord, filter: SessionResultFilter): boolean { + switch (filter.kind) { + case 'id': return filter.values.includes(record.header.id) + case 'cwd': return filter.values.includes(record.header.cwd ?? null) + case 'created-at': return inRange(record.header.createdAt, filter.range) + case 'parent': return filter.values.includes(record.header.parentSession ?? null) + case 'availability': return filter.values.some(value => value === 'live' ? record.live : record.persisted) + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: return assertNever(filter) + } +} + +function matchesEventFilter(record: SessionEventRecord, filter: SessionEventResultFilter): boolean { + switch (filter.kind) { + case 'seq': return inRange(record.seq, filter.range) + case 'time': return inRange(record.time, filter.range) + case 'type': return filter.values.includes(record.type) + case 'surface': return filter.values.includes(record.surface) + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: return assertNever(filter) + } +} + +function validateSessionFilter(filter: SessionResultFilter): void { + switch (filter.kind) { + case 'id': + case 'cwd': + case 'parent': + return + case 'created-at': + validateRange('created-at', filter.range) + return + case 'availability': + for (const value of filter.values) { + if (!(AVAILABILITIES as readonly string[]).includes(value)) invalidFilter(`unknown availability "${value}"`) + } + return + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: + assertNever(filter) + } +} + +function validateEventFilter(filter: SessionEventResultFilter): void { + switch (filter.kind) { + case 'seq': + case 'time': + validateRange(filter.kind, filter.range) + return + case 'type': + return + case 'surface': + for (const value of filter.values) { + if (!(SURFACE_STATES as readonly string[]).includes(value)) invalidFilter(`unknown surface status "${value}"`) + } + return + /* v8 ignore next -- closed discriminated union exhaustiveness guard */ + default: + assertNever(filter) + } +} + +function validateRange(name: string, range: SessionQueryRange): void { + if (range.from !== undefined && !Number.isFinite(range.from)) invalidFilter(`${name}.from must be finite`) + if (range.to !== undefined && !Number.isFinite(range.to)) invalidFilter(`${name}.to must be finite`) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + invalidFilter(`${name}.from must be <= ${name}.to`) + } +} + +function invalidFilter(message: string): never { + throw new SessionQueryError(`session-query filter: ${message}`, 'SESSION_QUERY_INVALID_FILTER') +} + +function inRange(value: number, range: SessionQueryRange): boolean { + return (range.from === undefined || value >= range.from) + && (range.to === undefined || value <= range.to) +} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts new file mode 100644 index 0000000000..46e3c15c32 --- /dev/null +++ b/packages/session-query/session-query/src/index.ts @@ -0,0 +1,225 @@ +/** + * Provider-neutral session-history retrieval over live and optionally + * persisted session logs. The public service composes logical-corpus reads, + * pure filters and tracing, semantic extraction, and provider coordination. + * + * @module @deepseek-ai/dsh-session-query + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { ContentBlockType } from '@deepseek-ai/dsh-llm' +import type { SessionEventType, SessionId } from '@deepseek-ai/dsh-session' +import type { + SessionContentTextExtractor, + SessionEventReadRequest, + SessionEventRecord, + SessionEventSearchHit, + SessionEventSearchRequest, + SessionEventTextExtractor, + SessionEventTrace, + SessionEventWindow, + SessionLineageTrace, + SessionRecord, + SessionSearchHit, + SessionSearchPage, + SessionSearchProvider, + SessionSearchRequest, + SessionQueryExecContext, +} from './types.ts' +import { + SESSION_QUERY_DEFAULT_LIMIT, + SESSION_QUERY_MAX_LIMIT, + SESSION_QUERY_READ_WINDOW_MAX, + SessionQueryError, + type Config, +} from './config.ts' +import { SessionTextExtractors } from './extraction.ts' +import { SessionCorpus } from './corpus.ts' +import { SessionProviderCoordinator } from './provider.ts' +import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' + +export type * from './types.ts' +export type { Config } from './config.ts' +export { + SESSION_QUERY_DEFAULT_LIMIT, + SESSION_QUERY_MAX_LIMIT, + SESSION_QUERY_READ_WINDOW_MAX, + SessionQueryError, +} from './config.ts' +export { filterEventResults, filterSessionResults } from './filters.ts' + +declare module 'cordis' { + interface Context { + sessionQuery: SessionQueryService + } +} + +/** Session-history retrieval and provider coordination service. */ +export class SessionQueryService extends Service { + static inject = ['sessions'] + static Config: z = z.object({ + searchProvider: z.string(), + defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_DEFAULT_LIMIT), + maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_MAX_LIMIT), + readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), + }) + + private readonly _readWindowMax: number + private readonly _extractors: SessionTextExtractors + private readonly _providers: SessionProviderCoordinator + private readonly _corpus: SessionCorpus + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'sessionQuery') + const defaultLimit = config.defaultLimit ?? SESSION_QUERY_DEFAULT_LIMIT + const maxLimit = config.maxLimit ?? SESSION_QUERY_MAX_LIMIT + this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX + if (defaultLimit > maxLimit) { + throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG') + } + this._extractors = new SessionTextExtractors(() => { this._providers.invalidateAll() }) + this._providers = new SessionProviderCoordinator(ctx, { + ...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {}, + defaultLimit, + maxLimit, + }, () => this._corpus, this._extractors) + this._corpus = new SessionCorpus(ctx, (active) => { this._providers.persistenceChanged(active) }) + } + + /** + * List the complete logical corpus using live-preferred records. + * @returns deterministic newest-first cloned session records. + */ + listSessions(): Promise { + return this._corpus.listSessions() + } + + /** + * List lightweight raw-log event records for one logical session. + * @param sessionId - live-preferred session id to read. + * @returns event records in ascending seq order. + */ + async listEvents(sessionId: SessionId): Promise { + const loaded = await this._corpus.loadLogical(sessionId) + return eventRecords(sessionId, loaded.events) + } + + /** + * Read one full event plus a bounded raw-log context window. + * @param request - target session/seq and context sizes. + * @returns cloned target and neighboring events. + */ + async readEvent(request: SessionEventReadRequest): Promise { + const before = this._readWindow('before', request.before) + const after = this._readWindow('after', request.after) + const loaded = await this._corpus.loadLogical(request.sessionId) + const target = loaded.events[request.seq] + if (target === undefined || target.seq !== request.seq) { + throw new SessionQueryError(`session "${request.sessionId}" has no event at seq ${request.seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') + } + const startSeq = Math.max(0, request.seq - before) + const endSeq = Math.min(loaded.events.length - 1, request.seq + after) + return { + session: cloneRecord(loaded.record), + target: structuredClone(target), + events: loaded.events.slice(startSeq, endSeq + 1).map(event => structuredClone(event)), + startSeq, + endSeq, + } + } + + /** + * Trace parent ancestry and the complete known descendant tree of a session. + * @param sessionId - logical session id to trace. + * @returns complete or explicitly partial lineage. + */ + async traceSession(sessionId: SessionId): Promise { + return traceLineage(await this._corpus.listSessions(), sessionId) + } + + /** + * Trace direct provenance and surface replacement relationships for any event. + * @param sessionId - logical session containing the target. + * @param seq - target event seq. + * @returns lightweight trace with related seq links. + */ + async traceEvent(sessionId: SessionId, seq: number): Promise { + return traceEventLog(sessionId, (await this._corpus.loadLogical(sessionId)).events, seq) + } + + /** + * Register one full-text provider with effect-scoped disposal. + * @param provider - provider and synchronization implementation. + * @returns disposer that unregisters the provider. + */ + registerSearchProvider(provider: SessionSearchProvider): () => void { + return this._providers.register(this.ctx, provider) + } + + /** + * Register semantic text extraction for one event type. + * @param type - declaration-merged event discriminant. + * @param extractor - stable version and typed extraction callback. + * @returns disposer that removes the extractor. + */ + registerEventTextExtractor( + type: K, + extractor: SessionEventTextExtractor, + ): () => void { + return this._extractors.registerEvent(this.ctx, type, extractor) + } + + /** + * Register semantic text extraction for one content block type. + * @param type - declaration-merged content-block discriminant. + * @param extractor - stable version and typed extraction callback. + * @returns disposer that removes the extractor. + */ + registerContentTextExtractor( + type: K, + extractor: SessionContentTextExtractor, + ): () => void { + return this._extractors.registerContent(this.ctx, type, extractor) + } + + /** + * Search the complete logical corpus and rank one result per session. + * @param request - query, pre-ranking filters, and pagination. + * @param exec - optional cancellation context. + * @returns ranked provider page. + */ + searchSessions( + request: SessionSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + return this._providers.searchSessions(request, exec) + } + + /** + * Search events within one logical session. + * @param request - target session, query, filters, and pagination. + * @param exec - optional cancellation context. + * @returns ranked provider page. + */ + searchEvents( + request: SessionEventSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + return this._providers.searchEvents(request, exec) + } + + private _readWindow(name: 'before' | 'after', value: number | undefined): number { + if (value === undefined) return 0 + if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) { + throw new SessionQueryError(`${name} must be an integer between 0 and ${this._readWindowMax}`, 'SESSION_QUERY_INVALID_WINDOW') + } + return value + } +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} + +export default SessionQueryService diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts new file mode 100644 index 0000000000..7000c0e6d1 --- /dev/null +++ b/packages/session-query/session-query/src/provider.ts @@ -0,0 +1,328 @@ +/** Search-provider selection, synchronization, pagination, and cancellation. */ + +import type { Context } from 'cordis' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionTextExtractors } from './extraction.ts' +import type { PersistenceView, SessionCorpus } from './corpus.ts' +import type { + SessionEventRecord, + SessionEventSearchHit, + SessionEventSearchRequest, + SessionQueryExecContext, + SessionRecord, + SessionSearchHit, + SessionSearchPage, + SessionSearchProvider, + SessionSearchRequest, +} from './types.ts' +import type { Config } from './config.ts' +import { SessionQueryError } from './config.ts' +import { filterEventResults, filterSessionResults } from './filters.ts' + +interface ProviderState { + provider: SessionSearchProvider + active: boolean + chain: Promise + liveIds: Set + fullSync: Promise | undefined + liveSync: Map> +} + +type NormalizedSessionSearchRequest = SessionSearchRequest & { limit: number } +type NormalizedEventSearchRequest = SessionEventSearchRequest & { limit: number } + +/** Coordinates one selected provider against live and persisted corpus layers. */ +export class SessionProviderCoordinator { + private readonly _configuredProviderId: string | undefined + private readonly _defaultLimit: number + private readonly _maxLimit: number + private readonly _providers = new Map() + + constructor( + private readonly _ctx: Context, + config: Required> & Pick, + private readonly _corpus: () => SessionCorpus, + private readonly _extractors: SessionTextExtractors, + ) { + this._configuredProviderId = config.searchProvider + this._defaultLimit = config.defaultLimit + this._maxLimit = config.maxLimit + _ctx.on('session/created', (session) => { this.invalidateLive(session.id) }) + _ctx.on('session/event', (session) => { this.invalidateLive(session.id) }) + _ctx.on('session/removed', (header) => { this.invalidateLive(header.id) }) + } + + /** + * Register one effect-scoped provider. + * @param ctx - contributing caller context. + * @param provider - provider implementation. + * @returns disposer for the registration. + */ + register(ctx: Context, provider: SessionSearchProvider): () => void { + if (this._providers.has(provider.id)) { + throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER') + } + const state: ProviderState = { + provider, + active: true, + chain: Promise.resolve(), + liveIds: new Set(), + fullSync: undefined, + liveSync: new Map(), + } + const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { + this._providers.set(provider.id, state) + void this._enqueue(state, () => provider.setPersistedActive(false)).catch((error: unknown) => { + this._ctx.logger.warn(`session-query provider "${provider.id}" failed initial deactivation: ${String(error)}`) + }) + yield () => { + state.active = false + this._providers.delete(provider.id) + } + }.bind(this), 'sessionQuery.registerSearchProvider()') + return () => void dispose() + } + + /** + * Search and group the complete logical corpus. + * @param request - normalized provider-neutral request input. + * @param exec - optional cancellation controls. + * @returns ranked session page. + */ + async searchSessions( + request: SessionSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + const state = this._resolveProvider() + const normalized = this._normalizeSessionSearch(request) + await waitFor(this._syncAll(state), exec?.signal) + const result = await waitFor(state.provider.searchSessions(normalized, exec), exec?.signal) + return this._validateSearchPage(state, result, normalized.limit) + } + + /** + * Search events within one logical session. + * @param request - target and provider-neutral request input. + * @param exec - optional cancellation controls. + * @returns ranked event page. + */ + async searchEvents( + request: SessionEventSearchRequest, + exec?: SessionQueryExecContext, + ): Promise> { + const state = this._resolveProvider() + const normalized = this._normalizeEventSearch(request) + const live = this._corpus().getLive(request.sessionId) + if (live !== undefined) { + await waitFor(this._syncLive(state, live), exec?.signal) + } else { + const persistence = await this._corpus().persistenceView() + if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) { + throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } + await waitFor(this._syncAll(state), exec?.signal) + } + const result = await waitFor(state.provider.searchEvents(normalized, exec), exec?.signal) + return this._validateSearchPage(state, result, normalized.limit) + } + + /** + * Invalidate provider synchronization after one live source change. + * @param sessionId - changed live session. + */ + invalidateLive(sessionId: SessionId): void { + for (const state of this._providers.values()) { + state.fullSync = undefined + state.liveSync.delete(sessionId) + } + } + + /** Invalidate all source/extractor-derived provider snapshots. */ + invalidateAll(): void { + for (const state of this._providers.values()) { + state.fullSync = undefined + state.liveSync.clear() + } + } + + /** + * React to persistence mount, inventory change, or unmount. + * @param active - whether canonical persistence remains mounted. + */ + persistenceChanged(active: boolean): void { + for (const state of this._providers.values()) state.fullSync = undefined + if (active) return + for (const state of this._providers.values()) { + void this._enqueue(state, () => state.provider.setPersistedActive(false)).catch((error: unknown) => { + this._ctx.logger.warn(`session-query provider "${state.provider.id}" failed persistence deactivation: ${String(error)}`) + }) + } + } + + private _syncAll(state: ProviderState): Promise { + if (state.fullSync !== undefined) return state.fullSync + const promise = this._enqueue(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + const persistence = await this._corpus().persistenceView() + if (persistence === undefined) { + await state.provider.setPersistedActive(false) + } else { + await this._syncPersisted(state, persistence) + } + await this._replaceLiveCorpus(state, this._corpus().listLive()) + }) + state.fullSync = promise + void promise.finally(() => { + /* v8 ignore next -- a newer invalidation may already own the sync slot */ + if (state.fullSync === promise) state.fullSync = undefined + }).catch(() => undefined) + return promise + } + + private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise { + await state.provider.setPersistedActive(false) + const inventory = new Map((await state.provider.persistedInventory()).map(entry => [entry.sessionId, entry.fingerprint])) + for (const header of persistence.headers) { + const snapshot = this._extractors.buildSnapshot(await persistence.load(header.id)) + if (inventory.get(header.id) !== snapshot.fingerprint) await state.provider.replacePersisted(snapshot) + inventory.delete(header.id) + } + for (const staleId of inventory.keys()) await state.provider.removePersisted(staleId) + await state.provider.setPersistedActive(true) + } + + private async _replaceLiveCorpus(state: ProviderState, sessions: readonly Session[]): Promise { + const liveIds = new Set(sessions.map(session => session.id)) + for (const staleId of state.liveIds) { + if (!liveIds.has(staleId)) await state.provider.removeLive(staleId) + } + for (const session of sessions) { + await state.provider.replaceLive(this._snapshotLive(session)) + } + state.liveIds = liveIds + } + + private _syncLive(state: ProviderState, session: Session): Promise { + const existing = state.liveSync.get(session.id) + if (existing !== undefined) return existing + const snapshot = this._snapshotLive(session) + const promise = this._enqueue(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + await state.provider.replaceLive(snapshot) + state.liveIds.add(session.id) + }) + state.liveSync.set(session.id, promise) + void promise.finally(() => { + /* v8 ignore next -- a newer invalidation may already own the target slot */ + if (state.liveSync.get(session.id) === promise) state.liveSync.delete(session.id) + }).catch(() => undefined) + return promise + } + + private _snapshotLive(session: Session): ReturnType { + return this._extractors.buildSnapshot(this._corpus().snapshotLive(session)) + } + + private _enqueue(state: ProviderState, operation: () => Promise): Promise { + const next = state.chain.then(operation, operation) + state.chain = next.then(() => undefined, () => undefined) + return next.catch((error: unknown) => { + /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ + if (error instanceof SessionQueryError) throw error + throw new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) + }) + } + + private _resolveProvider(): ProviderState { + if (this._configuredProviderId !== undefined) { + const state = this._providers.get(this._configuredProviderId) + if (state === undefined) { + throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is not registered`, 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING') + } + if (!state.provider.status().available) { + throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is unavailable`, 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE') + } + return state + } + const usable = [...this._providers.values()].filter(state => state.provider.status().available) + const [single] = usable + if (single === undefined) { + throw new SessionQueryError('no usable session-query provider is registered', 'SESSION_QUERY_PROVIDER_UNAVAILABLE') + } + if (usable.length > 1) { + throw new SessionQueryError(`multiple usable session-query providers are registered (${usable.map(state => state.provider.id).join(', ')}); configure one explicitly`, 'SESSION_QUERY_PROVIDER_AMBIGUOUS') + } + return single + } + + private _normalizeSessionSearch(request: SessionSearchRequest): NormalizedSessionSearchRequest { + const query = this._queryText(request.query) + const limit = this._limitValue(request.limit) + filterSessionResults([], request.sessionFilters ?? []) + filterEventResults([], request.eventFilters ?? []) + return { ...request, query, limit } + } + + private _normalizeEventSearch(request: SessionEventSearchRequest): NormalizedEventSearchRequest { + const query = this._queryText(request.query) + const limit = this._limitValue(request.limit) + filterEventResults([], request.filters ?? []) + return { ...request, query, limit } + } + + private _queryText(query: string): string { + const normalized = query.trim() + if (normalized.length === 0) { + throw new SessionQueryError('session-query search text must not be blank', 'SESSION_QUERY_INVALID_QUERY') + } + return normalized + } + + private _limitValue(limit: number | undefined): number { + const value = limit ?? this._defaultLimit + if (!Number.isInteger(value) || value < 1 || value > this._maxLimit) { + throw new SessionQueryError(`session-query limit must be an integer between 1 and ${this._maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT') + } + return value + } + + private _validateSearchPage(state: ProviderState, page: SessionSearchPage, limit: number): SessionSearchPage { + if (page.providerId !== state.provider.id) { + throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR') + } + return page.items.length <= limit ? page : { ...page, items: page.items.slice(0, limit) } + } +} + +function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return work + if (signal.aborted) return Promise.reject(aborted()) + return new Promise((resolve, reject) => { + const onAbort = () => { reject(aborted()) } + signal.addEventListener('abort', onAbort, { once: true }) + work.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + /* v8 ignore next -- Promise contracts reject with Error; retain a typed boundary for third-party providers */ + reject(error instanceof Error + ? error + : new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error })) + }, + ) + }) +} + +function aborted(): SessionQueryError { + return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED') +} + +function errorMessage(error: unknown): string { + /* v8 ignore next -- provider update contracts reject Error instances */ + return error instanceof Error ? error.message : 'unknown error' +} diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts new file mode 100644 index 0000000000..61363e8254 --- /dev/null +++ b/packages/session-query/session-query/src/tracing.ts @@ -0,0 +1,158 @@ +/** Session lineage and event surface/provenance tracing. */ + +import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { + SessionEventRecord, + SessionEventTrace, + SessionLineageNode, + SessionLineageTrace, + SessionRecord, +} from './types.ts' +import { SessionQueryError } from './config.ts' + +/** + * Classify raw events against the canonical surface fold. + * @param sessionId - owner of the event log. + * @param events - detached raw log. + * @returns lightweight records in seq order. + */ +export function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { + const fold = safeFold(events) + const current = new Set(fold.nodes.map(node => node.seq)) + const shadowed = new Set(fold.replacements.flatMap(replacement => replacement.shadowedSeqs)) + return events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', + })) +} + +/** + * Build one event trace from a validated logical event log. + * @param sessionId - owner of the event log. + * @param events - detached raw log. + * @param seq - target event seq. + * @returns direct provenance and replacement relationships. + */ +export function traceEventLog(sessionId: SessionId, events: readonly SessionEvent[], seq: number): SessionEventTrace { + const target = events[seq] + if (target === undefined || target.seq !== seq) { + throw new SessionQueryError(`session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') + } + const records = eventRecords(sessionId, events) + const fold = safeFold(events) + const shadowedBy = new Map() + const shadows = new Map() + for (const replacement of fold.replacements) { + shadows.set(replacement.seq, [...replacement.shadowedSeqs]) + for (const shadowed of replacement.shadowedSeqs) shadowedBy.set(shadowed, replacement.seq) + } + const references: number[] = [] + const referencedBy: number[] = [] + for (const event of events) { + if (!isSurfaceEvent(event)) continue + for (const source of event.sourceEventSeqs ?? []) { + if (event.seq === seq) references.push(source) + if (source === seq) referencedBy.push(event.seq) + } + } + const replacementChain: number[] = [] + let replacement = shadowedBy.get(seq) + while (replacement !== undefined) { + replacementChain.push(replacement) + replacement = shadowedBy.get(replacement) + } + const immediate = shadowedBy.get(seq) + // seq was checked against the contiguous event log, so its parallel record exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const targetRecord = records[seq]! + return { + target: { ...targetRecord }, + ...immediate !== undefined ? { shadowedBy: immediate } : {}, + replacementChain, + shadows: shadows.get(seq) ?? [], + references, + referencedBy, + } +} + +/** + * Trace ancestry and descendants within one materialized logical corpus. + * @param records - complete visible logical corpus. + * @param sessionId - target session id. + * @returns complete known lineage or explicit unresolved parent. + */ +export function traceLineage(records: readonly SessionRecord[], sessionId: SessionId): SessionLineageTrace { + const byId = new Map(records.map(record => [record.header.id, record])) + const target = byId.get(sessionId) + if (target === undefined) { + throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } + + const parents: SessionRecord[] = [] + const ancestrySeen = new Set([sessionId]) + let unresolvedParentId: SessionId | undefined + let parentId = target.header.parentSession + while (parentId !== undefined) { + if (ancestrySeen.has(parentId)) lineageCycle(parentId) + ancestrySeen.add(parentId) + const parent = byId.get(parentId) + if (parent === undefined) { + unresolvedParentId = parentId + break + } + parents.push(parent) + parentId = parent.header.parentSession + } + + const childrenByParent = new Map() + for (const record of records) { + const parent = record.header.parentSession + if (parent === undefined) continue + const children = childrenByParent.get(parent) ?? [] + children.push(record) + childrenByParent.set(parent, children) + } + for (const children of childrenByParent.values()) children.sort(compareSessionsAscending) + const buildChildren = (id: SessionId): SessionLineageNode[] => (childrenByParent.get(id) ?? []).map(child => ({ + session: cloneRecord(child), + children: buildChildren(child.header.id), + })) + + return { + target: cloneRecord(target), + parents: parents.map(cloneRecord), + ...unresolvedParentId !== undefined + ? { unresolvedParentId } + : { root: cloneRecord(parents.at(-1) ?? target) }, + children: buildChildren(sessionId), + } +} + +function safeFold(events: readonly SessionEvent[]): ReturnType { + try { + return foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError(`invalid session surface: ${errorMessage(error)}`, 'SESSION_QUERY_INVALID_SURFACE', { cause: error }) + } +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} + +function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { + return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id) +} + +function lineageCycle(id: SessionId): never { + throw new SessionQueryError(`session lineage contains a cycle at "${id}"`, 'SESSION_QUERY_INVALID_LINEAGE') +} + +function errorMessage(error: unknown): string { + /* v8 ignore next -- foldSurface throws Error instances */ + return error instanceof Error ? error.message : 'unknown error' +} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts new file mode 100644 index 0000000000..d3e819cf1a --- /dev/null +++ b/packages/session-query/session-query/src/types.ts @@ -0,0 +1,292 @@ +/** + * Public vocabulary for the session-query retrieval service: lightweight + * records, composable filters, traces, search requests/results, extractor + * registrations, and the provider synchronization contract. + * + * @module @deepseek-ai/dsh-session-query/types + */ + +import type { ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' +import type { + SessionEvent, + SessionEventType, + SessionHeader, + SessionId, +} from '@deepseek-ai/dsh-session' + +/** Whether an event is on the current surface, was replaced, or is log-only. */ +export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' + +/** Lightweight identity and availability for one logical session. */ +export interface SessionRecord { + /** Cloned immutable session header selected from the live-preferred corpus. */ + header: SessionHeader + /** Whether the id currently exists in `ctx.sessions`. */ + live: boolean + /** Whether the active persistence backend currently materializes the id. */ + persisted: boolean +} +/** Lightweight metadata for one event within a logical session. */ +export interface SessionEventRecord { + /** Session that owns the event. */ + sessionId: SessionId + /** Monotonic event seq within the session. */ + seq: number + /** Discriminant of the session event. */ + type: SessionEventType + /** Event timestamp in Unix epoch milliseconds. */ + time: number + /** Event placement in the folded session surface. */ + surface: SessionEventSurface +} + +/** Inclusive numeric range used by result and search filters. */ +export interface SessionQueryRange { + /** Inclusive lower bound. */ + from?: number + /** Inclusive upper bound. */ + to?: number +} + +/** Serializable filter applied to session records. */ +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | { kind: 'created-at'; range: SessionQueryRange } + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } + +/** Serializable filter applied to event records. */ +export type SessionEventResultFilter = + | { kind: 'seq'; range: SessionQueryRange } + | { kind: 'time'; range: SessionQueryRange } + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } + +/** Caller cancellation threaded through synchronization and provider search. */ +export interface SessionQueryExecContext { + /** Abort signal for waiting and provider-owned query work. */ + readonly signal?: AbortSignal +} + +/** Cheap local usability status returned by a search provider. */ +export type SessionSearchProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } + +/** Common pagination fields accepted by both search scopes. */ +export interface SessionSearchPageRequest { + /** Maximum number of hits on this page. */ + limit?: number + /** Opaque cursor returned by the same provider/request. */ + cursor?: string +} + +/** Cross-session full-text request. */ +export interface SessionSearchRequest extends SessionSearchPageRequest { + /** Plain text query interpreted by the selected provider. */ + query: string + /** Session metadata filters applied before event ranking/grouping. */ + sessionFilters?: readonly SessionResultFilter[] + /** Event metadata filters applied before best-event grouping. */ + eventFilters?: readonly SessionEventResultFilter[] +} + +/** Full-text request scoped to one session's events. */ +export interface SessionEventSearchRequest extends SessionSearchPageRequest { + /** Session whose events form the search corpus. */ + sessionId: SessionId + /** Plain text query interpreted by the selected provider. */ + query: string + /** Event metadata filters applied before ranking. */ + filters?: readonly SessionEventResultFilter[] +} + +/** One lightweight event search hit with provider-produced evidence text. */ +export interface SessionEventSearchHit extends SessionEventRecord { + /** Plain-text excerpt explaining the match. */ + snippet: string +} + +/** One session-ranked search hit and its strongest matching event. */ +export interface SessionSearchHit extends SessionRecord { + /** Strongest matching event used as the session's ranking evidence. */ + bestMatch: SessionEventSearchHit +} + +/** One provider-owned page of search results. */ +export interface SessionSearchPage { + /** Stable id of the provider that produced this page. */ + providerId: string + /** Ranked hits in deterministic provider order. */ + items: readonly T[] + /** Opaque next-page cursor, absent when the result is exhausted. */ + nextCursor?: string +} + +/** Request for one event plus raw neighboring log context. */ +export interface SessionEventReadRequest { + /** Session that owns the target event. */ + sessionId: SessionId + /** Target event seq. */ + seq: number + /** Number of preceding raw events to include. */ + before?: number + /** Number of following raw events to include. */ + after?: number +} + +/** Full target event and a bounded raw-log window. */ +export interface SessionEventWindow { + /** Logical session metadata at read time. */ + session: SessionRecord + /** Full cloned target event. */ + target: SessionEvent + /** Full cloned events from `startSeq` through `endSeq`. */ + events: SessionEvent[] + /** First seq included in `events`. */ + startSeq: number + /** Last seq included in `events`. */ + endSeq: number +} + +/** Recursive child node in a session lineage trace. */ +export interface SessionLineageNode { + /** Session represented by this lineage node. */ + session: SessionRecord + /** Direct children in deterministic creation order. */ + children: SessionLineageNode[] +} + +/** Complete known lineage around one session. */ +export interface SessionLineageTrace { + /** Session that was traced. */ + target: SessionRecord + /** Known parents from immediate parent outward. */ + parents: SessionRecord[] + /** Root when the complete parent chain is available. */ + root?: SessionRecord + /** First parent id outside the visible corpus, when the trace is partial. */ + unresolvedParentId?: SessionId + /** Complete known descendant forest rooted at the target's direct children. */ + children: SessionLineageNode[] +} + +/** Surface and provenance relationships for one event. */ +export interface SessionEventTrace { + /** Lightweight target record. */ + target: SessionEventRecord + /** Immediate replacement event that shadowed the target. */ + shadowedBy?: number + /** Replacement seqs from the target toward the current descendant. */ + replacementChain: number[] + /** Surface nodes directly shadowed by the target replacement event. */ + shadows: number[] + /** Direct provenance sources from `sourceEventSeqs`. */ + references: number[] + /** Events that directly name the target in `sourceEventSeqs`. */ + referencedBy: number[] +} + +/** Typed extractor for one declaration-merged session event type. */ +export interface SessionEventTextExtractor { + /** Stable cache-invalidation version chosen by the extractor owner. */ + version: string + /** + * Extract semantic searchable fragments from one event. + * @param event - event narrowed to the registered type. + * @returns plain-text fragments; blanks are discarded by the service. + */ + extract(event: SessionEvent): readonly string[] +} + +/** Typed extractor for one declaration-merged content block type. */ +export interface SessionContentTextExtractor { + /** Stable cache-invalidation version chosen by the extractor owner. */ + version: string + /** + * Extract semantic searchable fragments from one content block. + * @param block - block narrowed to the registered type. + * @returns plain-text fragments; blanks are discarded by the service. + */ + extract(block: ContentBlockMap[K]): readonly string[] +} + +/** One provider-neutral event document produced by registered extractors. */ +export interface SessionIndexDocument extends SessionEventRecord { + /** Normalized newline-joined text indexed by a search provider. */ + text: string +} + +/** One complete index layer for a live session or persisted checkpoint. */ +export interface SessionIndexSnapshot { + /** Layer metadata and live/persisted availability exposed in results. */ + session: SessionRecord + /** Stable SHA-256 identity of canonical source data and extractor versions. */ + fingerprint: string + /** Searchable event documents in seq order. */ + documents: readonly SessionIndexDocument[] +} + +/** Durable provider inventory entry used to reuse unchanged persisted rows. */ +export interface SessionPersistedIndexEntry { + /** Persisted session id. */ + sessionId: SessionId + /** Last indexed source/extractor fingerprint. */ + fingerprint: string +} + +/** Search and synchronization backend registered into `ctx.sessionQuery`. */ +export interface SessionSearchProvider { + /** Stable provider id, unique within the query service. */ + readonly id: string + /** + * Return cheap local usability without performing index or search I/O. + * @returns whether the provider can be selected. + */ + status(): SessionSearchProviderStatus + /** + * Read reusable persisted-layer fingerprints from derived storage. + * @returns durable inventory entries. + */ + persistedInventory(): Promise + /** + * Hide or expose reconciled persisted rows without deleting their cache. + * @param active - whether canonical persistence is mounted and reconciled. + */ + setPersistedActive(active: boolean): Promise + /** + * Atomically replace one persisted session's derived documents. + * @param snapshot - canonical persisted checkpoint and fingerprint. + */ + replacePersisted(snapshot: SessionIndexSnapshot): Promise + /** + * Delete one durable derived entry after canonical reconciliation proves it absent. + * @param sessionId - persisted id to remove. + */ + removePersisted(sessionId: SessionId): Promise + /** + * Replace one connection-local live override. + * @param snapshot - current live snapshot and availability. + */ + replaceLive(snapshot: SessionIndexSnapshot): Promise + /** + * Drop one live override, revealing its active persisted base when present. + * @param sessionId - live id to remove. + */ + removeLive(sessionId: SessionId): Promise + /** + * Search and group the complete logical corpus by session. + * @param request - query, pre-ranking filters, and pagination. + * @param exec - optional cancellation context. + * @returns one ranked session page. + */ + searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> + /** + * Search events within one logical session. + * @param request - target session, query, filters, and pagination. + * @param exec - optional cancellation context. + * @returns one ranked event page. + */ + searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> +} diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts new file mode 100644 index 0000000000..88fced88d3 --- /dev/null +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -0,0 +1,704 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionQueryService, { + SessionQueryError, + filterEventResults, + filterSessionResults, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEventSearchHit, + SessionEventSearchRequest, + SessionIndexSnapshot, + SessionRecord, + SessionSearchHit, + SessionSearchPage, + SessionSearchProvider, + SessionSearchProviderStatus, + SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' + +declare module '@deepseek-ai/dsh-llm' { + interface ContentBlockMap { + 'test/text': { type: 'test/text'; value: string } + } +} + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + 'test/note': { note: string } + } +} + +function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } +} + +function eventLog(text = 'hello'): SessionEvent[] { + return [{ + type: 'user/message', + seq: 0, + time: 10, + data: { content: [{ type: 'text', text }], source: { kind: 'user' } }, + surfaceOp: 'append', + }] +} + +class TestPersistence extends SessionPersistence { + static entries = new Map() + static listFailure: unknown + static loadFailure: unknown + + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { + this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.listFailure = undefined + this.loadFailure = undefined + } + + create(meta: SessionHeader): Promise { + TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + return Promise.resolve() + } + + append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + const entry = TestPersistence.entries.get(id) + if (entry === undefined) throw new Error('missing test session') + entry.events.push(...structuredClone(events)) + return Promise.resolve() + } + + load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + if (TestPersistence.loadFailure !== undefined) return Promise.reject(asError(TestPersistence.loadFailure)) + const entry = TestPersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + return Promise.resolve(structuredClone(entry)) + } + + list(): Promise { + if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure)) + return Promise.resolve([...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))) + } +} + +class FakeProvider implements SessionSearchProvider { + readonly id: string + statusValue: SessionSearchProviderStatus = { available: true } + persisted = new Map() + live = new Map() + activeHistory: boolean[] = [] + removedPersisted: SessionIdType[] = [] + removedLive: SessionIdType[] = [] + sessionRequests: SessionSearchRequest[] = [] + eventRequests: SessionEventSearchRequest[] = [] + failNextLive = false + failNextPersisted = false + failNextActive = false + sessionPage: SessionSearchPage + eventPage: SessionSearchPage + + constructor(id = 'fake') { + this.id = id + this.sessionPage = { providerId: id, items: [] } + this.eventPage = { providerId: id, items: [] } + } + + status(): SessionSearchProviderStatus { + return this.statusValue + } + + persistedInventory(): Promise { + return Promise.resolve([...this.persisted.values()].map(snapshot => ({ + sessionId: snapshot.session.header.id, + fingerprint: snapshot.fingerprint, + }))) + } + + setPersistedActive(active: boolean): Promise { + if (this.failNextActive) { + this.failNextActive = false + return Promise.reject(new Error('activation failed')) + } + this.activeHistory.push(active) + return Promise.resolve() + } + + replacePersisted(snapshot: SessionIndexSnapshot): Promise { + if (this.failNextPersisted) { + this.failNextPersisted = false + return Promise.reject(new Error('persisted index failed')) + } + this.persisted.set(snapshot.session.header.id, structuredClone(snapshot)) + return Promise.resolve() + } + + removePersisted(sessionId: SessionIdType): Promise { + this.removedPersisted.push(sessionId) + this.persisted.delete(sessionId) + return Promise.resolve() + } + + replaceLive(snapshot: SessionIndexSnapshot): Promise { + if (this.failNextLive) { + this.failNextLive = false + return Promise.reject(new Error('live index failed')) + } + this.live.set(snapshot.session.header.id, structuredClone(snapshot)) + return Promise.resolve() + } + + removeLive(sessionId: SessionIdType): Promise { + this.removedLive.push(sessionId) + this.live.delete(sessionId) + return Promise.resolve() + } + + searchSessions(request: SessionSearchRequest): Promise> { + this.sessionRequests.push(structuredClone(request)) + return Promise.resolve(structuredClone(this.sessionPage)) + } + + searchEvents(request: SessionEventSearchRequest): Promise> { + this.eventRequests.push(structuredClone(request)) + return Promise.resolve(structuredClone(this.eventPage)) + } +} + +async function liveContext(config: ConstructorParameters[1] = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQueryService, config) + return ctx +} + +function expectCode(code: string): Error { + return expect.objectContaining({ code }) as Error +} + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +describe('pure result filters', () => { + it('chains session filters as AND while values within one filter are OR', () => { + const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false } + const child: SessionRecord = { header: header('child', 2, { cwd: '/b', parentSession: root.header.id }), live: false, persisted: true } + const both: SessionRecord = { header: header('both', 3, { cwd: '/a', parentSession: root.header.id }), live: true, persisted: true } + const input = [child, root, both] + + const output = filterSessionResults(input, [ + { kind: 'cwd', values: ['/a', '/b'] }, + { kind: 'created-at', range: { from: 2, to: 3 } }, + { kind: 'parent', values: [root.header.id] }, + { kind: 'availability', values: ['live', 'persisted'] }, + { kind: 'id', values: [child.header.id, both.header.id] }, + ]) + + expect(output).toEqual([child, both]) + expect(output[0]).toBe(child) + expect(input).toEqual([child, root, both]) + expect(filterSessionResults(input, [{ kind: 'cwd', values: [null] }])).toEqual([]) + }) + + it('filters event ranges/types/status without reordering richer records', () => { + const events = [ + { sessionId: SessionId('s'), seq: 2, type: 'user/message' as const, time: 20, surface: 'current' as const, extra: true }, + { sessionId: SessionId('s'), seq: 1, type: 'tool/call' as const, time: 10, surface: 'shadowed' as const, extra: true }, + { sessionId: SessionId('s'), seq: 3, type: 'assistant/chunk' as const, time: 30, surface: 'log-only' as const, extra: true }, + ] + const output = filterEventResults(events, [ + { kind: 'seq', range: { from: 1, to: 2 } }, + { kind: 'time', range: { from: 10, to: 20 } }, + { kind: 'type', values: ['user/message', 'tool/call'] }, + { kind: 'surface', values: ['current', 'shadowed'] }, + ]) + expect(output).toEqual(events.slice(0, 2)) + expect(output[0]).toBe(events[0]) + }) + + it('rejects invalid serializable filter values', () => { + expect(() => filterSessionResults([], [{ kind: 'created-at', range: { from: 2, to: 1 } }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterEventResults([], [{ kind: 'seq', range: { from: Number.NaN } }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterEventResults([], [{ kind: 'time', range: { to: Number.POSITIVE_INFINITY } }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterEventResults([], [{ kind: 'surface', values: ['other' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionResults([], [{ kind: 'availability', values: ['other' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + + it('handles absent range bounds and root/availability alternatives', () => { + const record: SessionRecord = { header: header('root'), live: false, persisted: true } + expect(filterSessionResults([record], [ + { kind: 'parent', values: [null] }, + { kind: 'cwd', values: [null] }, + { kind: 'availability', values: ['persisted'] }, + ])).toEqual([record]) + const event = { sessionId: record.header.id, seq: 2, type: 'user/message' as const, time: 4, surface: 'current' as const } + expect(filterEventResults([event], [{ kind: 'seq', range: { to: 2 } }, { kind: 'time', range: { from: 4 } }])).toEqual([event]) + }) +}) + +describe('logical corpus reads and traces', () => { + it('lists, classifies, reads, and traces a live session using detached records', async () => { + const ctx = await liveContext({ readWindowMax: 2 }) + const session = ctx.sessions.create(SessionId('live'), { meta: { createdAt: 20, cwd: '/work' } }) + const original = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const chunk = session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' } }) + const answer = session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'answer' }] }, { surfaceOp: 'append', sourceEventSeqs: [chunk.seq] }) + const summary = session.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, sourceEventSeqs: [original.seq] }) + const resummary = session.append('assistant/message', { turn: 1, step: 3, content: [{ type: 'text', text: 'resummary' }] }, { surfaceOp: { op: 'replace', start: summary.seq, end: answer.seq }, sourceEventSeqs: [summary.seq, answer.seq] }) + + const listed = await ctx.sessionQuery.listSessions() + expect(listed).toEqual([{ header: session.header, live: true, persisted: false }]) + listed[0]!.header.createdAt = -1 + expect(session.header.createdAt).toBe(20) + expect((await ctx.sessionQuery.listEvents(session.id)).map(event => event.surface)) + .toEqual(['shadowed', 'log-only', 'shadowed', 'shadowed', 'current']) + + const window = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: answer.seq, before: 2, after: 2 }) + expect([window.startSeq, window.endSeq]).toEqual([0, 4]) + expect(window.target.seq).toBe(answer.seq) + if (window.events[0]?.type !== 'user/message') throw new Error('expected user message') + window.events[0].data.content = [] + expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) + + await expect(ctx.sessionQuery.traceEvent(session.id, original.seq)).resolves.toMatchObject({ + shadowedBy: summary.seq, + replacementChain: [summary.seq, resummary.seq], + referencedBy: [summary.seq], + }) + await expect(ctx.sessionQuery.traceEvent(session.id, summary.seq)).resolves.toMatchObject({ + shadows: [original.seq], + references: [original.seq], + referencedBy: [resummary.seq], + }) + await expect(ctx.sessionQuery.traceEvent(session.id, chunk.seq)).resolves.toMatchObject({ referencedBy: [answer.seq] }) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 99 })).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, before: 3 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) + await expect(ctx.sessionQuery.traceEvent(session.id, 99)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + }) + + it('turns malformed replacement logs into typed surface failures', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('bad-surface')) + session.append('assistant/message', { turn: 1, step: 1, content: [] }, { + surfaceOp: { op: 'replace', start: 9, end: 9 }, + sourceEventSeqs: [], + }) + await expect(ctx.sessionQuery.listEvents(session.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it('returns complete, partial, deterministic, and cycle-checked lineage', async () => { + const ctx = await liveContext() + const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } }) + const second = ctx.sessions.create(SessionId('second'), { meta: { createdAt: 2, parentSession: root.id } }) + const first = ctx.sessions.create(SessionId('first'), { meta: { createdAt: 2, parentSession: root.id } }) + const grandchild = ctx.sessions.create(SessionId('grandchild'), { meta: { createdAt: 3, parentSession: first.id } }) + const partial = ctx.sessions.create(SessionId('partial'), { meta: { createdAt: 4, parentSession: SessionId('missing') } }) + + const trace = await ctx.sessionQuery.traceSession(grandchild.id) + expect(trace.parents.map(record => record.header.id)).toEqual([first.id, root.id]) + expect(trace.root?.header.id).toBe(root.id) + const rootTrace = await ctx.sessionQuery.traceSession(root.id) + expect(rootTrace.children.map(node => node.session.header.id)).toEqual([first.id, second.id]) + expect(rootTrace.children[0]?.children[0]?.session.header.id).toBe(grandchild.id) + await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ unresolvedParentId: SessionId('missing') }) + await expect(ctx.sessionQuery.traceSession(SessionId('absent'))).rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + + const cyclic = await liveContext() + const a = new Session(SessionId('a'), [], header('a', 1, { parentSession: SessionId('b') })) + const b = new Session(SessionId('b'), [], header('b', 2, { parentSession: SessionId('a') })) + cyclic.sessions.enter(a) + cyclic.sessions.enter(b) + await expect(cyclic.sessionQuery.traceSession(a.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE')) + }) + + it('uses live content over a matching persisted base and scopes persistence failures', async () => { + const common = header('same', 5, { cwd: '/w' }) + const persistedOnly = header('persisted', 1) + TestPersistence.reset([ + { meta: common, events: eventLog('persisted version') }, + { meta: persistedOnly, events: eventLog('persisted only') }, + ]) + const ctx = await liveContext() + const live = ctx.sessions.create(common.id, { meta: { createdAt: common.createdAt, cwd: '/w' } }) + live.append('user/message', { content: [{ type: 'text', text: 'live version' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const persistenceFiber = await ctx.plugin(TestPersistence) + await expect(ctx.sessionQuery.listEvents(SessionId('not-listed'))) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + + const records = await ctx.sessionQuery.listSessions() + expect(records.map(record => [record.header.id, record.live, record.persisted])).toEqual([ + [common.id, true, true], + [persistedOnly.id, false, true], + ]) + const liveWindow = await ctx.sessionQuery.readEvent({ sessionId: common.id, seq: 0 }) + expect(liveWindow.target.type === 'user/message' && liveWindow.target.data.content[0]).toMatchObject({ text: 'live version' }) + await expect(ctx.sessionQuery.readEvent({ sessionId: persistedOnly.id, seq: 0 })) + .resolves.toMatchObject({ session: { persisted: true } }) + + TestPersistence.listFailure = new Error('list unavailable') + await expect(ctx.sessionQuery.listEvents(common.id)).resolves.toHaveLength(1) + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.listFailure = undefined + TestPersistence.loadFailure = new Error('load unavailable') + await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_TEST_FAILURE') + await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_TEST_FAILURE')) + + await persistenceFiber.dispose() + TestPersistence.loadFailure = undefined + await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([{ header: common, live: true, persisted: false }]) + }) + + it('rejects immutable source header conflicts', async () => { + TestPersistence.reset([{ meta: header('conflict', 1, { cwd: '/persisted' }), events: eventLog() }]) + const ctx = await liveContext() + ctx.sessions.create(SessionId('conflict'), { meta: { createdAt: 1, cwd: '/live' } }) + await ctx.plugin(TestPersistence) + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) +}) + +describe('provider selection and synchronization', () => { + it('selects one usable provider, validates requests/pages, and disposes registration', async () => { + const ctx = await liveContext({ defaultLimit: 2, maxLimit: 3 }) + const session = ctx.sessions.create(SessionId('s')) + session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + const dispose = ctx.sessionQuery.registerSearchProvider(provider) + const record: SessionRecord = { header: structuredClone(session.header), live: true, persisted: false } + const bestMatch = { sessionId: session.id, seq: 0, type: 'user/message' as const, time: session.events[0]!.time, surface: 'current' as const, snippet: 'hello' } + provider.sessionPage = { providerId: provider.id, items: [ + { ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch }, + ], nextCursor: 'next' } + + const page = await ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] }) + expect(page.items).toHaveLength(2) + expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 }) + expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello') + await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + await expect(ctx.sessionQuery.searchSessions({ query: 'x', limit: 4 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) + provider.eventPage = { providerId: 'wrong', items: [] } + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) + + provider.eventPage = { providerId: provider.id, items: [] } + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal })) + .resolves.toMatchObject({ providerId: provider.id }) + + dispose() + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) + }) + + it('coalesces concurrent synchronization and supports cancellation while provider search is pending', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('coalesce')) + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + let releaseLive!: () => void + const liveBarrier = new Promise((resolve) => { releaseLive = resolve }) + let replacements = 0 + provider.replaceLive = async (snapshot) => { + replacements += 1 + await liveBarrier + provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) + } + const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + await Promise.resolve() + releaseLive() + await Promise.all([first, second]) + expect(replacements).toBe(1) + + session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + let releaseCorpus!: () => void + const corpusBarrier = new Promise((resolve) => { releaseCorpus = resolve }) + let corpusReplacements = 0 + provider.replaceLive = async (snapshot) => { + corpusReplacements += 1 + await corpusBarrier + provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) + } + const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' }) + const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' }) + await Promise.resolve() + releaseCorpus() + await Promise.all([crossFirst, crossSecond]) + expect(corpusReplacements).toBe(1) + + let releaseSearch!: () => void + const searchBarrier = new Promise((resolve) => { releaseSearch = resolve }) + provider.searchSessions = async () => { + await searchBarrier + return { providerId: provider.id, items: [] } + } + const controller = new AbortController() + const pending = ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal }) + await Promise.resolve() + await Promise.resolve() + controller.abort() + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + releaseSearch() + await Promise.resolve() + + provider.searchSessions = () => Promise.reject(new Error('search failed')) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: new AbortController().signal })) + .rejects.toThrow('search failed') + }) + + it('searches a persisted target after corpus reconciliation', async () => { + const persisted = header('event-persisted', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + await expect(ctx.sessionQuery.searchEvents({ sessionId: persisted.id, query: 'target' })) + .resolves.toMatchObject({ providerId: provider.id }) + expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target') + }) + + it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => { + const ctx = await liveContext() + const first = new FakeProvider('first') + ctx.sessionQuery.registerSearchProvider(first) + expect(() => ctx.sessionQuery.registerSearchProvider(new FakeProvider('first'))).toThrow(expectCode('SESSION_QUERY_DUPLICATE_PROVIDER')) + const second = new FakeProvider('second') + ctx.sessionQuery.registerSearchProvider(second) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_AMBIGUOUS')) + + const configured = await liveContext({ searchProvider: 'chosen' }) + await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_MISSING')) + const chosen = new FakeProvider('chosen') + chosen.statusValue = { available: false, reason: 'unavailable' } + configured.sessionQuery.registerSearchProvider(chosen) + await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE')) + chosen.statusValue = { available: true } + await expect(configured.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'chosen' }) + }) + + it('removes provider registrations with their contributing fiber', async () => { + const ctx = await liveContext() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessionQuery.registerSearchProvider(new FakeProvider('scoped')) + }, { inject: ['sessionQuery'] })) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'scoped' }) + await fiber.dispose() + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) + }) + + it('reconciles persisted bases and live overrides, reuses fingerprints, and hides rows on unmount', async () => { + const persisted = header('persisted', 1) + const overlaid = header('overlaid', 1) + TestPersistence.reset([ + { meta: persisted, events: eventLog('persisted') }, + { meta: overlaid, events: eventLog('base') }, + ]) + const ctx = await liveContext() + const live = ctx.sessions.create(overlaid.id, { meta: { createdAt: overlaid.createdAt } }) + live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const persistenceFiber = await ctx.plugin(TestPersistence) + const provider = new FakeProvider() + provider.failNextActive = true + provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] }) + ctx.sessionQuery.registerSearchProvider(provider) + + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted') + expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override') + expect(provider.removedPersisted).toEqual([SessionId('stale')]) + expect(provider.activeHistory.at(-1)).toBe(true) + const fingerprint = provider.persisted.get(persisted.id)?.fingerprint + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.persisted.get(persisted.id)?.fingerprint).toBe(fingerprint) + + const announced = header('announced', 3) + TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('announced') }) + await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced') + + provider.failNextActive = true + await persistenceFiber.dispose() + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.activeHistory.at(-1)).toBe(false) + expect(provider.persisted.has(persisted.id)).toBe(true) + }) + + it('synchronizes only a live target for event search and retries dirty failures', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('target')) + session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'one' }) + expect(provider.live.get(session.id)?.documents[0]?.text).toBe('one') + session.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + provider.failNextLive = true + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).resolves.toMatchObject({ providerId: provider.id }) + expect(provider.live.get(session.id)?.documents.map(document => document.text)).toEqual(['one', 'two']) + + const controller = new AbortController() + controller.abort() + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('missing'), query: 'x' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + + it('removes a disposed live override and reveals the provider base', async () => { + const persisted = header('fallback', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('base') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + let session!: Session + const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(persisted.id, { meta: { createdAt: persisted.createdAt } }) + session.append('user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + }, { inject: ['sessions'] })) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.live.has(session.id)).toBe(true) + + await liveFiber.dispose() + await Promise.resolve() + await ctx.sessionQuery.searchSessions({ query: 'x' }) + expect(provider.removedLive).toContain(session.id) + expect(provider.live.has(session.id)).toBe(false) + expect(provider.persisted.get(session.id)?.documents[0]?.text).toBe('base') + }) + + it('retries failed persisted reconciliation without affecting canonical writes', async () => { + const persisted = header('retry', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('retry') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const provider = new FakeProvider() + provider.failNextPersisted = true + ctx.sessionQuery.registerSearchProvider(provider) + + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id }) + expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') + }) +}) + +describe('semantic text extractors', () => { + it('indexes core semantic text and excludes chunks and structural events', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('semantic')) + const nested: ContentBlock[] = [ + { type: 'text', text: 'visible' }, + { type: 'reasoning', text: 'thinking' }, + { type: 'tool-call', id: CallId('block-call'), name: 'block-tool', arguments: '{"x":1}' }, + { type: 'tool-result', toolCallId: CallId('block-call'), content: [{ type: 'text', text: 'block-result' }] }, + ] + session.append('user/message', { content: nested, source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' }, reason: 'policy reason' }) + session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'shell', arguments: '{"cmd":"pwd"}' }) + session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'tool output' }], isError: true, error: { name: 'ToolError', code: 'DENIED' } }, { surfaceOp: 'append' }) + session.append('todo/write', { todos: [{ content: 'finish tests', status: 'in_progress' }] }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'uncoded failure' } }) + session.append('turn/end', { turn: 2, reason: { kind: 'aborted' } }) + session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'cancelled' } }) + session.append('turn/end', { turn: 4, reason: { kind: 'rejected', reason: 'rejected detail' } }) + session.append('turn/end', { turn: 5, reason: { kind: 'disposed' } }) + session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) + session.append('turn/end', { turn: 7, reason: { kind: 'interrupted' } }) + session.append('turn/end', { turn: 8, reason: { kind: 'completed' } }) + session.append('tool/result', { turn: 1, step: 2, callId: CallId('c2'), content: [], isError: false }, { surfaceOp: 'append' }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw chunk' } }) + session.append('step/start', { turn: 1, step: 2 }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const documents = provider.live.get(session.id)?.documents ?? [] + expect(documents.map(document => document.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + expect(documents.map(document => document.text).join('\n')).toContain('visible\nthinking\nblock-tool\n{"x":1}\nblock-result') + expect(documents.map(document => document.text).join('\n')).toContain('blocked prompt\npolicy reason') + expect(documents.map(document => document.text).join('\n')).toContain('ToolError\nDENIED') + expect(documents.map(document => document.text).join('\n')).toContain('in_progress finish tests') + expect(documents.map(document => document.text).join('\n')).toContain('error\nmodel failed\nMODEL') + expect(documents.map(document => document.text).join('\n')).toContain('aborted\ncancelled') + expect(documents.map(document => document.text).join('\n')).toContain('rejected\nrejected detail') + expect(documents.map(document => document.text).join('\n')).toContain('disposed\nmax-tokens\ninterrupted') + expect(documents.map(document => document.text).join('\n')).not.toContain('raw chunk') + }) + + it('supports versioned effect-scoped custom event and content extractors', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('custom')) + session.append('test/note', { note: 'event note' }) + session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + let disposeEvent!: () => void + let disposeContent!: () => void + const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { + disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) + disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) + }, { inject: ['sessionQuery'] })) + + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const first = provider.live.get(session.id) + expect(first?.documents.map(document => document.text)).toEqual(['event note', 'block note']) + expect(() => ctx.sessionQuery.registerEventTextExtractor('test/note', { version: 'v2', extract: () => [] })) + .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) + expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: () => [] })) + .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) + expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) + .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) + + disposeEvent() + disposeContent() + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const second = provider.live.get(session.id) + expect(second?.documents).toEqual([]) + expect(second?.fingerprint).not.toBe(first?.fingerprint) + await extractorFiber.dispose() + }) +}) + +describe('configuration', () => { + it('rejects an impossible default page size and exposes typed errors', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + const error = new SessionQueryError('test', 'SESSION_QUERY_TEST') + expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_TEST' }) + }) + + it('uses constructor defaults and removes the service on plugin disposal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionQueryService) + const session = ctx.sessions.create(SessionId('defaults')) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, after: 51 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) + await fiber.dispose() + expect(ctx.sessionQuery).toBeUndefined() + + const direct = new Context() + await direct.plugin(SessionStore) + const service = new SessionQueryService(direct, {}) + const directSession = direct.sessions.create(SessionId('direct-defaults')) + await expect(service.readEvent({ sessionId: directSession.id, seq: 0, before: 51 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) + await direct.fiber.dispose() + }) +}) diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json new file mode 100644 index 0000000000..7153dae8bb --- /dev/null +++ b/packages/session-query/session-query/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4425c6bd41..3ef543eb88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,25 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-query/session-query: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 610ff3f1ea..eada21d0c4 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -78,6 +78,7 @@ const GROUP_ORDER = [ 'cordis', 'hooks', 'session-persistence', + 'session-query', 'support', 'ui', ] @@ -97,7 +98,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -106,9 +107,16 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'acp'], + consumers: ['agent-loop', 'acp', 'session-query'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'sessionQuery', + pkg: 'session-query', + title: 'Session retrieval read model', + mode: 'seam', + note: 'Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers.', + }, { key: 'systemPrompt', pkg: 'system-prompt', diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index dc66dc843e..0b661b3930 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -50,6 +50,7 @@ const GROUP_ORDER = [ 'cordis', 'hooks', 'session-persistence', + 'session-query', 'support', 'ui', ] diff --git a/tsconfig.base.json b/tsconfig.base.json index ddba51c53f..227b144f9a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -55,6 +55,7 @@ "./packages/cordis/*/src", "./packages/hooks/*/src", "./packages/session-persistence/*/src", + "./packages/session-query/*/src", "./packages/ui/*/src", "./packages/util/*/src", "./packages/support/*/src" diff --git a/tsconfig.build.json b/tsconfig.build.json index edbd2d742b..8cd4001285 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,6 +17,7 @@ { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, diff --git a/tsconfig.json b/tsconfig.json index 61d510437c..38a7107477 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ { "path": "./packages/session-persistence/session-persistence" }, { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, From 18028cad4ff80263b9e48e586e84342a43f1bc08 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 10 Jul 2026 17:29:52 +0800 Subject: [PATCH 02/11] fix(session-query): address review round 1 --- docs/core-data-structures/core.md | 1 + docs/core-data-structures/persistence.md | 14 ++ docs/core-data-structures/session-query.md | 221 ++++++++++++++++++ docs/core-data-structures/session.md | 20 ++ packages/core/session/tests/session.spec.ts | 6 +- .../tests/coordinator-contract.ts | 3 +- .../session-query/session-query/README.md | 2 +- .../session-query/session-query/src/corpus.ts | 28 ++- .../session-query/src/provider.ts | 5 +- .../session-query/session-query/src/types.ts | 2 +- .../session-query/tests/session-query.spec.ts | 62 ++++- scripts/type-equiv.manifest.json | 29 +++ vendor/README.md | 1 + vendor/cordis/src/events.ts | 10 +- 14 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 docs/core-data-structures/session-query.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9611adab6f..6a31eddd70 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,6 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | +| [session-query.md](session-query.md) | the retrieval seam: logical session/event records, filters, traces, search pages, extractors, and provider synchronization types | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 4dbb8fb2af..012e606f48 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -73,6 +73,20 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## `SessionPersistedChange` — committed-log notification range + +The observe-only `session/persisted` event carries the canonical header and the committed range. A repair can report `toSeq < fromSeq` when it only removes a torn fragment. + +Source: [`packages/session-persistence/session-persistence/src/index.ts`](../../packages/session-persistence/session-persistence/src/index.ts) + +```ts type-equiv +export interface SessionPersistedChange { + kind: 'append' | 'repair' + fromSeq: number + toSeq: number +} +``` + ## The backends Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md new file mode 100644 index 0000000000..8ad0f0cbf5 --- /dev/null +++ b/docs/core-data-structures/session-query.md @@ -0,0 +1,221 @@ +# Session Query + +The provider-neutral retrieval seam over live and optionally persisted sessions. The [package contract](../../packages/session-query/session-query) owns resolution, lifecycle, synchronization, and error behavior; this page catalogs the public data exchanged by callers, extractors, and search providers. + +Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) + +## Logical records and filters + +`SessionRecord` exposes source availability independently from its live-preferred header. `SessionEventRecord` classifies every raw event against the folded surface. + +```ts type-equiv +export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' +``` + +```ts type-equiv +export interface SessionRecord { + header: SessionHeader + live: boolean + persisted: boolean +} +``` + +```ts type-equiv +export interface SessionEventRecord { + sessionId: SessionId + seq: number + type: SessionEventType + time: number + surface: SessionEventSurface +} +``` + +Filters are serializable discriminated specs. Each spec is one transform in a chain; the literal types below are shared by in-memory filtering and provider pre-ranking requests. + +```ts type-equiv +export interface SessionQueryRange { + from?: number + to?: number +} +``` + +```ts type-equiv +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | { kind: 'created-at'; range: SessionQueryRange } + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } +``` + +```ts type-equiv +export type SessionEventResultFilter = + | { kind: 'seq'; range: SessionQueryRange } + | { kind: 'time'; range: SessionQueryRange } + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } +``` + +## Search requests and pages + +Both scopes use the same opaque-cursor page envelope. Session hits carry exactly one best event; event hits add only a plain-text snippet to the lightweight record. + +```ts type-equiv +export interface SessionQueryExecContext { + readonly signal?: AbortSignal +} +``` + +```ts type-equiv +export type SessionSearchProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } +``` + +```ts type-equiv +export interface SessionSearchPageRequest { + limit?: number + cursor?: string +} +``` + +```ts type-equiv +export interface SessionSearchRequest extends SessionSearchPageRequest { + query: string + sessionFilters?: readonly SessionResultFilter[] + eventFilters?: readonly SessionEventResultFilter[] +} +``` + +```ts type-equiv +export interface SessionEventSearchRequest extends SessionSearchPageRequest { + sessionId: SessionId + query: string + filters?: readonly SessionEventResultFilter[] +} +``` + +```ts type-equiv +export interface SessionEventSearchHit extends SessionEventRecord { + snippet: string +} +``` + +```ts type-equiv +export interface SessionSearchHit extends SessionRecord { + bestMatch: SessionEventSearchHit +} +``` + +```ts type-equiv +export interface SessionSearchPage { + providerId: string + items: readonly T[] + nextCursor?: string +} +``` + +## Event reads and traces + +An event read returns the full target plus a bounded raw-log window. Trace records retain lightweight seq links so callers choose which related event bodies to read. + +```ts type-equiv +export interface SessionEventReadRequest { + sessionId: SessionId + seq: number + before?: number + after?: number +} +``` + +```ts type-equiv +export interface SessionEventWindow { + session: SessionRecord + target: SessionEvent + events: SessionEvent[] + startSeq: number + endSeq: number +} +``` + +```ts type-equiv +export interface SessionLineageNode { + session: SessionRecord + children: SessionLineageNode[] +} +``` + +```ts type-equiv +export interface SessionLineageTrace { + target: SessionRecord + parents: SessionRecord[] + root?: SessionRecord + unresolvedParentId?: SessionId + children: SessionLineageNode[] +} +``` + +```ts type-equiv +export interface SessionEventTrace { + target: SessionEventRecord + shadowedBy?: number + replacementChain: number[] + shadows: number[] + references: number[] + referencedBy: number[] +} +``` + +## Extraction and provider synchronization + +Custom extractors are keyed by declaration-merged event or content discriminants and carry stable cache-invalidation versions. Providers receive complete event documents grouped into independently replaceable persisted and live snapshots. + +```ts type-equiv +export interface SessionEventTextExtractor { + version: string + extract(event: SessionEvent): readonly string[] +} +``` + +```ts type-equiv +export interface SessionContentTextExtractor { + version: string + extract(block: ContentBlockMap[K]): readonly string[] +} +``` + +```ts type-equiv +export interface SessionIndexDocument extends SessionEventRecord { + text: string +} +``` + +```ts type-equiv +export interface SessionIndexSnapshot { + session: SessionRecord + fingerprint: string + documents: readonly SessionIndexDocument[] +} +``` + +```ts type-equiv +export interface SessionPersistedIndexEntry { + sessionId: SessionId + fingerprint: string +} +``` + +```ts type-equiv +export interface SessionSearchProvider { + readonly id: string + status(): SessionSearchProviderStatus + persistedInventory(): Promise + setPersistedActive(active: boolean): Promise + replacePersisted(snapshot: SessionIndexSnapshot): Promise + removePersisted(sessionId: SessionId): Promise + replaceLive(snapshot: SessionIndexSnapshot): Promise + removeLive(sessionId: SessionId): Promise + searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> + searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> +} +``` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 1b12c3fc48..d8292c49b3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -196,6 +196,26 @@ export interface SurfaceNode { } ``` +### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay + +`foldSurface(events)` returns detached current nodes together with the actual node seqs shadowed by each declared replacement range. `SurfaceManager` uses the same transition functions for its incremental cache. + +```ts type-equiv +export interface SurfaceFoldReplacement { + seq: number + start: number + end: number + shadowedSeqs: number[] +} +``` + +```ts type-equiv +export interface SurfaceFoldResult { + nodes: SurfaceNode[] + replacements: SurfaceFoldReplacement[] +} +``` + ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e51ed923df..b32ae07fdd 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -374,9 +374,12 @@ describe('SessionStore', () => { expect(observations).toHaveLength(1) }) - it('contains rejected session/removed listeners during teardown', async () => { + it('contains failing session/removed listeners without starving later observers', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + const observed: SessionId[] = [] + ctx.on('session/removed', () => { throw new Error('synchronous observer failed') }) + ctx.on('session/removed', header => void observed.push(header.id)) ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) const session = ctx.sessions.prepare(SessionId('contained')) const detach = ctx.sessions.enter(session) @@ -385,6 +388,7 @@ describe('SessionStore', () => { await Promise.resolve() await Promise.resolve() expect(ctx.sessions.get(session.id)).toBeUndefined() + expect(observed).toEqual([session.id]) }) it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 6d95fbb4b2..4cf4e6ff67 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -128,11 +128,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] + ctx.on('session/persisted', () => { throw new Error('synchronous derived read model failed') }) ctx.on('session/persisted', (header, change) => { observed.push({ headerId: header.id, change: structuredClone(change) }) header.createdAt = -1 - return Promise.reject(new Error('derived read model failed')) }) + ctx.on('session/persisted', () => Promise.reject(new Error('asynchronous derived read model failed'))) try { const m = meta('notifications', WORK) await ctx.sessionPersistence.create(m) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index ab06959ea5..70b57dad07 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -22,7 +22,7 @@ Session filters cover id, exact cwd, inclusive creation time, parent id/root, an ## Full-text providers -`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. +`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 980e11e416..87fb7a5533 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -12,10 +12,18 @@ interface PersistenceBinding { token: symbol service: SessionPersistence headers: Map + /** Notifications retained until a list that began after them completes. */ + observations: Map + observationGeneration: number error?: unknown refreshing: Promise | undefined } +interface PersistedObservation { + generation: number + header: SessionHeader +} + /** Active persistence view used by provider reconciliation. */ export interface PersistenceView { /** Canonical headers in deterministic creation order. */ @@ -132,6 +140,8 @@ export class SessionCorpus { token: Symbol('session-query-persistence'), service, headers: new Map(), + observations: new Map(), + observationGeneration: 0, refreshing: undefined, } this._persistence = binding @@ -140,7 +150,10 @@ export class SessionCorpus { ctx.on('session/persisted', (header) => { /* v8 ignore next -- a stale notification can race optional-service disposal */ if (this._persistence?.token !== binding.token) return - binding.headers.set(header.id, structuredClone(header)) + const snapshot = structuredClone(header) + const observation = { generation: ++binding.observationGeneration, header: snapshot } + binding.headers.set(header.id, snapshot) + binding.observations.set(header.id, observation) this._onPersistenceChange(true) }) ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') @@ -155,10 +168,21 @@ export class SessionCorpus { private _refreshPersistence(binding: PersistenceBinding): Promise { if (binding.refreshing !== undefined) return binding.refreshing + const startGeneration = binding.observationGeneration const refresh = binding.service.list().then((headers) => { /* v8 ignore next -- a list completion can race optional-service disposal */ if (this._persistence?.token !== binding.token) return - binding.headers = new Map(headers.map(header => [header.id, structuredClone(header)])) + const nextHeaders = new Map(headers.map(header => [header.id, structuredClone(header)])) + for (const [id, observation] of binding.observations) { + // A notification newer than this list's snapshot is the authoritative + // read-your-writes layer; older ones must already be present in list(). + if (observation.generation > startGeneration) { + nextHeaders.set(id, structuredClone(observation.header)) + } else { + binding.observations.delete(id) + } + } + binding.headers = nextHeaders binding.error = undefined this._onPersistenceChange(true) }).catch((error: unknown) => { diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 7000c0e6d1..1574bcc0b2 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -292,7 +292,10 @@ export class SessionProviderCoordinator { if (page.providerId !== state.provider.id) { throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR') } - return page.items.length <= limit ? page : { ...page, items: page.items.slice(0, limit) } + if (page.items.length > limit) { + throw new SessionQueryError(`session-query provider "${state.provider.id}" returned ${page.items.length} items for limit ${limit}`, 'SESSION_QUERY_PROVIDER_ERROR') + } + return page } } diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index d3e819cf1a..43c63a034c 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -118,7 +118,7 @@ export interface SessionSearchHit extends SessionRecord { export interface SessionSearchPage { /** Stable id of the provider that produced this page. */ providerId: string - /** Ranked hits in deterministic provider order. */ + /** Ranked hits in deterministic provider order, no longer than the requested limit. */ items: readonly T[] /** Opaque next-page cursor, absent when the result is exhausted. */ nextCursor?: string diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 88fced88d3..0da0b4f41d 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -52,11 +52,15 @@ class TestPersistence extends SessionPersistence { static entries = new Map() static listFailure: unknown static loadFailure: unknown + static listBarrier: Promise | undefined + static onList: (() => void) | undefined static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listFailure = undefined this.loadFailure = undefined + this.listBarrier = undefined + this.onList = undefined } create(meta: SessionHeader): Promise { @@ -80,7 +84,9 @@ class TestPersistence extends SessionPersistence { list(): Promise { if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure)) - return Promise.resolve([...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))) + const snapshot = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) + TestPersistence.onList?.() + return (TestPersistence.listBarrier ?? Promise.resolve()).then(() => snapshot) } } @@ -182,6 +188,12 @@ function asError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)) } +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + describe('pure result filters', () => { it('chains session filters as AND while values within one filter are OR', () => { const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false } @@ -379,8 +391,8 @@ describe('provider selection and synchronization', () => { { ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch }, ], nextCursor: 'next' } - const page = await ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] }) - expect(page.items).toHaveLength(2) + await expect(ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] })) + .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 }) expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello') await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) @@ -534,6 +546,30 @@ describe('provider selection and synchronization', () => { expect(provider.persisted.has(persisted.id)).toBe(true) }) + it('preserves persisted observations that race an older inventory listing', async () => { + TestPersistence.reset() + const listStarted = deferred() + const releaseList = deferred() + TestPersistence.onList = listStarted.resolve + TestPersistence.listBarrier = releaseList.promise + const ctx = await liveContext() + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + await ctx.plugin(TestPersistence) + await listStarted.promise + + const announced = header('racing-announcement', 3) + TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('after durable notification') }) + await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) + const search = ctx.sessionQuery.searchSessions({ query: 'notification' }) + releaseList.resolve() + + await expect(search).resolves.toMatchObject({ providerId: provider.id }) + expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('after durable notification') + TestPersistence.listBarrier = undefined + TestPersistence.onList = undefined + }) + it('synchronizes only a live target for event search and retries dirty failures', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('target')) @@ -646,11 +682,9 @@ describe('semantic text extractors', () => { session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const provider = new FakeProvider() ctx.sessionQuery.registerSearchProvider(provider) - let disposeEvent!: () => void - let disposeContent!: () => void const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { - disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) - disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) + inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) + inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) }, { inject: ['sessionQuery'] })) await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) @@ -663,13 +697,21 @@ describe('semantic text extractors', () => { expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) - disposeEvent() - disposeContent() + await extractorFiber.dispose() await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const second = provider.live.get(session.id) expect(second?.documents).toEqual([]) expect(second?.fingerprint).not.toBe(first?.fingerprint) - await extractorFiber.dispose() + + const replacementFiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v2', extract: event => [`replacement ${event.data.note}`] }) + inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: block => [`replacement ${block.value}`] }) + }, { inject: ['sessionQuery'] })) + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + const third = provider.live.get(session.id) + expect(third?.documents.map(document => document.text)).toEqual(['replacement event note', 'replacement block note']) + expect(third?.fingerprint).not.toBe(second?.fingerprint) + await replacementFiber.dispose() }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 881ce06578..671419ab59 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -31,9 +31,38 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistedChange", "source": "packages/session-persistence/session-persistence/src/index.ts" }, + + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryRange", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryExecContext", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProviderStatus", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPageRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionContentTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexDocument", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexSnapshot", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionPersistedIndexEntry", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProvider", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, diff --git a/vendor/README.md b/vendor/README.md index bf0f0b5a8c..dabf1b17af 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,6 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. +6. **`cordis/src/events.ts`**: `parallel()` captures each listener invocation in its own promise before awaiting the group. A synchronous throw therefore rejects the dispatch without preventing later parallel listeners from starting. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 4461816537..6e5212b734 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -106,7 +106,15 @@ export class EventsService { /** Run listeners concurrently and wait for all of them. */ async parallel(...args: any[]) { - await Promise.all(this.dispatch('emit', args).map(cb => cb(...args))) + const callbacks = this.dispatch('emit', args) + const results = callbacks.map((cb) => { + try { + return Promise.resolve(cb(...args)) + } catch (error: unknown) { + return Promise.reject(error) + } + }) + await Promise.all(results) } /** Run listeners synchronously without waiting for returned promises. */ From 352ea6cf4f96d656f896f8a18519503d4bfbb917 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:19:34 +0800 Subject: [PATCH 03/11] fix(session-query): checkpoint review round 3 --- docs/core-data-structures/session-query.md | 47 ++++++++++++++++++- docs/event-producer-consumer.md | 6 +-- .../cordis/tool-cordis/src/api-catalog.ts | 10 +++- packages/core/session/tests/session.spec.ts | 6 +-- .../tests/coordinator-contract.ts | 3 +- .../session-query/session-query/README.md | 6 +++ .../session-query/session-query/src/config.ts | 36 +++++++++++++- .../session-query/session-query/src/index.ts | 2 +- .../session-query/src/provider.ts | 9 ++-- .../session-query/session-query/src/types.ts | 16 ++++++- .../session-query/tests/session-query.spec.ts | 35 ++++++++------ scripts/gen-doc-graphs.ts | 11 +++-- scripts/type-equiv.manifest.json | 3 ++ vendor/README.md | 2 +- vendor/cordis/src/events.ts | 12 ++--- 15 files changed, 153 insertions(+), 51 deletions(-) diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 8ad0f0cbf5..b824e99f1e 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -95,6 +95,20 @@ export interface SessionEventSearchRequest extends SessionSearchPageRequest { } ``` +The service resolves caller requests before crossing the provider seam, so provider implementations always receive a validated page limit. + +```ts type-equiv +export interface SessionSearchSpec extends SessionSearchRequest { + limit: number +} +``` + +```ts type-equiv +export interface SessionEventSearchSpec extends SessionEventSearchRequest { + limit: number +} +``` + ```ts type-equiv export interface SessionEventSearchHit extends SessionEventRecord { snippet: string @@ -115,6 +129,35 @@ export interface SessionSearchPage { } ``` +## Errors + +The service exposes a closed machine-routable error taxonomy; messages and causes provide detail but do not add codes. + +```ts type-equiv +export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' + | 'SESSION_QUERY_DUPLICATE_PROVIDER' + | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' + | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_EXTRACTOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_LINEAGE' + | 'SESSION_QUERY_INVALID_QUERY' + | 'SESSION_QUERY_INVALID_SURFACE' + | 'SESSION_QUERY_INVALID_WINDOW' + | 'SESSION_QUERY_PERSISTENCE_FAILED' + | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' + | 'SESSION_QUERY_PROVIDER_ERROR' + | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' + | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_SOURCE_CONFLICT' +``` + ## Event reads and traces An event read returns the full target plus a bounded raw-log window. Trace records retain lightweight seq links so callers choose which related event bodies to read. @@ -215,7 +258,7 @@ export interface SessionSearchProvider { removePersisted(sessionId: SessionId): Promise replaceLive(snapshot: SessionIndexSnapshot): Promise removeLive(sessionId: SessionId): Promise - searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> - searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> + searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> + searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23e3844375..0b8c4fce02 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -23,11 +23,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | -| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | +| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-query`](../packages/session-query/session-query) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7b3cd1297f..12872d689f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -737,6 +737,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSearchRequest', declaration: 'export interface SessionEventSearchRequest extends SessionSearchPageRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventResultFilter[];\n}', }, + { + name: 'SessionEventSearchSpec', + declaration: 'export interface SessionEventSearchSpec extends SessionEventSearchRequest {\n limit: number;\n}', + }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', @@ -819,7 +823,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSearchProvider', - declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise>;\n}', + declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise>;\n}', }, { name: 'SessionSearchProviderStatus', @@ -829,6 +833,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionSearchRequest', declaration: 'export interface SessionSearchRequest extends SessionSearchPageRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventResultFilter[];\n}', }, + { + name: 'SessionSearchSpec', + declaration: 'export interface SessionSearchSpec extends SessionSearchRequest {\n limit: number;\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};', diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index b32ae07fdd..e51ed923df 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -374,12 +374,9 @@ describe('SessionStore', () => { expect(observations).toHaveLength(1) }) - it('contains failing session/removed listeners without starving later observers', async () => { + it('contains rejected session/removed listeners during teardown', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const observed: SessionId[] = [] - ctx.on('session/removed', () => { throw new Error('synchronous observer failed') }) - ctx.on('session/removed', header => void observed.push(header.id)) ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) const session = ctx.sessions.prepare(SessionId('contained')) const detach = ctx.sessions.enter(session) @@ -388,7 +385,6 @@ describe('SessionStore', () => { await Promise.resolve() await Promise.resolve() expect(ctx.sessions.get(session.id)).toBeUndefined() - expect(observed).toEqual([session.id]) }) it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 4cf4e6ff67..6d95fbb4b2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -128,12 +128,11 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] - ctx.on('session/persisted', () => { throw new Error('synchronous derived read model failed') }) ctx.on('session/persisted', (header, change) => { observed.push({ headerId: header.id, change: structuredClone(change) }) header.createdAt = -1 + return Promise.reject(new Error('derived read model failed')) }) - ctx.on('session/persisted', () => Promise.reject(new Error('asynchronous derived read model failed'))) try { const m = meta('notifications', WORK) await ctx.sessionPersistence.create(m) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 70b57dad07..5dc5aecc34 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -28,6 +28,12 @@ The service feeds providers two independent layers: a durable persisted base (`p Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative. +Providers receive resolved `SessionSearchSpec` and `SessionEventSearchSpec` values whose `limit` is required after service defaulting and validation. Public service callers use `SessionSearchRequest` and `SessionEventSearchRequest`, where `limit` remains optional. + +## Errors + +`SessionQueryError.code` is the closed `SessionQueryErrorCode` union: `SESSION_QUERY_ABORTED`, `SESSION_QUERY_DUPLICATE_EXTRACTOR`, `SESSION_QUERY_DUPLICATE_PROVIDER`, `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INDEX_FAILED`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_EXTRACTOR`, `SESSION_QUERY_INVALID_FILTER`, `SESSION_QUERY_INVALID_LIMIT`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_QUERY`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_PROVIDER_AMBIGUOUS`, `SESSION_QUERY_PROVIDER_CONFIGURED_MISSING`, `SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE`, `SESSION_QUERY_PROVIDER_ERROR`, `SESSION_QUERY_PROVIDER_UNAVAILABLE`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. + ## Text extractors Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`. diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 6f8443aeb1..c70eb52988 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -25,5 +25,37 @@ export interface Config { readWindowMax?: number } -/** Typed session-query failure with a stable machine-routable code. */ -export class SessionQueryError extends HarnessError {} +/** Complete stable machine-routable failure taxonomy for session-query. */ +export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' + | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' + | 'SESSION_QUERY_DUPLICATE_PROVIDER' + | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' + | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_EXTRACTOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_LINEAGE' + | 'SESSION_QUERY_INVALID_QUERY' + | 'SESSION_QUERY_INVALID_SURFACE' + | 'SESSION_QUERY_INVALID_WINDOW' + | 'SESSION_QUERY_PERSISTENCE_FAILED' + | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' + | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' + | 'SESSION_QUERY_PROVIDER_ERROR' + | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' + | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_SOURCE_CONFLICT' + +/** Typed session-query failure whose `code` is one closed taxonomy member. */ +export class SessionQueryError extends HarnessError { + declare readonly code: SessionQueryErrorCode + + // The base stores the value; this signature narrows its open string code. + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(message: string, code: SessionQueryErrorCode, options?: ErrorOptions) { + super(message, code, options) + } +} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 46e3c15c32..1145332c23 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -40,7 +40,7 @@ import { SessionProviderCoordinator } from './provider.ts' import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' export type * from './types.ts' -export type { Config } from './config.ts' +export type { Config, SessionQueryErrorCode } from './config.ts' export { SESSION_QUERY_DEFAULT_LIMIT, SESSION_QUERY_MAX_LIMIT, diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 1574bcc0b2..5fd99fde0a 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -8,12 +8,14 @@ import type { SessionEventRecord, SessionEventSearchHit, SessionEventSearchRequest, + SessionEventSearchSpec, SessionQueryExecContext, SessionRecord, SessionSearchHit, SessionSearchPage, SessionSearchProvider, SessionSearchRequest, + SessionSearchSpec, } from './types.ts' import type { Config } from './config.ts' import { SessionQueryError } from './config.ts' @@ -28,9 +30,6 @@ interface ProviderState { liveSync: Map> } -type NormalizedSessionSearchRequest = SessionSearchRequest & { limit: number } -type NormalizedEventSearchRequest = SessionEventSearchRequest & { limit: number } - /** Coordinates one selected provider against live and persisted corpus layers. */ export class SessionProviderCoordinator { private readonly _configuredProviderId: string | undefined @@ -257,7 +256,7 @@ export class SessionProviderCoordinator { return single } - private _normalizeSessionSearch(request: SessionSearchRequest): NormalizedSessionSearchRequest { + private _normalizeSessionSearch(request: SessionSearchRequest): SessionSearchSpec { const query = this._queryText(request.query) const limit = this._limitValue(request.limit) filterSessionResults([], request.sessionFilters ?? []) @@ -265,7 +264,7 @@ export class SessionProviderCoordinator { return { ...request, query, limit } } - private _normalizeEventSearch(request: SessionEventSearchRequest): NormalizedEventSearchRequest { + private _normalizeEventSearch(request: SessionEventSearchRequest): SessionEventSearchSpec { const query = this._queryText(request.query) const limit = this._limitValue(request.limit) filterEventResults([], request.filters ?? []) diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 43c63a034c..11e6f6d0fc 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -102,6 +102,18 @@ export interface SessionEventSearchRequest extends SessionSearchPageRequest { filters?: readonly SessionEventResultFilter[] } +/** Provider-facing cross-session search spec after service normalization. */ +export interface SessionSearchSpec extends SessionSearchRequest { + /** Required page size validated and defaulted by the query service. */ + limit: number +} + +/** Provider-facing event search spec after service normalization. */ +export interface SessionEventSearchSpec extends SessionEventSearchRequest { + /** Required page size validated and defaulted by the query service. */ + limit: number +} + /** One lightweight event search hit with provider-produced evidence text. */ export interface SessionEventSearchHit extends SessionEventRecord { /** Plain-text excerpt explaining the match. */ @@ -281,12 +293,12 @@ export interface SessionSearchProvider { * @param exec - optional cancellation context. * @returns one ranked session page. */ - searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise> + searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> /** * Search events within one logical session. * @param request - target session, query, filters, and pagination. * @param exec - optional cancellation context. * @returns one ranked event page. */ - searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise> + searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 0da0b4f41d..88d7e906f6 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -12,14 +12,15 @@ import SessionQueryService, { } from '@deepseek-ai/dsh-session-query' import type { SessionEventSearchHit, - SessionEventSearchRequest, + SessionEventSearchSpec, SessionIndexSnapshot, + SessionQueryErrorCode, SessionRecord, SessionSearchHit, SessionSearchPage, SessionSearchProvider, SessionSearchProviderStatus, - SessionSearchRequest, + SessionSearchSpec, } from '@deepseek-ai/dsh-session-query' declare module '@deepseek-ai/dsh-llm' { @@ -98,8 +99,8 @@ class FakeProvider implements SessionSearchProvider { activeHistory: boolean[] = [] removedPersisted: SessionIdType[] = [] removedLive: SessionIdType[] = [] - sessionRequests: SessionSearchRequest[] = [] - eventRequests: SessionEventSearchRequest[] = [] + sessionRequests: SessionSearchSpec[] = [] + eventRequests: SessionEventSearchSpec[] = [] failNextLive = false failNextPersisted = false failNextActive = false @@ -162,12 +163,12 @@ class FakeProvider implements SessionSearchProvider { return Promise.resolve() } - searchSessions(request: SessionSearchRequest): Promise> { + searchSessions(request: SessionSearchSpec): Promise> { this.sessionRequests.push(structuredClone(request)) return Promise.resolve(structuredClone(this.sessionPage)) } - searchEvents(request: SessionEventSearchRequest): Promise> { + searchEvents(request: SessionEventSearchSpec): Promise> { this.eventRequests.push(structuredClone(request)) return Promise.resolve(structuredClone(this.eventPage)) } @@ -180,7 +181,7 @@ async function liveContext(config: ConstructorParameters { TestPersistence.listFailure = undefined TestPersistence.loadFailure = new Error('load unavailable') await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_TEST_FAILURE') - await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_TEST_FAILURE')) + TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_EVENT_NOT_FOUND') + await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) await persistenceFiber.dispose() TestPersistence.loadFailure = undefined @@ -682,9 +683,11 @@ describe('semantic text extractors', () => { session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const provider = new FakeProvider() ctx.sessionQuery.registerSearchProvider(provider) + let disposeEvent!: () => void + let disposeContent!: () => void const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) - inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) + disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) + disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) }, { inject: ['sessionQuery'] })) await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) @@ -697,11 +700,13 @@ describe('semantic text extractors', () => { expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) - await extractorFiber.dispose() + disposeEvent() + disposeContent() await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const second = provider.live.get(session.id) expect(second?.documents).toEqual([]) expect(second?.fingerprint).not.toBe(first?.fingerprint) + await extractorFiber.dispose() const replacementFiber = await ctx.plugin(Object.assign((inner: Context) => { inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v2', extract: event => [`replacement ${event.data.note}`] }) @@ -712,6 +717,8 @@ describe('semantic text extractors', () => { expect(third?.documents.map(document => document.text)).toEqual(['replacement event note', 'replacement block note']) expect(third?.fingerprint).not.toBe(second?.fingerprint) await replacementFiber.dispose() + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + expect(provider.live.get(session.id)?.documents).toEqual([]) }) }) @@ -721,8 +728,8 @@ describe('configuration', () => { await ctx.plugin(SessionStore) await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) - const error = new SessionQueryError('test', 'SESSION_QUERY_TEST') - expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_TEST' }) + const error = new SessionQueryError('test', 'SESSION_QUERY_INVALID_CONFIG') + expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_INVALID_CONFIG' }) }) it('uses constructor defaults and removes the service on plugin disposal', async () => { diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index eada21d0c4..0318e33fea 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -531,7 +531,7 @@ function collectEventRelations(): Map { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.text - if (!isCordisContextReceiver(node.expression, sf)) { + if (!isCordisContextReceiver(node.expression)) { ts.forEachChild(node, visit) return } @@ -561,9 +561,12 @@ function collectEventRelations(): Map { return out } -function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean { - const target = expr.expression.getText(sf) - return target === 'ctx' || target === 'this.ctx' +function isCordisContextReceiver(expr: ts.PropertyAccessExpression): boolean { + const receiver = expr.expression + if (ts.isIdentifier(receiver)) return receiver.text === 'ctx' || receiver.text === '_ctx' + return ts.isPropertyAccessExpression(receiver) + && receiver.expression.kind === ts.SyntaxKind.ThisKeyword + && (receiver.name.text === 'ctx' || receiver.name.text === '_ctx') } function eventArg(args: ts.NodeArray, method: string): string | undefined { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 671419ab59..32d8e91f6e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -49,9 +49,12 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPageRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, diff --git a/vendor/README.md b/vendor/README.md index dabf1b17af..856b958e8f 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -35,7 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. -6. **`cordis/src/events.ts`**: `parallel()` captures each listener invocation in its own promise before awaiting the group. A synchronous throw therefore rejects the dispatch without preventing later parallel listeners from starting. +6. **`cordis/src/events.ts`**: a `FIXME` documents the upstream `parallel()` bug where a synchronous listener throw aborts callback enumeration and starves later listeners; runtime behavior remains upstream-identical pending an upstream fix. ## Sync procedure diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 6e5212b734..e483afadf5 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -106,15 +106,9 @@ export class EventsService { /** Run listeners concurrently and wait for all of them. */ async parallel(...args: any[]) { - const callbacks = this.dispatch('emit', args) - const results = callbacks.map((cb) => { - try { - return Promise.resolve(cb(...args)) - } catch (error: unknown) { - return Promise.reject(error) - } - }) - await Promise.all(results) + // FIXME(cordis upstream): A synchronous listener throw aborts callback + // enumeration here and starves later parallel listeners. Fix upstream. + await Promise.all(this.dispatch('emit', args).map(cb => cb(...args))) } /** Run listeners synchronously without waiting for returned promises. */ From fa728a00bd1f63f9062ea8e158745c960e62b2ba Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:42:40 +0800 Subject: [PATCH 04/11] fix(session-query): reconcile concurrent live removals --- .../session-query/src/provider.ts | 24 ++++++++--- .../session-query/tests/session-query.spec.ts | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 5fd99fde0a..d6190701a7 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -26,10 +26,15 @@ interface ProviderState { active: boolean chain: Promise liveIds: Set - fullSync: Promise | undefined + fullSync: FullSync | undefined liveSync: Map> } +interface FullSync { + liveKey: string + promise: Promise +} + /** Coordinates one selected provider against live and persisted corpus layers. */ export class SessionProviderCoordinator { private readonly _configuredProviderId: string | undefined @@ -159,7 +164,15 @@ export class SessionProviderCoordinator { } private _syncAll(state: ProviderState): Promise { - if (state.fullSync !== undefined) return state.fullSync + // Capture the direct source before awaiting: only searches that observed + // the same live corpus may share an in-flight full synchronization. + const liveSessions = this._corpus().listLive() + const liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ + snapshot.session.header.id, + snapshot.fingerprint, + snapshot.session.persisted, + ])) + if (state.fullSync?.liveKey === liveKey) return state.fullSync.promise const promise = this._enqueue(state, async () => { /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ if (!state.active) return @@ -169,12 +182,13 @@ export class SessionProviderCoordinator { } else { await this._syncPersisted(state, persistence) } - await this._replaceLiveCorpus(state, this._corpus().listLive()) + await this._replaceLiveCorpus(state, liveSessions) }) - state.fullSync = promise + const fullSync = { liveKey, promise } + state.fullSync = fullSync void promise.finally(() => { /* v8 ignore next -- a newer invalidation may already own the sync slot */ - if (state.fullSync === promise) state.fullSync = undefined + if (state.fullSync === fullSync) state.fullSync = undefined }).catch(() => undefined) return promise } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 88d7e906f6..5daa085bb3 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -467,6 +467,48 @@ describe('provider selection and synchronization', () => { .rejects.toThrow('search failed') }) + it('reconciles a live removal observed while an older full sync is in flight', async () => { + const ctx = await liveContext() + const session = ctx.sessions.prepare(SessionId('removed-during-sync')) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + session.append('user/message', { content: [{ type: 'text', text: 'stale live hit' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + const replaceStarted = deferred() + const releaseReplace = deferred() + provider.replaceLive = async (snapshot) => { + replaceStarted.resolve() + await releaseReplace.promise + provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) + } + const searchLiveIds: SessionIdType[][] = [] + provider.searchSessions = () => { + searchLiveIds.push([...provider.live.keys()]) + const items: SessionSearchHit[] = [] + for (const snapshot of provider.live.values()) { + const document = snapshot.documents[0] + if (document === undefined) continue + items.push({ + ...structuredClone(snapshot.session), + bestMatch: { ...structuredClone(document), snippet: document.text }, + }) + } + return Promise.resolve({ providerId: provider.id, items }) + } + ctx.sessionQuery.registerSearchProvider(provider) + + const first = ctx.sessionQuery.searchSessions({ query: 'stale' }) + await replaceStarted.promise + detach() + const second = ctx.sessionQuery.searchSessions({ query: 'stale' }) + releaseReplace.resolve() + + await first + await expect(second).resolves.toMatchObject({ items: [] }) + expect(provider.removedLive).toContain(session.id) + expect(searchLiveIds.at(-1)).toEqual([]) + }) + it('searches a persisted target after corpus reconciliation', async () => { const persisted = header('event-persisted', 1) TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) @@ -528,6 +570,7 @@ describe('provider selection and synchronization', () => { await ctx.sessionQuery.searchSessions({ query: 'x' }) expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted') expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override') + expect(provider.live.get(overlaid.id)?.session).toMatchObject({ live: true, persisted: true }) expect(provider.removedPersisted).toEqual([SessionId('stale')]) expect(provider.activeHistory.at(-1)).toBe(true) const fingerprint = provider.persisted.get(persisted.id)?.fingerprint From 6c6ce08a39a752950d903035f700cdb9563a2276 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:52:17 +0800 Subject: [PATCH 05/11] fix(session-query): preserve sync error typing --- .../session-query/src/provider.ts | 28 +++++++++++++------ .../session-query/tests/session-query.spec.ts | 24 ++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index d6190701a7..a8b6e3038c 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -166,12 +166,18 @@ export class SessionProviderCoordinator { private _syncAll(state: ProviderState): Promise { // Capture the direct source before awaiting: only searches that observed // the same live corpus may share an in-flight full synchronization. - const liveSessions = this._corpus().listLive() - const liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ - snapshot.session.header.id, - snapshot.fingerprint, - snapshot.session.persisted, - ])) + let liveSessions: Session[] + let liveKey: string + try { + liveSessions = this._corpus().listLive() + liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ + snapshot.session.header.id, + snapshot.fingerprint, + snapshot.session.persisted, + ])) + } catch (error: unknown) { + return Promise.reject(this._synchronizationError(state, error)) + } if (state.fullSync?.liveKey === liveKey) return state.fullSync.promise const promise = this._enqueue(state, async () => { /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ @@ -242,12 +248,16 @@ export class SessionProviderCoordinator { const next = state.chain.then(operation, operation) state.chain = next.then(() => undefined, () => undefined) return next.catch((error: unknown) => { - /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ - if (error instanceof SessionQueryError) throw error - throw new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) + throw this._synchronizationError(state, error) }) } + private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError { + /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ + if (error instanceof SessionQueryError) return error + return new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) + } + private _resolveProvider(): ProviderState { if (this._configuredProviderId !== undefined) { const state = this._providers.get(this._configuredProviderId) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 5daa085bb3..6d7e9cab33 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -673,6 +673,30 @@ describe('provider selection and synchronization', () => { await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id }) expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') }) + + it('types synchronous extractor failures during full-search key construction', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('throwing-extractor')) + session.append('test/note', { note: 'unreachable' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + const cause = new Error('custom extractor failed') + ctx.sessionQuery.registerEventTextExtractor('test/note', { + version: 'throwing-v1', + extract: () => { throw cause }, + }) + + let thrown: unknown + try { + await ctx.sessionQuery.searchSessions({ query: 'x' }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(SessionQueryError) + expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) + expect(asError(thrown).message).toContain(`provider "${provider.id}"`) + expect(provider.sessionRequests).toEqual([]) + }) }) describe('semantic text extractors', () => { From f4b0dba3809f43944ebae8abbb0ebb7b1e879433 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 10:59:54 +0800 Subject: [PATCH 06/11] fix(session-query): contain sync cancellation failures --- .../session-query/src/provider.ts | 14 ++++- .../session-query/tests/session-query.spec.ts | 54 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index a8b6e3038c..7152e9f5e5 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -225,7 +225,12 @@ export class SessionProviderCoordinator { private _syncLive(state: ProviderState, session: Session): Promise { const existing = state.liveSync.get(session.id) if (existing !== undefined) return existing - const snapshot = this._snapshotLive(session) + let snapshot: ReturnType + try { + snapshot = this._snapshotLive(session) + } catch (error: unknown) { + return Promise.reject(this._synchronizationError(state, error)) + } const promise = this._enqueue(state, async () => { /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ if (!state.active) return @@ -324,7 +329,12 @@ export class SessionProviderCoordinator { function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { if (signal === undefined) return work - if (signal.aborted) return Promise.reject(aborted()) + if (signal.aborted) { + // Cancellation supersedes the caller's result, but shared work must still + // have a rejection observer when it has already failed synchronously. + void work.catch((_supersededError: unknown) => undefined) + return Promise.reject(aborted()) + } return new Promise((resolve, reject) => { const onAbort = () => { reject(aborted()) } signal.addEventListener('abort', onAbort, { once: true }) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 6d7e9cab33..5249cd22ae 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -697,6 +697,60 @@ describe('provider selection and synchronization', () => { expect(asError(thrown).message).toContain(`provider "${provider.id}"`) expect(provider.sessionRequests).toEqual([]) }) + + it('observes synchronous synchronization failure when the caller is already aborted', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('aborted-throwing-extractor')) + session.append('test/note', { note: 'unreachable' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + ctx.sessionQuery.registerEventTextExtractor('test/note', { + version: 'aborted-throwing-v1', + extract: () => { throw new Error('superseded extraction failure') }, + }) + const controller = new AbortController() + controller.abort() + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown) => { unhandled.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(unhandled).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('types synchronous live-target extraction failures and leaves retries clean', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('throwing-live-extractor')) + session.append('test/note', { note: 'unreachable' }) + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + const cause = new Error('live extractor failed') + const disposeExtractor = ctx.sessionQuery.registerEventTextExtractor('test/note', { + version: 'live-throwing-v1', + extract: () => { throw cause }, + }) + + let thrown: unknown + try { + await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(SessionQueryError) + expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) + expect(asError(thrown).message).toContain(`provider "${provider.id}"`) + expect(provider.eventRequests).toEqual([]) + + disposeExtractor() + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })) + .resolves.toMatchObject({ providerId: provider.id }) + expect(provider.eventRequests).toHaveLength(1) + }) }) describe('semantic text extractors', () => { From 8dd85bd1852ca7fc2a3f481c561bdc7184d001a0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 11:48:32 +0800 Subject: [PATCH 07/11] refactor(session-query): simplify provider synchronization --- docs/event-producer-consumer.md | 6 +- .../session-query/session-query/src/corpus.ts | 9 +- .../session-query/src/extraction.ts | 6 +- .../session-query/session-query/src/index.ts | 6 +- .../session-query/src/provider.ts | 154 ++++++------------ .../session-query/tests/session-query.spec.ts | 63 +++++-- 6 files changed, 106 insertions(+), 138 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0b8c4fce02..23e3844375 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -23,11 +23,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | -| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-query`](../packages/session-query/session-query) | +| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 87fb7a5533..84d6967e01 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -36,10 +36,7 @@ export interface PersistenceView { export class SessionCorpus { private _persistence: PersistenceBinding | undefined - constructor( - private readonly _ctx: Context, - private readonly _onPersistenceChange: (active: boolean) => void, - ) { + constructor(private readonly _ctx: Context) { _ctx.effect(() => { const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { this._attachPersistence(childCtx, childCtx.sessionPersistence) @@ -145,7 +142,6 @@ export class SessionCorpus { refreshing: undefined, } this._persistence = binding - this._onPersistenceChange(true) void this._refreshPersistence(binding) ctx.on('session/persisted', (header) => { /* v8 ignore next -- a stale notification can race optional-service disposal */ @@ -154,7 +150,6 @@ export class SessionCorpus { const observation = { generation: ++binding.observationGeneration, header: snapshot } binding.headers.set(header.id, snapshot) binding.observations.set(header.id, observation) - this._onPersistenceChange(true) }) ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') } @@ -163,7 +158,6 @@ export class SessionCorpus { /* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */ if (this._persistence?.token !== binding.token) return this._persistence = undefined - this._onPersistenceChange(false) } private _refreshPersistence(binding: PersistenceBinding): Promise { @@ -184,7 +178,6 @@ export class SessionCorpus { } binding.headers = nextHeaders binding.error = undefined - this._onPersistenceChange(true) }).catch((error: unknown) => { /* v8 ignore next -- a failed list can race optional-service disposal */ if (this._persistence?.token !== binding.token) return diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index 58db38c8ef..c4ad294a4f 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/packages/session-query/session-query/src/extraction.ts @@ -37,7 +37,7 @@ export class SessionTextExtractors { private readonly _eventExtractors = new Map() private readonly _contentExtractors = new Map() - constructor(private readonly _onChange: () => void) { + constructor() { this._installCoreExtractors() } @@ -63,10 +63,8 @@ export class SessionTextExtractors { } const dispose = ctx.effect(function* (this: SessionTextExtractors) { this._eventExtractors.set(type, stored) - this._onChange() yield () => { this._eventExtractors.delete(type) - this._onChange() } }.bind(this), `sessionQuery.eventExtractor(${type})`) return () => void dispose() @@ -94,10 +92,8 @@ export class SessionTextExtractors { } const dispose = ctx.effect(function* (this: SessionTextExtractors) { this._contentExtractors.set(type, stored) - this._onChange() yield () => { this._contentExtractors.delete(type) - this._onChange() } }.bind(this), `sessionQuery.contentExtractor(${type})`) return () => void dispose() diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 1145332c23..edab597dd5 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -78,13 +78,13 @@ export class SessionQueryService extends Service { if (defaultLimit > maxLimit) { throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG') } - this._extractors = new SessionTextExtractors(() => { this._providers.invalidateAll() }) - this._providers = new SessionProviderCoordinator(ctx, { + this._extractors = new SessionTextExtractors() + this._providers = new SessionProviderCoordinator({ ...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {}, defaultLimit, maxLimit, }, () => this._corpus, this._extractors) - this._corpus = new SessionCorpus(ctx, (active) => { this._providers.persistenceChanged(active) }) + this._corpus = new SessionCorpus(ctx) } /** diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index 7152e9f5e5..c3ec4ad0f4 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -26,13 +26,6 @@ interface ProviderState { active: boolean chain: Promise liveIds: Set - fullSync: FullSync | undefined - liveSync: Map> -} - -interface FullSync { - liveKey: string - promise: Promise } /** Coordinates one selected provider against live and persisted corpus layers. */ @@ -43,7 +36,6 @@ export class SessionProviderCoordinator { private readonly _providers = new Map() constructor( - private readonly _ctx: Context, config: Required> & Pick, private readonly _corpus: () => SessionCorpus, private readonly _extractors: SessionTextExtractors, @@ -51,9 +43,6 @@ export class SessionProviderCoordinator { this._configuredProviderId = config.searchProvider this._defaultLimit = config.defaultLimit this._maxLimit = config.maxLimit - _ctx.on('session/created', (session) => { this.invalidateLive(session.id) }) - _ctx.on('session/event', (session) => { this.invalidateLive(session.id) }) - _ctx.on('session/removed', (header) => { this.invalidateLive(header.id) }) } /** @@ -71,14 +60,9 @@ export class SessionProviderCoordinator { active: true, chain: Promise.resolve(), liveIds: new Set(), - fullSync: undefined, - liveSync: new Map(), } const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { this._providers.set(provider.id, state) - void this._enqueue(state, () => provider.setPersistedActive(false)).catch((error: unknown) => { - this._ctx.logger.warn(`session-query provider "${provider.id}" failed initial deactivation: ${String(error)}`) - }) yield () => { state.active = false this._providers.delete(provider.id) @@ -99,9 +83,12 @@ export class SessionProviderCoordinator { ): Promise> { const state = this._resolveProvider() const normalized = this._normalizeSessionSearch(request) - await waitFor(this._syncAll(state), exec?.signal) - const result = await waitFor(state.provider.searchSessions(normalized, exec), exec?.signal) - return this._validateSearchPage(state, result, normalized.limit) + const work = this._runFullSearch(state, async () => { + if (exec?.signal?.aborted) throw aborted() + const result = await state.provider.searchSessions(normalized, exec) + return this._validateSearchPage(state, result, normalized.limit) + }) + return waitFor(work, exec?.signal) } /** @@ -116,87 +103,41 @@ export class SessionProviderCoordinator { ): Promise> { const state = this._resolveProvider() const normalized = this._normalizeEventSearch(request) + const query = async (): Promise> => { + if (exec?.signal?.aborted) throw aborted() + const result = await state.provider.searchEvents(normalized, exec) + return this._validateSearchPage(state, result, normalized.limit) + } const live = this._corpus().getLive(request.sessionId) + let work: Promise> if (live !== undefined) { - await waitFor(this._syncLive(state, live), exec?.signal) + work = this._runLiveSearch(state, live, query) } else { const persistence = await this._corpus().persistenceView() if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) { throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') } - await waitFor(this._syncAll(state), exec?.signal) + work = this._runFullSearch(state, query) } - const result = await waitFor(state.provider.searchEvents(normalized, exec), exec?.signal) - return this._validateSearchPage(state, result, normalized.limit) + return waitFor(work, exec?.signal) } - /** - * Invalidate provider synchronization after one live source change. - * @param sessionId - changed live session. - */ - invalidateLive(sessionId: SessionId): void { - for (const state of this._providers.values()) { - state.fullSync = undefined - state.liveSync.delete(sessionId) - } - } - - /** Invalidate all source/extractor-derived provider snapshots. */ - invalidateAll(): void { - for (const state of this._providers.values()) { - state.fullSync = undefined - state.liveSync.clear() - } - } - - /** - * React to persistence mount, inventory change, or unmount. - * @param active - whether canonical persistence remains mounted. - */ - persistenceChanged(active: boolean): void { - for (const state of this._providers.values()) state.fullSync = undefined - if (active) return - for (const state of this._providers.values()) { - void this._enqueue(state, () => state.provider.setPersistedActive(false)).catch((error: unknown) => { - this._ctx.logger.warn(`session-query provider "${state.provider.id}" failed persistence deactivation: ${String(error)}`) + private _runFullSearch(state: ProviderState, query: () => Promise): Promise { + const liveSessions = this._corpus().listLive() + return this._serialize(state, async () => { + await this._synchronize(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + const persistence = await this._corpus().persistenceView() + if (persistence === undefined) { + await state.provider.setPersistedActive(false) + } else { + await this._syncPersisted(state, persistence) + } + await this._replaceLiveCorpus(state, liveSessions) }) - } - } - - private _syncAll(state: ProviderState): Promise { - // Capture the direct source before awaiting: only searches that observed - // the same live corpus may share an in-flight full synchronization. - let liveSessions: Session[] - let liveKey: string - try { - liveSessions = this._corpus().listLive() - liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [ - snapshot.session.header.id, - snapshot.fingerprint, - snapshot.session.persisted, - ])) - } catch (error: unknown) { - return Promise.reject(this._synchronizationError(state, error)) - } - if (state.fullSync?.liveKey === liveKey) return state.fullSync.promise - const promise = this._enqueue(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return - const persistence = await this._corpus().persistenceView() - if (persistence === undefined) { - await state.provider.setPersistedActive(false) - } else { - await this._syncPersisted(state, persistence) - } - await this._replaceLiveCorpus(state, liveSessions) + return query() }) - const fullSync = { liveKey, promise } - state.fullSync = fullSync - void promise.finally(() => { - /* v8 ignore next -- a newer invalidation may already own the sync slot */ - if (state.fullSync === fullSync) state.fullSync = undefined - }).catch(() => undefined) - return promise } private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise { @@ -222,39 +163,42 @@ export class SessionProviderCoordinator { state.liveIds = liveIds } - private _syncLive(state: ProviderState, session: Session): Promise { - const existing = state.liveSync.get(session.id) - if (existing !== undefined) return existing + private _runLiveSearch(state: ProviderState, session: Session, query: () => Promise): Promise { let snapshot: ReturnType try { snapshot = this._snapshotLive(session) } catch (error: unknown) { return Promise.reject(this._synchronizationError(state, error)) } - const promise = this._enqueue(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return - await state.provider.replaceLive(snapshot) - state.liveIds.add(session.id) + return this._serialize(state, async () => { + await this._synchronize(state, async () => { + /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ + if (!state.active) return + await state.provider.replaceLive(snapshot) + state.liveIds.add(session.id) + }) + return query() }) - state.liveSync.set(session.id, promise) - void promise.finally(() => { - /* v8 ignore next -- a newer invalidation may already own the target slot */ - if (state.liveSync.get(session.id) === promise) state.liveSync.delete(session.id) - }).catch(() => undefined) - return promise } private _snapshotLive(session: Session): ReturnType { return this._extractors.buildSnapshot(this._corpus().snapshotLive(session)) } - private _enqueue(state: ProviderState, operation: () => Promise): Promise { + /** Serialize reconciliation and its provider query as one stable transaction. */ + private _serialize(state: ProviderState, operation: () => Promise): Promise { const next = state.chain.then(operation, operation) state.chain = next.then(() => undefined, () => undefined) - return next.catch((error: unknown) => { + return next + } + + /** Translate only derived-index update failures, never provider query failures. */ + private async _synchronize(state: ProviderState, operation: () => Promise): Promise { + try { + await operation() + } catch (error: unknown) { throw this._synchronizationError(state, error) - }) + } } private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError { diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 5249cd22ae..642bfb263f 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -103,7 +103,6 @@ class FakeProvider implements SessionSearchProvider { eventRequests: SessionEventSearchSpec[] = [] failNextLive = false failNextPersisted = false - failNextActive = false sessionPage: SessionSearchPage eventPage: SessionSearchPage @@ -125,10 +124,6 @@ class FakeProvider implements SessionSearchProvider { } setPersistedActive(active: boolean): Promise { - if (this.failNextActive) { - this.failNextActive = false - return Promise.reject(new Error('activation failed')) - } this.activeHistory.push(active) return Promise.resolve() } @@ -409,7 +404,7 @@ describe('provider selection and synchronization', () => { await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) }) - it('coalesces concurrent synchronization and supports cancellation while provider search is pending', async () => { + it('serializes concurrent synchronization and supports cancellation while provider search is pending', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('coalesce')) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -418,34 +413,40 @@ describe('provider selection and synchronization', () => { let releaseLive!: () => void const liveBarrier = new Promise((resolve) => { releaseLive = resolve }) + const liveStarted = deferred() let replacements = 0 provider.replaceLive = async (snapshot) => { replacements += 1 + liveStarted.resolve() await liveBarrier provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) } const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - await Promise.resolve() + await liveStarted.promise + expect(replacements).toBe(1) releaseLive() await Promise.all([first, second]) - expect(replacements).toBe(1) + expect(replacements).toBe(2) session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) let releaseCorpus!: () => void const corpusBarrier = new Promise((resolve) => { releaseCorpus = resolve }) + const corpusStarted = deferred() let corpusReplacements = 0 provider.replaceLive = async (snapshot) => { corpusReplacements += 1 + corpusStarted.resolve() await corpusBarrier provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) } const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' }) const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' }) - await Promise.resolve() + await corpusStarted.promise + expect(corpusReplacements).toBe(1) releaseCorpus() await Promise.all([crossFirst, crossSecond]) - expect(corpusReplacements).toBe(1) + expect(corpusReplacements).toBe(2) let releaseSearch!: () => void const searchBarrier = new Promise((resolve) => { releaseSearch = resolve }) @@ -467,6 +468,42 @@ describe('provider selection and synchronization', () => { .rejects.toThrow('search failed') }) + it('holds a provider query stable until later reconciliation can begin', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('stable-query')) + session.append('user/message', { content: [{ type: 'text', text: 'stable' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const provider = new FakeProvider() + const queryStarted = deferred() + const releaseQuery = deferred() + provider.searchEvents = async () => { + queryStarted.resolve() + await releaseQuery.promise + return { providerId: provider.id, items: [] } + } + const reconciliationStarted = deferred() + let reconciling = false + provider.setPersistedActive = (active) => { + if (!active) { + reconciling = true + reconciliationStarted.resolve() + } + return Promise.resolve() + } + ctx.sessionQuery.registerSearchProvider(provider) + + const eventSearch = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'stable' }) + await queryStarted.promise + const fullSearch = ctx.sessionQuery.searchSessions({ query: 'stable' }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(reconciling).toBe(false) + + releaseQuery.resolve() + await eventSearch + await reconciliationStarted.promise + expect(reconciling).toBe(true) + await fullSearch + }) + it('reconciles a live removal observed while an older full sync is in flight', async () => { const ctx = await liveContext() const session = ctx.sessions.prepare(SessionId('removed-during-sync')) @@ -563,7 +600,6 @@ describe('provider selection and synchronization', () => { live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const persistenceFiber = await ctx.plugin(TestPersistence) const provider = new FakeProvider() - provider.failNextActive = true provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] }) ctx.sessionQuery.registerSearchProvider(provider) @@ -583,7 +619,6 @@ describe('provider selection and synchronization', () => { await ctx.sessionQuery.searchSessions({ query: 'x' }) expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced') - provider.failNextActive = true await persistenceFiber.dispose() await ctx.sessionQuery.searchSessions({ query: 'x' }) expect(provider.activeHistory.at(-1)).toBe(false) @@ -674,7 +709,7 @@ describe('provider selection and synchronization', () => { expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') }) - it('types synchronous extractor failures during full-search key construction', async () => { + it('types extractor failures during queued full synchronization', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('throwing-extractor')) session.append('test/note', { note: 'unreachable' }) @@ -714,7 +749,7 @@ describe('provider selection and synchronization', () => { const onUnhandled = (reason: unknown) => { unhandled.push(reason) } process.on('unhandledRejection', onUnhandled) try { - await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) await new Promise((resolve) => { setImmediate(resolve) }) expect(unhandled).toEqual([]) From 8fd68731bab2696abfba3a2d38fd5c47c27811d3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 12:02:51 +0800 Subject: [PATCH 08/11] fix(session-query): harden provider operation lifecycle --- docs/cordis-catalog/services.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-query/session-query/README.md | 2 +- .../session-query/session-query/src/index.ts | 4 +- .../session-query/src/provider.ts | 52 ++++++------ .../session-query/tests/session-query.spec.ts | 83 ++++++++++++++++++- 6 files changed, 114 insertions(+), 31 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 04d07f5795..c8d083593e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -179,7 +179,7 @@ async listEvents(sessionId: SessionId): Promise async readEvent(request: SessionEventReadRequest): Promise async traceSession(sessionId: SessionId): Promise async traceEvent(sessionId: SessionId, seq: number): Promise -registerSearchProvider(provider: SessionSearchProvider): () => void +registerSearchProvider(provider: SessionSearchProvider): () => Promise registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise> diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 12872d689f..2179326c49 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -144,7 +144,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async readEvent(request: SessionEventReadRequest): Promise', 'async traceSession(sessionId: SessionId): Promise', 'async traceEvent(sessionId: SessionId, seq: number): Promise', - 'registerSearchProvider(provider: SessionSearchProvider): () => void', + 'registerSearchProvider(provider: SessionSearchProvider): () => Promise', 'registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void', 'registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void', 'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise>', diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 5dc5aecc34..47977e6f7a 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -22,7 +22,7 @@ Session filters cover id, exact cwd, inclusive creation time, parent id/root, an ## Full-text providers -`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. +`registerSearchProvider(provider)` is effect-scoped and ids are unique. Its async disposer removes the provider from selection immediately, lets already accepted transactions finish, and settles after they drain. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index edab597dd5..c07c0b8d05 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -151,9 +151,9 @@ export class SessionQueryService extends Service { /** * Register one full-text provider with effect-scoped disposal. * @param provider - provider and synchronization implementation. - * @returns disposer that unregisters the provider. + * @returns async disposer that immediately unregisters selection and awaits accepted provider work. */ - registerSearchProvider(provider: SessionSearchProvider): () => void { + registerSearchProvider(provider: SessionSearchProvider): () => Promise { return this._providers.register(this.ctx, provider) } diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts index c3ec4ad0f4..e4235c6d8a 100644 --- a/packages/session-query/session-query/src/provider.ts +++ b/packages/session-query/session-query/src/provider.ts @@ -23,7 +23,6 @@ import { filterEventResults, filterSessionResults } from './filters.ts' interface ProviderState { provider: SessionSearchProvider - active: boolean chain: Promise liveIds: Set } @@ -49,26 +48,25 @@ export class SessionProviderCoordinator { * Register one effect-scoped provider. * @param ctx - contributing caller context. * @param provider - provider implementation. - * @returns disposer for the registration. + * @returns async disposer that deselects immediately and drains accepted work. */ - register(ctx: Context, provider: SessionSearchProvider): () => void { + register(ctx: Context, provider: SessionSearchProvider): () => Promise { if (this._providers.has(provider.id)) { throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER') } const state: ProviderState = { provider, - active: true, chain: Promise.resolve(), liveIds: new Set(), } const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { this._providers.set(provider.id, state) - yield () => { - state.active = false + yield async () => { this._providers.delete(provider.id) + await state.chain } }.bind(this), 'sessionQuery.registerSearchProvider()') - return () => void dispose() + return async () => { await dispose() } } /** @@ -83,7 +81,7 @@ export class SessionProviderCoordinator { ): Promise> { const state = this._resolveProvider() const normalized = this._normalizeSessionSearch(request) - const work = this._runFullSearch(state, async () => { + const work = this._runFullSearch(state, undefined, async () => { if (exec?.signal?.aborted) throw aborted() const result = await state.provider.searchSessions(normalized, exec) return this._validateSearchPage(state, result, normalized.limit) @@ -113,22 +111,25 @@ export class SessionProviderCoordinator { if (live !== undefined) { work = this._runLiveSearch(state, live, query) } else { - const persistence = await this._corpus().persistenceView() - if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) { - throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - work = this._runFullSearch(state, query) + work = this._runFullSearch(state, request.sessionId, query) } return waitFor(work, exec?.signal) } - private _runFullSearch(state: ProviderState, query: () => Promise): Promise { + private _runFullSearch( + state: ProviderState, + requiredSessionId: SessionId | undefined, + query: () => Promise, + ): Promise { const liveSessions = this._corpus().listLive() return this._serialize(state, async () => { await this._synchronize(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return const persistence = await this._corpus().persistenceView() + const missingRequired = requiredSessionId !== undefined + && (persistence === undefined || !persistence.headers.some(header => header.id === requiredSessionId)) + if (missingRequired) { + throw new SessionQueryError(`session "${requiredSessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') + } if (persistence === undefined) { await state.provider.setPersistedActive(false) } else { @@ -172,8 +173,6 @@ export class SessionProviderCoordinator { } return this._serialize(state, async () => { await this._synchronize(state, async () => { - /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ - if (!state.active) return await state.provider.replaceLive(snapshot) state.liveIds.add(session.id) }) @@ -272,32 +271,35 @@ export class SessionProviderCoordinator { } function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { - if (signal === undefined) return work + const observed = work.catch((error: unknown) => { throw operationError(error) }) + if (signal === undefined) return observed if (signal.aborted) { // Cancellation supersedes the caller's result, but shared work must still // have a rejection observer when it has already failed synchronously. - void work.catch((_supersededError: unknown) => undefined) + void observed.catch((_supersededError: unknown) => undefined) return Promise.reject(aborted()) } return new Promise((resolve, reject) => { const onAbort = () => { reject(aborted()) } signal.addEventListener('abort', onAbort, { once: true }) - work.then( + observed.then( (value) => { signal.removeEventListener('abort', onAbort) resolve(value) }, (error: unknown) => { signal.removeEventListener('abort', onAbort) - /* v8 ignore next -- Promise contracts reject with Error; retain a typed boundary for third-party providers */ - reject(error instanceof Error - ? error - : new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error })) + reject(operationError(error)) }, ) }) } +function operationError(error: unknown): Error { + if (error instanceof Error) return error + return new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error }) +} + function aborted(): SessionQueryError { return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED') } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 642bfb263f..36a8676640 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -400,10 +400,37 @@ describe('provider selection and synchronization', () => { await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal })) .resolves.toMatchObject({ providerId: provider.id }) - dispose() + await dispose() await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) }) + it('deselects immediately and drains accepted work before disposal settles', async () => { + const ctx = await liveContext() + const provider = new FakeProvider() + const queryStarted = deferred() + const releaseQuery = deferred() + provider.searchSessions = async () => { + queryStarted.resolve() + await releaseQuery.promise + return { providerId: provider.id, items: [] } + } + const dispose = ctx.sessionQuery.registerSearchProvider(provider) + + const accepted = ctx.sessionQuery.searchSessions({ query: 'accepted' }) + await queryStarted.promise + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await expect(ctx.sessionQuery.searchSessions({ query: 'future' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) + await Promise.resolve() + expect(disposed).toBe(false) + + releaseQuery.resolve() + await expect(accepted).resolves.toMatchObject({ providerId: provider.id }) + await disposal + expect(disposed).toBe(true) + }) + it('serializes concurrent synchronization and supports cancellation while provider search is pending', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('coalesce')) @@ -559,6 +586,60 @@ describe('provider selection and synchronization', () => { expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target') }) + it('cancels a persisted-only event search while persistence listing is blocked', async () => { + const persisted = header('blocked-persisted-target', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) + const listStarted = deferred() + const releaseList = deferred() + TestPersistence.onList = listStarted.resolve + TestPersistence.listBarrier = releaseList.promise + const ctx = await liveContext() + const persistenceFiber = await ctx.plugin(TestPersistence) + await listStarted.promise + const provider = new FakeProvider() + const disposeProvider = ctx.sessionQuery.registerSearchProvider(provider) + const controller = new AbortController() + + const pending = ctx.sessionQuery.searchEvents( + { sessionId: persisted.id, query: 'target' }, + { signal: controller.signal }, + ) + controller.abort() + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + expect(provider.eventRequests).toEqual([]) + + releaseList.resolve() + await disposeProvider() + await persistenceFiber.dispose() + TestPersistence.listBarrier = undefined + TestPersistence.onList = undefined + }) + + it('normalizes non-Error query rejections and preserves Error identity', async () => { + const ctx = await liveContext() + const provider = new FakeProvider() + ctx.sessionQuery.registerSearchProvider(provider) + const signals = [undefined, new AbortController().signal] + + for (const [index, signal] of signals.entries()) { + const exec = signal === undefined ? undefined : { signal } + const identity = new Error(`query failure ${index}`) + provider.searchSessions = () => Promise.reject(identity) + const preserved = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) + .then(() => undefined, (error: unknown) => error) + expect(preserved).toBe(identity) + + const rejection = { index } + // Deliberately violate the Promise convention to test the provider boundary. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + provider.searchSessions = () => Promise.reject(rejection) + const normalized = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) + .then(() => undefined, (error: unknown) => error) + expect(normalized).toBeInstanceOf(SessionQueryError) + expect(normalized).toMatchObject({ code: 'SESSION_QUERY_PROVIDER_ERROR', cause: rejection }) + } + }) + it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => { const ctx = await liveContext() const first = new FakeProvider('first') From ad32c57e724e159291d132233d3c330ce9173ee0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 12:20:35 +0800 Subject: [PATCH 09/11] refactor(session-query): narrow phase one to exact reads --- docs/architecture.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 10 +- docs/cordis-catalog/events.md | 24 +- docs/cordis-catalog/services.md | 15 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/persistence.md | 14 - docs/core-data-structures/session-query.md | 229 +--- docs/event-producer-consumer.md | 6 +- docs/rfc/INDEX.md | 4 +- .../2026-07-10-session-query-service.md | 54 +- ...026-07-10-sqlite-session-query-provider.md | 49 +- packages/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 111 +- packages/core/session/README.md | 6 +- packages/core/session/src/index.ts | 15 - packages/core/session/tests/session.spec.ts | 37 - .../session-persistence/README.md | 2 - .../session-persistence/src/coordinator.ts | 60 +- .../session-persistence/src/index.ts | 23 - .../tests/coordinator-contract.ts | 68 -- packages/session-query/README.md | 6 +- .../session-query/session-query/README.md | 45 +- .../session-query/session-query/package.json | 2 +- .../session-query/session-query/src/config.ts | 34 +- .../session-query/session-query/src/corpus.ts | 260 ++--- .../session-query/src/extraction.ts | 254 ---- .../session-query/src/filters.ts | 123 -- .../session-query/session-query/src/index.ts | 177 +-- .../session-query/src/provider.ts | 310 ----- .../session-query/src/tracing.ts | 158 --- .../session-query/session-query/src/types.ts | 260 +---- .../session-query/tests/session-query.spec.ts | 1028 +++-------------- scripts/gen-doc-graphs.ts | 15 +- scripts/type-equiv.manifest.json | 23 - 35 files changed, 396 insertions(+), 3036 deletions(-) delete mode 100644 packages/session-query/session-query/src/extraction.ts delete mode 100644 packages/session-query/session-query/src/filters.ts delete mode 100644 packages/session-query/session-query/src/provider.ts delete mode 100644 packages/session-query/session-query/src/tracing.ts diff --git a/docs/architecture.md b/docs/architecture.md index 8a9cb0a4f2..2e113f956b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,7 @@ Composition is preferred over inheritance. `packages/core/` is a repository grou | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live/persisted session retrieval and search-provider coordination | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads | ## Event diff --git a/docs/capability-seams.md b/docs/capability-seams.md index dc2f0a05bc..c57c130bf4 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -25,7 +25,7 @@ flowchart LR pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_acp["acp"] - svc_sessionQuery["ctx.sessionQuery
Session retrieval read model"] + svc_sessionQuery["ctx.sessionQuery
Exact session-history reads"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -159,7 +159,7 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d008878b6a..532f52af77 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -488,20 +488,14 @@ Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:5 Requires: `sessions` ```ts config-catalog -/** Configuration for the provider-neutral session-query service. */ +/** Configuration for exact session-query reads. */ export interface Config { - /** Explicit provider id; omitted auto-selects exactly one usable provider. */ - searchProvider?: string - /** Default search result page size. Defaults to 20. */ - defaultLimit?: number - /** Maximum accepted search page size. Defaults to 100. */ - maxLimit?: number /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number } ``` -Source: [`packages/session-query/session-query/src/config.ts:17`](../packages/session-query/session-query/src/config.ts) +Source: [`packages/session-query/session-query/src/config.ts:9`](../packages/session-query/session-query/src/config.ts) ## `@deepseek-ai/dsh-stdio-agent` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 42be61a24e..a9dfb7e05c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -237,7 +237,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -247,27 +247,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:65`](../../packages/core/session/src/index.ts) - -### `session/persisted` — parallel - -A persistence backend committed a canonical session-log change. This is an observe-only notification for derived read models: the durable write has already succeeded, and listener failures are contained rather than propagated into append, load, flush, or teardown. - -```ts cordis-catalog -'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void -``` - -Source: [`packages/session-persistence/session-persistence/src/index.ts:50`](../../packages/session-persistence/session-persistence/src/index.ts) - -### `session/removed` — parallel - -A session left the live store. The header is snapshotted after the store entry is removed; listener failures are contained and cannot break the owning fiber's teardown. - -```ts cordis-catalog -'session/removed'(header: SessionHeader): Promise | void -``` - -Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c8d083593e..c079fbed01 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -167,26 +167,19 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:125`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` -Session-history retrieval and provider coordination service. +Live-preferred logical-corpus and exact-event read service. ```ts cordis-catalog listSessions(): Promise async listEvents(sessionId: SessionId): Promise async readEvent(request: SessionEventReadRequest): Promise -async traceSession(sessionId: SessionId): Promise -async traceEvent(sessionId: SessionId, seq: number): Promise -registerSearchProvider(provider: SessionSearchProvider): () => Promise -registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void -registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void -searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise> -searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise> ``` -Source: [`packages/session-query/session-query/src/index.ts:59`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -204,7 +197,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:413`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:405`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6a31eddd70..c0e1213731 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,7 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | -| [session-query.md](session-query.md) | the retrieval seam: logical session/event records, filters, traces, search pages, extractors, and provider synchronization types | +| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 012e606f48..4dbb8fb2af 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -73,20 +73,6 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. -## `SessionPersistedChange` — committed-log notification range - -The observe-only `session/persisted` event carries the canonical header and the committed range. A repair can report `toSeq < fromSeq` when it only removes a torn fragment. - -Source: [`packages/session-persistence/session-persistence/src/index.ts`](../../packages/session-persistence/session-persistence/src/index.ts) - -```ts type-equiv -export interface SessionPersistedChange { - kind: 'append' | 'repair' - fromSeq: number - toSeq: number -} -``` - ## The backends Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index b824e99f1e..ded8ca3f7e 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -1,12 +1,12 @@ # Session Query -The provider-neutral retrieval seam over live and optionally persisted sessions. The [package contract](../../packages/session-query/session-query) owns resolution, lifecycle, synchronization, and error behavior; this page catalogs the public data exchanged by callers, extractors, and search providers. +Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase. Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) -## Logical records and filters +## Logical records -`SessionRecord` exposes source availability independently from its live-preferred header. `SessionEventRecord` classifies every raw event against the folded surface. +`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation. ```ts type-equiv export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' @@ -30,137 +30,9 @@ export interface SessionEventRecord { } ``` -Filters are serializable discriminated specs. Each spec is one transform in a chain; the literal types below are shared by in-memory filtering and provider pre-ranking requests. +## Bounded event reads -```ts type-equiv -export interface SessionQueryRange { - from?: number - to?: number -} -``` - -```ts type-equiv -export type SessionResultFilter = - | { kind: 'id'; values: readonly SessionId[] } - | { kind: 'cwd'; values: readonly (string | null)[] } - | { kind: 'created-at'; range: SessionQueryRange } - | { kind: 'parent'; values: readonly (SessionId | null)[] } - | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } -``` - -```ts type-equiv -export type SessionEventResultFilter = - | { kind: 'seq'; range: SessionQueryRange } - | { kind: 'time'; range: SessionQueryRange } - | { kind: 'type'; values: readonly SessionEventType[] } - | { kind: 'surface'; values: readonly SessionEventSurface[] } -``` - -## Search requests and pages - -Both scopes use the same opaque-cursor page envelope. Session hits carry exactly one best event; event hits add only a plain-text snippet to the lightweight record. - -```ts type-equiv -export interface SessionQueryExecContext { - readonly signal?: AbortSignal -} -``` - -```ts type-equiv -export type SessionSearchProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } -``` - -```ts type-equiv -export interface SessionSearchPageRequest { - limit?: number - cursor?: string -} -``` - -```ts type-equiv -export interface SessionSearchRequest extends SessionSearchPageRequest { - query: string - sessionFilters?: readonly SessionResultFilter[] - eventFilters?: readonly SessionEventResultFilter[] -} -``` - -```ts type-equiv -export interface SessionEventSearchRequest extends SessionSearchPageRequest { - sessionId: SessionId - query: string - filters?: readonly SessionEventResultFilter[] -} -``` - -The service resolves caller requests before crossing the provider seam, so provider implementations always receive a validated page limit. - -```ts type-equiv -export interface SessionSearchSpec extends SessionSearchRequest { - limit: number -} -``` - -```ts type-equiv -export interface SessionEventSearchSpec extends SessionEventSearchRequest { - limit: number -} -``` - -```ts type-equiv -export interface SessionEventSearchHit extends SessionEventRecord { - snippet: string -} -``` - -```ts type-equiv -export interface SessionSearchHit extends SessionRecord { - bestMatch: SessionEventSearchHit -} -``` - -```ts type-equiv -export interface SessionSearchPage { - providerId: string - items: readonly T[] - nextCursor?: string -} -``` - -## Errors - -The service exposes a closed machine-routable error taxonomy; messages and causes provide detail but do not add codes. - -```ts type-equiv -export type SessionQueryErrorCode = - | 'SESSION_QUERY_ABORTED' - | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' - | 'SESSION_QUERY_DUPLICATE_PROVIDER' - | 'SESSION_QUERY_EVENT_NOT_FOUND' - | 'SESSION_QUERY_INDEX_FAILED' - | 'SESSION_QUERY_INVALID_CONFIG' - | 'SESSION_QUERY_INVALID_EXTRACTOR' - | 'SESSION_QUERY_INVALID_FILTER' - | 'SESSION_QUERY_INVALID_LIMIT' - | 'SESSION_QUERY_INVALID_LINEAGE' - | 'SESSION_QUERY_INVALID_QUERY' - | 'SESSION_QUERY_INVALID_SURFACE' - | 'SESSION_QUERY_INVALID_WINDOW' - | 'SESSION_QUERY_PERSISTENCE_FAILED' - | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' - | 'SESSION_QUERY_PROVIDER_ERROR' - | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' - | 'SESSION_QUERY_SESSION_NOT_FOUND' - | 'SESSION_QUERY_SOURCE_CONFLICT' -``` - -## Event reads and traces - -An event read returns the full target plus a bounded raw-log window. Trace records retain lightweight seq links so callers choose which related event bodies to read. +The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. ```ts type-equiv export interface SessionEventReadRequest { @@ -173,7 +45,7 @@ export interface SessionEventReadRequest { ```ts type-equiv export interface SessionEventWindow { - session: SessionRecord + session: SessionHeader target: SessionEvent events: SessionEvent[] startSeq: number @@ -181,84 +53,17 @@ export interface SessionEventWindow { } ``` -```ts type-equiv -export interface SessionLineageNode { - session: SessionRecord - children: SessionLineageNode[] -} -``` +## Errors + +The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. ```ts type-equiv -export interface SessionLineageTrace { - target: SessionRecord - parents: SessionRecord[] - root?: SessionRecord - unresolvedParentId?: SessionId - children: SessionLineageNode[] -} -``` - -```ts type-equiv -export interface SessionEventTrace { - target: SessionEventRecord - shadowedBy?: number - replacementChain: number[] - shadows: number[] - references: number[] - referencedBy: number[] -} -``` - -## Extraction and provider synchronization - -Custom extractors are keyed by declaration-merged event or content discriminants and carry stable cache-invalidation versions. Providers receive complete event documents grouped into independently replaceable persisted and live snapshots. - -```ts type-equiv -export interface SessionEventTextExtractor { - version: string - extract(event: SessionEvent): readonly string[] -} -``` - -```ts type-equiv -export interface SessionContentTextExtractor { - version: string - extract(block: ContentBlockMap[K]): readonly string[] -} -``` - -```ts type-equiv -export interface SessionIndexDocument extends SessionEventRecord { - text: string -} -``` - -```ts type-equiv -export interface SessionIndexSnapshot { - session: SessionRecord - fingerprint: string - documents: readonly SessionIndexDocument[] -} -``` - -```ts type-equiv -export interface SessionPersistedIndexEntry { - sessionId: SessionId - fingerprint: string -} -``` - -```ts type-equiv -export interface SessionSearchProvider { - readonly id: string - status(): SessionSearchProviderStatus - persistedInventory(): Promise - setPersistedActive(active: boolean): Promise - replacePersisted(snapshot: SessionIndexSnapshot): Promise - removePersisted(sessionId: SessionId): Promise - replaceLive(snapshot: SessionIndexSnapshot): Promise - removeLive(sessionId: SessionId): Promise - searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> - searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> -} +export type SessionQueryErrorCode = + | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_SURFACE' + | 'SESSION_QUERY_INVALID_WINDOW' + | 'SESSION_QUERY_PERSISTENCE_FAILED' + | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_SOURCE_CONFLICT' ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23e3844375..acbe700965 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -24,10 +24,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | -| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 4c77469f63..a4c6c213af 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -12,7 +12,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | | [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | -| [SQLite FTS5 session-query provider](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | +| [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | ### Simplification @@ -68,7 +68,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 | | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | -| [Provider-neutral session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | +| [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index eeb3a13fc7..7e13256669 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -1,61 +1,41 @@ -# RFC: Provider-neutral session query service +# RFC: Exact session query service Status: implemented ## Problem -Session logs contain the harness's durable working memory, but the existing services expose them only as live objects or backend-specific persisted records. Consumers that want history search, compacted-event recall, lineage inspection, or another agent's status otherwise have to choose a storage backend, duplicate live-versus-persisted precedence, and reconstruct surface provenance independently. Live state also advances between persistence checkpoints, so treating durable storage as the only query source makes current-turn reads stale. +Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. -Search is only one operation in that read model. Metadata filtering must compose without another database round trip, event and session lineage need deterministic graph semantics, and an event read must return exact canonical content rather than a search snippet. Folding all of those responsibilities into one SQLite package would make storage technology the public API and would prevent live-only deployments from using the non-search capabilities. +Full-text search is related but materially larger. Designing provider registration, extraction, synchronization, invalidation, ranking, and cursor contracts before a real backend exists creates two speculative state machines: one in the interface service and another in the eventual database package. ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a trusted provider-neutral read model over one logical corpus: live `SessionStore` entries plus an optional, dynamically mounted `SessionPersistence` service. Matching ids resolve to one record. Live events take precedence because they include appends after the latest checkpoint; the record still exposes independent `live` and `persisted` flags. The service compares immutable headers and fails with a typed source-conflict error when the two sources cannot represent the same session. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, and bounded `readEvent(request)`. It does not expose filters, lineage or provenance traversals, text extractors, search requests, provider registration, or derived-index synchronization. -The service owns source observation, reconciliation, precedence, cloning, filters, tracing, extraction, and provider selection. It exposes lightweight session and event records, bounded exact-event reads, complete known session lineage, event surface/provenance traces, and two full-text scopes. A search backend owns only indexing, ranking, snippets, cursors, and backend-specific query validation. +The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. -Persistence is optional. Live-only reads and provider synchronization work without it. Unmounting persistence hides the provider's durable base rather than deleting derived cache rows, so remounting can reuse fingerprints. An installed but unreadable backend fails cross-session operations; a read of a known live session remains independent of that failure. +An exact target read first checks the live store and snapshots the live header and event log. This path never consults persistence, so a failing durable backend cannot make known live history unreadable. With no live target, the service lists current persistence metadata, proves the id exists, loads it, and rejects a list/load header mismatch. All returned headers and events cross one structured-clone boundary. -## Surface and lineage semantics +## Surface semantics -`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seq range. Session-query derives `current`, `shadowed`, and `log-only` classifications and replacement chains from that result, so query and model-history derivation cannot disagree about positional replacement semantics. +`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` uses that result to classify every raw event as `current`, `shadowed`, or `log-only`, so inspection cannot disagree with model-history derivation about positional replacement semantics. -Event traces accept any raw event. They return direct `sourceEventSeqs` references, reverse references, nodes directly shadowed by a replacement, its immediate replacer, and the transitive replacement chain toward the current surface. Related content is deliberately not embedded; exact content remains the job of the bounded event read. - -Session traces walk parents nearest-first. A complete chain reports its root; a partial corpus reports the first unresolved parent id. Descendants form a complete known tree ordered by creation time and id. A cycle connected to the target is an invalid lineage error rather than a truncated result. - -## Filters and public records - -Serializable discriminated filter specs cover session identity, cwd, creation time, parent/root, availability, event seq/time/type, and surface status. Alternatives within one spec are OR; specs in a supplied array are AND. The exported generic transforms are pure, preserve order and item identity, and work on base records or richer hits. Search requests accept the same specs before ranking. Applying a transform to one materialized page never triggers a refill. - -Public records are intentionally small. `SessionRecord` carries a cloned header and source flags. `SessionEventRecord` carries session id, seq, type, time, and surface status. Search adds a plain snippet to event hits and exactly one best event to session hits; numeric provider scores remain private. Search pages default to 20 and reject limits above 100. Exact event reads default to no neighbors and cap each side with the configurable `readWindowMax`, default 50. - -## Lifecycle notifications - -Two observe-only Cordis notifications keep derived read models current without joining the write transaction. `session/removed` fires after a live entry leaves `SessionStore`. `session/persisted` fires only after an ordinary append or load-time repair commits and carries the affected seq range. Both snapshot their payloads and contain synchronous dispatch errors and rejected listeners, so observers cannot fail session teardown or durability. - -A persistence load preserves an existing live owner in coordinator state. HMR adoption of a torn durable prefix truncates only the uncommitted fragment while the live session remains authoritative; it does not publish a repair notification or synthesize an interrupted turn mid-turn. A later real append produces the ordinary committed notification. - -## Provider and extractor contracts - -A selected search provider receives separate persisted-base and live-override operations. Persisted reconciliation begins inactive, compares the provider inventory with SHA-256 fingerprints over canonicalized header/events and relevant extractor versions, replaces only changed sessions, removes proven-stale rows, and then activates the base. Live snapshots always replace the matching override; removal reveals an active persisted base. Search waits for relevant queued reconciliation, with corpus scope for session search and target scope for a live event search. A failed update stays retryable and fails affected searches with a typed derived-index error without affecting canonical writes. Caller cancellation stops waiting and reaches provider query work through `AbortSignal`. - -Core extractors cover semantic messages, reasoning, tools, todos, blocked prompts, context and steering, and error/status detail. Chunks, request headers, and structural events add no document. Declaration-merged event and content-block owners can install one effect-scoped extractor per type with a stable version; unknown types stay non-searchable. +`readEvent()` returns the complete target plus raw neighbors by contiguous seq. `before` and `after` default to zero and are independently bounded by `readWindowMax`, default 50. The result carries a cloned `SessionHeader`, not a source-availability record, because determining a live target's persisted flag would violate the guarantee that live exact reads do not depend on persistence health. ## Security boundary -The service is context-wide trusted infrastructure, not an authorization layer. A model-facing history tool or human UI applies explicit caller/session scope before invoking cross-session operations. This decision exposes no unscoped model tool and changes no transcript or snapshot surface. +The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. This phase adds no model-facing tool and changes no transcript or snapshot surface. ## Alternatives considered -- **Put all query behavior in a SQLite implementation** — rejected because filters, exact reads, source precedence, lineage, and surface provenance are storage-independent, and live-only deployments still need them. It would also let backend details become the public service contract. -- **Query only persisted sessions** — rejected because persistence checkpoints occur at turn boundaries; a current live session would be stale precisely when an agent inspects its latest work. -- **Mirror every live append into persistence before querying** — rejected because query observation must not add durability latency or change the turn checkpoint contract. The live override is an ephemeral derived layer. -- **Express every chained filter as SQL** — rejected because post-filters operate over already materialized pages and must preserve item identity and caller-chosen composition. Serializable pure transforms also remain usable without a search provider. -- **Make session-query part of the compaction capability** — rejected because retrieval reads all session structure and has consumers beyond recall; compaction is one producer of replacement provenance, not the owner of the read model. +- **Put logical-corpus resolution directly in every consumer** — rejected because source precedence, conflicts, optional-service lifecycle, cloning, and surface classification are shared correctness rules. +- **Query only persistence** — rejected because checkpoints can lag the current live log. +- **Cache persisted metadata and listen for writes/removals** — rejected because exact reads can ask the authoritative sources directly, while cache invalidation adds lifecycle and concurrency state before scale requires it. +- **Define a provider-neutral search protocol now** — rejected because no provider consumes it. The first SQLite FTS package should own one reconciliation/transaction state machine; a smaller shared seam can be extracted later only when a second implementation proves the boundary. +- **Include lineage, provenance, and generic filters in phase one** — rejected because no current consumer requires them and canonical logs remain sufficient to add them with evidence later. ## Consequences -Consumers gain one coherent API for current and durable history, deterministic traces, and backend-neutral search. Derived index failures and optional persistence are isolated from canonical session writes, and unchanged persisted sessions can reuse provider rows across restarts. +Phase one has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads remain usable in live-only deployments and deterministic when persistence is present. -The service carries non-trivial reconciliation state and performs canonical log loads to validate fingerprints. Cross-session search intentionally waits for whole-corpus synchronization, and live precedence means providers must implement a two-layer model. Authorization remains the responsibility of future consumers. Full-text search is unavailable until an implementation package registers a provider; that implementation is intentionally outside this decision's package. +Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the phase-two database. Full-text search is unavailable until that package defines and implements its complete contract. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md index 08c1c6fc75..acfdf23bee 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -1,50 +1,51 @@ -# RFC: SQLite FTS5 session-query provider +# RFC: SQLite FTS5 session search Status: proposed ## Problem -The provider-neutral session-query service defines full-text scopes and synchronization but deliberately ships no index. A first backend must search semantic event documents across large persisted histories without rebuilding unchanged sessions at every process start, while keeping unflushed live overrides current and disposable. It also needs deterministic ranking and pagination semantics strong enough for model tools and UI clients to continue a result set safely. +The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. -Using the canonical session-persistence database directly would couple two failure domains and schemas: query rows are derived and rebuildable, while session logs are authoritative. A query schema reset, corrupt index, or experimental tokenizer must never endanger durable conversation history. +Splitting those concerns across a speculative provider coordinator and a database implementation would create two coupled reconciliation state machines. The first real implementation should own the source observation, extraction, SQLite transaction, generation, and query as one lifecycle. ## Proposal -Add an `@deepseek-ai/dsh-session-query-sqlite` implementation in a separate phase-two pull request after the provider-neutral phase is complete. It will register one `SessionSearchProvider` on `ctx.sessionQuery` and own a separate derived SQLite database. Persisted event documents survive provider restarts; live overrides remain connection-local and disappear when the provider closes. +Add `@deepseek-ai/dsh-session-query-sqlite` beside the exact-read package. The package will expose a search service or extend the family with the smallest API required by its actual consumers; phase one does not pre-commit a provider-registration protocol. It will depend on `ctx.sessions` and optional `ctx.sessionPersistence`, own a separate derived SQLite database, and reuse the canonical `foldSurface()` classification. -The provider will use SQLite FTS5 with the trigram tokenizer. A query splits on whitespace and requires every term. Terms shorter than three characters fail with a typed provider error rather than silently changing matching semantics. Each searchable event is one document, including current, shadowed, and log-only states by default. Event search ranks documents within one session; session search groups by session and ranks it by exactly one strongest matching event. Ties are deterministic, public hits contain plain-text snippets, and numeric FTS scores remain internal. +The implementation owns one serialized reconciliation/DB transaction state machine. A transaction observes authoritative persisted metadata and live snapshots, extracts semantic documents, updates derived tables, advances relevant cursor generations, and executes or enables the corresponding query. No second service maintains parallel fingerprints, dirty flags, live-id sets, or invalidation generations. -## Storage and reconciliation +Persisted documents survive restarts. Live overrides are connection-local and shadow the persisted rows for the same session, then disappear when the live owner or database closes. The derived database remains separate from canonical persistence so index reset, corruption, tokenizer changes, and schema churn cannot endanger durable conversation logs. -The database path, journal mode, page/result limits, and snippet length are validated configuration. Durable tables store provider schema version, persisted-session fingerprints, lightweight session metadata, event metadata, text, and the FTS virtual table. A provider-schema mismatch is the exceptional full reset; ordinary startup calls `persistedInventory()` and lets the service replace only new or changed sessions and remove canonical deletions. +## Search semantics to decide with implementation -The live layer uses temporary or connection-local tables with the same searchable shape. A live snapshot shadows every persisted document for that session. Removing the override reveals the active persisted base. `setPersistedActive(false)` excludes durable rows from results without deleting their fingerprint cache. Reopening the database proves that persisted rows remain and live rows do not. +The implementation must define both cross-session and within-session scopes from executable use cases. Each searchable event is one document with session metadata, event metadata, surface classification, normalized semantic text, and a bounded plain-text snippet. Session results group by their strongest matching event; numeric backend scores remain private. -## Query and cursor semantics +Filters compile to parameterized SQL before ranking. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. -Search request filters compile to parameterized metadata predicates before FTS ranking. Query terms are escaped as data, never interpolated into FTS syntax. Snippets are plain text with bounded length and no provider-specific markup contract. +Tokenizer choice remains an implementation experiment. FTS5 trigram supports substring recall but rejects useful terms shorter than three characters and increases index size; the proposal must benchmark that tradeoff against the default Unicode tokenizer before making it contract. -Opaque cursors bind to the normalized request shape and a generation. Session-search cursors bind to the global logical-corpus generation. Event-search cursors bind only to the target session generation. A relevant change makes the cursor stale and produces a typed error; unrelated session changes do not invalidate an inner-session cursor. Stable tie fields are encoded after rank so resumed pages neither duplicate nor skip hits. +## Extraction and reconciliation -Provider update operations are transactional. An index write failure leaves the prior committed generation queryable only after the owning service has successfully retried the dirty update; affected searches fail rather than returning a knowingly stale page. Abort signals interrupt waits and SQLite query work where the runtime permits. +The package starts with first-party semantic extraction for messages, reasoning, tool calls/results, blocked prompts, context, steering, todos, and error/status detail. Structural events and stream chunks contribute no document. Unknown declaration-merged event/content types remain non-searchable unless a real extension consumer demonstrates the need for a public extractor registry. + +Reconciliation may use stable fingerprints to avoid rewriting unchanged persisted sessions, but the database package owns their calculation and storage. It must never report a row current when source observation or extraction failed. Provider-schema mismatch may reset only the derived database; ordinary source changes use transactional upsert/delete. Mounted but unreadable persistence fails affected searches without affecting canonical writes or known live exact reads. ## Alternatives considered -- **Use the session-persistence SQLite database and add FTS tables there** — rejected because derived-index schema churn, resets, and corruption recovery must not share the authoritative log's transaction or failure boundary. -- **Persist live overrides immediately** — rejected because live events are not canonical until the existing persistence checkpoint commits. Ephemeral overlay rows preserve read-your-writes without inventing a second durability path. -- **Use the default FTS5 unicode tokenizer** — rejected for the first backend because substring-oriented history recall is a core use case. Trigram search gives predictable mid-token matching at the accepted cost of rejecting sub-three-character terms. -- **Return raw BM25 scores** — rejected because scores are provider-specific and unstable across corpus changes. Ranking is observable; numeric scale is not part of the service API. -- **Keep cursors valid across index changes** — rejected because rank and grouping can move after a relevant write, making continued pages duplicate or omit hits. +- **Add FTS tables to the canonical persistence database** — rejected because a rebuildable index must not share the authoritative log's schema/reset/failure boundary. +- **Reintroduce phase-one provider coordination** — rejected because there is one planned implementation and no evidence for a stable multi-provider seam. +- **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. +- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. ## Acceptance criteria -- Restart tests prove an unchanged persisted fingerprint performs no FTS replacement, while new, changed, and deleted sessions reconcile correctly. -- Reopening proves persisted rows survive, live rows disappear, removing a live override reveals its persisted base, and the provider works with no persistence service. -- Tests cover both search scopes, all metadata filters, surface defaults, snippets, AND-term escaping, short-term rejection, deterministic ties, pagination, request-bound cursors, scoped stale generations, cancellation, and recovery after a failed index update. -- A provider-schema mismatch resets only the derived database. Normal source changes never trigger a full reset. -- A keyless end-to-end restart test combines a real persistence backend with the real SQLite query provider. -- The implementation, package wiring, and tests land only in the separate phase-two pull request; phase one contains this proposal but no SQLite query code. +- Restart tests cover unchanged, new, changed, and deleted persisted sessions without rebuilding the whole index. +- Reopening preserves persisted rows and removes live rows; live rows shadow and then reveal their persisted base. +- Tests cover both search scopes, metadata filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. +- A schema mismatch resets only the derived database. +- A keyless end-to-end test combines a real persistence backend with the real SQLite search package. +- The RFC is amended to the measured tokenizer and public API actually implemented before moving to `implemented/`. ## Risks -Trigram indexes use more space than word-token indexes, and loading canonical logs to recompute fingerprints still has startup I/O cost even when FTS replacement is skipped. FTS5 ranking and snippet behavior can differ across SQLite runtime versions, so deterministic tie fields and provider-owned snippet tests must pin only the contract the package controls. A global generation makes cross-session cursors conservative: any corpus change invalidates them. The separate derived database adds configuration and lifecycle work, but it preserves the authoritative store's safety boundary. +A single owner is simpler but initially less reusable than a provider-neutral seam. That is intentional: a second real backend can reveal what to extract. SQLite runtime differences can affect FTS ranking and snippets, so tests must pin only contract-controlled ordering and presentation. The separate database adds configuration and lifecycle work, but preserves the canonical store's safety boundary. diff --git a/packages/README.md b/packages/README.md index 8bc5c07637..da3e61e3a5 100644 --- a/packages/README.md +++ b/packages/README.md @@ -23,7 +23,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, filters, tracing, and full-text provider seam | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2179326c49..545be42957 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -137,18 +137,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionQuery', - summary: 'Session-history retrieval and provider coordination service.', + summary: 'Live-preferred logical-corpus and exact-event read service.', methods: [ 'listSessions(): Promise', 'async listEvents(sessionId: SessionId): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', - 'async traceSession(sessionId: SessionId): Promise', - 'async traceEvent(sessionId: SessionId, seq: number): Promise', - 'registerSearchProvider(provider: SessionSearchProvider): () => Promise', - 'registerEventTextExtractor( type: K, extractor: SessionEventTextExtractor, ): () => void', - 'registerContentTextExtractor( type: K, extractor: SessionContentTextExtractor, ): () => void', - 'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise>', - 'searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise>', ], }, { @@ -337,18 +330,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'session/flush\'(session: Session): Promise | void', summary: 'Awaited durability checkpoint.', }, - { - name: 'session/persisted', - mode: 'parallel', - signature: '\'session/persisted\'(header: SessionHeader, change: SessionPersistedChange): Promise | void', - summary: 'A persistence backend committed a canonical session-log change.', - }, - { - name: 'session/removed', - mode: 'parallel', - signature: '\'session/removed\'(header: SessionHeader): Promise | void', - summary: 'A session left the live store.', - }, { name: 'subagent/end', mode: 'emit', @@ -705,10 +686,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n}', }, - { - name: 'SessionContentTextExtractor', - declaration: 'export interface SessionContentTextExtractor {\n version: string;\n extract(block: ContentBlockMap[K]): readonly string[];\n}', - }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -725,41 +702,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventRecord', declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', }, - { - name: 'SessionEventResultFilter', - declaration: 'export type SessionEventResultFilter = {\n kind: \'seq\';\n range: SessionQueryRange;\n} | {\n kind: \'time\';\n range: SessionQueryRange;\n} | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n};', - }, - { - name: 'SessionEventSearchHit', - declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', - }, - { - name: 'SessionEventSearchRequest', - declaration: 'export interface SessionEventSearchRequest extends SessionSearchPageRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventResultFilter[];\n}', - }, - { - name: 'SessionEventSearchSpec', - declaration: 'export interface SessionEventSearchSpec extends SessionEventSearchRequest {\n limit: number;\n}', - }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', }, - { - name: 'SessionEventTextExtractor', - declaration: 'export interface SessionEventTextExtractor {\n version: string;\n extract(event: SessionEvent): readonly string[];\n}', - }, - { - name: 'SessionEventTrace', - declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n shadowedBy?: number;\n replacementChain: number[];\n shadows: number[];\n references: number[];\n referencedBy: number[];\n}', - }, { name: 'SessionEventType', declaration: 'export type SessionEventType = keyof SessionEventMap;', }, { name: 'SessionEventWindow', - declaration: 'export interface SessionEventWindow {\n session: SessionRecord;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', + declaration: 'export interface SessionEventWindow {\n session: SessionHeader;\n target: SessionEvent;\n events: SessionEvent[];\n startSeq: number;\n endSeq: number;\n}', }, { name: 'SessionForkSource', @@ -773,70 +726,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, - { - name: 'SessionIndexDocument', - declaration: 'export interface SessionIndexDocument extends SessionEventRecord {\n text: string;\n}', - }, - { - name: 'SessionIndexSnapshot', - declaration: 'export interface SessionIndexSnapshot {\n session: SessionRecord;\n fingerprint: string;\n documents: readonly SessionIndexDocument[];\n}', - }, - { - name: 'SessionLineageNode', - declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n children: SessionLineageNode[];\n}', - }, - { - name: 'SessionLineageTrace', - declaration: 'export interface SessionLineageTrace {\n target: SessionRecord;\n parents: SessionRecord[];\n root?: SessionRecord;\n unresolvedParentId?: SessionId;\n children: SessionLineageNode[];\n}', - }, - { - name: 'SessionPersistedIndexEntry', - declaration: 'export interface SessionPersistedIndexEntry {\n sessionId: SessionId;\n fingerprint: string;\n}', - }, - { - name: 'SessionQueryExecContext', - declaration: 'export interface SessionQueryExecContext {\n readonly signal?: AbortSignal;\n}', - }, - { - name: 'SessionQueryRange', - declaration: 'export interface SessionQueryRange {\n from?: number;\n to?: number;\n}', - }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', }, - { - name: 'SessionResultFilter', - declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | {\n kind: \'created-at\';\n range: SessionQueryRange;\n} | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly (\'live\' | \'persisted\')[];\n};', - }, - { - name: 'SessionSearchHit', - declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}', - }, - { - name: 'SessionSearchPage', - declaration: 'export interface SessionSearchPage {\n providerId: string;\n items: readonly T[];\n nextCursor?: string;\n}', - }, - { - name: 'SessionSearchPageRequest', - declaration: 'export interface SessionSearchPageRequest {\n limit?: number;\n cursor?: string;\n}', - }, - { - name: 'SessionSearchProvider', - declaration: 'export interface SessionSearchProvider {\n readonly id: string;\n status(): SessionSearchProviderStatus;\n persistedInventory(): Promise;\n setPersistedActive(active: boolean): Promise;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise;\n removePersisted(sessionId: SessionId): Promise;\n replaceLive(snapshot: SessionIndexSnapshot): Promise;\n removeLive(sessionId: SessionId): Promise;\n searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise>;\n searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise>;\n}', - }, - { - name: 'SessionSearchProviderStatus', - declaration: 'export type SessionSearchProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'misconfigured\' | \'unavailable\';\n};', - }, - { - name: 'SessionSearchRequest', - declaration: 'export interface SessionSearchRequest extends SessionSearchPageRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventResultFilter[];\n}', - }, - { - name: 'SessionSearchSpec', - declaration: 'export interface SessionSearchSpec extends SessionSearchRequest {\n limit: number;\n}', - }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n};', diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 89d2ffdf03..4de99ad961 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -25,7 +25,11 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Events -The generated [Cordis event catalog](../../../docs/cordis-catalog/events.md) is the signature reference. `session/removed` is an observe-only notification emitted with a cloned header after the entry leaves the store; listener failures cannot fail owner teardown. +| Event | Mode | Purpose | +|---|---|---| +| `session/created` | emit | A session was created | +| `session/event` | emit | An event was appended (sync, fire-and-forget) | +| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) | ### Class: `Session` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 88cb198c49..ebbf28d63e 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -37,14 +37,6 @@ declare module 'cordis' { * @mode emit */ 'session/created'(session: Session): void - /** - * A session left the live store. The header is snapshotted after the store - * entry is removed; listener failures are contained and cannot break the - * owning fiber's teardown. - * @param header - immutable identity and lineage of the removed session. - * @mode parallel - */ - 'session/removed'(header: SessionHeader): Promise | void /** * An event was appended to a session log (sync, fire-and-forget). This is * the per-append feed a UI or invariant plugin tails. @@ -509,15 +501,8 @@ export class SessionStore extends Service { session.onAppend = (event) => { this.ctx.emit('session/event', session, event) } this.store.set(session.id, session) return () => { - if (this.store.get(session.id) !== session) return session.onAppend = undefined this.store.delete(session.id) - const header = structuredClone(session.header) - void Promise.resolve() - .then(() => this.ctx.parallel('session/removed', header)) - .catch((error: unknown) => { - this.ctx.logger.warn(`session store: session/removed listener failed for "${session.id}": ${String(error)}`) - }) } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index e51ed923df..f338b635f3 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -350,43 +350,6 @@ describe('SessionStore', () => { expect(observed).toBe(0) }) - it('announces a cloned header only after the session leaves the store', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const observations: Array<{ id: string; live: boolean }> = [] - ctx.on('session/removed', (header) => { - observations.push({ id: header.id, live: ctx.sessions.get(header.id) !== undefined }) - header.createdAt = -1 - }) - const session = ctx.sessions.prepare(SessionId('removed'), { meta: { createdAt: 7 } }) - const detach = ctx.sessions.enter(session) - - detach() - await Promise.resolve() - await Promise.resolve() - - expect(observations).toEqual([{ id: 'removed', live: false }]) - expect(session.header.createdAt).toBe(7) - // A repeated disposer cannot remove or announce a later same-id owner. - const replacement = ctx.sessions.create(SessionId('removed')) - detach() - expect(ctx.sessions.get(replacement.id)).toBe(replacement) - expect(observations).toHaveLength(1) - }) - - it('contains rejected session/removed listeners during teardown', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - ctx.on('session/removed', () => Promise.reject(new Error('observer failed'))) - const session = ctx.sessions.prepare(SessionId('contained')) - const detach = ctx.sessions.enter(session) - - expect(detach).not.toThrow() - await Promise.resolve() - await Promise.resolve() - expect(ctx.sessions.get(session.id)).toBeUndefined() - }) - it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 557b968609..8bd3fed568 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -26,8 +26,6 @@ The two first-party backends were byte-identical (or same-algorithm) for ALL of `PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). -After an append or load-time repair commits, the coordinator emits the observe-only `session/persisted` notification described in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). Its snapshotted header and seq range let derived read models invalidate safely; synchronous dispatch failures and rejected listeners are contained and never fail durability. Truncate-only HMR adoption emits no repair notification while the live session still owns the open turn. - The `PersistenceBackend` hooks (the only seam between the coordinator and storage): | Hook | Role | diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index b04387dab1..0180999842 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -27,7 +27,7 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { assertSerializable, seedCoversPrefix, type SessionPersistedChange } from './index.ts' +import { assertSerializable, seedCoversPrefix } from './index.ts' /** * A stored session's durable prefix as read back from a backend: its @@ -229,13 +229,13 @@ export class PersistenceCoordinator { // event inside it — before the op runs would otherwise have those changes // persisted. The clone is taken synchronously (at call time). const batch = events.map(e => structuredClone(e)) - return this.serialize(id, () => this._appendCore(id, batch)) + return this.serialize(id, () => this.appendCore(id, batch)) } - private async _appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { + private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { if (events.length === 0) return let state = this.states.get(id) - if (state === undefined) state = await this.adopt(id) // calls _loadCore, not load + if (state === undefined) state = await this.adopt(id) // calls loadCore, not load // Contiguity contract: each event's seq must continue the stored log. for (const [i, event] of events.entries()) { @@ -247,14 +247,8 @@ export class PersistenceCoordinator { await this.backend.appendBatch(state.meta, events, state.materialized) // The durable write is the transaction: mark materialized + advance the // cursor as soon as it commits (uniform across backends). - const fromSeq = state.cursor state.materialized = true state.cursor += events.length - this._notifyPersisted(state.meta, { - kind: 'append', - fromSeq, - toSeq: state.cursor - 1, - }) } /** @@ -265,10 +259,10 @@ export class PersistenceCoordinator { * @returns the header plus the event log, ending on a balanced `turn/end`. */ load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.serialize(id, () => this._loadCore(id)) + return this.serialize(id, () => this.loadCore(id)) } - private async _loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) const { meta, events, tornMarker } = stored @@ -287,21 +281,10 @@ export class PersistenceCoordinator { // there is no state-path ordering dependency (uniform across backends). if (tornMarker !== undefined || closers.length > 0) { await this.backend.commitRepair(meta, tornMarker, closers) - this._notifyPersisted(meta, { - kind: 'repair', - fromSeq: events.length, - toSeq: balanced.length - 1, - }) } - const owner = this.states.get(id)?.owner - // The state keeps its OWN copy of the meta; preserve a live owner already - // bound to the id so a read-side load cannot downgrade adoption state. - this.states.set(id, { - meta: { ...meta }, - cursor: balanced.length, - materialized: true, - ...owner !== undefined ? { owner } : {}, - }) + // The state keeps its OWN copy of the meta; the returned value is separate so + // a consumer mutating loaded.meta cannot corrupt the backend's metadata. + this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) return { meta, events: balanced } } @@ -331,11 +314,11 @@ export class PersistenceCoordinator { /** Build a state for a session discovered in storage but not yet in memory. */ private async adopt(id: SessionId): Promise { - // _loadCore (NOT load) — adopt runs inside an already-serialized op, so + // loadCore (NOT load) — adopt runs inside an already-serialized op, so // re-entering the chain via the public load() would deadlock. - await this._loadCore(id) + await this.loadCore(id) const state = this.states.get(id) - /* v8 ignore next -- _loadCore always sets the state for the id */ + /* v8 ignore next -- loadCore always sets the state for the id */ if (!state) throw new Error(`failed to adopt session "${id}"`) return state } @@ -492,7 +475,7 @@ export class PersistenceCoordinator { // resume. const live = await this.backend.loadLive(id, session.header.cwd) if (live !== undefined) { - // Do NOT route through _loadCore(): that crash-repairs open turns as + // Do NOT route through loadCore(): that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. await this.serialize(id, () => this.adoptLivePrefix(session, seed, live)) @@ -532,7 +515,7 @@ export class PersistenceCoordinator { owner: session, }) const suffix = seed.slice(events.length) - if (suffix.length > 0) await this._appendCore(session.header.id, suffix) + if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } private async flush(session: Session): Promise { @@ -563,20 +546,9 @@ export class PersistenceCoordinator { /* v8 ignore next -- state is always set by the awaited init before flush */ const cursor = state?.cursor ?? 0 const fresh = batch.filter(e => e.seq >= cursor) - // _appendCore (NOT the serialized append) — drain already runs inside the + // appendCore (NOT the serialized append) — drain already runs inside the // per-session chain, so re-entering via append() would deadlock. - if (fresh.length > 0) await this._appendCore(session.header.id, fresh) + if (fresh.length > 0) await this.appendCore(session.header.id, fresh) buffer.splice(0, batch.length) } - - /** Notify derived read models after source data commits. */ - private _notifyPersisted(meta: SessionHeader, change: SessionPersistedChange): void { - const header = structuredClone(meta) - const snapshot = structuredClone(change) - void Promise.resolve() - .then(() => this.ctx.parallel('session/persisted', header, snapshot)) - .catch((error: unknown) => { - this.ctx.logger.warn(`${this.backend.name}: session/persisted listener failed after ${change.kind} for "${meta.id}": ${String(error)}`) - }) - } } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index ea98f70e9f..1588ed2526 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -36,29 +36,6 @@ declare module 'cordis' { interface Context { sessionPersistence: SessionPersistence } - - interface Events { - /** - * A persistence backend committed a canonical session-log change. This is - * an observe-only notification for derived read models: the durable write - * has already succeeded, and listener failures are contained rather than - * propagated into append, load, flush, or teardown. - * @param header - snapshotted persisted session metadata. - * @param change - committed seq range and whether it was an append or repair. - * @mode parallel - */ - 'session/persisted'(header: SessionHeader, change: SessionPersistedChange): Promise | void - } -} - -/** A committed persisted-log change observed by derived read models. */ -export interface SessionPersistedChange { - /** Whether ordinary append or load-time repair committed the change. */ - kind: 'append' | 'repair' - /** First seq affected by the commit. */ - fromSeq: number - /** Last seq appended; less than `fromSeq` when repair only removed a torn fragment. */ - toSeq: number } /** diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 6d95fbb4b2..42583c4fe3 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -31,7 +31,6 @@ import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '../src/index.ts' -import type { SessionPersistedChange } from '../src/index.ts' import { meta, oneTurnLog, appendLog } from './contract.ts' /** @@ -124,40 +123,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('announces committed append and repair ranges without coupling listener failures to writes', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = [] - ctx.on('session/persisted', (header, change) => { - observed.push({ headerId: header.id, change: structuredClone(change) }) - header.createdAt = -1 - return Promise.reject(new Error('derived read model failed')) - }) - try { - const m = meta('notifications', WORK) - await ctx.sessionPersistence.create(m) - await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined() - await ctx.sessionPersistence.append(m.id, [ - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, - ]) - await expect(ctx.sessionPersistence.load(m.id)).resolves.toMatchObject({ meta: { createdAt: m.createdAt } }) - await Promise.resolve() - await Promise.resolve() - - expect(observed).toEqual([ - { headerId: m.id, change: { kind: 'append', fromSeq: 0, toSeq: 5 } }, - { headerId: m.id, change: { kind: 'append', fromSeq: 6, toSeq: 7 } }, - { headerId: m.id, change: { kind: 'repair', fromSeq: 8, toSeq: 9 } }, - ]) - expect((await ctx.sessionPersistence.load(m.id)).meta.createdAt).toBe(m.createdAt) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - it('round-trips the seed boundary (seedLength) through persistence', async () => { // A forked child records how many leading events were inherited via the // seed; the boundary must survive a reload (so a resume/replay can tell the @@ -403,10 +368,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // Crash-tail a torn fragment past the (open) committed turn, then reload. await first.dispose() if (fix.corruptTail) await fix.corruptTail(SessionId('hmr-open'), WORK) - const repairs: SessionPersistedChange[] = [] - ctx.on('session/persisted', (_header, change) => { - if (change.kind === 'repair') repairs.push(structuredClone(change)) - }) const second = await fix.mount(ctx) // The live session is still the authority: it appends the REAL step/turn // end. Adoption must truncate the torn tail but NOT synthesize closers. @@ -417,7 +378,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open')) expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) - expect(repairs).toEqual([]) await second.dispose() } finally { await ctx.fiber.dispose() @@ -425,34 +385,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('a query-side load preserves the existing live owner binding', async () => { - const fix = await makeFixture() - const { ctx, fiber } = await freshCtx(fix) - let session!: Session - const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(SessionId('load-owner'), { meta: { cwd: WORK } }) - send(session, oneTurnLog()) - }, { inject: ['sessions'] })) - try { - await ctx.parallel('session/flush', session) - const loaded = await ctx.sessionPersistence.load(session.id) - await liveFiber.dispose() - - let replacement!: Session - await ctx.plugin(Object.assign((inner: Context) => { - replacement = inner.sessions.create(session.id, { - seed: loaded.events, - meta: { cwd: WORK, createdAt: loaded.meta.createdAt }, - }) - }, { inject: ['sessions'] })) - await expect(inits(ctx.sessionPersistence).get(replacement)).rejects.toThrow(/different live session|id collision/) - } finally { - await fiber.dispose() - await ctx.fiber.dispose() - await fix.cleanup() - } - }) - // --- collision / id reuse --- it('a NEW live session colliding on a persisted id is rejected, not silently adopted', async () => { diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 15e5b13295..8b0c06a30c 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -1,9 +1,9 @@ # session-query/ — session retrieval capability family -Trusted read-model infrastructure over live and durable session logs. The interface package owns `ctx.sessionQuery`, logical-corpus resolution, filters, traces, text extractors, and the full-text provider contract. A search backend is a separate implementation package; a model tool or UI remains a separate consumer. +Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads. | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Retrieval service and provider contract | `ctx.sessionQuery` | +| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` | -The family is independent of the [compaction capability](../compact/README.md): it reads compaction provenance from the canonical session log but does not participate in compaction policy or execution. The provider-neutral decision is recorded in the [session-query RFC](../../docs/rfc/implemented/feature/2026-07-10-session-query-service.md); the first proposed backend is specified separately in the [SQLite provider RFC](../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package. diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 47977e6f7a..55f9b32fcd 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,52 +1,23 @@ # @deepseek-ai/dsh-session-query -Provider-neutral session-history retrieval (`ctx.sessionQuery`). The service presents live `ctx.sessions` state and, when mounted, `ctx.sessionPersistence` state as one logical corpus. A matching id produces one record: live events win, while independent `live` and `persisted` flags report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT` instead of silently merging unrelated histories. +Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. -## Reads and traces +## Reads -- `listSessions()` returns cloned lightweight records in deterministic newest-first order. -- `listEvents(sessionId)` classifies each raw event as `current`, `shadowed`, or `log-only` using the shared `dsh-session` surface fold. -- `readEvent(request)` returns the cloned target and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax` (default 50). -- `traceSession(sessionId)` returns nearest-first parents, a known root or explicit unresolved parent id, and the complete deterministic descendant tree. A connected lineage cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. -- `traceEvent(sessionId, seq)` returns direct provenance references and reverse references, direct shadows, the immediate replacer, and the transitive replacement chain toward the current surface node. Related nodes stay seq links; callers use `readEvent()` for content. +- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. +- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. -An installed persistence backend is optional and may mount or unmount dynamically. Cross-session operations fail with `SESSION_QUERY_PERSISTENCE_FAILED` while installed persistence is unreadable. A read targeting a known live session never depends on persistence health. Provider-side persisted rows are deactivated rather than deleted when persistence is absent. +Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations. -## Filters - -`filterSessionResults()` and `filterEventResults()` are pure generic transforms over records or richer hits. Each discriminated filter is serializable. Values within one filter are OR alternatives; filters in the supplied array are an AND chain. The functions preserve order and item identity and return a fresh array. - -Session filters cover id, exact cwd, inclusive creation time, parent id/root, and live/persisted availability. Event filters cover inclusive seq/time, event type, and surface status. Search requests accept the same specs as pre-ranking filters. Applying the pure functions to a materialized provider page is a post-filter: it never fetches replacement hits to refill the page. - -## Full-text providers - -`registerSearchProvider(provider)` is effect-scoped and ids are unique. Its async disposer removes the provider from selection immediately, lets already accepted transactions finish, and settles after they drain. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event. - -The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search. - -Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative. - -Providers receive resolved `SessionSearchSpec` and `SessionEventSearchSpec` values whose `limit` is required after service defaulting and validation. Public service callers use `SessionSearchRequest` and `SessionEventSearchRequest`, where `limit` remains optional. - -## Errors - -`SessionQueryError.code` is the closed `SessionQueryErrorCode` union: `SESSION_QUERY_ABORTED`, `SESSION_QUERY_DUPLICATE_EXTRACTOR`, `SESSION_QUERY_DUPLICATE_PROVIDER`, `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INDEX_FAILED`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_EXTRACTOR`, `SESSION_QUERY_INVALID_FILTER`, `SESSION_QUERY_INVALID_LIMIT`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_QUERY`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_PROVIDER_AMBIGUOUS`, `SESSION_QUERY_PROVIDER_CONFIGURED_MISSING`, `SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE`, `SESSION_QUERY_PROVIDER_ERROR`, `SESSION_QUERY_PROVIDER_UNAVAILABLE`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. - -## Text extractors - -Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`. - -Extractor registrations are unique per discriminant and effect-scoped. Their stable versions participate in fingerprints, so changing extraction semantics invalidates only sessions whose indexed source uses that extractor. +`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. ## Configuration | Key | Default | Contract | |---|---:|---| -| `searchProvider` | omitted | Explicit provider id; omission requires exactly one available provider. | -| `defaultLimit` | `20` | Search page size when the request omits `limit`. | -| `maxLimit` | `100` | Maximum accepted search page size; must be at least `defaultLimit`. | | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | -The package ships no full-text backend and no model-facing tool. The proposed SQLite implementation is a later, independent phase described in the [SQLite provider RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index a3a3a2839a..9f78d4f1db 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-query", - "description": "Provider-neutral live and persisted session retrieval service (ctx.sessionQuery)", + "description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index c70eb52988..2736f68cbd 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -1,51 +1,23 @@ -/** - * Public configuration, defaults, and typed failures for session-query. - * - * @module @deepseek-ai/dsh-session-query/config - */ +/** Public configuration and typed failures for session-query. */ import { HarnessError } from '@deepseek-ai/dsh-llm' -/** Default page size for provider-backed search. */ -export const SESSION_QUERY_DEFAULT_LIMIT = 20 -/** Maximum page size accepted by provider-backed search. */ -export const SESSION_QUERY_MAX_LIMIT = 100 /** Default maximum `before`/`after` raw-event window. */ export const SESSION_QUERY_READ_WINDOW_MAX = 50 -/** Configuration for the provider-neutral session-query service. */ +/** Configuration for exact session-query reads. */ export interface Config { - /** Explicit provider id; omitted auto-selects exactly one usable provider. */ - searchProvider?: string - /** Default search result page size. Defaults to 20. */ - defaultLimit?: number - /** Maximum accepted search page size. Defaults to 100. */ - maxLimit?: number /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number } -/** Complete stable machine-routable failure taxonomy for session-query. */ +/** Stable machine-routable failure taxonomy for exact session reads. */ export type SessionQueryErrorCode = - | 'SESSION_QUERY_ABORTED' - | 'SESSION_QUERY_DUPLICATE_EXTRACTOR' - | 'SESSION_QUERY_DUPLICATE_PROVIDER' | 'SESSION_QUERY_EVENT_NOT_FOUND' - | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' - | 'SESSION_QUERY_INVALID_EXTRACTOR' - | 'SESSION_QUERY_INVALID_FILTER' - | 'SESSION_QUERY_INVALID_LIMIT' - | 'SESSION_QUERY_INVALID_LINEAGE' - | 'SESSION_QUERY_INVALID_QUERY' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' - | 'SESSION_QUERY_PROVIDER_AMBIGUOUS' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING' - | 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE' - | 'SESSION_QUERY_PROVIDER_ERROR' - | 'SESSION_QUERY_PROVIDER_UNAVAILABLE' | 'SESSION_QUERY_SESSION_NOT_FOUND' | 'SESSION_QUERY_SOURCE_CONFLICT' diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 84d6967e01..ebc3d92577 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,45 +1,32 @@ /** Live/persisted logical-corpus resolution for session-query. */ import type { Context } from 'cordis' -import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' -import type { LoadedSession } from './extraction.ts' -import { canonicalJson } from './extraction.ts' import { SessionQueryError } from './config.ts' -interface PersistenceBinding { - token: symbol - service: SessionPersistence - headers: Map - /** Notifications retained until a list that began after them completes. */ - observations: Map - observationGeneration: number - error?: unknown - refreshing: Promise | undefined -} - -interface PersistedObservation { - generation: number +/** Detached source selected for one exact read. */ +export interface LogicalSession { + /** Cloned source header. */ header: SessionHeader + /** Cloned raw event log. */ + events: SessionEvent[] } -/** Active persistence view used by provider reconciliation. */ -export interface PersistenceView { - /** Canonical headers in deterministic creation order. */ - headers: SessionHeader[] - /** Load one canonical persisted source. */ - load(id: SessionId): Promise -} - -/** Resolves one live-preferred corpus while containing optional persistence lifecycle. */ +/** Resolves a live-preferred corpus against the persistence service mounted now. */ export class SessionCorpus { - private _persistence: PersistenceBinding | undefined + private _persistence: SessionPersistence | undefined constructor(private readonly _ctx: Context) { _ctx.effect(() => { const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { - this._attachPersistence(childCtx, childCtx.sessionPersistence) + const service = childCtx.sessionPersistence + this._persistence = service + childCtx.effect(() => () => { + /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ + if (this._persistence === service) this._persistence = undefined + }, 'sessionQuery.persistenceBinding') }) return () => void fiber.dispose() }, 'sessionQuery.optionalPersistence') @@ -47,23 +34,22 @@ export class SessionCorpus { /** * List the complete logical corpus with live precedence and cloned headers. - * @returns logical records in deterministic newest-first order. + * @returns records in deterministic newest-first order. */ async listSessions(): Promise { - const binding = await this._ensurePersistence() + const persistence = this._persistence + const persisted = persistence === undefined ? [] : await listPersisted(persistence) const records = new Map() - if (binding !== undefined) { - for (const header of binding.headers.values()) { - records.set(header.id, { header: structuredClone(header), live: false, persisted: true }) - } + for (const header of persisted) { + records.set(header.id, { header: structuredClone(header), live: false, persisted: true }) } for (const session of this._ctx.sessions.list()) { - const persisted = binding?.headers.get(session.id) - if (persisted !== undefined) this._assertCompatibleHeaders(session.header, persisted) + const durable = records.get(session.id) + if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header) records.set(session.id, { header: structuredClone(session.header), live: true, - persisted: persisted !== undefined, + persisted: durable !== undefined, }) } return [...records.values()].sort(compareSessions) @@ -71,156 +57,69 @@ export class SessionCorpus { /** * Load one logical source, preferring a detached live snapshot. + * + * A known live target never consults persistence, so an optional backend's + * failure cannot make current in-memory history unreadable. * @param sessionId - session to resolve. - * @returns detached live-preferred metadata and events. + * @returns detached live-preferred header and events. */ - async loadLogical(sessionId: SessionId): Promise { + async load(sessionId: SessionId): Promise { const live = this._ctx.sessions.get(sessionId) - if (live !== undefined) return this.snapshotLive(live) - const binding = await this._ensurePersistence() - if (binding === undefined || !binding.headers.has(sessionId)) { - throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - return this._loadPersisted(binding, sessionId) - } - - /** - * Return a detached live source with current availability flags. - * @param session - live session to snapshot. - * @returns detached metadata and events. - */ - snapshotLive(session: Session): LoadedSession { - const persistedHeader = this._persistence?.headers.get(session.id) - if (persistedHeader !== undefined) this._assertCompatibleHeaders(session.header, persistedHeader) - return { - record: { - header: structuredClone(session.header), - live: true, - persisted: persistedHeader !== undefined, - }, - events: session.events.map(event => structuredClone(event)), - } - } - - /** - * Get one live session without consulting persistence. - * @param sessionId - live id to resolve. - * @returns current store object, or undefined. - */ - getLive(sessionId: SessionId): Session | undefined { - return this._ctx.sessions.get(sessionId) - } - - /** - * List live sessions in store order. - * @returns fresh array of current store objects. - */ - listLive(): Session[] { - return this._ctx.sessions.list() - } - - /** - * Resolve an authoritative persisted view. - * @returns cloned headers and loader, or undefined while unmounted. - */ - async persistenceView(): Promise { - const binding = await this._ensurePersistence() - if (binding === undefined) return undefined - return { - headers: [...binding.headers.values()].map(header => structuredClone(header)).sort(compareHeadersAscending), - load: id => this._loadPersisted(binding, id), - } - } - - private _attachPersistence(ctx: Context, service: SessionPersistence): void { - const binding: PersistenceBinding = { - token: Symbol('session-query-persistence'), - service, - headers: new Map(), - observations: new Map(), - observationGeneration: 0, - refreshing: undefined, - } - this._persistence = binding - void this._refreshPersistence(binding) - ctx.on('session/persisted', (header) => { - /* v8 ignore next -- a stale notification can race optional-service disposal */ - if (this._persistence?.token !== binding.token) return - const snapshot = structuredClone(header) - const observation = { generation: ++binding.observationGeneration, header: snapshot } - binding.headers.set(header.id, snapshot) - binding.observations.set(header.id, observation) - }) - ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') - } - - private _detachPersistence(binding: PersistenceBinding): void { - /* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */ - if (this._persistence?.token !== binding.token) return - this._persistence = undefined - } - - private _refreshPersistence(binding: PersistenceBinding): Promise { - if (binding.refreshing !== undefined) return binding.refreshing - const startGeneration = binding.observationGeneration - const refresh = binding.service.list().then((headers) => { - /* v8 ignore next -- a list completion can race optional-service disposal */ - if (this._persistence?.token !== binding.token) return - const nextHeaders = new Map(headers.map(header => [header.id, structuredClone(header)])) - for (const [id, observation] of binding.observations) { - // A notification newer than this list's snapshot is the authoritative - // read-your-writes layer; older ones must already be present in list(). - if (observation.generation > startGeneration) { - nextHeaders.set(id, structuredClone(observation.header)) - } else { - binding.observations.delete(id) - } - } - binding.headers = nextHeaders - binding.error = undefined - }).catch((error: unknown) => { - /* v8 ignore next -- a failed list can race optional-service disposal */ - if (this._persistence?.token !== binding.token) return - binding.error = error - }).finally(() => { - /* v8 ignore next -- a newer refresh may already own the slot */ - if (binding.refreshing === refresh) binding.refreshing = undefined - }) - binding.refreshing = refresh - return refresh - } - - private async _ensurePersistence(): Promise { - const binding = this._persistence - if (binding === undefined) return undefined - await this._refreshPersistence(binding) - if (binding.error !== undefined) { - const cause = binding.error - throw new SessionQueryError(`session persistence listing failed: ${errorMessage(cause)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause }) - } - return binding - } - - private async _loadPersisted(binding: PersistenceBinding, sessionId: SessionId): Promise { + if (live !== undefined) return snapshotLive(live) + const persistence = this._persistence + if (persistence === undefined) throw notFound(sessionId) + const listed = (await listPersisted(persistence)).find(header => header.id === sessionId) + if (listed === undefined) throw notFound(sessionId) + let loaded: Awaited> try { - const loaded = await binding.service.load(sessionId) - const listed = binding.headers.get(sessionId) - /* v8 ignore else -- every internal persisted load starts from a listed header */ - if (listed !== undefined) this._assertCompatibleHeaders(loaded.meta, listed) - return { - record: { header: structuredClone(loaded.meta), live: false, persisted: true }, - events: loaded.events.map(event => structuredClone(event)), - } + loaded = await persistence.load(sessionId) } catch (error: unknown) { - if (error instanceof SessionQueryError) throw error - throw new SessionQueryError(`failed to load session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error }) + throw new SessionQueryError( + `failed to load session "${sessionId}": ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } + assertCompatibleHeaders(loaded.meta, listed) + return { + header: structuredClone(loaded.meta), + events: loaded.events.map(event => structuredClone(event)), } } +} - private _assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { - if (canonicalJson(a) !== canonicalJson(b)) { - throw new SessionQueryError(`live and persisted headers conflict for session "${a.id}"`, 'SESSION_QUERY_SOURCE_CONFLICT') - } +async function listPersisted(persistence: SessionPersistence): Promise { + try { + return await persistence.list() + } catch (error: unknown) { + throw new SessionQueryError( + `session persistence listing failed: ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } +} + +function snapshotLive(session: Session): LogicalSession { + return { + header: structuredClone(session.header), + events: session.events.map(event => structuredClone(event)), + } +} + +function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { + if ( + a.version !== b.version + || a.id !== b.id + || a.createdAt !== b.createdAt + || a.cwd !== b.cwd + || a.parentSession !== b.parentSession + || a.seedLength !== b.seedLength + ) { + throw new SessionQueryError( + `live and persisted headers conflict for session "${a.id}"`, + 'SESSION_QUERY_SOURCE_CONFLICT', + ) } } @@ -228,11 +127,10 @@ function compareSessions(a: SessionRecord, b: SessionRecord): number { return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id) } -function compareHeadersAscending(a: SessionHeader, b: SessionHeader): number { - return a.createdAt - b.createdAt || a.id.localeCompare(b.id) +function notFound(sessionId: SessionId): SessionQueryError { + return new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') } function errorMessage(error: unknown): string { - /* v8 ignore next -- persistence service contracts reject Error instances */ return error instanceof Error ? error.message : 'unknown error' } diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts deleted file mode 100644 index c4ad294a4f..0000000000 --- a/packages/session-query/session-query/src/extraction.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** Semantic text extraction and stable provider snapshot fingerprints. */ - -import { createHash } from 'node:crypto' -import type { Context } from 'cordis' -import type { ContentBlock, ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, SessionEventType } from '@deepseek-ai/dsh-session' -import type { - SessionContentTextExtractor, - SessionEventTextExtractor, - SessionIndexDocument, - SessionIndexSnapshot, - SessionRecord, -} from './types.ts' -import { SessionQueryError } from './config.ts' -import { eventRecords } from './tracing.ts' - -/** Canonical session source consumed by extraction and provider reconciliation. */ -export interface LoadedSession { - /** Logical source metadata. */ - record: SessionRecord - /** Detached canonical events. */ - events: SessionEvent[] -} - -interface StoredEventExtractor { - version: string - extract(event: SessionEvent): readonly string[] -} - -interface StoredContentExtractor { - version: string - extract(block: ContentBlock): readonly string[] -} - -/** Owns core/custom semantic extractors and builds versioned index snapshots. */ -export class SessionTextExtractors { - private readonly _eventExtractors = new Map() - private readonly _contentExtractors = new Map() - - constructor() { - this._installCoreExtractors() - } - - /** - * Register one effect-scoped event extractor. - * @param ctx - contributing caller context. - * @param type - event discriminant. - * @param extractor - versioned semantic extractor. - * @returns disposer for the registration. - */ - registerEvent( - ctx: Context, - type: K, - extractor: SessionEventTextExtractor, - ): () => void { - this._validateVersion(type, extractor.version) - if (this._eventExtractors.has(type)) { - throw new SessionQueryError(`session event text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') - } - const stored: StoredEventExtractor = { - version: extractor.version, - extract: event => extractor.extract(event as SessionEvent), - } - const dispose = ctx.effect(function* (this: SessionTextExtractors) { - this._eventExtractors.set(type, stored) - yield () => { - this._eventExtractors.delete(type) - } - }.bind(this), `sessionQuery.eventExtractor(${type})`) - return () => void dispose() - } - - /** - * Register one effect-scoped content-block extractor. - * @param ctx - contributing caller context. - * @param type - content-block discriminant. - * @param extractor - versioned semantic extractor. - * @returns disposer for the registration. - */ - registerContent( - ctx: Context, - type: K, - extractor: SessionContentTextExtractor, - ): () => void { - this._validateVersion(type, extractor.version) - if (this._contentExtractors.has(type)) { - throw new SessionQueryError(`session content text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR') - } - const stored: StoredContentExtractor = { - version: extractor.version, - extract: block => extractor.extract(block as ContentBlockMap[K]), - } - const dispose = ctx.effect(function* (this: SessionTextExtractors) { - this._contentExtractors.set(type, stored) - yield () => { - this._contentExtractors.delete(type) - } - }.bind(this), `sessionQuery.contentExtractor(${type})`) - return () => void dispose() - } - - /** - * Build one provider-neutral snapshot and SHA-256 source/version fingerprint. - * @param loaded - detached canonical source. - * @returns lightweight documents and stable fingerprint. - */ - buildSnapshot(loaded: LoadedSession): SessionIndexSnapshot { - const records = eventRecords(loaded.record.header.id, loaded.events) - const documents: SessionIndexDocument[] = [] - const eventVersions = new Set() - const blockVersions = new Set() - for (const event of loaded.events) { - const extractor = this._eventExtractors.get(event.type) - if (extractor === undefined) continue - eventVersions.add(`${event.type}@${extractor.version}`) - collectBlockVersions(event.data, this._contentExtractors, blockVersions) - const text = normalizeText(extractor.extract(event)) - if (text.length === 0) continue - // The event record array parallels the contiguous log. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - documents.push({ ...records[event.seq]!, text }) - } - const fingerprint = createHash('sha256').update(canonicalJson({ - header: loaded.record.header, - events: loaded.events, - eventExtractors: [...eventVersions].sort(), - contentExtractors: [...blockVersions].sort(), - })).digest('hex') - return { - session: cloneRecord(loaded.record), - fingerprint, - documents, - } - } - - private _installCoreExtractors(): void { - this._contentExtractors.set('text', { version: '1', extract: block => [(block as ContentBlockMap['text']).text] }) - this._contentExtractors.set('reasoning', { version: '1', extract: block => [(block as ContentBlockMap['reasoning']).text] }) - this._contentExtractors.set('tool-call', { - version: '1', - extract: (block) => { - const call = block as ContentBlockMap['tool-call'] - return [call.name, call.arguments] - }, - }) - this._contentExtractors.set('tool-result', { - version: '1', - extract: block => this._extractBlocks((block as ContentBlockMap['tool-result']).content), - }) - for (const type of ['user/message', 'assistant/message', 'context/message', 'steering/message'] as const) { - this._eventExtractors.set(type, { - version: '1', - extract: event => this._extractBlocks((event as SessionEvent).data.content), - }) - } - this._eventExtractors.set('prompt/blocked', { - version: '1', - extract: (event) => { - const data = (event as SessionEvent<'prompt/blocked'>).data - return [...this._extractBlocks(data.content), data.reason] - }, - }) - this._eventExtractors.set('tool/call', { - version: '1', - extract: (event) => { - const data = (event as SessionEvent<'tool/call'>).data - return [data.name, data.arguments] - }, - }) - this._eventExtractors.set('tool/result', { - version: '1', - extract: (event) => { - const data = (event as SessionEvent<'tool/result'>).data - return [...this._extractBlocks(data.content), data.error?.name ?? '', data.error?.code ?? ''] - }, - }) - this._eventExtractors.set('todo/write', { - version: '1', - extract: event => (event as SessionEvent<'todo/write'>).data.todos.map(todo => `${todo.status} ${todo.content}`), - }) - this._eventExtractors.set('turn/end', { - version: '1', - extract: (event) => { - const reason = (event as SessionEvent<'turn/end'>).data.reason - switch (reason.kind) { - case 'error': return ['error', reason.message, reason.code ?? ''] - case 'aborted': return ['aborted', reason.reason ?? ''] - case 'rejected': return ['rejected', reason.reason] - case 'disposed': return ['disposed'] - case 'max-tokens': return ['max-tokens'] - case 'interrupted': return ['interrupted'] - case 'completed': return [] - // TurnEndReasonMap is merge-extensible; unknown variants contribute no text. - /* v8 ignore next -- only an external declaration-merged reason can reach this fallback */ - default: return [] - } - }, - }) - } - - private _extractBlocks(blocks: readonly ContentBlock[]): string[] { - const fragments: string[] = [] - for (const block of blocks) { - const extractor = this._contentExtractors.get(block.type) - if (extractor !== undefined) fragments.push(...extractor.extract(block)) - } - return fragments - } - - private _validateVersion(type: string, version: string): void { - if (version.trim().length === 0) { - throw new SessionQueryError(`session-query extractor "${type}" requires a non-blank version`, 'SESSION_QUERY_INVALID_EXTRACTOR') - } - } -} - -/** - * Encode canonical JSON with recursively sorted object keys. - * @param value - JSON-compatible source value. - * @returns deterministic JSON text. - */ -export function canonicalJson(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` - const object = value as Record - return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}` -} - -function normalizeText(fragments: readonly string[]): string { - return fragments.map(fragment => fragment.trim()).filter(Boolean).join('\n') -} - -function collectBlockVersions( - value: unknown, - extractors: ReadonlyMap, - versions: Set, -): void { - if (Array.isArray(value)) { - for (const item of value) collectBlockVersions(item, extractors, versions) - return - } - if (value === null || typeof value !== 'object') return - const object = value as Record - if (typeof object.type === 'string') { - const type = object.type as ContentBlockType - const extractor = extractors.get(type) - if (extractor !== undefined) versions.add(`${type}@${extractor.version}`) - } - for (const nested of Object.values(object)) collectBlockVersions(nested, extractors, versions) -} - -function cloneRecord(record: SessionRecord): SessionRecord { - return { ...record, header: structuredClone(record.header) } -} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts deleted file mode 100644 index 296361cfbf..0000000000 --- a/packages/session-query/session-query/src/filters.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** Pure serializable session-query result filters. */ - -import { assertNever } from '@deepseek-ai/dsh-llm' -import type { - SessionEventRecord, - SessionEventResultFilter, - SessionQueryRange, - SessionRecord, - SessionResultFilter, -} from './types.ts' -import { SessionQueryError } from './config.ts' - -const AVAILABILITIES = ['live', 'persisted'] as const -const SURFACE_STATES = ['current', 'shadowed', 'log-only'] as const - -/** - * Apply an ordered AND-chain of session filters while preserving item order - * and the concrete generic item type. - * @param results - session records or richer session search hits. - * @param filters - serializable filters applied in order. - * @returns a fresh filtered array. - */ -export function filterSessionResults( - results: readonly T[], - filters: readonly SessionResultFilter[], -): T[] { - for (const filter of filters) validateSessionFilter(filter) - return results.filter(result => filters.every(filter => matchesSessionFilter(result, filter))) -} - -/** - * Apply an ordered AND-chain of event filters while preserving item order and - * the concrete generic item type. - * @param results - event records or richer event search hits. - * @param filters - serializable filters applied in order. - * @returns a fresh filtered array. - */ -export function filterEventResults( - results: readonly T[], - filters: readonly SessionEventResultFilter[], -): T[] { - for (const filter of filters) validateEventFilter(filter) - return results.filter(result => filters.every(filter => matchesEventFilter(result, filter))) -} - -function matchesSessionFilter(record: SessionRecord, filter: SessionResultFilter): boolean { - switch (filter.kind) { - case 'id': return filter.values.includes(record.header.id) - case 'cwd': return filter.values.includes(record.header.cwd ?? null) - case 'created-at': return inRange(record.header.createdAt, filter.range) - case 'parent': return filter.values.includes(record.header.parentSession ?? null) - case 'availability': return filter.values.some(value => value === 'live' ? record.live : record.persisted) - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: return assertNever(filter) - } -} - -function matchesEventFilter(record: SessionEventRecord, filter: SessionEventResultFilter): boolean { - switch (filter.kind) { - case 'seq': return inRange(record.seq, filter.range) - case 'time': return inRange(record.time, filter.range) - case 'type': return filter.values.includes(record.type) - case 'surface': return filter.values.includes(record.surface) - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: return assertNever(filter) - } -} - -function validateSessionFilter(filter: SessionResultFilter): void { - switch (filter.kind) { - case 'id': - case 'cwd': - case 'parent': - return - case 'created-at': - validateRange('created-at', filter.range) - return - case 'availability': - for (const value of filter.values) { - if (!(AVAILABILITIES as readonly string[]).includes(value)) invalidFilter(`unknown availability "${value}"`) - } - return - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: - assertNever(filter) - } -} - -function validateEventFilter(filter: SessionEventResultFilter): void { - switch (filter.kind) { - case 'seq': - case 'time': - validateRange(filter.kind, filter.range) - return - case 'type': - return - case 'surface': - for (const value of filter.values) { - if (!(SURFACE_STATES as readonly string[]).includes(value)) invalidFilter(`unknown surface status "${value}"`) - } - return - /* v8 ignore next -- closed discriminated union exhaustiveness guard */ - default: - assertNever(filter) - } -} - -function validateRange(name: string, range: SessionQueryRange): void { - if (range.from !== undefined && !Number.isFinite(range.from)) invalidFilter(`${name}.from must be finite`) - if (range.to !== undefined && !Number.isFinite(range.to)) invalidFilter(`${name}.to must be finite`) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - invalidFilter(`${name}.from must be <= ${name}.to`) - } -} - -function invalidFilter(message: string): never { - throw new SessionQueryError(`session-query filter: ${message}`, 'SESSION_QUERY_INVALID_FILTER') -} - -function inRange(value: number, range: SessionQueryRange): boolean { - return (range.from === undefined || value >= range.from) - && (range.to === undefined || value <= range.to) -} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index c07c0b8d05..828fe2ec88 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -1,53 +1,29 @@ /** - * Provider-neutral session-history retrieval over live and optionally - * persisted session logs. The public service composes logical-corpus reads, - * pure filters and tracing, semantic extraction, and provider coordination. + * Exact session-history reads over live and optionally persisted logs. * * @module @deepseek-ai/dsh-session-query */ import { Context, Service } from 'cordis' import z from 'schemastery' -import type { ContentBlockType } from '@deepseek-ai/dsh-llm' -import type { SessionEventType, SessionId } from '@deepseek-ai/dsh-session' +import { foldSurface } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { - SessionContentTextExtractor, SessionEventReadRequest, SessionEventRecord, - SessionEventSearchHit, - SessionEventSearchRequest, - SessionEventTextExtractor, - SessionEventTrace, SessionEventWindow, - SessionLineageTrace, SessionRecord, - SessionSearchHit, - SessionSearchPage, - SessionSearchProvider, - SessionSearchRequest, - SessionQueryExecContext, } from './types.ts' import { - SESSION_QUERY_DEFAULT_LIMIT, - SESSION_QUERY_MAX_LIMIT, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, type Config, } from './config.ts' -import { SessionTextExtractors } from './extraction.ts' import { SessionCorpus } from './corpus.ts' -import { SessionProviderCoordinator } from './provider.ts' -import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' export type * from './types.ts' export type { Config, SessionQueryErrorCode } from './config.ts' -export { - SESSION_QUERY_DEFAULT_LIMIT, - SESSION_QUERY_MAX_LIMIT, - SESSION_QUERY_READ_WINDOW_MAX, - SessionQueryError, -} from './config.ts' -export { filterEventResults, filterSessionResults } from './filters.ts' +export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts' declare module 'cordis' { interface Context { @@ -55,35 +31,25 @@ declare module 'cordis' { } } -/** Session-history retrieval and provider coordination service. */ +/** Live-preferred logical-corpus and exact-event read service. */ export class SessionQueryService extends Service { static inject = ['sessions'] static Config: z = z.object({ - searchProvider: z.string(), - defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_DEFAULT_LIMIT), - maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_MAX_LIMIT), readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), }) private readonly _readWindowMax: number - private readonly _extractors: SessionTextExtractors - private readonly _providers: SessionProviderCoordinator private readonly _corpus: SessionCorpus constructor(ctx: Context, config: Config = {}) { super(ctx, 'sessionQuery') - const defaultLimit = config.defaultLimit ?? SESSION_QUERY_DEFAULT_LIMIT - const maxLimit = config.maxLimit ?? SESSION_QUERY_MAX_LIMIT this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX - if (defaultLimit > maxLimit) { - throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG') + if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) { + throw new SessionQueryError( + 'session-query: readWindowMax must be a non-negative integer', + 'SESSION_QUERY_INVALID_CONFIG', + ) } - this._extractors = new SessionTextExtractors() - this._providers = new SessionProviderCoordinator({ - ...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {}, - defaultLimit, - maxLimit, - }, () => this._corpus, this._extractors) this._corpus = new SessionCorpus(ctx) } @@ -101,7 +67,7 @@ export class SessionQueryService extends Service { * @returns event records in ascending seq order. */ async listEvents(sessionId: SessionId): Promise { - const loaded = await this._corpus.loadLogical(sessionId) + const loaded = await this._corpus.load(sessionId) return eventRecords(sessionId, loaded.events) } @@ -113,113 +79,58 @@ export class SessionQueryService extends Service { async readEvent(request: SessionEventReadRequest): Promise { const before = this._readWindow('before', request.before) const after = this._readWindow('after', request.after) - const loaded = await this._corpus.loadLogical(request.sessionId) + const loaded = await this._corpus.load(request.sessionId) const target = loaded.events[request.seq] if (target === undefined || target.seq !== request.seq) { - throw new SessionQueryError(`session "${request.sessionId}" has no event at seq ${request.seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') + throw new SessionQueryError( + `session "${request.sessionId}" has no event at seq ${request.seq}`, + 'SESSION_QUERY_EVENT_NOT_FOUND', + ) } const startSeq = Math.max(0, request.seq - before) const endSeq = Math.min(loaded.events.length - 1, request.seq + after) return { - session: cloneRecord(loaded.record), - target: structuredClone(target), - events: loaded.events.slice(startSeq, endSeq + 1).map(event => structuredClone(event)), + session: loaded.header, + target, + events: loaded.events.slice(startSeq, endSeq + 1), startSeq, endSeq, } } - /** - * Trace parent ancestry and the complete known descendant tree of a session. - * @param sessionId - logical session id to trace. - * @returns complete or explicitly partial lineage. - */ - async traceSession(sessionId: SessionId): Promise { - return traceLineage(await this._corpus.listSessions(), sessionId) - } - - /** - * Trace direct provenance and surface replacement relationships for any event. - * @param sessionId - logical session containing the target. - * @param seq - target event seq. - * @returns lightweight trace with related seq links. - */ - async traceEvent(sessionId: SessionId, seq: number): Promise { - return traceEventLog(sessionId, (await this._corpus.loadLogical(sessionId)).events, seq) - } - - /** - * Register one full-text provider with effect-scoped disposal. - * @param provider - provider and synchronization implementation. - * @returns async disposer that immediately unregisters selection and awaits accepted provider work. - */ - registerSearchProvider(provider: SessionSearchProvider): () => Promise { - return this._providers.register(this.ctx, provider) - } - - /** - * Register semantic text extraction for one event type. - * @param type - declaration-merged event discriminant. - * @param extractor - stable version and typed extraction callback. - * @returns disposer that removes the extractor. - */ - registerEventTextExtractor( - type: K, - extractor: SessionEventTextExtractor, - ): () => void { - return this._extractors.registerEvent(this.ctx, type, extractor) - } - - /** - * Register semantic text extraction for one content block type. - * @param type - declaration-merged content-block discriminant. - * @param extractor - stable version and typed extraction callback. - * @returns disposer that removes the extractor. - */ - registerContentTextExtractor( - type: K, - extractor: SessionContentTextExtractor, - ): () => void { - return this._extractors.registerContent(this.ctx, type, extractor) - } - - /** - * Search the complete logical corpus and rank one result per session. - * @param request - query, pre-ranking filters, and pagination. - * @param exec - optional cancellation context. - * @returns ranked provider page. - */ - searchSessions( - request: SessionSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - return this._providers.searchSessions(request, exec) - } - - /** - * Search events within one logical session. - * @param request - target session, query, filters, and pagination. - * @param exec - optional cancellation context. - * @returns ranked provider page. - */ - searchEvents( - request: SessionEventSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - return this._providers.searchEvents(request, exec) - } - private _readWindow(name: 'before' | 'after', value: number | undefined): number { if (value === undefined) return 0 if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) { - throw new SessionQueryError(`${name} must be an integer between 0 and ${this._readWindowMax}`, 'SESSION_QUERY_INVALID_WINDOW') + throw new SessionQueryError( + `${name} must be an integer between 0 and ${this._readWindowMax}`, + 'SESSION_QUERY_INVALID_WINDOW', + ) } return value } } -function cloneRecord(record: SessionRecord): SessionRecord { - return { ...record, header: structuredClone(record.header) } +function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { + let folded: ReturnType + try { + folded = foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError( + /* v8 ignore next -- foldSurface throws Error instances */ + `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, + 'SESSION_QUERY_INVALID_SURFACE', + { cause: error }, + ) + } + const current = new Set(folded.nodes.map(node => node.seq)) + const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs)) + return events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', + })) } export default SessionQueryService diff --git a/packages/session-query/session-query/src/provider.ts b/packages/session-query/session-query/src/provider.ts deleted file mode 100644 index e4235c6d8a..0000000000 --- a/packages/session-query/session-query/src/provider.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** Search-provider selection, synchronization, pagination, and cancellation. */ - -import type { Context } from 'cordis' -import type { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionTextExtractors } from './extraction.ts' -import type { PersistenceView, SessionCorpus } from './corpus.ts' -import type { - SessionEventRecord, - SessionEventSearchHit, - SessionEventSearchRequest, - SessionEventSearchSpec, - SessionQueryExecContext, - SessionRecord, - SessionSearchHit, - SessionSearchPage, - SessionSearchProvider, - SessionSearchRequest, - SessionSearchSpec, -} from './types.ts' -import type { Config } from './config.ts' -import { SessionQueryError } from './config.ts' -import { filterEventResults, filterSessionResults } from './filters.ts' - -interface ProviderState { - provider: SessionSearchProvider - chain: Promise - liveIds: Set -} - -/** Coordinates one selected provider against live and persisted corpus layers. */ -export class SessionProviderCoordinator { - private readonly _configuredProviderId: string | undefined - private readonly _defaultLimit: number - private readonly _maxLimit: number - private readonly _providers = new Map() - - constructor( - config: Required> & Pick, - private readonly _corpus: () => SessionCorpus, - private readonly _extractors: SessionTextExtractors, - ) { - this._configuredProviderId = config.searchProvider - this._defaultLimit = config.defaultLimit - this._maxLimit = config.maxLimit - } - - /** - * Register one effect-scoped provider. - * @param ctx - contributing caller context. - * @param provider - provider implementation. - * @returns async disposer that deselects immediately and drains accepted work. - */ - register(ctx: Context, provider: SessionSearchProvider): () => Promise { - if (this._providers.has(provider.id)) { - throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER') - } - const state: ProviderState = { - provider, - chain: Promise.resolve(), - liveIds: new Set(), - } - const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { - this._providers.set(provider.id, state) - yield async () => { - this._providers.delete(provider.id) - await state.chain - } - }.bind(this), 'sessionQuery.registerSearchProvider()') - return async () => { await dispose() } - } - - /** - * Search and group the complete logical corpus. - * @param request - normalized provider-neutral request input. - * @param exec - optional cancellation controls. - * @returns ranked session page. - */ - async searchSessions( - request: SessionSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - const state = this._resolveProvider() - const normalized = this._normalizeSessionSearch(request) - const work = this._runFullSearch(state, undefined, async () => { - if (exec?.signal?.aborted) throw aborted() - const result = await state.provider.searchSessions(normalized, exec) - return this._validateSearchPage(state, result, normalized.limit) - }) - return waitFor(work, exec?.signal) - } - - /** - * Search events within one logical session. - * @param request - target and provider-neutral request input. - * @param exec - optional cancellation controls. - * @returns ranked event page. - */ - async searchEvents( - request: SessionEventSearchRequest, - exec?: SessionQueryExecContext, - ): Promise> { - const state = this._resolveProvider() - const normalized = this._normalizeEventSearch(request) - const query = async (): Promise> => { - if (exec?.signal?.aborted) throw aborted() - const result = await state.provider.searchEvents(normalized, exec) - return this._validateSearchPage(state, result, normalized.limit) - } - const live = this._corpus().getLive(request.sessionId) - let work: Promise> - if (live !== undefined) { - work = this._runLiveSearch(state, live, query) - } else { - work = this._runFullSearch(state, request.sessionId, query) - } - return waitFor(work, exec?.signal) - } - - private _runFullSearch( - state: ProviderState, - requiredSessionId: SessionId | undefined, - query: () => Promise, - ): Promise { - const liveSessions = this._corpus().listLive() - return this._serialize(state, async () => { - await this._synchronize(state, async () => { - const persistence = await this._corpus().persistenceView() - const missingRequired = requiredSessionId !== undefined - && (persistence === undefined || !persistence.headers.some(header => header.id === requiredSessionId)) - if (missingRequired) { - throw new SessionQueryError(`session "${requiredSessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - if (persistence === undefined) { - await state.provider.setPersistedActive(false) - } else { - await this._syncPersisted(state, persistence) - } - await this._replaceLiveCorpus(state, liveSessions) - }) - return query() - }) - } - - private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise { - await state.provider.setPersistedActive(false) - const inventory = new Map((await state.provider.persistedInventory()).map(entry => [entry.sessionId, entry.fingerprint])) - for (const header of persistence.headers) { - const snapshot = this._extractors.buildSnapshot(await persistence.load(header.id)) - if (inventory.get(header.id) !== snapshot.fingerprint) await state.provider.replacePersisted(snapshot) - inventory.delete(header.id) - } - for (const staleId of inventory.keys()) await state.provider.removePersisted(staleId) - await state.provider.setPersistedActive(true) - } - - private async _replaceLiveCorpus(state: ProviderState, sessions: readonly Session[]): Promise { - const liveIds = new Set(sessions.map(session => session.id)) - for (const staleId of state.liveIds) { - if (!liveIds.has(staleId)) await state.provider.removeLive(staleId) - } - for (const session of sessions) { - await state.provider.replaceLive(this._snapshotLive(session)) - } - state.liveIds = liveIds - } - - private _runLiveSearch(state: ProviderState, session: Session, query: () => Promise): Promise { - let snapshot: ReturnType - try { - snapshot = this._snapshotLive(session) - } catch (error: unknown) { - return Promise.reject(this._synchronizationError(state, error)) - } - return this._serialize(state, async () => { - await this._synchronize(state, async () => { - await state.provider.replaceLive(snapshot) - state.liveIds.add(session.id) - }) - return query() - }) - } - - private _snapshotLive(session: Session): ReturnType { - return this._extractors.buildSnapshot(this._corpus().snapshotLive(session)) - } - - /** Serialize reconciliation and its provider query as one stable transaction. */ - private _serialize(state: ProviderState, operation: () => Promise): Promise { - const next = state.chain.then(operation, operation) - state.chain = next.then(() => undefined, () => undefined) - return next - } - - /** Translate only derived-index update failures, never provider query failures. */ - private async _synchronize(state: ProviderState, operation: () => Promise): Promise { - try { - await operation() - } catch (error: unknown) { - throw this._synchronizationError(state, error) - } - } - - private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError { - /* v8 ignore next -- service-created typed synchronization errors pass through unchanged */ - if (error instanceof SessionQueryError) return error - return new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error }) - } - - private _resolveProvider(): ProviderState { - if (this._configuredProviderId !== undefined) { - const state = this._providers.get(this._configuredProviderId) - if (state === undefined) { - throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is not registered`, 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING') - } - if (!state.provider.status().available) { - throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is unavailable`, 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE') - } - return state - } - const usable = [...this._providers.values()].filter(state => state.provider.status().available) - const [single] = usable - if (single === undefined) { - throw new SessionQueryError('no usable session-query provider is registered', 'SESSION_QUERY_PROVIDER_UNAVAILABLE') - } - if (usable.length > 1) { - throw new SessionQueryError(`multiple usable session-query providers are registered (${usable.map(state => state.provider.id).join(', ')}); configure one explicitly`, 'SESSION_QUERY_PROVIDER_AMBIGUOUS') - } - return single - } - - private _normalizeSessionSearch(request: SessionSearchRequest): SessionSearchSpec { - const query = this._queryText(request.query) - const limit = this._limitValue(request.limit) - filterSessionResults([], request.sessionFilters ?? []) - filterEventResults([], request.eventFilters ?? []) - return { ...request, query, limit } - } - - private _normalizeEventSearch(request: SessionEventSearchRequest): SessionEventSearchSpec { - const query = this._queryText(request.query) - const limit = this._limitValue(request.limit) - filterEventResults([], request.filters ?? []) - return { ...request, query, limit } - } - - private _queryText(query: string): string { - const normalized = query.trim() - if (normalized.length === 0) { - throw new SessionQueryError('session-query search text must not be blank', 'SESSION_QUERY_INVALID_QUERY') - } - return normalized - } - - private _limitValue(limit: number | undefined): number { - const value = limit ?? this._defaultLimit - if (!Number.isInteger(value) || value < 1 || value > this._maxLimit) { - throw new SessionQueryError(`session-query limit must be an integer between 1 and ${this._maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT') - } - return value - } - - private _validateSearchPage(state: ProviderState, page: SessionSearchPage, limit: number): SessionSearchPage { - if (page.providerId !== state.provider.id) { - throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR') - } - if (page.items.length > limit) { - throw new SessionQueryError(`session-query provider "${state.provider.id}" returned ${page.items.length} items for limit ${limit}`, 'SESSION_QUERY_PROVIDER_ERROR') - } - return page - } -} - -function waitFor(work: Promise, signal: AbortSignal | undefined): Promise { - const observed = work.catch((error: unknown) => { throw operationError(error) }) - if (signal === undefined) return observed - if (signal.aborted) { - // Cancellation supersedes the caller's result, but shared work must still - // have a rejection observer when it has already failed synchronously. - void observed.catch((_supersededError: unknown) => undefined) - return Promise.reject(aborted()) - } - return new Promise((resolve, reject) => { - const onAbort = () => { reject(aborted()) } - signal.addEventListener('abort', onAbort, { once: true }) - observed.then( - (value) => { - signal.removeEventListener('abort', onAbort) - resolve(value) - }, - (error: unknown) => { - signal.removeEventListener('abort', onAbort) - reject(operationError(error)) - }, - ) - }) -} - -function operationError(error: unknown): Error { - if (error instanceof Error) return error - return new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error }) -} - -function aborted(): SessionQueryError { - return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED') -} - -function errorMessage(error: unknown): string { - /* v8 ignore next -- provider update contracts reject Error instances */ - return error instanceof Error ? error.message : 'unknown error' -} diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts deleted file mode 100644 index 61363e8254..0000000000 --- a/packages/session-query/session-query/src/tracing.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** Session lineage and event surface/provenance tracing. */ - -import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { - SessionEventRecord, - SessionEventTrace, - SessionLineageNode, - SessionLineageTrace, - SessionRecord, -} from './types.ts' -import { SessionQueryError } from './config.ts' - -/** - * Classify raw events against the canonical surface fold. - * @param sessionId - owner of the event log. - * @param events - detached raw log. - * @returns lightweight records in seq order. - */ -export function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { - const fold = safeFold(events) - const current = new Set(fold.nodes.map(node => node.seq)) - const shadowed = new Set(fold.replacements.flatMap(replacement => replacement.shadowedSeqs)) - return events.map(event => ({ - sessionId, - seq: event.seq, - type: event.type, - time: event.time, - surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', - })) -} - -/** - * Build one event trace from a validated logical event log. - * @param sessionId - owner of the event log. - * @param events - detached raw log. - * @param seq - target event seq. - * @returns direct provenance and replacement relationships. - */ -export function traceEventLog(sessionId: SessionId, events: readonly SessionEvent[], seq: number): SessionEventTrace { - const target = events[seq] - if (target === undefined || target.seq !== seq) { - throw new SessionQueryError(`session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND') - } - const records = eventRecords(sessionId, events) - const fold = safeFold(events) - const shadowedBy = new Map() - const shadows = new Map() - for (const replacement of fold.replacements) { - shadows.set(replacement.seq, [...replacement.shadowedSeqs]) - for (const shadowed of replacement.shadowedSeqs) shadowedBy.set(shadowed, replacement.seq) - } - const references: number[] = [] - const referencedBy: number[] = [] - for (const event of events) { - if (!isSurfaceEvent(event)) continue - for (const source of event.sourceEventSeqs ?? []) { - if (event.seq === seq) references.push(source) - if (source === seq) referencedBy.push(event.seq) - } - } - const replacementChain: number[] = [] - let replacement = shadowedBy.get(seq) - while (replacement !== undefined) { - replacementChain.push(replacement) - replacement = shadowedBy.get(replacement) - } - const immediate = shadowedBy.get(seq) - // seq was checked against the contiguous event log, so its parallel record exists. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const targetRecord = records[seq]! - return { - target: { ...targetRecord }, - ...immediate !== undefined ? { shadowedBy: immediate } : {}, - replacementChain, - shadows: shadows.get(seq) ?? [], - references, - referencedBy, - } -} - -/** - * Trace ancestry and descendants within one materialized logical corpus. - * @param records - complete visible logical corpus. - * @param sessionId - target session id. - * @returns complete known lineage or explicit unresolved parent. - */ -export function traceLineage(records: readonly SessionRecord[], sessionId: SessionId): SessionLineageTrace { - const byId = new Map(records.map(record => [record.header.id, record])) - const target = byId.get(sessionId) - if (target === undefined) { - throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') - } - - const parents: SessionRecord[] = [] - const ancestrySeen = new Set([sessionId]) - let unresolvedParentId: SessionId | undefined - let parentId = target.header.parentSession - while (parentId !== undefined) { - if (ancestrySeen.has(parentId)) lineageCycle(parentId) - ancestrySeen.add(parentId) - const parent = byId.get(parentId) - if (parent === undefined) { - unresolvedParentId = parentId - break - } - parents.push(parent) - parentId = parent.header.parentSession - } - - const childrenByParent = new Map() - for (const record of records) { - const parent = record.header.parentSession - if (parent === undefined) continue - const children = childrenByParent.get(parent) ?? [] - children.push(record) - childrenByParent.set(parent, children) - } - for (const children of childrenByParent.values()) children.sort(compareSessionsAscending) - const buildChildren = (id: SessionId): SessionLineageNode[] => (childrenByParent.get(id) ?? []).map(child => ({ - session: cloneRecord(child), - children: buildChildren(child.header.id), - })) - - return { - target: cloneRecord(target), - parents: parents.map(cloneRecord), - ...unresolvedParentId !== undefined - ? { unresolvedParentId } - : { root: cloneRecord(parents.at(-1) ?? target) }, - children: buildChildren(sessionId), - } -} - -function safeFold(events: readonly SessionEvent[]): ReturnType { - try { - return foldSurface(events) - } catch (error: unknown) { - throw new SessionQueryError(`invalid session surface: ${errorMessage(error)}`, 'SESSION_QUERY_INVALID_SURFACE', { cause: error }) - } -} - -function cloneRecord(record: SessionRecord): SessionRecord { - return { ...record, header: structuredClone(record.header) } -} - -function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { - return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id) -} - -function lineageCycle(id: SessionId): never { - throw new SessionQueryError(`session lineage contains a cycle at "${id}"`, 'SESSION_QUERY_INVALID_LINEAGE') -} - -function errorMessage(error: unknown): string { - /* v8 ignore next -- foldSurface throws Error instances */ - return error instanceof Error ? error.message : 'unknown error' -} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 11e6f6d0fc..5c49695dda 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -1,31 +1,24 @@ /** - * Public vocabulary for the session-query retrieval service: lightweight - * records, composable filters, traces, search requests/results, extractor - * registrations, and the provider synchronization contract. + * Public records for exact reads over the live-preferred logical session corpus. * * @module @deepseek-ai/dsh-session-query/types */ -import type { ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm' -import type { - SessionEvent, - SessionEventType, - SessionHeader, - SessionId, -} from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -/** Whether an event is on the current surface, was replaced, or is log-only. */ +/** Whether an event is current model context, replaced context, or raw-log-only. */ export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' -/** Lightweight identity and availability for one logical session. */ +/** Lightweight identity and source availability for one logical session. */ export interface SessionRecord { - /** Cloned immutable session header selected from the live-preferred corpus. */ + /** Cloned session header selected from the live-preferred corpus. */ header: SessionHeader /** Whether the id currently exists in `ctx.sessions`. */ live: boolean /** Whether the active persistence backend currently materializes the id. */ persisted: boolean } + /** Lightweight metadata for one event within a logical session. */ export interface SessionEventRecord { /** Session that owns the event. */ @@ -40,102 +33,6 @@ export interface SessionEventRecord { surface: SessionEventSurface } -/** Inclusive numeric range used by result and search filters. */ -export interface SessionQueryRange { - /** Inclusive lower bound. */ - from?: number - /** Inclusive upper bound. */ - to?: number -} - -/** Serializable filter applied to session records. */ -export type SessionResultFilter = - | { kind: 'id'; values: readonly SessionId[] } - | { kind: 'cwd'; values: readonly (string | null)[] } - | { kind: 'created-at'; range: SessionQueryRange } - | { kind: 'parent'; values: readonly (SessionId | null)[] } - | { kind: 'availability'; values: readonly ('live' | 'persisted')[] } - -/** Serializable filter applied to event records. */ -export type SessionEventResultFilter = - | { kind: 'seq'; range: SessionQueryRange } - | { kind: 'time'; range: SessionQueryRange } - | { kind: 'type'; values: readonly SessionEventType[] } - | { kind: 'surface'; values: readonly SessionEventSurface[] } - -/** Caller cancellation threaded through synchronization and provider search. */ -export interface SessionQueryExecContext { - /** Abort signal for waiting and provider-owned query work. */ - readonly signal?: AbortSignal -} - -/** Cheap local usability status returned by a search provider. */ -export type SessionSearchProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' } - -/** Common pagination fields accepted by both search scopes. */ -export interface SessionSearchPageRequest { - /** Maximum number of hits on this page. */ - limit?: number - /** Opaque cursor returned by the same provider/request. */ - cursor?: string -} - -/** Cross-session full-text request. */ -export interface SessionSearchRequest extends SessionSearchPageRequest { - /** Plain text query interpreted by the selected provider. */ - query: string - /** Session metadata filters applied before event ranking/grouping. */ - sessionFilters?: readonly SessionResultFilter[] - /** Event metadata filters applied before best-event grouping. */ - eventFilters?: readonly SessionEventResultFilter[] -} - -/** Full-text request scoped to one session's events. */ -export interface SessionEventSearchRequest extends SessionSearchPageRequest { - /** Session whose events form the search corpus. */ - sessionId: SessionId - /** Plain text query interpreted by the selected provider. */ - query: string - /** Event metadata filters applied before ranking. */ - filters?: readonly SessionEventResultFilter[] -} - -/** Provider-facing cross-session search spec after service normalization. */ -export interface SessionSearchSpec extends SessionSearchRequest { - /** Required page size validated and defaulted by the query service. */ - limit: number -} - -/** Provider-facing event search spec after service normalization. */ -export interface SessionEventSearchSpec extends SessionEventSearchRequest { - /** Required page size validated and defaulted by the query service. */ - limit: number -} - -/** One lightweight event search hit with provider-produced evidence text. */ -export interface SessionEventSearchHit extends SessionEventRecord { - /** Plain-text excerpt explaining the match. */ - snippet: string -} - -/** One session-ranked search hit and its strongest matching event. */ -export interface SessionSearchHit extends SessionRecord { - /** Strongest matching event used as the session's ranking evidence. */ - bestMatch: SessionEventSearchHit -} - -/** One provider-owned page of search results. */ -export interface SessionSearchPage { - /** Stable id of the provider that produced this page. */ - providerId: string - /** Ranked hits in deterministic provider order, no longer than the requested limit. */ - items: readonly T[] - /** Opaque next-page cursor, absent when the result is exhausted. */ - nextCursor?: string -} - /** Request for one event plus raw neighboring log context. */ export interface SessionEventReadRequest { /** Session that owns the target event. */ @@ -150,8 +47,8 @@ export interface SessionEventReadRequest { /** Full target event and a bounded raw-log window. */ export interface SessionEventWindow { - /** Logical session metadata at read time. */ - session: SessionRecord + /** Cloned header for the live-preferred source read. */ + session: SessionHeader /** Full cloned target event. */ target: SessionEvent /** Full cloned events from `startSeq` through `endSeq`. */ @@ -161,144 +58,3 @@ export interface SessionEventWindow { /** Last seq included in `events`. */ endSeq: number } - -/** Recursive child node in a session lineage trace. */ -export interface SessionLineageNode { - /** Session represented by this lineage node. */ - session: SessionRecord - /** Direct children in deterministic creation order. */ - children: SessionLineageNode[] -} - -/** Complete known lineage around one session. */ -export interface SessionLineageTrace { - /** Session that was traced. */ - target: SessionRecord - /** Known parents from immediate parent outward. */ - parents: SessionRecord[] - /** Root when the complete parent chain is available. */ - root?: SessionRecord - /** First parent id outside the visible corpus, when the trace is partial. */ - unresolvedParentId?: SessionId - /** Complete known descendant forest rooted at the target's direct children. */ - children: SessionLineageNode[] -} - -/** Surface and provenance relationships for one event. */ -export interface SessionEventTrace { - /** Lightweight target record. */ - target: SessionEventRecord - /** Immediate replacement event that shadowed the target. */ - shadowedBy?: number - /** Replacement seqs from the target toward the current descendant. */ - replacementChain: number[] - /** Surface nodes directly shadowed by the target replacement event. */ - shadows: number[] - /** Direct provenance sources from `sourceEventSeqs`. */ - references: number[] - /** Events that directly name the target in `sourceEventSeqs`. */ - referencedBy: number[] -} - -/** Typed extractor for one declaration-merged session event type. */ -export interface SessionEventTextExtractor { - /** Stable cache-invalidation version chosen by the extractor owner. */ - version: string - /** - * Extract semantic searchable fragments from one event. - * @param event - event narrowed to the registered type. - * @returns plain-text fragments; blanks are discarded by the service. - */ - extract(event: SessionEvent): readonly string[] -} - -/** Typed extractor for one declaration-merged content block type. */ -export interface SessionContentTextExtractor { - /** Stable cache-invalidation version chosen by the extractor owner. */ - version: string - /** - * Extract semantic searchable fragments from one content block. - * @param block - block narrowed to the registered type. - * @returns plain-text fragments; blanks are discarded by the service. - */ - extract(block: ContentBlockMap[K]): readonly string[] -} - -/** One provider-neutral event document produced by registered extractors. */ -export interface SessionIndexDocument extends SessionEventRecord { - /** Normalized newline-joined text indexed by a search provider. */ - text: string -} - -/** One complete index layer for a live session or persisted checkpoint. */ -export interface SessionIndexSnapshot { - /** Layer metadata and live/persisted availability exposed in results. */ - session: SessionRecord - /** Stable SHA-256 identity of canonical source data and extractor versions. */ - fingerprint: string - /** Searchable event documents in seq order. */ - documents: readonly SessionIndexDocument[] -} - -/** Durable provider inventory entry used to reuse unchanged persisted rows. */ -export interface SessionPersistedIndexEntry { - /** Persisted session id. */ - sessionId: SessionId - /** Last indexed source/extractor fingerprint. */ - fingerprint: string -} - -/** Search and synchronization backend registered into `ctx.sessionQuery`. */ -export interface SessionSearchProvider { - /** Stable provider id, unique within the query service. */ - readonly id: string - /** - * Return cheap local usability without performing index or search I/O. - * @returns whether the provider can be selected. - */ - status(): SessionSearchProviderStatus - /** - * Read reusable persisted-layer fingerprints from derived storage. - * @returns durable inventory entries. - */ - persistedInventory(): Promise - /** - * Hide or expose reconciled persisted rows without deleting their cache. - * @param active - whether canonical persistence is mounted and reconciled. - */ - setPersistedActive(active: boolean): Promise - /** - * Atomically replace one persisted session's derived documents. - * @param snapshot - canonical persisted checkpoint and fingerprint. - */ - replacePersisted(snapshot: SessionIndexSnapshot): Promise - /** - * Delete one durable derived entry after canonical reconciliation proves it absent. - * @param sessionId - persisted id to remove. - */ - removePersisted(sessionId: SessionId): Promise - /** - * Replace one connection-local live override. - * @param snapshot - current live snapshot and availability. - */ - replaceLive(snapshot: SessionIndexSnapshot): Promise - /** - * Drop one live override, revealing its active persisted base when present. - * @param sessionId - live id to remove. - */ - removeLive(sessionId: SessionId): Promise - /** - * Search and group the complete logical corpus by session. - * @param request - query, pre-ranking filters, and pagination. - * @param exec - optional cancellation context. - * @returns one ranked session page. - */ - searchSessions(request: SessionSearchSpec, exec?: SessionQueryExecContext): Promise> - /** - * Search events within one logical session. - * @param request - target session, query, filters, and pagination. - * @param exec - optional cancellation context. - * @returns one ranked event page. - */ - searchEvents(request: SessionEventSearchSpec, exec?: SessionQueryExecContext): Promise> -} diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 36a8676640..29b863b678 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,39 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { CallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { - SessionQueryError, - filterEventResults, - filterSessionResults, + type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' -import type { - SessionEventSearchHit, - SessionEventSearchSpec, - SessionIndexSnapshot, - SessionQueryErrorCode, - SessionRecord, - SessionSearchHit, - SessionSearchPage, - SessionSearchProvider, - SessionSearchProviderStatus, - SessionSearchSpec, -} from '@deepseek-ai/dsh-session-query' - -declare module '@deepseek-ai/dsh-llm' { - interface ContentBlockMap { - 'test/text': { type: 'test/text'; value: string } - } -} - -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - 'test/note': { note: string } - } -} function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } @@ -53,15 +25,13 @@ class TestPersistence extends SessionPersistence { static entries = new Map() static listFailure: unknown static loadFailure: unknown - static listBarrier: Promise | undefined - static onList: (() => void) | undefined + static afterList: (() => void) | undefined static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listFailure = undefined this.loadFailure = undefined - this.listBarrier = undefined - this.onList = undefined + this.afterList = undefined } create(meta: SessionHeader): Promise { @@ -71,101 +41,23 @@ class TestPersistence extends SessionPersistence { append(id: SessionIdType, events: readonly SessionEvent[]): Promise { const entry = TestPersistence.entries.get(id) - if (entry === undefined) throw new Error('missing test session') + if (entry === undefined) return Promise.reject(new Error('missing test session')) entry.events.push(...structuredClone(events)) return Promise.resolve() } load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - if (TestPersistence.loadFailure !== undefined) return Promise.reject(asError(TestPersistence.loadFailure)) + if (TestPersistence.loadFailure !== undefined) return rejectUnknown(TestPersistence.loadFailure) const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) return Promise.resolve(structuredClone(entry)) } list(): Promise { - if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure)) - const snapshot = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) - TestPersistence.onList?.() - return (TestPersistence.listBarrier ?? Promise.resolve()).then(() => snapshot) - } -} - -class FakeProvider implements SessionSearchProvider { - readonly id: string - statusValue: SessionSearchProviderStatus = { available: true } - persisted = new Map() - live = new Map() - activeHistory: boolean[] = [] - removedPersisted: SessionIdType[] = [] - removedLive: SessionIdType[] = [] - sessionRequests: SessionSearchSpec[] = [] - eventRequests: SessionEventSearchSpec[] = [] - failNextLive = false - failNextPersisted = false - sessionPage: SessionSearchPage - eventPage: SessionSearchPage - - constructor(id = 'fake') { - this.id = id - this.sessionPage = { providerId: id, items: [] } - this.eventPage = { providerId: id, items: [] } - } - - status(): SessionSearchProviderStatus { - return this.statusValue - } - - persistedInventory(): Promise { - return Promise.resolve([...this.persisted.values()].map(snapshot => ({ - sessionId: snapshot.session.header.id, - fingerprint: snapshot.fingerprint, - }))) - } - - setPersistedActive(active: boolean): Promise { - this.activeHistory.push(active) - return Promise.resolve() - } - - replacePersisted(snapshot: SessionIndexSnapshot): Promise { - if (this.failNextPersisted) { - this.failNextPersisted = false - return Promise.reject(new Error('persisted index failed')) - } - this.persisted.set(snapshot.session.header.id, structuredClone(snapshot)) - return Promise.resolve() - } - - removePersisted(sessionId: SessionIdType): Promise { - this.removedPersisted.push(sessionId) - this.persisted.delete(sessionId) - return Promise.resolve() - } - - replaceLive(snapshot: SessionIndexSnapshot): Promise { - if (this.failNextLive) { - this.failNextLive = false - return Promise.reject(new Error('live index failed')) - } - this.live.set(snapshot.session.header.id, structuredClone(snapshot)) - return Promise.resolve() - } - - removeLive(sessionId: SessionIdType): Promise { - this.removedLive.push(sessionId) - this.live.delete(sessionId) - return Promise.resolve() - } - - searchSessions(request: SessionSearchSpec): Promise> { - this.sessionRequests.push(structuredClone(request)) - return Promise.resolve(structuredClone(this.sessionPage)) - } - - searchEvents(request: SessionEventSearchSpec): Promise> { - this.eventRequests.push(structuredClone(request)) - return Promise.resolve(structuredClone(this.eventPage)) + if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure) + const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) + TestPersistence.afterList?.() + return Promise.resolve(headers) } } @@ -180,811 +72,181 @@ function expectCode(code: SessionQueryErrorCode): Error { return expect.objectContaining({ code }) as Error } -function asError(value: unknown): Error { - return value instanceof Error ? value : new Error(String(value)) +function rejectUnknown(reason: unknown): Promise { + return new Promise((_resolve, reject) => { + // Exercise containment for an implementation that violates the Error rejection convention. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(reason) + }) } -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { resolve = done }) - return { promise, resolve } -} - -describe('pure result filters', () => { - it('chains session filters as AND while values within one filter are OR', () => { - const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false } - const child: SessionRecord = { header: header('child', 2, { cwd: '/b', parentSession: root.header.id }), live: false, persisted: true } - const both: SessionRecord = { header: header('both', 3, { cwd: '/a', parentSession: root.header.id }), live: true, persisted: true } - const input = [child, root, both] - - const output = filterSessionResults(input, [ - { kind: 'cwd', values: ['/a', '/b'] }, - { kind: 'created-at', range: { from: 2, to: 3 } }, - { kind: 'parent', values: [root.header.id] }, - { kind: 'availability', values: ['live', 'persisted'] }, - { kind: 'id', values: [child.header.id, both.header.id] }, - ]) - - expect(output).toEqual([child, both]) - expect(output[0]).toBe(child) - expect(input).toEqual([child, root, both]) - expect(filterSessionResults(input, [{ kind: 'cwd', values: [null] }])).toEqual([]) - }) - - it('filters event ranges/types/status without reordering richer records', () => { - const events = [ - { sessionId: SessionId('s'), seq: 2, type: 'user/message' as const, time: 20, surface: 'current' as const, extra: true }, - { sessionId: SessionId('s'), seq: 1, type: 'tool/call' as const, time: 10, surface: 'shadowed' as const, extra: true }, - { sessionId: SessionId('s'), seq: 3, type: 'assistant/chunk' as const, time: 30, surface: 'log-only' as const, extra: true }, - ] - const output = filterEventResults(events, [ - { kind: 'seq', range: { from: 1, to: 2 } }, - { kind: 'time', range: { from: 10, to: 20 } }, - { kind: 'type', values: ['user/message', 'tool/call'] }, - { kind: 'surface', values: ['current', 'shadowed'] }, - ]) - expect(output).toEqual(events.slice(0, 2)) - expect(output[0]).toBe(events[0]) - }) - - it('rejects invalid serializable filter values', () => { - expect(() => filterSessionResults([], [{ kind: 'created-at', range: { from: 2, to: 1 } }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterEventResults([], [{ kind: 'seq', range: { from: Number.NaN } }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterEventResults([], [{ kind: 'time', range: { to: Number.POSITIVE_INFINITY } }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterEventResults([], [{ kind: 'surface', values: ['other' as never] }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - expect(() => filterSessionResults([], [{ kind: 'availability', values: ['other' as never] }])) - .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - }) - - it('handles absent range bounds and root/availability alternatives', () => { - const record: SessionRecord = { header: header('root'), live: false, persisted: true } - expect(filterSessionResults([record], [ - { kind: 'parent', values: [null] }, - { kind: 'cwd', values: [null] }, - { kind: 'availability', values: ['persisted'] }, - ])).toEqual([record]) - const event = { sessionId: record.header.id, seq: 2, type: 'user/message' as const, time: 4, surface: 'current' as const } - expect(filterEventResults([event], [{ kind: 'seq', range: { to: 2 } }, { kind: 'time', range: { from: 4 } }])).toEqual([event]) - }) -}) - -describe('logical corpus reads and traces', () => { - it('lists, classifies, reads, and traces a live session using detached records', async () => { - const ctx = await liveContext({ readWindowMax: 2 }) - const session = ctx.sessions.create(SessionId('live'), { meta: { createdAt: 20, cwd: '/work' } }) - const original = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const chunk = session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' } }) - const answer = session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'answer' }] }, { surfaceOp: 'append', sourceEventSeqs: [chunk.seq] }) - const summary = session.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, sourceEventSeqs: [original.seq] }) - const resummary = session.append('assistant/message', { turn: 1, step: 3, content: [{ type: 'text', text: 'resummary' }] }, { surfaceOp: { op: 'replace', start: summary.seq, end: answer.seq }, sourceEventSeqs: [summary.seq, answer.seq] }) - - const listed = await ctx.sessionQuery.listSessions() - expect(listed).toEqual([{ header: session.header, live: true, persisted: false }]) - listed[0]!.header.createdAt = -1 - expect(session.header.createdAt).toBe(20) - expect((await ctx.sessionQuery.listEvents(session.id)).map(event => event.surface)) - .toEqual(['shadowed', 'log-only', 'shadowed', 'shadowed', 'current']) - - const window = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: answer.seq, before: 2, after: 2 }) - expect([window.startSeq, window.endSeq]).toEqual([0, 4]) - expect(window.target.seq).toBe(answer.seq) - if (window.events[0]?.type !== 'user/message') throw new Error('expected user message') - window.events[0].data.content = [] - expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) - - await expect(ctx.sessionQuery.traceEvent(session.id, original.seq)).resolves.toMatchObject({ - shadowedBy: summary.seq, - replacementChain: [summary.seq, resummary.seq], - referencedBy: [summary.seq], - }) - await expect(ctx.sessionQuery.traceEvent(session.id, summary.seq)).resolves.toMatchObject({ - shadows: [original.seq], - references: [original.seq], - referencedBy: [resummary.seq], - }) - await expect(ctx.sessionQuery.traceEvent(session.id, chunk.seq)).resolves.toMatchObject({ referencedBy: [answer.seq] }) - await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 99 })).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) - await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, before: 3 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) - await expect(ctx.sessionQuery.traceEvent(session.id, 99)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) - }) - - it('turns malformed replacement logs into typed surface failures', async () => { +describe('session-query exact reads', () => { + it('lists live sessions deterministically and returns detached headers', async () => { const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('bad-surface')) - session.append('assistant/message', { turn: 1, step: 1, content: [] }, { - surfaceOp: { op: 'replace', start: 9, end: 9 }, - sourceEventSeqs: [], - }) - await expect(ctx.sessionQuery.listEvents(session.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) - }) - - it('returns complete, partial, deterministic, and cycle-checked lineage', async () => { - const ctx = await liveContext() - const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } }) - const second = ctx.sessions.create(SessionId('second'), { meta: { createdAt: 2, parentSession: root.id } }) - const first = ctx.sessions.create(SessionId('first'), { meta: { createdAt: 2, parentSession: root.id } }) - const grandchild = ctx.sessions.create(SessionId('grandchild'), { meta: { createdAt: 3, parentSession: first.id } }) - const partial = ctx.sessions.create(SessionId('partial'), { meta: { createdAt: 4, parentSession: SessionId('missing') } }) - - const trace = await ctx.sessionQuery.traceSession(grandchild.id) - expect(trace.parents.map(record => record.header.id)).toEqual([first.id, root.id]) - expect(trace.root?.header.id).toBe(root.id) - const rootTrace = await ctx.sessionQuery.traceSession(root.id) - expect(rootTrace.children.map(node => node.session.header.id)).toEqual([first.id, second.id]) - expect(rootTrace.children[0]?.children[0]?.session.header.id).toBe(grandchild.id) - await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ unresolvedParentId: SessionId('missing') }) - await expect(ctx.sessionQuery.traceSession(SessionId('absent'))).rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - - const cyclic = await liveContext() - const a = new Session(SessionId('a'), [], header('a', 1, { parentSession: SessionId('b') })) - const b = new Session(SessionId('b'), [], header('b', 2, { parentSession: SessionId('a') })) - cyclic.sessions.enter(a) - cyclic.sessions.enter(b) - await expect(cyclic.sessionQuery.traceSession(a.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE')) - }) - - it('uses live content over a matching persisted base and scopes persistence failures', async () => { - const common = header('same', 5, { cwd: '/w' }) - const persistedOnly = header('persisted', 1) - TestPersistence.reset([ - { meta: common, events: eventLog('persisted version') }, - { meta: persistedOnly, events: eventLog('persisted only') }, - ]) - const ctx = await liveContext() - const live = ctx.sessions.create(common.id, { meta: { createdAt: common.createdAt, cwd: '/w' } }) - live.append('user/message', { content: [{ type: 'text', text: 'live version' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const persistenceFiber = await ctx.plugin(TestPersistence) - await expect(ctx.sessionQuery.listEvents(SessionId('not-listed'))) - .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('z'), { meta: { createdAt: 2 } }) + ctx.sessions.create(SessionId('a'), { meta: { createdAt: 2 } }) const records = await ctx.sessionQuery.listSessions() - expect(records.map(record => [record.header.id, record.live, record.persisted])).toEqual([ - [common.id, true, true], - [persistedOnly.id, false, true], - ]) - const liveWindow = await ctx.sessionQuery.readEvent({ sessionId: common.id, seq: 0 }) - expect(liveWindow.target.type === 'user/message' && liveWindow.target.data.content[0]).toMatchObject({ text: 'live version' }) - await expect(ctx.sessionQuery.readEvent({ sessionId: persistedOnly.id, seq: 0 })) - .resolves.toMatchObject({ session: { persisted: true } }) - - TestPersistence.listFailure = new Error('list unavailable') - await expect(ctx.sessionQuery.listEvents(common.id)).resolves.toHaveLength(1) - await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.listFailure = undefined - TestPersistence.loadFailure = new Error('load unavailable') - await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_EVENT_NOT_FOUND') - await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) - - await persistenceFiber.dispose() - TestPersistence.loadFailure = undefined - await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([{ header: common, live: true, persisted: false }]) + expect(records.map(record => record.header.id)).toEqual([SessionId('a'), SessionId('z'), older.id]) + expect(records.every(record => record.live && !record.persisted)).toBe(true) + records[2]!.header.createdAt = 99 + expect(older.header.createdAt).toBe(1) }) - it('rejects immutable source header conflicts', async () => { - TestPersistence.reset([{ meta: header('conflict', 1, { cwd: '/persisted' }), events: eventLog() }]) + it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() - ctx.sessions.create(SessionId('conflict'), { meta: { createdAt: 1, cwd: '/live' } }) - await ctx.plugin(TestPersistence) - await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) - }) -}) - -describe('provider selection and synchronization', () => { - it('selects one usable provider, validates requests/pages, and disposes registration', async () => { - const ctx = await liveContext({ defaultLimit: 2, maxLimit: 3 }) - const session = ctx.sessions.create(SessionId('s')) - session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - const dispose = ctx.sessionQuery.registerSearchProvider(provider) - const record: SessionRecord = { header: structuredClone(session.header), live: true, persisted: false } - const bestMatch = { sessionId: session.id, seq: 0, type: 'user/message' as const, time: session.events[0]!.time, surface: 'current' as const, snippet: 'hello' } - provider.sessionPage = { providerId: provider.id, items: [ - { ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch }, - ], nextCursor: 'next' } - - await expect(ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] })) - .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) - expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 }) - expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello') - await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) - await expect(ctx.sessionQuery.searchSessions({ query: 'x', limit: 4 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) - provider.eventPage = { providerId: 'wrong', items: [] } - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR')) - - provider.eventPage = { providerId: provider.id, items: [] } - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal })) - .resolves.toMatchObject({ providerId: provider.id }) - - await dispose() - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) - }) - - it('deselects immediately and drains accepted work before disposal settles', async () => { - const ctx = await liveContext() - const provider = new FakeProvider() - const queryStarted = deferred() - const releaseQuery = deferred() - provider.searchSessions = async () => { - queryStarted.resolve() - await releaseQuery.promise - return { providerId: provider.id, items: [] } - } - const dispose = ctx.sessionQuery.registerSearchProvider(provider) - - const accepted = ctx.sessionQuery.searchSessions({ query: 'accepted' }) - await queryStarted.promise - let disposed = false - const disposal = dispose().then(() => { disposed = true }) - await expect(ctx.sessionQuery.searchSessions({ query: 'future' })) - .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) - await Promise.resolve() - expect(disposed).toBe(false) - - releaseQuery.resolve() - await expect(accepted).resolves.toMatchObject({ providerId: provider.id }) - await disposal - expect(disposed).toBe(true) - }) - - it('serializes concurrent synchronization and supports cancellation while provider search is pending', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('coalesce')) - session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - let releaseLive!: () => void - const liveBarrier = new Promise((resolve) => { releaseLive = resolve }) - const liveStarted = deferred() - let replacements = 0 - provider.replaceLive = async (snapshot) => { - replacements += 1 - liveStarted.resolve() - await liveBarrier - provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) - } - const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - await liveStarted.promise - expect(replacements).toBe(1) - releaseLive() - await Promise.all([first, second]) - expect(replacements).toBe(2) - - session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - let releaseCorpus!: () => void - const corpusBarrier = new Promise((resolve) => { releaseCorpus = resolve }) - const corpusStarted = deferred() - let corpusReplacements = 0 - provider.replaceLive = async (snapshot) => { - corpusReplacements += 1 - corpusStarted.resolve() - await corpusBarrier - provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) - } - const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' }) - const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' }) - await corpusStarted.promise - expect(corpusReplacements).toBe(1) - releaseCorpus() - await Promise.all([crossFirst, crossSecond]) - expect(corpusReplacements).toBe(2) - - let releaseSearch!: () => void - const searchBarrier = new Promise((resolve) => { releaseSearch = resolve }) - provider.searchSessions = async () => { - await searchBarrier - return { providerId: provider.id, items: [] } - } - const controller = new AbortController() - const pending = ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal }) - await Promise.resolve() - await Promise.resolve() - controller.abort() - await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - releaseSearch() - await Promise.resolve() - - provider.searchSessions = () => Promise.reject(new Error('search failed')) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: new AbortController().signal })) - .rejects.toThrow('search failed') - }) - - it('holds a provider query stable until later reconciliation can begin', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('stable-query')) - session.append('user/message', { content: [{ type: 'text', text: 'stable' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - const queryStarted = deferred() - const releaseQuery = deferred() - provider.searchEvents = async () => { - queryStarted.resolve() - await releaseQuery.promise - return { providerId: provider.id, items: [] } - } - const reconciliationStarted = deferred() - let reconciling = false - provider.setPersistedActive = (active) => { - if (!active) { - reconciling = true - reconciliationStarted.resolve() - } - return Promise.resolve() - } - ctx.sessionQuery.registerSearchProvider(provider) - - const eventSearch = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'stable' }) - await queryStarted.promise - const fullSearch = ctx.sessionQuery.searchSessions({ query: 'stable' }) - await new Promise((resolve) => { setImmediate(resolve) }) - expect(reconciling).toBe(false) - - releaseQuery.resolve() - await eventSearch - await reconciliationStarted.promise - expect(reconciling).toBe(true) - await fullSearch - }) - - it('reconciles a live removal observed while an older full sync is in flight', async () => { - const ctx = await liveContext() - const session = ctx.sessions.prepare(SessionId('removed-during-sync')) - const detach = ctx.sessions.enter(session) - ctx.sessions.announce(session) - session.append('user/message', { content: [{ type: 'text', text: 'stale live hit' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - const replaceStarted = deferred() - const releaseReplace = deferred() - provider.replaceLive = async (snapshot) => { - replaceStarted.resolve() - await releaseReplace.promise - provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) - } - const searchLiveIds: SessionIdType[][] = [] - provider.searchSessions = () => { - searchLiveIds.push([...provider.live.keys()]) - const items: SessionSearchHit[] = [] - for (const snapshot of provider.live.values()) { - const document = snapshot.documents[0] - if (document === undefined) continue - items.push({ - ...structuredClone(snapshot.session), - bestMatch: { ...structuredClone(document), snippet: document.text }, - }) - } - return Promise.resolve({ providerId: provider.id, items }) - } - ctx.sessionQuery.registerSearchProvider(provider) - - const first = ctx.sessionQuery.searchSessions({ query: 'stale' }) - await replaceStarted.promise - detach() - const second = ctx.sessionQuery.searchSessions({ query: 'stale' }) - releaseReplace.resolve() - - await first - await expect(second).resolves.toMatchObject({ items: [] }) - expect(provider.removedLive).toContain(session.id) - expect(searchLiveIds.at(-1)).toEqual([]) - }) - - it('searches a persisted target after corpus reconciliation', async () => { - const persisted = header('event-persisted', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - await expect(ctx.sessionQuery.searchEvents({ sessionId: persisted.id, query: 'target' })) - .resolves.toMatchObject({ providerId: provider.id }) - expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target') - }) - - it('cancels a persisted-only event search while persistence listing is blocked', async () => { - const persisted = header('blocked-persisted-target', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }]) - const listStarted = deferred() - const releaseList = deferred() - TestPersistence.onList = listStarted.resolve - TestPersistence.listBarrier = releaseList.promise - const ctx = await liveContext() - const persistenceFiber = await ctx.plugin(TestPersistence) - await listStarted.promise - const provider = new FakeProvider() - const disposeProvider = ctx.sessionQuery.registerSearchProvider(provider) - const controller = new AbortController() - - const pending = ctx.sessionQuery.searchEvents( - { sessionId: persisted.id, query: 'target' }, - { signal: controller.signal }, + const session = ctx.sessions.create(SessionId('surface')) + const first = session.append( + 'user/message', + { content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'draft' }, + }) + session.append( + 'assistant/message', + { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, + { surfaceOp: { op: 'replace', start: first.seq, end: first.seq } }, ) - controller.abort() - await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - expect(provider.eventRequests).toEqual([]) - releaseList.resolve() - await disposeProvider() - await persistenceFiber.dispose() - TestPersistence.listBarrier = undefined - TestPersistence.onList = undefined + expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface)) + .toEqual(['shadowed', 'log-only', 'current']) }) - it('normalizes non-Error query rejections and preserves Error identity', async () => { - const ctx = await liveContext() - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - const signals = [undefined, new AbortController().signal] + it('returns a bounded detached raw-event window and validates the request', async () => { + const ctx = await liveContext({ readWindowMax: 1 }) + const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } }) + for (const text of ['one', 'two', 'three']) { + session.append( + 'user/message', + { content: [{ type: 'text', text }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + } - for (const [index, signal] of signals.entries()) { - const exec = signal === undefined ? undefined : { signal } - const identity = new Error(`query failure ${index}`) - provider.searchSessions = () => Promise.reject(identity) - const preserved = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) - .then(() => undefined, (error: unknown) => error) - expect(preserved).toBe(identity) + const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 }) + expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1]) + expect(result.session).toEqual(session.header) + result.session.createdAt = -1 + if (result.events[0]?.type !== 'user/message') throw new Error('expected user message') + result.events[0].data.content = [] + expect(session.header.createdAt).not.toBe(-1) + expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1) - const rejection = { index } - // Deliberately violate the Promise convention to test the provider boundary. - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - provider.searchSessions = () => Promise.reject(rejection) - const normalized = await ctx.sessionQuery.searchSessions({ query: 'x' }, exec) - .then(() => undefined, (error: unknown) => error) - expect(normalized).toBeInstanceOf(SessionQueryError) - expect(normalized).toMatchObject({ code: 'SESSION_QUERY_PROVIDER_ERROR', cause: rejection }) + await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) + .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + for (const request of [ + { sessionId: session.id, seq: 0, before: -1 }, + { sessionId: session.id, seq: 0, before: 2 }, + { sessionId: session.id, seq: 0, after: 0.5 }, + ]) { + await expect(ctx.sessionQuery.readEvent(request)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) } }) - it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => { - const ctx = await liveContext() - const first = new FakeProvider('first') - ctx.sessionQuery.registerSearchProvider(first) - expect(() => ctx.sessionQuery.registerSearchProvider(new FakeProvider('first'))).toThrow(expectCode('SESSION_QUERY_DUPLICATE_PROVIDER')) - const second = new FakeProvider('second') - ctx.sessionQuery.registerSearchProvider(second) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_AMBIGUOUS')) - - const configured = await liveContext({ searchProvider: 'chosen' }) - await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_MISSING')) - const chosen = new FakeProvider('chosen') - chosen.statusValue = { available: false, reason: 'unavailable' } - configured.sessionQuery.registerSearchProvider(chosen) - await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE')) - chosen.statusValue = { available: true } - await expect(configured.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'chosen' }) - }) - - it('removes provider registrations with their contributing fiber', async () => { - const ctx = await liveContext() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionQuery.registerSearchProvider(new FakeProvider('scoped')) - }, { inject: ['sessionQuery'] })) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'scoped' }) - await fiber.dispose() - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) - }) - - it('reconciles persisted bases and live overrides, reuses fingerprints, and hides rows on unmount', async () => { - const persisted = header('persisted', 1) - const overlaid = header('overlaid', 1) + it('merges authoritative persistence with live precedence and detects conflicts', async () => { + const shared = header('shared', 3, { cwd: '/same' }) + const durable = header('durable', 2) TestPersistence.reset([ - { meta: persisted, events: eventLog('persisted') }, - { meta: overlaid, events: eventLog('base') }, + { meta: shared, events: eventLog('persisted') }, + { meta: durable, events: eventLog('durable') }, ]) const ctx = await liveContext() - const live = ctx.sessions.create(overlaid.id, { meta: { createdAt: overlaid.createdAt } }) - live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const persistenceFiber = await ctx.plugin(TestPersistence) - const provider = new FakeProvider() - provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] }) - ctx.sessionQuery.registerSearchProvider(provider) + const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } }) + live.append( + 'user/message', + { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const persistence = await ctx.plugin(TestPersistence) - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted') - expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override') - expect(provider.live.get(overlaid.id)?.session).toMatchObject({ live: true, persisted: true }) - expect(provider.removedPersisted).toEqual([SessionId('stale')]) - expect(provider.activeHistory.at(-1)).toBe(true) - const fingerprint = provider.persisted.get(persisted.id)?.fingerprint - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.persisted.get(persisted.id)?.fingerprint).toBe(fingerprint) + expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted])) + .toEqual([[shared.id, true, true], [durable.id, false, true]]) + const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 }) + expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0]) + .toMatchObject({ text: 'live' }) + await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 })) + .resolves.toMatchObject({ session: durable }) - const announced = header('announced', 3) - TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('announced') }) - await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced') - - await persistenceFiber.dispose() - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.activeHistory.at(-1)).toBe(false) - expect(provider.persisted.has(persisted.id)).toBe(true) + TestPersistence.entries.get(shared.id)!.meta.cwd = '/conflict' + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + await persistence.dispose() + await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([ + { header: shared, live: true, persisted: false }, + ]) }) - it('preserves persisted observations that race an older inventory listing', async () => { + it('keeps known live reads independent from persistence health', async () => { TestPersistence.reset() - const listStarted = deferred() - const releaseList = deferred() - TestPersistence.onList = listStarted.resolve - TestPersistence.listBarrier = releaseList.promise const ctx = await liveContext() - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) + const live = ctx.sessions.create(SessionId('live')) + live.append( + 'user/message', + { content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) await ctx.plugin(TestPersistence) - await listStarted.promise + TestPersistence.listFailure = new Error('list unavailable') + TestPersistence.loadFailure = new Error('load unavailable') - const announced = header('racing-announcement', 3) - TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('after durable notification') }) - await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 }) - const search = ctx.sessionQuery.searchSessions({ query: 'notification' }) - releaseList.resolve() - - await expect(search).resolves.toMatchObject({ providerId: provider.id }) - expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('after durable notification') - TestPersistence.listBarrier = undefined - TestPersistence.onList = undefined + await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1) + await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } }) + await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) - it('synchronizes only a live target for event search and retries dirty failures', async () => { + it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => { + const durable = header('durable') + TestPersistence.reset([{ meta: durable, events: eventLog() }]) const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('target')) - session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'one' }) - expect(provider.live.get(session.id)?.documents[0]?.text).toBe('one') - session.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - provider.failNextLive = true - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).resolves.toMatchObject({ providerId: provider.id }) - expect(provider.live.get(session.id)?.documents.map(document => document.text)).toEqual(['one', 'two']) - - const controller = new AbortController() - controller.abort() - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) - .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('missing'), query: 'x' })) + await expect(ctx.sessionQuery.listEvents(SessionId('absent'))) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - }) - - it('removes a disposed live override and reveals the provider base', async () => { - const persisted = header('fallback', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('base') }]) - const ctx = await liveContext() await ctx.plugin(TestPersistence) - let session!: Session - const liveFiber = await ctx.plugin(Object.assign((inner: Context) => { - session = inner.sessions.create(persisted.id, { meta: { createdAt: persisted.createdAt } }) - session.append('user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - }, { inject: ['sessions'] })) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.live.has(session.id)).toBe(true) + await expect(ctx.sessionQuery.listEvents(SessionId('absent'))) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - await liveFiber.dispose() - await Promise.resolve() - await ctx.sessionQuery.searchSessions({ query: 'x' }) - expect(provider.removedLive).toContain(session.id) - expect(provider.live.has(session.id)).toBe(false) - expect(provider.persisted.get(session.id)?.documents[0]?.text).toBe('base') - }) - - it('retries failed persisted reconciliation without affecting canonical writes', async () => { - const persisted = header('retry', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('retry') }]) - const ctx = await liveContext() - await ctx.plugin(TestPersistence) - const provider = new FakeProvider() - provider.failNextPersisted = true - ctx.sessionQuery.registerSearchProvider(provider) - - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id }) - expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') - }) - - it('types extractor failures during queued full synchronization', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('throwing-extractor')) - session.append('test/note', { note: 'unreachable' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - const cause = new Error('custom extractor failed') - ctx.sessionQuery.registerEventTextExtractor('test/note', { - version: 'throwing-v1', - extract: () => { throw cause }, - }) - - let thrown: unknown - try { - await ctx.sessionQuery.searchSessions({ query: 'x' }) - } catch (error: unknown) { - thrown = error + TestPersistence.loadFailure = 'raw failure' + await expect(ctx.sessionQuery.listEvents(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.loadFailure = undefined + TestPersistence.entries.get(durable.id)!.meta.cwd = '/changed-after-list' + TestPersistence.afterList = () => { + TestPersistence.entries.get(durable.id)!.meta.cwd = '/changed-during-read' } - expect(thrown).toBeInstanceOf(SessionQueryError) - expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) - expect(asError(thrown).message).toContain(`provider "${provider.id}"`) - expect(provider.sessionRequests).toEqual([]) + await expect(ctx.sessionQuery.listEvents(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) - it('observes synchronous synchronization failure when the caller is already aborted', async () => { + it('turns malformed surfaces and direct invalid config into typed errors', async () => { const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('aborted-throwing-extractor')) - session.append('test/note', { note: 'unreachable' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - ctx.sessionQuery.registerEventTextExtractor('test/note', { - version: 'aborted-throwing-v1', - extract: () => { throw new Error('superseded extraction failure') }, - }) - const controller = new AbortController() - controller.abort() - const unhandled: unknown[] = [] - const onUnhandled = (reason: unknown) => { unhandled.push(reason) } - process.on('unhandledRejection', onUnhandled) - try { - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal })) - .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) - await new Promise((resolve) => { setImmediate(resolve) }) - expect(unhandled).toEqual([]) - } finally { - process.off('unhandledRejection', onUnhandled) - } - }) - - it('types synchronous live-target extraction failures and leaves retries clean', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('throwing-live-extractor')) - session.append('test/note', { note: 'unreachable' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - const cause = new Error('live extractor failed') - const disposeExtractor = ctx.sessionQuery.registerEventTextExtractor('test/note', { - version: 'live-throwing-v1', - extract: () => { throw cause }, - }) - - let thrown: unknown - try { - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - } catch (error: unknown) { - thrown = error - } - expect(thrown).toBeInstanceOf(SessionQueryError) - expect(thrown).toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause }) - expect(asError(thrown).message).toContain(`provider "${provider.id}"`) - expect(provider.eventRequests).toEqual([]) - - disposeExtractor() - await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })) - .resolves.toMatchObject({ providerId: provider.id }) - expect(provider.eventRequests).toHaveLength(1) - }) -}) - -describe('semantic text extractors', () => { - it('indexes core semantic text and excludes chunks and structural events', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('semantic')) - const nested: ContentBlock[] = [ - { type: 'text', text: 'visible' }, - { type: 'reasoning', text: 'thinking' }, - { type: 'tool-call', id: CallId('block-call'), name: 'block-tool', arguments: '{"x":1}' }, - { type: 'tool-result', toolCallId: CallId('block-call'), content: [{ type: 'text', text: 'block-result' }] }, - ] - session.append('user/message', { content: nested, source: { kind: 'user' } }, { surfaceOp: 'append' }) - session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' }, reason: 'policy reason' }) - session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'shell', arguments: '{"cmd":"pwd"}' }) - session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'tool output' }], isError: true, error: { name: 'ToolError', code: 'DENIED' } }, { surfaceOp: 'append' }) - session.append('todo/write', { todos: [{ content: 'finish tests', status: 'in_progress' }] }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' } }) - session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'uncoded failure' } }) - session.append('turn/end', { turn: 2, reason: { kind: 'aborted' } }) - session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'cancelled' } }) - session.append('turn/end', { turn: 4, reason: { kind: 'rejected', reason: 'rejected detail' } }) - session.append('turn/end', { turn: 5, reason: { kind: 'disposed' } }) - session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) - session.append('turn/end', { turn: 7, reason: { kind: 'interrupted' } }) - session.append('turn/end', { turn: 8, reason: { kind: 'completed' } }) - session.append('tool/result', { turn: 1, step: 2, callId: CallId('c2'), content: [], isError: false }, { surfaceOp: 'append' }) - session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw chunk' } }) - session.append('step/start', { turn: 1, step: 2 }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const documents = provider.live.get(session.id)?.documents ?? [] - expect(documents.map(document => document.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) - expect(documents.map(document => document.text).join('\n')).toContain('visible\nthinking\nblock-tool\n{"x":1}\nblock-result') - expect(documents.map(document => document.text).join('\n')).toContain('blocked prompt\npolicy reason') - expect(documents.map(document => document.text).join('\n')).toContain('ToolError\nDENIED') - expect(documents.map(document => document.text).join('\n')).toContain('in_progress finish tests') - expect(documents.map(document => document.text).join('\n')).toContain('error\nmodel failed\nMODEL') - expect(documents.map(document => document.text).join('\n')).toContain('aborted\ncancelled') - expect(documents.map(document => document.text).join('\n')).toContain('rejected\nrejected detail') - expect(documents.map(document => document.text).join('\n')).toContain('disposed\nmax-tokens\ninterrupted') - expect(documents.map(document => document.text).join('\n')).not.toContain('raw chunk') - }) - - it('supports versioned effect-scoped custom event and content extractors', async () => { - const ctx = await liveContext() - const session = ctx.sessions.create(SessionId('custom')) - session.append('test/note', { note: 'event note' }) - session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const provider = new FakeProvider() - ctx.sessionQuery.registerSearchProvider(provider) - let disposeEvent!: () => void - let disposeContent!: () => void - const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => { - disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] }) - disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] }) - }, { inject: ['sessionQuery'] })) - - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const first = provider.live.get(session.id) - expect(first?.documents.map(document => document.text)).toEqual(['event note', 'block note']) - expect(() => ctx.sessionQuery.registerEventTextExtractor('test/note', { version: 'v2', extract: () => [] })) - .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) - expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: () => [] })) - .toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR')) - expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] })) - .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR')) - - disposeEvent() - disposeContent() - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const second = provider.live.get(session.id) - expect(second?.documents).toEqual([]) - expect(second?.fingerprint).not.toBe(first?.fingerprint) - await extractorFiber.dispose() - - const replacementFiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v2', extract: event => [`replacement ${event.data.note}`] }) - inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: block => [`replacement ${block.value}`] }) - }, { inject: ['sessionQuery'] })) - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - const third = provider.live.get(session.id) - expect(third?.documents.map(document => document.text)).toEqual(['replacement event note', 'replacement block note']) - expect(third?.fingerprint).not.toBe(second?.fingerprint) - await replacementFiber.dispose() - await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) - expect(provider.live.get(session.id)?.documents).toEqual([]) - }) -}) - -describe('configuration', () => { - it('rejects an impossible default page size and exposes typed errors', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 })) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) - const error = new SessionQueryError('test', 'SESSION_QUERY_INVALID_CONFIG') - expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_INVALID_CONFIG' }) - }) - - it('uses constructor defaults and removes the service on plugin disposal', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionQueryService) - const session = ctx.sessions.create(SessionId('defaults')) - await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, after: 51 })) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) - await fiber.dispose() - expect(ctx.sessionQuery).toBeUndefined() + const session = ctx.sessions.create(SessionId('bad-surface')) + session.append( + 'assistant/message', + { turn: 1, step: 1, content: [] }, + { surfaceOp: { op: 'replace', start: 9, end: 9 } }, + ) + await expect(ctx.sessionQuery.listEvents(session.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) const direct = new Context() await direct.plugin(SessionStore) - const service = new SessionQueryService(direct, {}) - const directSession = direct.sessions.create(SessionId('direct-defaults')) - await expect(service.readEvent({ sessionId: directSession.id, seq: 0, before: 51 })) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW')) - await direct.fiber.dispose() + expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService) + const invalid = new Context() + await invalid.plugin(SessionStore) + expect(() => new SessionQueryService(invalid, { readWindowMax: -1 })) + .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + }) + + it('leaves the optional persistence dependency optional', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionQueryService) + expect(ctx.sessionQuery).toBeInstanceOf(SessionQueryService) + await fiber.dispose() + expect(ctx.sessionQuery).toBeUndefined() }) }) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0318e33fea..53ab0a9b3a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -113,9 +113,9 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'sessionQuery', pkg: 'session-query', - title: 'Session retrieval read model', + title: 'Exact session-history reads', mode: 'seam', - note: 'Resolves live and optional persisted logs into one corpus and coordinates registered full-text providers.', + note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.', }, { key: 'systemPrompt', @@ -531,7 +531,7 @@ function collectEventRelations(): Map { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.text - if (!isCordisContextReceiver(node.expression)) { + if (!isCordisContextReceiver(node.expression, sf)) { ts.forEachChild(node, visit) return } @@ -561,12 +561,9 @@ function collectEventRelations(): Map { return out } -function isCordisContextReceiver(expr: ts.PropertyAccessExpression): boolean { - const receiver = expr.expression - if (ts.isIdentifier(receiver)) return receiver.text === 'ctx' || receiver.text === '_ctx' - return ts.isPropertyAccessExpression(receiver) - && receiver.expression.kind === ts.SyntaxKind.ThisKeyword - && (receiver.name.text === 'ctx' || receiver.name.text === '_ctx') +function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean { + const target = expr.expression.getText(sf) + return target === 'ctx' || target === 'this.ctx' } function eventArg(args: ts.NodeArray, method: string): string | undefined { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 32d8e91f6e..ba8cb64c6e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -36,36 +36,13 @@ { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistedChange", "source": "packages/session-persistence/session-persistence/src/index.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryRange", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryExecContext", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProviderStatus", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPageRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchSpec", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionContentTextExtractor", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexDocument", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexSnapshot", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionPersistedIndexEntry", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProvider", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, From 42d85cf76f6a496c4d3c20822cbb7b435f8ab8b0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 11 Jul 2026 12:29:19 +0800 Subject: [PATCH 10/11] fix(session): avoid retaining surface fold history --- packages/core/session/src/surface.ts | 27 +++++++++++---------- packages/core/session/tests/surface.spec.ts | 13 ++++++++++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 60c3633a1b..112faf0bd4 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -85,7 +85,6 @@ export interface SurfaceFoldResult { interface SurfaceFoldState { nodes: SurfaceNode[] nodeBySeq: Map - replacements: SurfaceFoldReplacement[] replaceGeneration: number } @@ -94,13 +93,15 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState { return { nodes: [], nodeBySeq: new Map(), - replacements: [], replaceGeneration, } } -/** Apply one event to a surface fold state. */ -function applySurfaceEvent(state: SurfaceFoldState, event: SessionEvent): void { +/** Apply one event and return replacement metadata only when one occurred. */ +function applySurfaceEvent( + state: SurfaceFoldState, + event: SessionEvent, +): SurfaceFoldReplacement | undefined { if (!isSurfaceEvent(event)) return if (event.surfaceOp === 'append') { @@ -112,13 +113,12 @@ function applySurfaceEvent(state: SurfaceFoldState, event: SessionEvent): void { return } - const shadowedSeqs = replaceSurface(state, event.seq, event.surfaceOp) - state.replacements.push({ + return { seq: event.seq, start: event.surfaceOp.start, end: event.surfaceOp.end, - shadowedSeqs, - }) + shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp), + } } /** Apply one positional replacement and return the nodes it removed. */ @@ -170,13 +170,14 @@ function replaceSurface( */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() - for (const event of events) applySurfaceEvent(state, event) + const replacements: SurfaceFoldReplacement[] = [] + for (const event of events) { + const replacement = applySurfaceEvent(state, event) + if (replacement !== undefined) replacements.push(replacement) + } return { nodes: state.nodes.map(node => ({ ...node })), - replacements: state.replacements.map(replacement => ({ - ...replacement, - shadowedSeqs: [...replacement.shadowedSeqs], - })), + replacements, } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index e127b4a4b3..071bf1af86 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -33,6 +33,19 @@ describe('SurfaceManager', () => { expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0]) }) + it('does not retain fold-only replacement history in incremental state', () => { + const s = new Session(SessionId('incremental-state')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } }) + + expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }]) + const manager = s.surface as unknown as { _state: object } + expect(Object.hasOwn(manager._state, 'replacements')).toBe(false) + expect(foldSurface(s.events).replacements).toEqual([ + { seq: 1, start: 0, end: 0, shadowedSeqs: [0] }, + ]) + }) + it('foldSurface reports the same invalid replacement failures as the incremental manager', () => { const s = new Session(SessionId('shared-fold-invalid')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) From bea27efc4bd7b54ee4224b80bf5200afdf433712 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sun, 12 Jul 2026 10:09:47 +0800 Subject: [PATCH 11/11] fix(session-query): reject markerless surface events --- packages/core/session/README.md | 2 +- packages/core/session/src/surface.ts | 7 ++++++- packages/core/session/tests/surface.spec.ts | 12 ++++++++++++ .../session-query/tests/session-query.spec.ts | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 4de99ad961..99be514fe9 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -47,7 +47,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. -- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges. `SurfaceManager` shares the same transitions while retaining its incremental cache. +- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 112faf0bd4..3e4ce6d89c 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -102,7 +102,10 @@ function applySurfaceEvent( state: SurfaceFoldState, event: SessionEvent, ): SurfaceFoldReplacement | undefined { - if (!isSurfaceEvent(event)) return + if (!isSurfaceEligibleType(event.type)) return + if (!isSurfaceEvent(event)) { + throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`) + } if (event.surfaceOp === 'append') { const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined @@ -167,6 +170,8 @@ function replaceSurface( * models cannot disagree with `deriveMessages()` about replacement ranges. * @param events - session events in contiguous seq order. * @returns the current surface and every positional replacement. + * @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a + * replacement names nodes that are absent or reversed on the current surface. */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 071bf1af86..1260b450a4 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -55,6 +55,18 @@ describe('SurfaceManager', () => { expect(() => s.surface.nodes).toThrow(/start seq 42 not found/) }) + it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { + const malformed: SessionEvent = { + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } }, + } + + expect(() => foldSurface([malformed])) + .toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/) + }) + it('rebuilds a linked list from surfaceOp: append markers', () => { const s = surfaceSession() const nodes = s.surface.nodes diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 29b863b678..b449e59bb5 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -232,6 +232,21 @@ describe('session-query exact reads', () => { await expect(ctx.sessionQuery.listEvents(session.id)) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + const persisted = header('bad-persisted-surface') + TestPersistence.reset([{ + meta: persisted, + events: [{ + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } }, + }], + }]) + const persistence = await ctx.plugin(TestPersistence) + await expect(ctx.sessionQuery.listEvents(persisted.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + await persistence.dispose() + const direct = new Context() await direct.plugin(SessionStore) expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService)