feat(session-query): add relationship tracing (round 1)
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
# session-query/ — session retrieval capability family
|
||||
|
||||
Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads.
|
||||
Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, bounded event reads, lineage, and direct event relationships.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` |
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus exact-read and relationship-tracing service | `ctx.sessionQuery` |
|
||||
|
||||
The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package.
|
||||
The family is independent of compaction: it reads canonical lineage, surface operations, and logged provenance but does not participate in compaction policy or execution. Full-text search remains a proposed SQLite package rather than a speculative provider seam in this interface package.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-session-query
|
||||
|
||||
Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
Exact session-history retrieval and relationship tracing through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect.
|
||||
|
||||
@@ -9,10 +9,14 @@ This is trusted context-wide infrastructure. It performs no caller authorization
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
|
||||
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
|
||||
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
|
||||
|
||||
Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations.
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations.
|
||||
|
||||
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
`traceEvent()` validates the whole loaded log before returning relationships: provenance arrays are nonempty and duplicate-free, references name known earlier events, only surface event types carry sources, and each positional replacement names every surface node it removed. Provenance violations fail with `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures remain `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract.
|
||||
|
||||
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_PROVENANCE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -20,4 +24,4 @@ Persistence is optional and may mount or unmount dynamically. A cross-corpus lis
|
||||
|---|---:|---|
|
||||
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. |
|
||||
|
||||
This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
The service has no filters, extraction registry, search-provider protocol, index synchronization, or model-facing tool. The [tracing decision](../../../docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md) owns relationship semantics. Content-bearing full-text-search results and their chainable filters belong together in the proposed [SQLite search package](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-query",
|
||||
"description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)",
|
||||
"description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -5,16 +5,18 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
/** Default maximum `before`/`after` raw-event window. */
|
||||
export const SESSION_QUERY_READ_WINDOW_MAX = 50
|
||||
|
||||
/** Configuration for exact session-query reads. */
|
||||
/** Configuration for exact session-query reads and traces. */
|
||||
export interface Config {
|
||||
/** Maximum accepted raw read context on either side. Defaults to 50. */
|
||||
readWindowMax?: number
|
||||
}
|
||||
|
||||
/** Stable machine-routable failure taxonomy for exact session reads. */
|
||||
/** Stable machine-routable failure taxonomy for exact session reads and traces. */
|
||||
export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_EVENT_NOT_FOUND'
|
||||
| 'SESSION_QUERY_INVALID_CONFIG'
|
||||
| 'SESSION_QUERY_INVALID_LINEAGE'
|
||||
| 'SESSION_QUERY_INVALID_PROVENANCE'
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* Exact session-history reads over live and optionally persisted logs.
|
||||
* Exact session-history reads and traces over live and optionally persisted logs.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionEventReadRequest,
|
||||
SessionEventRecord,
|
||||
SessionEventTrace,
|
||||
SessionEventTraceRequest,
|
||||
SessionEventWindow,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
} from './types.ts'
|
||||
import {
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import { SessionCorpus } from './corpus.ts'
|
||||
import { eventRecords, traceEventLog, traceLineage } from './tracing.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export type { Config, SessionQueryErrorCode } from './config.ts'
|
||||
@@ -31,7 +34,7 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Live-preferred logical-corpus and exact-event read service. */
|
||||
/** Live-preferred logical-corpus exact-read and relationship-tracing service. */
|
||||
export class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -71,6 +74,26 @@ export class SessionQueryService extends Service {
|
||||
return eventRecords(sessionId, loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace known ancestry and descendants from one corpus observation.
|
||||
* @param sessionId - logical session id to trace.
|
||||
* @returns a complete lineage or an explicit unresolved parent boundary.
|
||||
*/
|
||||
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
|
||||
const records = await this._corpus.listSessions()
|
||||
return traceLineage(records, sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace one event's direct positional and provenance relationships.
|
||||
* @param request - target session id and event seq.
|
||||
* @returns direct links plus the target's positional replacement chain.
|
||||
*/
|
||||
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace> {
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
return traceEventLog(request.sessionId, loaded.events, request.seq)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one full event plus a bounded raw-log context window.
|
||||
* @param request - target session/seq and context sizes.
|
||||
@@ -110,27 +133,4 @@ export class SessionQueryService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
|
||||
let folded: ReturnType<typeof foldSurface>
|
||||
try {
|
||||
folded = foldSurface(events)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
/* v8 ignore next -- foldSurface throws Error instances */
|
||||
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
'SESSION_QUERY_INVALID_SURFACE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const current = new Set(folded.nodes.map(node => node.seq))
|
||||
const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs))
|
||||
return events.map(event => ({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only',
|
||||
}))
|
||||
}
|
||||
|
||||
export default SessionQueryService
|
||||
|
||||
277
packages/session-query/session-query/src/tracing.ts
Normal file
277
packages/session-query/session-query/src/tracing.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/** One-shot session-lineage and event-relationship tracing helpers. */
|
||||
|
||||
import { foldSurface, isSurfaceEligibleType } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
import type {
|
||||
SessionEventRecord,
|
||||
SessionEventTrace,
|
||||
SessionLineageNode,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
} from './types.ts'
|
||||
|
||||
interface EventLogAnalysis {
|
||||
records: SessionEventRecord[]
|
||||
replacedBy: Map<number, number>
|
||||
replacedEventSeqs: Map<number, number[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a raw event log with one canonical surface fold.
|
||||
* @param sessionId - owner of the event log.
|
||||
* @param events - detached raw event log.
|
||||
* @returns lightweight records in ascending log order.
|
||||
*/
|
||||
export function eventRecords(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEventRecord[] {
|
||||
return analyzeEventLog(sessionId, events).records
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace one target after one canonical surface fold and whole-log validation.
|
||||
* @param sessionId - owner of the event log.
|
||||
* @param events - detached raw event log.
|
||||
* @param seq - target event seq.
|
||||
* @returns direct surface and provenance 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 analysis = analyzeEventLog(sessionId, events)
|
||||
validateProvenance(events, analysis.replacedEventSeqs)
|
||||
|
||||
const replacementChain: number[] = []
|
||||
let replacement = analysis.replacedBy.get(seq)
|
||||
while (replacement !== undefined) {
|
||||
replacementChain.push(replacement)
|
||||
replacement = analysis.replacedBy.get(replacement)
|
||||
}
|
||||
|
||||
const sourceEventSeqs = eventSources(target)
|
||||
const derivedEventSeqs: number[] = []
|
||||
for (const event of events) {
|
||||
if (event.seq <= seq) continue
|
||||
if (eventSources(event).includes(seq)) derivedEventSeqs.push(event.seq)
|
||||
}
|
||||
|
||||
// The target check above proves the parallel record exists at this index.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const targetRecord = analysis.records[seq]!
|
||||
const replacedBy = analysis.replacedBy.get(seq)
|
||||
return {
|
||||
target: { ...targetRecord },
|
||||
...replacedBy === undefined ? {} : { replacedBy },
|
||||
replacementChain,
|
||||
replacedEventSeqs: [...(analysis.replacedEventSeqs.get(seq) ?? [])],
|
||||
sourceEventSeqs: [...sourceEventSeqs],
|
||||
derivedEventSeqs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace one target's known ancestry and recursively known descendants.
|
||||
* @param records - complete logical corpus from one observation.
|
||||
* @param sessionId - target session id.
|
||||
* @returns complete or explicitly partial lineage.
|
||||
*/
|
||||
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 ancestors: 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
|
||||
}
|
||||
ancestors.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 descendants = buildDescendants(childrenByParent, sessionId)
|
||||
const common = {
|
||||
target: cloneRecord(target),
|
||||
ancestors: ancestors.map(cloneRecord),
|
||||
descendants,
|
||||
}
|
||||
if (unresolvedParentId !== undefined) {
|
||||
return { ...common, complete: false, unresolvedParentId }
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
complete: true,
|
||||
root: cloneRecord(ancestors.at(-1) ?? target),
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeEventLog(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): EventLogAnalysis {
|
||||
const folded = safeFold(events)
|
||||
const current = new Set(folded.nodes.map(node => node.seq))
|
||||
const shadowed = new Set<number>()
|
||||
const replacedBy = new Map<number, number>()
|
||||
const replacedEventSeqs = new Map<number, number[]>()
|
||||
for (const replacement of folded.replacements) {
|
||||
const removed = [...replacement.shadowedSeqs]
|
||||
replacedEventSeqs.set(replacement.seq, removed)
|
||||
for (const removedSeq of removed) {
|
||||
shadowed.add(removedSeq)
|
||||
replacedBy.set(removedSeq, replacement.seq)
|
||||
}
|
||||
}
|
||||
return {
|
||||
records: 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',
|
||||
})),
|
||||
replacedBy,
|
||||
replacedEventSeqs,
|
||||
}
|
||||
}
|
||||
|
||||
function validateProvenance(
|
||||
events: readonly SessionEvent[],
|
||||
replacedEventSeqs: ReadonlyMap<number, readonly number[]>,
|
||||
): void {
|
||||
for (const event of events) {
|
||||
const sources = rawEventSources(event)
|
||||
if (sources === undefined) continue
|
||||
if (!isSurfaceEligibleType(event.type)) {
|
||||
invalidProvenance(`non-surface event at seq ${event.seq} carries sourceEventSeqs`)
|
||||
}
|
||||
if (!Array.isArray(sources) || sources.length === 0) {
|
||||
invalidProvenance(`event at seq ${event.seq} has an empty or invalid sourceEventSeqs`)
|
||||
}
|
||||
const unique = new Set<unknown>()
|
||||
for (const source of sources as unknown[]) {
|
||||
if (unique.has(source)) {
|
||||
invalidProvenance(`event at seq ${event.seq} repeats source seq ${String(source)}`)
|
||||
}
|
||||
unique.add(source)
|
||||
if (
|
||||
typeof source !== 'number'
|
||||
|| !Number.isInteger(source)
|
||||
|| source < 0
|
||||
|| source >= event.seq
|
||||
|| events[source]?.seq !== source
|
||||
) {
|
||||
invalidProvenance(`event at seq ${event.seq} references unknown or non-earlier source seq ${String(source)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [replacementSeq, removedSeqs] of replacedEventSeqs) {
|
||||
// The fold reports only replacement events from the input log.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const replacement = events.find(event => event.seq === replacementSeq)!
|
||||
const sources = rawEventSources(replacement)
|
||||
if (!Array.isArray(sources)) {
|
||||
invalidProvenance(`replacement at seq ${replacementSeq} omits its shadowed surface sources`)
|
||||
}
|
||||
const sourceSet = new Set(sources as unknown[])
|
||||
for (const removedSeq of removedSeqs) {
|
||||
if (!sourceSet.has(removedSeq)) {
|
||||
invalidProvenance(`replacement at seq ${replacementSeq} omits shadowed surface seq ${removedSeq}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rawEventSources(event: SessionEvent): unknown {
|
||||
return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
|
||||
}
|
||||
|
||||
function eventSources(event: SessionEvent): number[] {
|
||||
const sources = rawEventSources(event)
|
||||
return Array.isArray(sources) ? sources as number[] : []
|
||||
}
|
||||
|
||||
function safeFold(events: readonly SessionEvent[]): ReturnType<typeof foldSurface> {
|
||||
try {
|
||||
return foldSurface(events)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
/* v8 ignore next -- foldSurface throws Error instances */
|
||||
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
'SESSION_QUERY_INVALID_SURFACE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDescendants(
|
||||
childrenByParent: ReadonlyMap<SessionId, readonly SessionRecord[]>,
|
||||
sessionId: SessionId,
|
||||
): SessionLineageNode[] {
|
||||
return (childrenByParent.get(sessionId) ?? []).map(child => ({
|
||||
session: cloneRecord(child),
|
||||
descendants: buildDescendants(childrenByParent, child.header.id),
|
||||
}))
|
||||
}
|
||||
|
||||
function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number {
|
||||
return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)
|
||||
}
|
||||
|
||||
function cloneRecord(record: SessionRecord): SessionRecord {
|
||||
return { ...record, header: structuredClone(record.header) }
|
||||
}
|
||||
|
||||
function lineageCycle(id: SessionId): never {
|
||||
throw new SessionQueryError(
|
||||
`session lineage contains a cycle at "${id}"`,
|
||||
'SESSION_QUERY_INVALID_LINEAGE',
|
||||
)
|
||||
}
|
||||
|
||||
function invalidProvenance(message: string): never {
|
||||
throw new SessionQueryError(
|
||||
`invalid session provenance: ${message}`,
|
||||
'SESSION_QUERY_INVALID_PROVENANCE',
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Public records for exact reads over the live-preferred logical session corpus.
|
||||
* Public records for exact reads and relationship traces over the
|
||||
* live-preferred logical session corpus.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query/types
|
||||
*/
|
||||
@@ -33,6 +34,61 @@ export interface SessionEventRecord {
|
||||
surface: SessionEventSurface
|
||||
}
|
||||
|
||||
/** Recursive descendant node in a session-lineage trace. */
|
||||
export interface SessionLineageNode {
|
||||
/** Detached logical-corpus record for this descendant. */
|
||||
session: SessionRecord
|
||||
/** Direct children, each carrying its own recursive descendants. */
|
||||
descendants: SessionLineageNode[]
|
||||
}
|
||||
|
||||
/** Known ancestry and descendants for one logical session. */
|
||||
export type SessionLineageTrace = {
|
||||
/** Detached record for the session that was traced. */
|
||||
target: SessionRecord
|
||||
/** Known parents from the immediate parent outward. */
|
||||
ancestors: SessionRecord[]
|
||||
/** Complete known descendant trees rooted at the target's direct children. */
|
||||
descendants: SessionLineageNode[]
|
||||
} & (
|
||||
| {
|
||||
/** The complete parent chain is present in the logical corpus. */
|
||||
complete: true
|
||||
/** Detached record at the top of the complete lineage. */
|
||||
root: SessionRecord
|
||||
}
|
||||
| {
|
||||
/** The parent chain leaves the visible logical corpus. */
|
||||
complete: false
|
||||
/** First parent id that is not present in the logical corpus. */
|
||||
unresolvedParentId: SessionId
|
||||
}
|
||||
)
|
||||
|
||||
/** Request for direct surface and provenance relationships around one event. */
|
||||
export interface SessionEventTraceRequest {
|
||||
/** Session that owns the target event. */
|
||||
sessionId: SessionId
|
||||
/** Target event seq. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Direct surface and provenance relationships for one event. */
|
||||
export interface SessionEventTrace {
|
||||
/** Lightweight target record. */
|
||||
target: SessionEventRecord
|
||||
/** Immediate positional replacement event, when the target was shadowed. */
|
||||
replacedBy?: number
|
||||
/** Positional replacers from the immediate replacement to the final replacement. */
|
||||
replacementChain: number[]
|
||||
/** Surface nodes directly removed when the target itself performed a replacement. */
|
||||
replacedEventSeqs: number[]
|
||||
/** Direct logged provenance sources in their recorded order. */
|
||||
sourceEventSeqs: number[]
|
||||
/** Later events that directly name the target as a provenance source, in log order. */
|
||||
derivedEventSeqs: number[]
|
||||
}
|
||||
|
||||
/** Request for one event plus raw neighboring log context. */
|
||||
export interface SessionEventReadRequest {
|
||||
/** Session that owns the target event. */
|
||||
|
||||
376
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
376
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
}
|
||||
|
||||
function appendEvent(seq: number, sources?: number[]): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: seq + 1,
|
||||
data: { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
...sources === undefined ? {} : { sourceEventSeqs: sources },
|
||||
}
|
||||
}
|
||||
|
||||
class TracePersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listCalls = 0
|
||||
static loadCalls = 0
|
||||
static listFailure: Error | undefined
|
||||
static loadFailure: Error | undefined
|
||||
static afterList: (() => void) | undefined
|
||||
|
||||
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
|
||||
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
|
||||
this.listCalls = 0
|
||||
this.loadCalls = 0
|
||||
this.listFailure = undefined
|
||||
this.loadFailure = undefined
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
|
||||
const entry = TracePersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
entry.events.push(...structuredClone(events))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
TracePersistence.loadCalls += 1
|
||||
if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure)
|
||||
const entry = TracePersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
TracePersistence.listCalls += 1
|
||||
if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure)
|
||||
const result = [...TracePersistence.entries.values()].map(entry => structuredClone(entry.meta))
|
||||
TracePersistence.afterList?.()
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
async function queryContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function expectCode(code: SessionQueryErrorCode): Error {
|
||||
return expect.objectContaining({ code }) as Error
|
||||
}
|
||||
|
||||
function appendTraceEvents(session: Session): void {
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'draft' },
|
||||
})
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [0] },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1, 0] },
|
||||
)
|
||||
session.append(
|
||||
'context/message',
|
||||
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [0, 2] },
|
||||
)
|
||||
}
|
||||
|
||||
describe('session lineage tracing', () => {
|
||||
it('returns complete ancestry, deterministic descendant trees, and detached records', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 0 } })
|
||||
const parent = ctx.sessions.create(SessionId('parent'), {
|
||||
meta: { createdAt: 1, parentSession: root.id },
|
||||
})
|
||||
const target = ctx.sessions.create(SessionId('target'), {
|
||||
meta: { createdAt: 2, parentSession: parent.id },
|
||||
})
|
||||
ctx.sessions.create(SessionId('b'), { meta: { createdAt: 4, parentSession: target.id } })
|
||||
const childA = ctx.sessions.create(SessionId('a'), {
|
||||
meta: { createdAt: 4, parentSession: target.id },
|
||||
})
|
||||
ctx.sessions.create(SessionId('older'), { meta: { createdAt: 3, parentSession: target.id } })
|
||||
ctx.sessions.create(SessionId('grandchild'), {
|
||||
meta: { createdAt: 5, parentSession: childA.id },
|
||||
})
|
||||
|
||||
const trace = await ctx.sessionQuery.traceSession(target.id)
|
||||
expect(trace.complete).toBe(true)
|
||||
if (!trace.complete) throw new Error('expected complete lineage')
|
||||
expect(trace.ancestors.map(record => record.header.id)).toEqual([parent.id, root.id])
|
||||
expect(trace.root.header.id).toBe(root.id)
|
||||
expect(trace.descendants.map(node => node.session.header.id))
|
||||
.toEqual([SessionId('older'), SessionId('a'), SessionId('b')])
|
||||
expect(trace.descendants[1]?.descendants.map(node => node.session.header.id))
|
||||
.toEqual([SessionId('grandchild')])
|
||||
|
||||
trace.target.header.createdAt = 99
|
||||
trace.ancestors[0]!.header.createdAt = 99
|
||||
trace.root.header.createdAt = 99
|
||||
trace.descendants[0]!.session.header.createdAt = 99
|
||||
const repeated = await ctx.sessionQuery.traceSession(target.id)
|
||||
expect(repeated.target.header.createdAt).toBe(2)
|
||||
expect(repeated.ancestors[0]?.header.createdAt).toBe(1)
|
||||
expect(repeated.descendants[0]?.session.header.createdAt).toBe(3)
|
||||
})
|
||||
|
||||
it('represents root and unresolved-parent traces explicitly', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } })
|
||||
const partial = ctx.sessions.create(SessionId('partial'), {
|
||||
meta: { createdAt: 2, parentSession: SessionId('outside') },
|
||||
})
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(root.id)).resolves.toMatchObject({
|
||||
complete: true,
|
||||
root: { header: { id: root.id } },
|
||||
ancestors: [],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({
|
||||
complete: false,
|
||||
unresolvedParentId: SessionId('outside'),
|
||||
ancestors: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects target-connected cycles and missing targets', async () => {
|
||||
const ctx = await queryContext()
|
||||
ctx.sessions.create(SessionId('a'), {
|
||||
meta: { createdAt: 1, parentSession: SessionId('b') },
|
||||
})
|
||||
ctx.sessions.create(SessionId('b'), {
|
||||
meta: { createdAt: 2, parentSession: SessionId('a') },
|
||||
})
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(SessionId('a')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE'))
|
||||
await expect(ctx.sessionQuery.traceSession(SessionId('missing')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
})
|
||||
|
||||
it('uses one cross-corpus observation and preserves persistence failure semantics', async () => {
|
||||
const durable = header('durable')
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(durable.id)).resolves.toMatchObject({
|
||||
target: { live: false, persisted: true },
|
||||
complete: true,
|
||||
})
|
||||
expect(TracePersistence.listCalls).toBe(1)
|
||||
expect(TracePersistence.loadCalls).toBe(0)
|
||||
|
||||
TracePersistence.listFailure = new Error('unavailable')
|
||||
await expect(ctx.sessionQuery.traceSession(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('session event tracing', () => {
|
||||
it('returns direct replacement and provenance links in their contract order', async () => {
|
||||
const ctx = await queryContext()
|
||||
const session = ctx.sessions.create(SessionId('trace'))
|
||||
appendTraceEvents(session)
|
||||
|
||||
const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 1 })
|
||||
expect(original.target).toMatchObject({
|
||||
sessionId: session.id,
|
||||
seq: 1,
|
||||
type: 'user/message',
|
||||
surface: 'shadowed',
|
||||
})
|
||||
expect(original).toMatchObject({
|
||||
replacedBy: 2,
|
||||
replacementChain: [2, 4],
|
||||
replacedEventSeqs: [],
|
||||
sourceEventSeqs: [0],
|
||||
derivedEventSeqs: [2],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }))
|
||||
.resolves.toMatchObject({
|
||||
replacedBy: 4,
|
||||
replacementChain: [4],
|
||||
replacedEventSeqs: [1],
|
||||
sourceEventSeqs: [1, 0],
|
||||
derivedEventSeqs: [4],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 0 }))
|
||||
.resolves.toMatchObject({
|
||||
target: { surface: 'log-only' },
|
||||
replacementChain: [],
|
||||
sourceEventSeqs: [],
|
||||
derivedEventSeqs: [1, 2, 4],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }))
|
||||
.resolves.toMatchObject({
|
||||
replacementChain: [],
|
||||
replacedEventSeqs: [2],
|
||||
sourceEventSeqs: [0, 2],
|
||||
derivedEventSeqs: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns fresh trace arrays and target records', async () => {
|
||||
const ctx = await queryContext()
|
||||
const session = ctx.sessions.create(SessionId('detached'))
|
||||
appendTraceEvents(session)
|
||||
|
||||
const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })
|
||||
first.target.time = -1
|
||||
first.replacementChain.push(99)
|
||||
first.replacedEventSeqs.push(99)
|
||||
first.sourceEventSeqs.push(99)
|
||||
first.derivedEventSeqs.push(99)
|
||||
const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })
|
||||
expect(repeated.target.time).not.toBe(-1)
|
||||
expect(repeated.replacementChain).toEqual([4])
|
||||
expect(repeated.replacedEventSeqs).toEqual([1])
|
||||
expect(repeated.sourceEventSeqs).toEqual([1, 0])
|
||||
expect(repeated.derivedEventSeqs).toEqual([4])
|
||||
})
|
||||
|
||||
it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => {
|
||||
const durable = header('shared', 1, { cwd: '/same' })
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
|
||||
const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } })
|
||||
live.append(
|
||||
'context/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ target: { type: 'context/message' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const failedCtx = await queryContext()
|
||||
await failedCtx.plugin(TracePersistence)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TracePersistence.listFailure = undefined
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TracePersistence.loadFailure = undefined
|
||||
TracePersistence.afterList = () => {
|
||||
TracePersistence.entries.get(durable.id)!.meta.cwd = '/changed'
|
||||
}
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
})
|
||||
|
||||
it('checks target existence before surface or provenance analysis', async () => {
|
||||
const bad = header('bad-target')
|
||||
const malformed: SessionEvent[] = [appendEvent(0), {
|
||||
type: 'assistant/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { turn: 1, step: 1, content: [] },
|
||||
surfaceOp: { op: 'replace', start: 9, end: 9 },
|
||||
sourceEventSeqs: [],
|
||||
}]
|
||||
TracePersistence.reset([{ meta: bad, events: malformed }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 9 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-surface sources', [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] },
|
||||
]],
|
||||
['invalid source array', [
|
||||
{ ...appendEvent(0), sourceEventSeqs: 'invalid' },
|
||||
]],
|
||||
['empty sources', [
|
||||
appendEvent(0, []),
|
||||
]],
|
||||
['duplicate sources', [
|
||||
appendEvent(0),
|
||||
appendEvent(1, [0, 0]),
|
||||
]],
|
||||
['missing earlier source', [
|
||||
appendEvent(0),
|
||||
appendEvent(1, [-1]),
|
||||
]],
|
||||
['future source', [
|
||||
appendEvent(0, [1]),
|
||||
appendEvent(1),
|
||||
]],
|
||||
['replacement without sources', [
|
||||
appendEvent(0),
|
||||
{ ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
]],
|
||||
['replacement missing a shadowed source', [
|
||||
{ type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } },
|
||||
appendEvent(1),
|
||||
{ ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } },
|
||||
]],
|
||||
] as const)('rejects invalid whole-log provenance: %s', async (_name, rawEvents) => {
|
||||
const durable = header('invalid-provenance')
|
||||
const events = structuredClone(rawEvents) as unknown as SessionEvent[]
|
||||
TracePersistence.reset([{ meta: durable, events }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_PROVENANCE'))
|
||||
})
|
||||
|
||||
it('keeps listEvents tolerant of malformed provenance alone', async () => {
|
||||
const durable = header('list-regression')
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(durable.id)).resolves.toMatchObject([
|
||||
{ seq: 0, surface: 'current' },
|
||||
{ seq: 1, surface: 'current' },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user