feat(session): add cross-session references
This commit is contained in:
@@ -6,13 +6,14 @@ Exact session-history retrieval and relationship tracing through `ctx.sessionQue
|
||||
|
||||
- `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.
|
||||
- `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture.
|
||||
- `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. 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.
|
||||
|
||||
`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
|
||||
`listEvents()`, `readSurface()`, and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
|
||||
|
||||
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
SessionEventWindow,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
SessionSurfaceSnapshot,
|
||||
} from './types.ts'
|
||||
import {
|
||||
SESSION_QUERY_READ_WINDOW_MAX,
|
||||
@@ -74,6 +75,21 @@ export class SessionQueryService extends Service {
|
||||
return tracing.eventRecords(sessionId, loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one session's complete current model surface from one corpus observation.
|
||||
* @param sessionId - live-preferred session id to read.
|
||||
* @returns cloned header, current surface, and raw-log capture boundary.
|
||||
* @throws when source resolution fails or the session surface is invalid.
|
||||
*/
|
||||
async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return {
|
||||
session: structuredClone(loaded.header),
|
||||
capturedThroughSeq: loaded.events.at(-1)?.seq ?? null,
|
||||
events: tracing.currentSurfaceEvents(sessionId, loaded.events),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace known ancestry and descendants from one corpus observation.
|
||||
* @param sessionId - logical session id to trace.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** One-shot session-lineage and event-relationship tracing helpers. */
|
||||
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
import type {
|
||||
SessionEventRecord,
|
||||
@@ -15,6 +15,7 @@ interface EventLogAnalysis {
|
||||
records: SessionEventRecord[]
|
||||
replacedBy: Map<number, number>
|
||||
replacedEventSeqs: Map<number, number[]>
|
||||
currentSeqs: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,30 @@ export function eventRecords(
|
||||
return analyzeEventLog(sessionId, events).records
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold and return the current model surface after validating the whole log.
|
||||
* @param sessionId - owner used in query diagnostics.
|
||||
* @param events - detached raw event log from one corpus observation.
|
||||
* @returns detached current surface events in folded order.
|
||||
*/
|
||||
export function currentSurfaceEvents(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfaceEvent[] {
|
||||
const analysis = analyzeEventLog(sessionId, events)
|
||||
return analysis.currentSeqs.map((seq) => {
|
||||
const event = events[seq]
|
||||
/* v8 ignore next 6 -- analyzeEventLog validated contiguous seqs and foldSurface returned only surface-event seqs. */
|
||||
if (event === undefined || event.seq !== seq || !isSurfaceEvent(event)) {
|
||||
throw new SessionQueryError(
|
||||
`invalid session surface: current node ${seq} is not a surface event`,
|
||||
'SESSION_QUERY_INVALID_SURFACE',
|
||||
)
|
||||
}
|
||||
return structuredClone(event)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace one target after one canonical surface fold and whole-log validation.
|
||||
* @param sessionId - owner of the event log.
|
||||
@@ -184,6 +209,7 @@ function analyzeEventLog(
|
||||
})),
|
||||
replacedBy,
|
||||
replacedEventSeqs,
|
||||
currentSeqs: [...folded.nodes],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-session-query/types
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionEventType, SessionHeader, SessionId, SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Whether an event is current model context, replaced context, or raw-log-only. */
|
||||
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
|
||||
@@ -20,6 +20,16 @@ export interface SessionRecord {
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
/** One atomic live-preferred observation of a session's current model surface. */
|
||||
export interface SessionSurfaceSnapshot {
|
||||
/** Cloned session header selected from the same corpus observation as `events`. */
|
||||
session: SessionHeader
|
||||
/** Highest raw-log seq included in the observation, or `null` for an empty log. */
|
||||
capturedThroughSeq: number | null
|
||||
/** Cloned current surface events in model-history order. */
|
||||
events: SurfaceEvent[]
|
||||
}
|
||||
|
||||
/** Lightweight metadata for one event within a logical session. */
|
||||
export interface SessionEventRecord {
|
||||
/** Session that owns the event. */
|
||||
|
||||
@@ -121,6 +121,64 @@ describe('session-query exact reads', () => {
|
||||
.toEqual(['shadowed', 'log-only', 'current'])
|
||||
})
|
||||
|
||||
it('reads a detached current surface with its raw-log capture boundary', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('surface-snapshot'), { meta: { cwd: '/work' } })
|
||||
const first = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'old' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'draft' },
|
||||
})
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
|
||||
)
|
||||
const retained = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'retained tail' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
|
||||
{ surfaceOp: { op: 'replace', start: 2, end: retained.seq }, sourceEventSeqs: [2, retained.seq] },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'latest answer' }] },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
|
||||
const snapshot = await ctx.sessionQuery.readSurface(session.id)
|
||||
expect(snapshot.session).toEqual(session.header)
|
||||
expect(snapshot.capturedThroughSeq).toBe(5)
|
||||
expect(snapshot.events.map(event => [event.seq, event.type])).toEqual([
|
||||
[4, 'user/message'],
|
||||
[5, 'assistant/message'],
|
||||
])
|
||||
if (snapshot.events[0]?.type !== 'user/message') throw new Error('expected current user message')
|
||||
snapshot.events[0].data.content = []
|
||||
Object.assign(snapshot.session, { cwd: '/mutated' })
|
||||
|
||||
expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1)
|
||||
expect(session.header.cwd).toBe('/work')
|
||||
})
|
||||
|
||||
it('returns an empty current surface with a null capture boundary', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('empty-surface'))
|
||||
await expect(ctx.sessionQuery.readSurface(session.id)).resolves.toMatchObject({
|
||||
capturedThroughSeq: null,
|
||||
events: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a bounded detached raw-event window and validates the request', async () => {
|
||||
const ctx = await liveContext({ readWindowMax: 1 })
|
||||
const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } })
|
||||
@@ -173,8 +231,15 @@ describe('session-query exact reads', () => {
|
||||
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 })
|
||||
expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0])
|
||||
.toMatchObject({ text: 'live' })
|
||||
await expect(ctx.sessionQuery.readSurface(shared.id)).resolves.toMatchObject({
|
||||
events: [{ data: { content: [{ text: 'live' }] } }],
|
||||
})
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ session: durable })
|
||||
await expect(ctx.sessionQuery.readSurface(durable.id)).resolves.toMatchObject({
|
||||
session: durable,
|
||||
events: [{ data: { content: [{ text: 'durable' }] } }],
|
||||
})
|
||||
|
||||
const sharedEntry = TestPersistence.entries.get(shared.id)!
|
||||
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/conflict' }
|
||||
|
||||
Reference in New Issue
Block a user