feat(session-query): checkpoint build round 1
This commit is contained in:
@@ -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/<group>/<pkg>/`. 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<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -135,6 +135,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'abstract list(): Promise<SessionHeader[]>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
summary: 'Session-history retrieval and provider coordination service.',
|
||||
methods: [
|
||||
'listSessions(): Promise<SessionRecord[]>',
|
||||
'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
|
||||
'async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>',
|
||||
'async traceSession(sessionId: SessionId): Promise<SessionLineageTrace>',
|
||||
'async traceEvent(sessionId: SessionId, seq: number): Promise<SessionEventTrace>',
|
||||
'registerSearchProvider(provider: SessionSearchProvider): () => void',
|
||||
'registerEventTextExtractor<K extends SessionEventType>( type: K, extractor: SessionEventTextExtractor<K>, ): () => void',
|
||||
'registerContentTextExtractor<K extends ContentBlockType>( type: K, extractor: SessionContentTextExtractor<K>, ): () => void',
|
||||
'searchSessions( request: SessionSearchRequest, exec?: SessionQueryExecContext, ): Promise<SessionSearchPage<SessionSearchHit>>',
|
||||
'searchEvents( request: SessionEventSearchRequest, exec?: SessionQueryExecContext, ): Promise<SessionSearchPage<SessionEventSearchHit>>',
|
||||
],
|
||||
},
|
||||
{
|
||||
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> | void',
|
||||
summary: 'Awaited durability checkpoint.',
|
||||
},
|
||||
{
|
||||
name: 'session/persisted',
|
||||
mode: 'parallel',
|
||||
signature: '\'session/persisted\'(header: SessionHeader, change: SessionPersistedChange): Promise<void> | void',
|
||||
summary: 'A persistence backend committed a canonical session-log change.',
|
||||
},
|
||||
{
|
||||
name: 'session/removed',
|
||||
mode: 'parallel',
|
||||
signature: '\'session/removed\'(header: SessionHeader): Promise<void> | 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<K extends ContentBlockType = ContentBlockType> {\n version: string;\n extract(block: ContentBlockMap[K]): readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEvent',
|
||||
declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\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<K extends SessionEventType = SessionEventType> {\n version: string;\n extract(event: SessionEvent<K>): 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<T> {\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<readonly SessionPersistedIndexEntry[]>;\n setPersistedActive(active: boolean): Promise<void>;\n replacePersisted(snapshot: SessionIndexSnapshot): Promise<void>;\n removePersisted(sessionId: SessionId): Promise<void>;\n replaceLive(snapshot: SessionIndexSnapshot): Promise<void>;\n removeLive(sessionId: SessionId): Promise<void>;\n searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionSearchHit>>;\n searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionEventSearchHit>>;\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};',
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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> | 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)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number, SurfaceNode>
|
||||
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<SurfaceOp, { op: 'replace' }>,
|
||||
): 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<number, SurfaceNode>()
|
||||
/** 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<SurfaceOp, { op: 'replace' }>,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TornMarker>` hooks (the only seam between the coordinator and storage):
|
||||
|
||||
| Hook | Role |
|
||||
|
||||
@@ -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<TornMarker = unknown> {
|
||||
// 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<void> {
|
||||
private async _appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
|
||||
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<TornMarker = unknown> {
|
||||
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<TornMarker = unknown> {
|
||||
* @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<TornMarker = unknown> {
|
||||
// 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<TornMarker = unknown> {
|
||||
|
||||
/** Build a state for a session discovered in storage but not yet in memory. */
|
||||
private async adopt(id: SessionId): Promise<SessionState> {
|
||||
// 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<TornMarker = unknown> {
|
||||
// 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<TornMarker = unknown> {
|
||||
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<void> {
|
||||
@@ -546,9 +563,20 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
/* 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)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> | 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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
9
packages/session-query/README.md
Normal file
9
packages/session-query/README.md
Normal file
@@ -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).
|
||||
46
packages/session-query/session-query/README.md
Normal file
46
packages/session-query/session-query/README.md
Normal file
@@ -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).
|
||||
44
packages/session-query/session-query/package.json
Normal file
44
packages/session-query/session-query/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
29
packages/session-query/session-query/src/config.ts
Normal file
29
packages/session-query/session-query/src/config.ts
Normal file
@@ -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 {}
|
||||
221
packages/session-query/session-query/src/corpus.ts
Normal file
221
packages/session-query/session-query/src/corpus.ts
Normal file
@@ -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<SessionId, SessionHeader>
|
||||
error?: unknown
|
||||
refreshing: Promise<void> | 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<LoadedSession>
|
||||
}
|
||||
|
||||
/** 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<SessionRecord[]> {
|
||||
const binding = await this._ensurePersistence()
|
||||
const records = new Map<SessionId, SessionRecord>()
|
||||
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<LoadedSession> {
|
||||
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<PersistenceView | undefined> {
|
||||
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<void> {
|
||||
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<PersistenceBinding | undefined> {
|
||||
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<LoadedSession> {
|
||||
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'
|
||||
}
|
||||
258
packages/session-query/session-query/src/extraction.ts
Normal file
258
packages/session-query/session-query/src/extraction.ts
Normal file
@@ -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<SessionEventType, StoredEventExtractor>()
|
||||
private readonly _contentExtractors = new Map<ContentBlockType, StoredContentExtractor>()
|
||||
|
||||
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<K extends SessionEventType>(
|
||||
ctx: Context,
|
||||
type: K,
|
||||
extractor: SessionEventTextExtractor<K>,
|
||||
): () => 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<K>),
|
||||
}
|
||||
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<K extends ContentBlockType>(
|
||||
ctx: Context,
|
||||
type: K,
|
||||
extractor: SessionContentTextExtractor<K>,
|
||||
): () => 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<string>()
|
||||
const blockVersions = new Set<string>()
|
||||
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<typeof type>).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<string, unknown>
|
||||
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<ContentBlockType, StoredContentExtractor>,
|
||||
versions: Set<string>,
|
||||
): 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<string, unknown>
|
||||
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) }
|
||||
}
|
||||
123
packages/session-query/session-query/src/filters.ts
Normal file
123
packages/session-query/session-query/src/filters.ts
Normal file
@@ -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<T extends SessionRecord>(
|
||||
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<T extends SessionEventRecord>(
|
||||
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)
|
||||
}
|
||||
225
packages/session-query/session-query/src/index.ts
Normal file
225
packages/session-query/session-query/src/index.ts
Normal file
@@ -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<Config> = 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<SessionRecord[]> {
|
||||
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<SessionEventRecord[]> {
|
||||
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<SessionEventWindow> {
|
||||
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<SessionLineageTrace> {
|
||||
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<SessionEventTrace> {
|
||||
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<K extends SessionEventType>(
|
||||
type: K,
|
||||
extractor: SessionEventTextExtractor<K>,
|
||||
): () => 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<K extends ContentBlockType>(
|
||||
type: K,
|
||||
extractor: SessionContentTextExtractor<K>,
|
||||
): () => 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<SessionSearchPage<SessionSearchHit>> {
|
||||
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<SessionSearchPage<SessionEventSearchHit>> {
|
||||
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
|
||||
328
packages/session-query/session-query/src/provider.ts
Normal file
328
packages/session-query/session-query/src/provider.ts
Normal file
@@ -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<void>
|
||||
liveIds: Set<SessionId>
|
||||
fullSync: Promise<void> | undefined
|
||||
liveSync: Map<SessionId, Promise<void>>
|
||||
}
|
||||
|
||||
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<string, ProviderState>()
|
||||
|
||||
constructor(
|
||||
private readonly _ctx: Context,
|
||||
config: Required<Pick<Config, 'defaultLimit' | 'maxLimit'>> & Pick<Config, 'searchProvider'>,
|
||||
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<SessionSearchPage<SessionSearchHit>> {
|
||||
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<SessionSearchPage<SessionEventSearchHit>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SessionTextExtractors['buildSnapshot']> {
|
||||
return this._extractors.buildSnapshot(this._corpus().snapshotLive(session))
|
||||
}
|
||||
|
||||
private _enqueue(state: ProviderState, operation: () => Promise<void>): Promise<void> {
|
||||
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<SessionRecord>([], request.sessionFilters ?? [])
|
||||
filterEventResults<SessionEventRecord>([], request.eventFilters ?? [])
|
||||
return { ...request, query, limit }
|
||||
}
|
||||
|
||||
private _normalizeEventSearch(request: SessionEventSearchRequest): NormalizedEventSearchRequest {
|
||||
const query = this._queryText(request.query)
|
||||
const limit = this._limitValue(request.limit)
|
||||
filterEventResults<SessionEventRecord>([], 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<T>(state: ProviderState, page: SessionSearchPage<T>, limit: number): SessionSearchPage<T> {
|
||||
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<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return work
|
||||
if (signal.aborted) return Promise.reject(aborted())
|
||||
return new Promise<T>((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'
|
||||
}
|
||||
158
packages/session-query/session-query/src/tracing.ts
Normal file
158
packages/session-query/session-query/src/tracing.ts
Normal file
@@ -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<number, number>()
|
||||
const shadows = new Map<number, number[]>()
|
||||
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>([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<SessionId, SessionRecord[]>()
|
||||
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<typeof foldSurface> {
|
||||
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'
|
||||
}
|
||||
292
packages/session-query/session-query/src/types.ts
Normal file
292
packages/session-query/session-query/src/types.ts
Normal file
@@ -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<T> {
|
||||
/** 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<K extends SessionEventType = SessionEventType> {
|
||||
/** 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<K>): readonly string[]
|
||||
}
|
||||
|
||||
/** Typed extractor for one declaration-merged content block type. */
|
||||
export interface SessionContentTextExtractor<K extends ContentBlockType = ContentBlockType> {
|
||||
/** 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<readonly SessionPersistedIndexEntry[]>
|
||||
/**
|
||||
* Hide or expose reconciled persisted rows without deleting their cache.
|
||||
* @param active - whether canonical persistence is mounted and reconciled.
|
||||
*/
|
||||
setPersistedActive(active: boolean): Promise<void>
|
||||
/**
|
||||
* Atomically replace one persisted session's derived documents.
|
||||
* @param snapshot - canonical persisted checkpoint and fingerprint.
|
||||
*/
|
||||
replacePersisted(snapshot: SessionIndexSnapshot): Promise<void>
|
||||
/**
|
||||
* Delete one durable derived entry after canonical reconciliation proves it absent.
|
||||
* @param sessionId - persisted id to remove.
|
||||
*/
|
||||
removePersisted(sessionId: SessionId): Promise<void>
|
||||
/**
|
||||
* Replace one connection-local live override.
|
||||
* @param snapshot - current live snapshot and availability.
|
||||
*/
|
||||
replaceLive(snapshot: SessionIndexSnapshot): Promise<void>
|
||||
/**
|
||||
* Drop one live override, revealing its active persisted base when present.
|
||||
* @param sessionId - live id to remove.
|
||||
*/
|
||||
removeLive(sessionId: SessionId): Promise<void>
|
||||
/**
|
||||
* 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<SessionSearchPage<SessionSearchHit>>
|
||||
/**
|
||||
* 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<SessionSearchPage<SessionEventSearchHit>>
|
||||
}
|
||||
704
packages/session-query/session-query/tests/session-query.spec.ts
Normal file
704
packages/session-query/session-query/tests/session-query.spec.ts
Normal file
@@ -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> = {}): 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<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
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<void> {
|
||||
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
|
||||
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<SessionHeader[]> {
|
||||
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<SessionIdType, SessionIndexSnapshot>()
|
||||
live = new Map<SessionIdType, SessionIndexSnapshot>()
|
||||
activeHistory: boolean[] = []
|
||||
removedPersisted: SessionIdType[] = []
|
||||
removedLive: SessionIdType[] = []
|
||||
sessionRequests: SessionSearchRequest[] = []
|
||||
eventRequests: SessionEventSearchRequest[] = []
|
||||
failNextLive = false
|
||||
failNextPersisted = false
|
||||
failNextActive = false
|
||||
sessionPage: SessionSearchPage<SessionSearchHit>
|
||||
eventPage: SessionSearchPage<SessionEventSearchHit>
|
||||
|
||||
constructor(id = 'fake') {
|
||||
this.id = id
|
||||
this.sessionPage = { providerId: id, items: [] }
|
||||
this.eventPage = { providerId: id, items: [] }
|
||||
}
|
||||
|
||||
status(): SessionSearchProviderStatus {
|
||||
return this.statusValue
|
||||
}
|
||||
|
||||
persistedInventory(): Promise<readonly { sessionId: SessionIdType; fingerprint: string }[]> {
|
||||
return Promise.resolve([...this.persisted.values()].map(snapshot => ({
|
||||
sessionId: snapshot.session.header.id,
|
||||
fingerprint: snapshot.fingerprint,
|
||||
})))
|
||||
}
|
||||
|
||||
setPersistedActive(active: boolean): Promise<void> {
|
||||
if (this.failNextActive) {
|
||||
this.failNextActive = false
|
||||
return Promise.reject(new Error('activation failed'))
|
||||
}
|
||||
this.activeHistory.push(active)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
replacePersisted(snapshot: SessionIndexSnapshot): Promise<void> {
|
||||
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<void> {
|
||||
this.removedPersisted.push(sessionId)
|
||||
this.persisted.delete(sessionId)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
replaceLive(snapshot: SessionIndexSnapshot): Promise<void> {
|
||||
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<void> {
|
||||
this.removedLive.push(sessionId)
|
||||
this.live.delete(sessionId)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
searchSessions(request: SessionSearchRequest): Promise<SessionSearchPage<SessionSearchHit>> {
|
||||
this.sessionRequests.push(structuredClone(request))
|
||||
return Promise.resolve(structuredClone(this.sessionPage))
|
||||
}
|
||||
|
||||
searchEvents(request: SessionEventSearchRequest): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
this.eventRequests.push(structuredClone(request))
|
||||
return Promise.resolve(structuredClone(this.eventPage))
|
||||
}
|
||||
}
|
||||
|
||||
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
|
||||
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<void>((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<void>((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<void>((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()
|
||||
})
|
||||
})
|
||||
30
packages/session-query/session-query/tsconfig.json
Normal file
30
packages/session-query/session-query/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user