Merge remote-tracking branch 'origin/master' into session-query-search
# Conflicts: # .agents/notes/implemented/feature/2026-07-10-session-query-service.md # .agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md # .agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.md # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/persistence.md # docs/core-data-structures/session-query.md # docs/module-graph.md # docs/rfc/INDEX.md # packages/README.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/session-persistence/session-persistence-jsonl/src/index.ts # packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts # packages/session-persistence/session-persistence-sqlite/README.md # packages/session-persistence/session-persistence-sqlite/src/index.ts # packages/session-persistence/session-persistence-sqlite/src/schema.ts # packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts # packages/session-persistence/session-persistence/README.md # packages/session-persistence/session-persistence/package.json # packages/session-query/README.md # packages/session-query/session-query/README.md # packages/session-query/session-query/package.json # packages/session-query/session-query/src/config.ts # packages/session-query/session-query/src/index.ts # packages/session-query/session-query/src/types.ts # pnpm-lock.yaml # scripts/gen-doc-graphs.ts # scripts/type-equiv.manifest.json # tsconfig.host.json # tsconfig.json
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# session-query/ — session retrieval capability family
|
||||
|
||||
Trusted exact reads, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs.
|
||||
Trusted exact reads, relationship traces, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus reads, semantic extraction/filtering, and the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` |
|
||||
| [`session-query/`](session-query/README.md) | Logical-corpus title, event, lineage, relationship, and semantic-filter reads plus the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` |
|
||||
| [`session-query-sqlite/`](session-query-sqlite/README.md) | SQLite FTS5 search with persistent bases and live overlays | `ctx.sessionSearch` |
|
||||
|
||||
The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator.
|
||||
The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator.
|
||||
|
||||
@@ -38,6 +38,10 @@ Abort signals stop queued work and caller waits around asynchronous source obser
|
||||
|
||||
None, as this trusted search backend returns hits only to callers and registers no model-facing prompt, schema, tool, or message.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No caller authorization** — this is a trusted context-wide service; a model tool or UI must enforce its own access policy.
|
||||
|
||||
@@ -11,21 +11,27 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
@@ -37,10 +43,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ interface SearchRow {
|
||||
cwd: string | null
|
||||
parent_session: string | null
|
||||
seed_length: number | null
|
||||
delegation_depth: number | null
|
||||
live: number
|
||||
persisted: number
|
||||
seq: number
|
||||
@@ -486,8 +487,8 @@ export class SessionSearchSqlite extends SessionSearchService {
|
||||
const db = this._requireDb()
|
||||
db.prepare(`
|
||||
INSERT INTO persisted_sessions
|
||||
(id, version, created_at, cwd, parent_session, seed_length, revision, generation)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
entry.header.id,
|
||||
entry.header.version,
|
||||
@@ -495,6 +496,7 @@ export class SessionSearchSqlite extends SessionSearchService {
|
||||
entry.header.cwd ?? null,
|
||||
entry.header.parentSession ?? null,
|
||||
entry.header.seedLength ?? null,
|
||||
entry.header.delegationDepth ?? null,
|
||||
revision,
|
||||
generation,
|
||||
)
|
||||
@@ -521,8 +523,8 @@ export class SessionSearchSqlite extends SessionSearchService {
|
||||
const db = this._requireDb()
|
||||
db.prepare(`
|
||||
INSERT INTO temp.live_sessions
|
||||
(id, version, created_at, cwd, parent_session, seed_length, fingerprint, generation)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, generation)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
entry.header.id,
|
||||
entry.header.version,
|
||||
@@ -530,6 +532,7 @@ export class SessionSearchSqlite extends SessionSearchService {
|
||||
entry.header.cwd ?? null,
|
||||
entry.header.parentSession ?? null,
|
||||
entry.header.seedLength ?? null,
|
||||
entry.header.delegationDepth ?? null,
|
||||
entry.fingerprint,
|
||||
generation,
|
||||
)
|
||||
@@ -673,6 +676,7 @@ function selectedDocumentsSql(): { sql: string } {
|
||||
ps.cwd AS cwd,
|
||||
ps.parent_session AS parent_session,
|
||||
ps.seed_length AS seed_length,
|
||||
ps.delegation_depth AS delegation_depth,
|
||||
0 AS live,
|
||||
1 AS persisted,
|
||||
CAST(pd.seq AS INTEGER) AS seq,
|
||||
@@ -694,6 +698,7 @@ function selectedDocumentsSql(): { sql: string } {
|
||||
ls.cwd AS cwd,
|
||||
ls.parent_session AS parent_session,
|
||||
ls.seed_length AS seed_length,
|
||||
ls.delegation_depth AS delegation_depth,
|
||||
1 AS live,
|
||||
CASE WHEN ? = 1 AND EXISTS (
|
||||
SELECT 1 FROM persisted_sessions AS ps WHERE ps.id = ld.session_id
|
||||
@@ -792,6 +797,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
|
||||
&& a.cwd === b.cwd
|
||||
&& a.parentSession === b.parentSession
|
||||
&& a.seedLength === b.seedLength
|
||||
&& (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
|
||||
}
|
||||
|
||||
function rowHeader(row: SearchRow): SessionHeader {
|
||||
@@ -802,6 +808,7 @@ function rowHeader(row: SearchRow): SessionHeader {
|
||||
...row.cwd === null ? {} : { cwd: row.cwd },
|
||||
...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId },
|
||||
...row.seed_length === null ? {} : { seedLength: row.seed_length },
|
||||
...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
packages/session-query/session-query-sqlite/src/invariant.ts
Normal file
30
packages/session-query/session-query-sqlite/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-query-sqlite`.
|
||||
* @module @deepseek-ai/dsh-session-query-sqlite/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-query-sqlite'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-query-sqlite-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: reconciliation, cursor generations, and derived-index
|
||||
* ownership are validated at each serialized query boundary.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -5,7 +5,7 @@ import { mkdir } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
/** Current derived-index schema version. Incompatible versions reset in place. */
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 2
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 3
|
||||
|
||||
/** SQLite application id protecting unrelated databases from derived resets. */
|
||||
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
|
||||
@@ -79,6 +79,7 @@ function ensurePersistentSchema(db: DatabaseSync): void {
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
delegation_depth INTEGER,
|
||||
revision TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL
|
||||
) STRICT
|
||||
@@ -107,6 +108,7 @@ function ensureTemporarySchema(db: DatabaseSync): void {
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
delegation_depth INTEGER,
|
||||
fingerprint TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL
|
||||
) STRICT
|
||||
|
||||
@@ -75,6 +75,10 @@ class TestPersistence extends SessionPersistence {
|
||||
static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined
|
||||
static failure: unknown
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
|
||||
this.entries = new Map()
|
||||
this.revisions = new Map()
|
||||
@@ -152,7 +156,9 @@ async function liveContext(config: ConstructorParameters<typeof SessionSearchSql
|
||||
describe('SQLite session search', () => {
|
||||
it('searches two-character Unicode61 tokens in live-only sessions', async () => {
|
||||
const ctx = await liveContext({ path: ':memory:', snippetChars: 20 })
|
||||
const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: '/work', createdAt: 10, seedLength: 1 } })
|
||||
const session = ctx.sessions.create(SessionId('live'), {
|
||||
meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2 },
|
||||
})
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'An AI helper' }], source: { kind: 'user' } },
|
||||
@@ -171,7 +177,7 @@ describe('SQLite session search', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } },
|
||||
{ type: 'user/message', seq: 2, time: 12, data: { content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
{ type: 'user/message', seq: 2, time: 12, data: { content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'needle failure' } } },
|
||||
]
|
||||
ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
|
||||
@@ -779,11 +785,14 @@ describe('SQLite reconciliation and source lifecycle', () => {
|
||||
})
|
||||
|
||||
it('rejects immutable header conflicts between live and persisted sources', async () => {
|
||||
const shared = header('conflict', 10)
|
||||
const shared = header('conflict', 10, { delegationDepth: 1 })
|
||||
TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
ctx.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 11 } })
|
||||
ctx.sessions.create(shared.id, {
|
||||
seed: messageEvents('live needle'),
|
||||
meta: { createdAt: 10, delegationDepth: 2 },
|
||||
})
|
||||
|
||||
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../session-query"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
# @deepseek-ai/dsh-session-query
|
||||
|
||||
Session-history query contracts and provider-independent helpers. The concrete `ctx.sessionQuery` service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus for exact reads and semantic scans. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry.
|
||||
Exact session-history retrieval, relationship tracing, and provider-independent filtering 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`. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry.
|
||||
|
||||
## Reads
|
||||
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
|
||||
- `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.
|
||||
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
|
||||
- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title.
|
||||
- `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. 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. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles.
|
||||
|
||||
## Filtering and extraction
|
||||
|
||||
@@ -26,6 +30,8 @@ The package has no provider coordinator or registration protocol. A concrete bac
|
||||
|
||||
`SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts).
|
||||
|
||||
`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`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Contract |
|
||||
@@ -36,7 +42,11 @@ The package has no provider coordinator or registration protocol. A concrete bac
|
||||
|
||||
None, as this trusted query service returns cloned session records only to its callers and registers no model-facing prompt, schema, tool, or message.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No caller authorization** — this is trusted context-wide infrastructure; a future model tool or UI must constrain which sessions its caller may inspect.
|
||||
- **No traversal or provider registry** — lineage/provenance traversal, extractor and search-provider registries, index synchronization, and a model-facing tool are absent. SQLite ownership and tokenizer decisions are recorded in the [implemented search RFC](../../../docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
- **No registries or model-facing tool** — extractor and search-provider registries, recursive event-provenance traversal, and a model-facing tool are absent. The [tracing decision](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md) owns relationship semantics; SQLite ownership and tokenizer decisions live in the [implemented search note](../../../.agents/notes/implemented/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",
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,8 +28,10 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -38,8 +45,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ 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 session reads and search. */
|
||||
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
|
||||
export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_ABORTED'
|
||||
| 'SESSION_QUERY_EVENT_NOT_FOUND'
|
||||
@@ -21,6 +21,7 @@ export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_INVALID_FILTER'
|
||||
| 'SESSION_QUERY_INVALID_LIMIT'
|
||||
| 'SESSION_QUERY_INVALID_QUERY'
|
||||
| 'SESSION_QUERY_INVALID_LINEAGE'
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
|
||||
@@ -66,7 +66,7 @@ function classifySurface(events: readonly SessionEvent[]): Map<number, SessionEv
|
||||
)
|
||||
}
|
||||
const result = new Map<number, SessionEventSurface>()
|
||||
for (const node of folded.nodes) result.set(node.seq, 'current')
|
||||
for (const seq of folded.nodes) result.set(seq, 'current')
|
||||
for (const replacement of folded.replacements) {
|
||||
for (const seq of replacement.shadowedSeqs) result.set(seq, 'shadowed')
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export function extractSessionEventText(event: SessionEvent): string {
|
||||
case 'step/end':
|
||||
case 'assistant/chunk':
|
||||
case 'request/header':
|
||||
case 'request/header-delta':
|
||||
return ''
|
||||
// SessionEventMap is merge-extensible. Unknown events remain
|
||||
// non-searchable until a concrete first-party consumer defines semantics.
|
||||
@@ -48,9 +47,11 @@ export function extractSessionEventText(event: SessionEvent): string {
|
||||
function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
|
||||
switch (reason.kind) {
|
||||
case 'error':
|
||||
return joinText(['error', reason.message, reason.code ?? ''])
|
||||
return 'failure' in reason
|
||||
? joinText(['error', reason.failure.message, reason.failure.code])
|
||||
: joinText(['error', reason.message, reason.code ?? ''])
|
||||
case 'aborted':
|
||||
return joinText(['aborted', reason.reason ?? ''])
|
||||
return 'aborted'
|
||||
case 'rejected':
|
||||
return joinText(['rejected', reason.reason])
|
||||
case 'disposed':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -7,6 +7,8 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionEventResultFilter,
|
||||
SessionEventReadRequest,
|
||||
@@ -14,13 +16,17 @@ import type {
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchDocument,
|
||||
SessionEventSearchRequest,
|
||||
SessionEventTrace,
|
||||
SessionEventTraceRequest,
|
||||
SessionEventWindow,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
SessionResultFilter,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
SessionSearchPage,
|
||||
SessionSearchRequest,
|
||||
SessionSurfaceSnapshot,
|
||||
} from './types.ts'
|
||||
import {
|
||||
SESSION_QUERY_READ_WINDOW_MAX,
|
||||
@@ -28,13 +34,14 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import { SessionCorpus } from './corpus.ts'
|
||||
import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
|
||||
import { buildSessionEventSearchDocuments } from './documents.ts'
|
||||
import {
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
} from './filters.ts'
|
||||
import * as tracing from './tracing.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export { SessionSearchCursor } from './cursor.ts'
|
||||
@@ -92,7 +99,7 @@ export abstract class SessionSearchService extends Service {
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>>
|
||||
}
|
||||
|
||||
/** Live-preferred logical-corpus and exact-event read service. */
|
||||
/** Live-preferred logical-corpus read, filtering, and relationship-tracing service. */
|
||||
export class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -132,6 +139,16 @@ export class SessionQueryService extends Service {
|
||||
return this._filterSessions(ownedFilters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest log-backed title from one live-preferred logical session.
|
||||
* @param sessionId - live or persisted session id to read.
|
||||
* @returns latest title snapshot, or `undefined` when the log has no title event.
|
||||
*/
|
||||
async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return foldSessionTitle(loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* List lightweight raw-log event records for one logical session.
|
||||
* @param sessionId - live-preferred session id to read.
|
||||
@@ -139,7 +156,7 @@ export class SessionQueryService extends Service {
|
||||
*/
|
||||
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return buildSessionEventRecords(sessionId, loaded.events)
|
||||
return tracing.eventRecords(sessionId, loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +186,43 @@ export class SessionQueryService extends Service {
|
||||
return filterSessionEventDocuments(documents, filters)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns a complete lineage or an explicit unresolved parent boundary.
|
||||
* @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
|
||||
*/
|
||||
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
|
||||
const records = await this._corpus.listSessions()
|
||||
return tracing.traceSession(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.
|
||||
* @throws when source resolution fails, the target is absent, or surface/provenance validation fails.
|
||||
*/
|
||||
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace> {
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
return tracing.traceEvent(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.
|
||||
|
||||
30
packages/session-query/session-query/src/invariant.ts
Normal file
30
packages/session-query/session-query/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-query`.
|
||||
* @module @deepseek-ai/dsh-session-query/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-query'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-query-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: query results are immutable per-call projections whose lineage and event
|
||||
* relations are validated while they are built; the service retains no observable result state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -16,6 +16,7 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade
|
||||
|| a.cwd !== b.cwd
|
||||
|| a.parentSession !== b.parentSession
|
||||
|| a.seedLength !== b.seedLength
|
||||
|| (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0)
|
||||
) {
|
||||
throw new SessionQueryError(
|
||||
`session source headers conflict for session "${a.id}"`,
|
||||
|
||||
248
packages/session-query/session-query/src/tracing.ts
Normal file
248
packages/session-query/session-query/src/tracing.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/** One-shot session-lineage and event-relationship tracing helpers. */
|
||||
|
||||
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,
|
||||
SessionEventTrace,
|
||||
SessionLineageNode,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
} from './types.ts'
|
||||
|
||||
interface EventLogAnalysis {
|
||||
records: SessionEventRecord[]
|
||||
replacedBy: Map<number, number>
|
||||
replacedEventSeqs: Map<number, number[]>
|
||||
currentSeqs: 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param events - detached raw event log.
|
||||
* @param seq - target event seq.
|
||||
* @returns direct surface and provenance relationships.
|
||||
*/
|
||||
export function traceEvent(
|
||||
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)
|
||||
|
||||
const replacementChain: number[] = []
|
||||
let replacement = analysis.replacedBy.get(seq)
|
||||
while (replacement !== undefined) {
|
||||
replacementChain.push(replacement)
|
||||
replacement = analysis.replacedBy.get(replacement)
|
||||
}
|
||||
|
||||
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: [...eventSources(target)],
|
||||
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 traceSession(
|
||||
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)) {
|
||||
throw new SessionQueryError(
|
||||
`session lineage contains a cycle at "${parentId}"`,
|
||||
'SESSION_QUERY_INVALID_LINEAGE',
|
||||
)
|
||||
}
|
||||
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((a, b) => a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id))
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
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) {
|
||||
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'
|
||||
: replacedBy.has(event.seq) ? 'shadowed' : 'log-only',
|
||||
})),
|
||||
replacedBy,
|
||||
replacedEventSeqs,
|
||||
currentSeqs: [...folded.nodes],
|
||||
}
|
||||
}
|
||||
|
||||
function eventSources(event: SessionEvent): readonly number[] {
|
||||
return (event as SessionEvent<SurfaceEventType>).sourceEventSeqs ?? []
|
||||
}
|
||||
|
||||
function buildDescendants(
|
||||
childrenByParent: ReadonlyMap<SessionId, readonly SessionRecord[]>,
|
||||
sessionId: SessionId,
|
||||
): SessionLineageNode[] {
|
||||
const descendants: SessionLineageNode[] = []
|
||||
const stack = [{ sessionId, descendants }]
|
||||
while (stack.length > 0) {
|
||||
// The length guard proves a frame exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const frame = stack.pop()!
|
||||
const nodes: SessionLineageNode[] = []
|
||||
for (const child of childrenByParent.get(frame.sessionId) ?? []) {
|
||||
const node = { session: cloneRecord(child), descendants: [] }
|
||||
nodes.push(node)
|
||||
frame.descendants.push(node)
|
||||
}
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
// The loop bounds prove this indexed node exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const node = nodes[index]!
|
||||
stack.push({ sessionId: node.session.header.id, descendants: node.descendants })
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
function cloneRecord(record: SessionRecord): SessionRecord {
|
||||
return { ...record, header: structuredClone(record.header) }
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionEventType,
|
||||
SessionHeader,
|
||||
SessionId,
|
||||
SurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SessionSearchCursor } from './cursor.ts'
|
||||
|
||||
export type { SessionSearchCursor } from './cursor.ts'
|
||||
@@ -22,6 +29,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. */
|
||||
@@ -36,6 +53,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. */
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('session-query semantic extraction', () => {
|
||||
]
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent, provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
|
||||
{ type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' },
|
||||
{ type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } },
|
||||
@@ -73,7 +73,7 @@ describe('session-query semantic extraction', () => {
|
||||
const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
|
||||
[{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'],
|
||||
[{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'],
|
||||
[{ kind: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'],
|
||||
[{ kind: 'error', step: 2, failure: { message: 'provider boom', code: 'SERVER' } }, 'error\nprovider boom\nSERVER'],
|
||||
[{ kind: 'aborted' }, 'aborted'],
|
||||
[{ kind: 'rejected', reason: 'denied' }, 'rejected\ndenied'],
|
||||
[{ kind: 'disposed' }, 'disposed'],
|
||||
@@ -90,11 +90,10 @@ describe('session-query semantic extraction', () => {
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
|
||||
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { model: 'test' } }, reason: 'initial' } },
|
||||
{ type: 'request/header-delta', seq: 5, time: 1, data: {} },
|
||||
{ type: 'future/event', seq: 6, time: 1, data: { text: 'hidden' } } as never,
|
||||
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' } },
|
||||
{ type: 'future/event', seq: 5, time: 1, data: { text: 'hidden' } } as never,
|
||||
]
|
||||
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '', ''])
|
||||
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', ''])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +101,7 @@ describe('session-query document and filter helpers', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
|
||||
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
|
||||
]
|
||||
|
||||
@@ -173,7 +172,7 @@ describe('session-query document and filter helpers', () => {
|
||||
type: 'assistant/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }] },
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }], provenance: { provider: 'mock', model: 'mock' } },
|
||||
surfaceOp: { op: 'replace', start: 9, end: 9 },
|
||||
}]
|
||||
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
@@ -7,6 +7,7 @@ import SessionQueryService, {
|
||||
type SessionEventSurface,
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
@@ -35,6 +36,10 @@ class TestPersistence extends SessionPersistence {
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
@@ -90,6 +95,58 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
}
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('reads the latest title from one live-preferred or persisted log without widening listSessions', async () => {
|
||||
const persistedHeader = header('persisted-title', 2)
|
||||
const sharedHeader = header('shared-title', 3)
|
||||
TestPersistence.reset([
|
||||
{
|
||||
meta: persistedHeader,
|
||||
events: [{
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time: 20,
|
||||
data: {
|
||||
title: 'Persisted title',
|
||||
messageSeqs: [4],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
meta: sharedHeader,
|
||||
events: [{
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time: 30,
|
||||
data: {
|
||||
title: 'Stale durable title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } })
|
||||
shared.append('session/title', {
|
||||
title: 'Live title',
|
||||
messageSeqs: [7],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('query-test'),
|
||||
},
|
||||
})
|
||||
await ctx.plugin(TestPersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.readTitle(persistedHeader.id)).resolves.toMatchObject({
|
||||
title: 'Persisted title', eventSeq: 0, updatedAt: 20,
|
||||
})
|
||||
await expect(ctx.sessionQuery.readTitle(shared.id)).resolves.toMatchObject({
|
||||
title: 'Live title', eventSeq: 0,
|
||||
})
|
||||
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
|
||||
})
|
||||
|
||||
it('lists live sessions deterministically and returns detached headers', async () => {
|
||||
const ctx = await liveContext()
|
||||
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
|
||||
@@ -138,6 +195,8 @@ describe('session-query exact reads', () => {
|
||||
it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('surface'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const first = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } },
|
||||
@@ -150,17 +209,76 @@ describe('session-query exact reads', () => {
|
||||
})
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq } },
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
|
||||
)
|
||||
|
||||
expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface))
|
||||
expect((await ctx.sessionQuery.listEvents(session.id)).slice(2).map(record => record.surface))
|
||||
.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' } })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
for (const text of ['one', 'two', 'three']) {
|
||||
session.append(
|
||||
'user/message',
|
||||
@@ -169,14 +287,14 @@ describe('session-query exact reads', () => {
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 })
|
||||
expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1])
|
||||
const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 2, before: 1, after: 1 })
|
||||
expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([1, 3, 2])
|
||||
expect(result.session).toEqual(session.header)
|
||||
Object.assign(result.session, { createdAt: -1 })
|
||||
if (result.events[0]?.type !== 'user/message') throw new Error('expected user message')
|
||||
result.events[0].data.content = []
|
||||
expect(session.header.createdAt).not.toBe(-1)
|
||||
expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1)
|
||||
expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1)
|
||||
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
|
||||
@@ -198,6 +316,7 @@ describe('session-query exact reads', () => {
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } })
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
|
||||
@@ -207,15 +326,24 @@ describe('session-query exact reads', () => {
|
||||
|
||||
expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted]))
|
||||
.toEqual([[shared.id, true, true], [durable.id, false, true]])
|
||||
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 })
|
||||
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 1 })
|
||||
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' }
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/same', delegationDepth: 1 }
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
await persistence.dispose()
|
||||
await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([
|
||||
{ header: shared, live: true, persisted: false },
|
||||
@@ -226,6 +354,7 @@ describe('session-query exact reads', () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(SessionId('live'))
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } },
|
||||
@@ -235,8 +364,8 @@ describe('session-query exact reads', () => {
|
||||
TestPersistence.listFailure = new Error('list unavailable')
|
||||
TestPersistence.loadFailure = new Error('load unavailable')
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1)
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } })
|
||||
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2)
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } })
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
@@ -265,17 +394,8 @@ describe('session-query exact reads', () => {
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
})
|
||||
|
||||
it('turns malformed surfaces and direct invalid config into typed errors', async () => {
|
||||
it('turns persisted malformed surfaces and direct invalid config into typed errors', 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 } },
|
||||
)
|
||||
await expect(ctx.sessionQuery.listEvents(session.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
const persisted = header('bad-persisted-surface')
|
||||
TestPersistence.reset([{
|
||||
meta: persisted,
|
||||
|
||||
435
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
435
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
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'
|
||||
|
||||
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
|
||||
|
||||
/** Test-only mutable view used to verify detached returned metadata. */
|
||||
function mutableHeader(value: SessionHeader): MutableSessionHeader {
|
||||
return value
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
return 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)
|
||||
}
|
||||
|
||||
listSnapshots(): Promise<never[]> {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
}
|
||||
|
||||
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('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
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: [2] },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] },
|
||||
)
|
||||
session.append(
|
||||
'context/message',
|
||||
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [2, 4] },
|
||||
)
|
||||
}
|
||||
|
||||
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')])
|
||||
|
||||
mutableHeader(trace.target.header).createdAt = 99
|
||||
mutableHeader(trace.ancestors[0]!.header).createdAt = 99
|
||||
mutableHeader(trace.root.header).createdAt = 99
|
||||
mutableHeader(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'))
|
||||
})
|
||||
|
||||
it('constructs deeply nested descendants without consuming the JavaScript call stack', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('deep-0'), { meta: { createdAt: 0 } })
|
||||
let parent = root
|
||||
for (let depth = 1; depth < 3_000; depth += 1) {
|
||||
parent = ctx.sessions.create(SessionId(`deep-${depth}`), {
|
||||
meta: { createdAt: depth, parentSession: parent.id },
|
||||
})
|
||||
}
|
||||
|
||||
const trace = await ctx.sessionQuery.traceSession(root.id)
|
||||
expect(trace.complete).toBe(true)
|
||||
let node = trace.descendants[0]
|
||||
for (let depth = 1; depth < 3_000; depth += 1) {
|
||||
if (node === undefined) throw new Error(`lineage ended before depth ${depth}`)
|
||||
if (depth === 2_999) expect(node.session.header.id).toBe(SessionId('deep-2999'))
|
||||
node = node.descendants[0]
|
||||
}
|
||||
expect(node).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
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: 3 })
|
||||
expect(original.target).toMatchObject({
|
||||
sessionId: session.id,
|
||||
seq: 3,
|
||||
type: 'user/message',
|
||||
surface: 'shadowed',
|
||||
})
|
||||
expect(original).toMatchObject({
|
||||
replacedBy: 4,
|
||||
replacementChain: [4, 8],
|
||||
replacedEventSeqs: [],
|
||||
sourceEventSeqs: [2],
|
||||
derivedEventSeqs: [4],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }))
|
||||
.resolves.toMatchObject({
|
||||
replacedBy: 8,
|
||||
replacementChain: [8],
|
||||
replacedEventSeqs: [3],
|
||||
sourceEventSeqs: [3, 2],
|
||||
derivedEventSeqs: [8],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }))
|
||||
.resolves.toMatchObject({
|
||||
target: { surface: 'log-only' },
|
||||
replacementChain: [],
|
||||
sourceEventSeqs: [],
|
||||
derivedEventSeqs: [3, 4, 8],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 8 }))
|
||||
.resolves.toMatchObject({
|
||||
replacementChain: [],
|
||||
replacedEventSeqs: [4],
|
||||
sourceEventSeqs: [2, 4],
|
||||
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: 4 })
|
||||
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: 4 })
|
||||
expect(repeated.target.time).not.toBe(-1)
|
||||
expect(repeated.replacementChain).toEqual([8])
|
||||
expect(repeated.replacedEventSeqs).toEqual([3])
|
||||
expect(repeated.sourceEventSeqs).toEqual([3, 2])
|
||||
expect(repeated.derivedEventSeqs).toEqual([8])
|
||||
})
|
||||
|
||||
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('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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: 1 }))
|
||||
.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 = () => {
|
||||
mutableHeader(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: [], provenance: { provider: 'mock', model: 'mock' } },
|
||||
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, []),
|
||||
]],
|
||||
['sparse sources', [
|
||||
appendEvent(0, Array<number>(1)),
|
||||
]],
|
||||
['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 an invalid surface log: %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_SURFACE'))
|
||||
})
|
||||
|
||||
it('rejects surfaceOp on a non-surface event as an invalid surface', async () => {
|
||||
const durable = header('invalid-non-surface-op')
|
||||
const events = [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
surfaceOp: 'append',
|
||||
}] 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_SURFACE'))
|
||||
})
|
||||
|
||||
it('applies the same surface contract to listEvents', 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))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
})
|
||||
@@ -26,8 +26,14 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user