Merge PR #224 updates into prose cleanup
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
* the derived LLM message history. Persistence is a plugin concern (subscribe
|
||||
* to `session/event`, drain on `session/flush`).
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
* @module @deepseek-ai/dsh-session
|
||||
*/
|
||||
|
||||
@@ -15,12 +14,12 @@ import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { isJsonValue } from './json.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { isJsonValue } from './json.ts'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceNode } from './surface.ts'
|
||||
@@ -35,25 +34,67 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A session was created in the store.
|
||||
* Dispatch uses the session's captured owner scope.
|
||||
* A session was created in the store. A synchronous listener throw vetoes
|
||||
* publication and rollback emits the matching `session/disposed` edge;
|
||||
* returned-promise rejection is observed and logged but cannot retroactively
|
||||
* veto this synchronous boundary. A synchronous listener that requests the
|
||||
* advanced detach does not remove the entry immediately: removal and the
|
||||
* paired `session/disposed` edge wait until the creation dispatch unwinds.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* @param session - the session just entered and announced.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget).
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
* A previously announced session left the store. Emitted exactly once on
|
||||
* normal detach or publication rollback, and never for a prepared/entered
|
||||
* session whose `session/created` announcement did not begin. Listener
|
||||
* failures (including returned-promise rejections) are logged and contained
|
||||
* per listener so teardown always reaches quiescence.
|
||||
* Scope-filtered dispatch uses the same owner carrier captured at entry;
|
||||
* agent-scoped listeners hear only their own session's teardown.
|
||||
* @param session - the session that is no longer live in the store.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget). This is
|
||||
* the per-append feed a UI or invariant plugin tails. The log push is the
|
||||
* commit point; synchronous throws and returned-promise rejections from
|
||||
* observers are logged and contained per listener, so they cannot make a
|
||||
* committed append appear to fail or starve later listeners. The exact
|
||||
* callback list and Cordis internal-dispatch checks resolve before the push;
|
||||
* callbacks themselves run only after it.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* @param session - the session whose log grew.
|
||||
* @param event - the appended event, exactly as recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
/**
|
||||
* Awaited durability checkpoint.
|
||||
*
|
||||
* Scope-filtered dispatch: keyed to the session's captured owner.
|
||||
* Awaited durability checkpoint. The agent loop awaits
|
||||
* `ctx.sessions.flush(session)` at every turn end; persistence
|
||||
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
|
||||
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
|
||||
* and the caller waits for all of them, but none can veto. Dispatch it
|
||||
* through {@link SessionStore.flush} — the store owns the carrier — never
|
||||
* via a raw `ctx.parallel`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @mode parallel
|
||||
*/
|
||||
@@ -80,6 +121,137 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
|
||||
]
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
: source
|
||||
const snapshot = snapshotJsonValue(input)
|
||||
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
|
||||
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
throw new Error('session header is not a plain JSON record')
|
||||
}
|
||||
const record = snapshot as Record<string, unknown>
|
||||
if (record.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
|
||||
}
|
||||
if (record.id !== id) {
|
||||
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
|
||||
}
|
||||
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
|
||||
throw new Error('session header createdAt must be a finite number')
|
||||
}
|
||||
if (record.cwd !== undefined) {
|
||||
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
|
||||
if (!isAbsolute(record.cwd)) {
|
||||
throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
|
||||
}
|
||||
}
|
||||
if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
|
||||
throw new Error('session header parentSession must be a string')
|
||||
}
|
||||
if (record.seedLength !== undefined
|
||||
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
|
||||
throw new Error('session header seedLength must be a non-negative safe integer')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
|
||||
function assertSurfaceMetadataShape(
|
||||
type: string,
|
||||
surfaceOp: unknown,
|
||||
sourceEventSeqs: unknown,
|
||||
): void {
|
||||
const eligible = isSurfaceEligibleType(type)
|
||||
if (!eligible) {
|
||||
if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
|
||||
throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
if (surfaceOp !== 'append') {
|
||||
if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
|
||||
throw new Error(`session event "${type}" carries an invalid surfaceOp`)
|
||||
}
|
||||
const op = surfaceOp as Record<string, unknown>
|
||||
const keys = Object.keys(op)
|
||||
if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
|
||||
|| op['op'] !== 'replace'
|
||||
|| typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
|
||||
|| typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
|
||||
throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
|
||||
}
|
||||
}
|
||||
if (sourceEventSeqs !== undefined) {
|
||||
if (!Array.isArray(sourceEventSeqs)
|
||||
|| sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
|
||||
throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
|
||||
const event = value
|
||||
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
|
||||
if (Object.keys(event).some(key => !allowed.has(key))
|
||||
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
|
||||
|| !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
|
||||
|| !Number.isSafeInteger(event['seq']) || event['seq'] < 0
|
||||
|| !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
|
||||
|| !Number.isSafeInteger(event['time']) || event['time'] < 0
|
||||
|| !Object.hasOwn(event, 'data')) {
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
}
|
||||
|
||||
type SessionCallback = (...args: unknown[]) => unknown
|
||||
|
||||
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
|
||||
function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
|
||||
return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
|
||||
}
|
||||
|
||||
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
|
||||
function invokeContainedSessionObservers(
|
||||
ctx: Context,
|
||||
name: 'session/event' | 'session/disposed',
|
||||
id: SessionId,
|
||||
args: unknown[],
|
||||
callbacks: SessionCallback[],
|
||||
): void {
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** All mutable lifecycle state for one exact store entry. */
|
||||
interface SessionEntry {
|
||||
readonly id: SessionId
|
||||
readonly session: Session
|
||||
readonly carrier: Scoped<Session>
|
||||
readonly emitCtx: Context
|
||||
announced: boolean
|
||||
announcing: boolean
|
||||
appending: boolean
|
||||
detachRequested: boolean
|
||||
detach(): void
|
||||
}
|
||||
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
@@ -88,8 +260,6 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
/** Set by the store so appends are observable; undefined when detached. */
|
||||
onAppend: ((event: SessionEvent) => void) | undefined
|
||||
|
||||
/**
|
||||
* Derived surface — a cached linked list of message-producing events.
|
||||
@@ -107,43 +277,65 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable creation metadata (format version, cwd, lineage, seed boundary).
|
||||
* Supplied by the store via `ctx.sessions.create()`. When a `Session` is
|
||||
* constructed bare (tests, ad-hoc replay), a minimal header is synthesized
|
||||
* (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
|
||||
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
|
||||
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
* storage concern, not replayable conversation state.
|
||||
*/
|
||||
readonly header: SessionHeader
|
||||
|
||||
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
|
||||
constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
// Validate seed JSON and contiguous sequence numbers just as append would.
|
||||
seed.forEach((event, index) => {
|
||||
if (event.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
// live log that no persistence backend could store: each event's `data`
|
||||
// must be JSON-serializable, and `seq` must be contiguous from 0 (the
|
||||
// `seq = log.length` contract the whole system relies on). Without this,
|
||||
// a bad seed would surface only later as a backend rejection or a silent
|
||||
// divergence between the live log and disk.
|
||||
this.log = Array.from(seed, (source, index) => {
|
||||
// The seed is a persistence/replay boundary: validate and detach the
|
||||
// complete event in one lossless-JSON pass.
|
||||
const snapshot = snapshotJsonValue(source)
|
||||
if (snapshot === undefined) {
|
||||
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
|
||||
}
|
||||
if (!isJsonValue(event.data)) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
|
||||
assertSessionEventEnvelope(snapshot, index)
|
||||
if (snapshot.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
}
|
||||
// Seed events bypass append's overloads, so enforce surface markers at runtime.
|
||||
if (isSurfaceEligibleType(event.type)
|
||||
&& (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`)
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
|
||||
// the sole source of derived history, so a marker-less message event
|
||||
// would load fine yet vanish from deriveMessages(). `append` enforces
|
||||
// this at compile time via its typed overload; a seed arrives as raw
|
||||
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
|
||||
// runtime here rather than silently resuming with empty history.
|
||||
const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
|
||||
try {
|
||||
assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
|
||||
}
|
||||
return deepFreeze(snapshot)
|
||||
})
|
||||
// Clone seed events so callers cannot mutate the durable log after validation.
|
||||
this.log = seed.map(event => structuredClone(event))
|
||||
}
|
||||
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
}
|
||||
|
||||
/** Cached immutable public snapshot of the private append-only log. */
|
||||
private eventsSnapshot: readonly SessionEvent[] | undefined
|
||||
|
||||
/**
|
||||
* The append-only event log, exposed live by reference (readonly-typed, not
|
||||
* a snapshot): later appends are visible through the same array.
|
||||
* An immutable snapshot of the append-only event log. The snapshot is reused
|
||||
* until the next append; a previously returned array does not grow later.
|
||||
* Events and their nested data are deep-frozen at acceptance, so neither a
|
||||
* cast nor ordinary JavaScript can rewrite durable history.
|
||||
*/
|
||||
get events(): readonly SessionEvent[] {
|
||||
return this.log
|
||||
this.eventsSnapshot ??= Object.freeze([...this.log])
|
||||
return this.eventsSnapshot
|
||||
}
|
||||
|
||||
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
|
||||
@@ -152,43 +344,91 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one typed event to the log and synchronously notify observers via `onAppend`. The
|
||||
* hot path never blocks on I/O — persistence plugins buffer asynchronously.
|
||||
* Append one typed event to the log and synchronously notify observers via
|
||||
* the store-owned, module-private publication hooks. The hot path never blocks
|
||||
* on I/O — persistence plugins buffer asynchronously. Once the event enters
|
||||
* the log, the append is committed: observer failures are logged and
|
||||
* contained per listener, so they do not change the return value or prevent
|
||||
* later listeners from observing the same accepted event.
|
||||
*
|
||||
* @param type - The event type (key of {@link SessionEventMap}).
|
||||
* @param data - The event payload; must be JSON-serializable.
|
||||
* @param opts - required surface placement and optional provenance for message-producing events.
|
||||
* @returns the event with assigned sequence, time, and snapshotted data.
|
||||
* @throws if data is not losslessly JSON-serializable or surface placement is missing.
|
||||
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
|
||||
* the surface linked list; `sourceEventSeqs` records provenance (the seq
|
||||
* numbers of events this one derives from). REQUIRED for
|
||||
* {@link SurfaceEventType} events (every message-producing event must
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
* `data` that entered the log, so reading `event.data` back sees the logged
|
||||
* value, never the caller's still-mutable input.
|
||||
* @throws if `type` is not a string, or if `data` or surface metadata is not
|
||||
* losslessly JSON-serializable
|
||||
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
|
||||
* circular reference, sparse array, or an exotic object such as
|
||||
* Map/Set/Date/class instance). One recursive pass reads, validates, and
|
||||
* copies each nested value once, so a stateful getter cannot supply one value
|
||||
* to validation and another to storage. The event log is the durable source
|
||||
* of truth, so a bad event fails at the append site rather than later during
|
||||
* a backend flush. A synchronous internal dispatch validation failure or an
|
||||
* append reentered while this acceptance/publication boundary is open also
|
||||
* rejects before the log changes.
|
||||
*/
|
||||
append<T extends SessionEventType>(
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
|
||||
): SessionEvent<T> {
|
||||
if (!isJsonValue(data)) {
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
const surfaceMetadata = {
|
||||
...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
|
||||
...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
|
||||
}
|
||||
const dataSnapshot = snapshotJsonValue(data)
|
||||
if (dataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
}
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
// Recheck the conditional overload when `T` has widened to the full union.
|
||||
if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
// Snapshot caller-owned data and metadata before they enter durable history.
|
||||
// The generic conditional spreads require an internal union-boundary cast.
|
||||
const event = {
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: structuredClone(data),
|
||||
...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {},
|
||||
...surfaceOpts?.surfaceOp !== undefined ? {
|
||||
surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp),
|
||||
} : {},
|
||||
} as unknown as SessionEvent<T>
|
||||
this.log.push(event as unknown as SessionEvent)
|
||||
this.onAppend?.(event as unknown as SessionEvent)
|
||||
return event
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
|
||||
const entry = attachments.get(this)
|
||||
if (entry?.appending) {
|
||||
throw new Error('session append cannot reenter while another append is being published')
|
||||
}
|
||||
if (entry !== undefined) entry.appending = true
|
||||
try {
|
||||
const event = deepFreeze({
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>)
|
||||
let callbacks: SessionCallback[] | undefined
|
||||
const callbackArgs: unknown[] = [this, event]
|
||||
if (entry !== undefined) {
|
||||
callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
|
||||
}
|
||||
this.log.push(event as SessionEvent)
|
||||
this.eventsSnapshot = undefined
|
||||
if (callbacks !== undefined && entry !== undefined) {
|
||||
invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
|
||||
}
|
||||
return event
|
||||
} finally {
|
||||
if (entry !== undefined) {
|
||||
entry.appending = false
|
||||
if (entry.detachRequested && !entry.announcing) entry.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached fold of the request-header events — see {@link requestHeader}. */
|
||||
@@ -224,9 +464,21 @@ export class Session {
|
||||
private derivedGeneration = 0
|
||||
|
||||
/**
|
||||
* Derive the LLM message history by walking the session surface — the linked list of
|
||||
* message-producing events maintained by `surfaceOp` markers.
|
||||
* Derive the LLM message history by walking the session surface — the linked
|
||||
* list of message-producing events maintained by `surfaceOp` markers. The
|
||||
* surface is the single source of derived history: every message-producing
|
||||
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
|
||||
* turn boundary) is correctly absent, and a compaction `replace` deletes the
|
||||
* shadowed nodes from the derivation. The projection rules are
|
||||
* {@link deriveEventMessage}, folded per node.
|
||||
*
|
||||
* CACHED: each surface node is projected exactly once, when first seen — a
|
||||
* call costs O(new nodes), and a surface rewrite (a `replace`;
|
||||
* {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
|
||||
* a fresh snapshot per call (later appends never grow an array a caller
|
||||
* already holds); the `Message` objects in it are SHARED and **deep-frozen**.
|
||||
* Their content reuses the already frozen durable event data, so the cache
|
||||
* needs no second deep clone and consumers still cannot mutate the log.
|
||||
* @returns a fresh array of the shared, frozen derived history.
|
||||
*/
|
||||
deriveMessages(): Message[] {
|
||||
@@ -252,10 +504,16 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a single event into the LLM message it derives to, or null when it produces none —
|
||||
* a non-surface event (chunk, boundary, log-only record) or an empty-content
|
||||
* assistant/message (which exists only to host usage).
|
||||
*
|
||||
* Project a single event into the LLM message it derives to, or null when
|
||||
* it produces none — a non-surface event (chunk, boundary, log-only record)
|
||||
* or an empty-content assistant/message (which exists only to host usage).
|
||||
* The per-node pure function {@link deriveMessages} folds over the surface;
|
||||
* an external reconstructor (or the dev invariant) folds the same function
|
||||
* over a log prefix's surface to rebuild the exact messages any request was
|
||||
* built from (the reconstructability RFC). The returned message wrapper is
|
||||
* fresh; its content reuses the logged event's already deep-frozen durable
|
||||
* data, so changing the wrapper cannot rewrite the log and changing content
|
||||
* throws.
|
||||
* @param event - the event to project.
|
||||
* @returns the derived message, or null when the event produces none.
|
||||
*/
|
||||
@@ -266,29 +524,29 @@ export class Session {
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
return { role: 'user', content: structuredClone(event.data.content) }
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
// Skip an empty-content assistant/message: it exists only to host a
|
||||
// max-tokens step's usage and must not inject a content-less assistant
|
||||
// turn into the provider transcript.
|
||||
if (event.data.content.length === 0) return null
|
||||
return { role: 'assistant', content: structuredClone(event.data.content) }
|
||||
return { role: 'assistant', content: event.data.content }
|
||||
}
|
||||
case 'tool/result': {
|
||||
const { callId, content, isError } = event.data
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
|
||||
return { role: 'user', content: renderTagged('context', content, source) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
|
||||
return { role: 'user', content: renderTagged('steering', content, source) }
|
||||
}
|
||||
default:
|
||||
// A non-surface event (boundary, chunk, log-only record) projects to
|
||||
@@ -331,15 +589,7 @@ export class SessionForkError extends Error {
|
||||
* subscribe to `session/event` and flush on `session/flush` / dispose.
|
||||
*/
|
||||
export class SessionStore extends Service {
|
||||
private store = new Map<SessionId, Session>()
|
||||
/**
|
||||
* Each live session's dispatch carrier, captured at {@link enter} from the
|
||||
* ENTERING context's scope tag (an agent session is entered through
|
||||
* `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒
|
||||
* subject-less carrier). WeakMap so a detached session drops its carrier
|
||||
* with the entry.
|
||||
*/
|
||||
private carriers = new WeakMap<Session, Scoped<Session>>()
|
||||
private store = new Map<SessionId, SessionEntry>()
|
||||
private counter = 0
|
||||
|
||||
constructor(ctx: Context) {
|
||||
@@ -347,15 +597,32 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, enter, and announce a session owned by the calling fiber.
|
||||
* Create a session owned by the calling fiber: disposing that fiber stops
|
||||
* event notification and removes the session from the store. `options.seed`
|
||||
* populates the session with a copy of those events (replay/fork);
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`,
|
||||
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
|
||||
* fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
* — fold the session lifecycle into the agent's own effect via
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see
|
||||
* `dsh-agent-loop`'s creation transaction).
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - optional seed and header metadata.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the live session, already entered and announced.
|
||||
* @throws if the id exists or cwd is not absolute.
|
||||
* @throws if a session with `id` already exists, metadata is not a plain
|
||||
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
||||
* non-absolute path (storage backends key directories off it).
|
||||
*/
|
||||
create(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
const session = this.prepare(id, options)
|
||||
// Yield detach before announcement so listener failure rolls back entry.
|
||||
// Single effect owned by the calling fiber. Yield the detach BEFORE
|
||||
// announcing so a throwing `session/created` listener rolls the attach back
|
||||
// (the generator effect disposes already-yielded disposers on a throw)
|
||||
// instead of leaking the store entry and its publication hooks.
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
yield this.enter(session)
|
||||
this.announce(session)
|
||||
@@ -364,68 +631,159 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a session WITHOUT entering it into the store — validate the id/cwd and construct the
|
||||
* {@link Session} (with its immutable {@link SessionHeader}).
|
||||
* Build a session WITHOUT entering it into the store — validate the id/cwd and
|
||||
* construct the {@link Session} (with its immutable {@link SessionHeader}).
|
||||
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
|
||||
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
||||
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
||||
* chain rather than as racing sibling effects — which would remove the publication hooks
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the constructed session, NOT yet in the store.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* @throws if a session with `id` already exists, metadata is not a plain
|
||||
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
||||
* non-absolute path.
|
||||
*/
|
||||
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
const sessionId = SessionId(id ?? `session-${++this.counter}`)
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const cwd = options?.meta?.cwd
|
||||
if (cwd !== undefined && !isAbsolute(cwd)) {
|
||||
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
|
||||
let sessionId: SessionId
|
||||
if (id === undefined) {
|
||||
do sessionId = SessionId(`session-${++this.counter}`)
|
||||
while (this.store.has(sessionId))
|
||||
} else {
|
||||
sessionId = SessionId(id)
|
||||
}
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const seed = options?.seed
|
||||
const meta = options?.meta
|
||||
const header: SessionHeader = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: sessionId,
|
||||
createdAt: options?.meta?.createdAt ?? Date.now(),
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
|
||||
...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {},
|
||||
createdAt: meta?.createdAt ?? Date.now(),
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
}
|
||||
return new Session(sessionId, options?.seed, header)
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a {@link prepare}d session into the store: wire `onAppend` → `session/event` and
|
||||
* add it to the store.
|
||||
* Enter a {@link prepare}d session into the store: install the module-private
|
||||
* append publication hooks and add it to the store. Returns the DETACH
|
||||
* disposer (hooks + store removal). Does NOT emit `session/created` —
|
||||
* the caller yields this disposer inside its effect and THEN calls
|
||||
* {@link announce}, so a throwing `session/created` listener rolls the attach
|
||||
* back instead of leaking it.
|
||||
*
|
||||
* Re-checks the id for a duplicate: `prepare` and `enter` are public
|
||||
* cross-package primitives and a caller may interleave arbitrary work (or
|
||||
* another create) between them, so a stale prepared session must NOT overwrite
|
||||
* a live store entry of the same id — its detach disposer would later delete
|
||||
* the REAL session. The {@link create} convenience and the agent factory call
|
||||
* the two back-to-back so they never trip this, but the public seam cannot
|
||||
* assume that.
|
||||
*
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @returns the detach disposer (`onAppend = undefined` + store removal).
|
||||
* @returns the detach disposer (publication hooks + store removal). When called from
|
||||
* a synchronous `session/created` listener, removal and disposal wait until
|
||||
* that creation dispatch unwinds.
|
||||
* @throws if a session with this id is already in the store.
|
||||
*/
|
||||
enter(session: Session): () => void {
|
||||
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
|
||||
// The carrier is decided HERE, once, from the ENTERING context's scope tag (`this.ctx` is
|
||||
// the caller's context — the tracker mechanism): every session/created|event|flush dispatch
|
||||
// for this session uses it, so the session's whole event feed is scope-filtered
|
||||
// consistently.
|
||||
const id = session.id
|
||||
const carrier = scopeTarget(session, scopeOf(this.ctx))
|
||||
this.carriers.set(session, carrier)
|
||||
const emitCtx = this.ctx
|
||||
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
|
||||
this.store.set(session.id, session)
|
||||
// This is the authoritative collision boundary after arbitrary unpublished
|
||||
// preparation. Only one exact same-id transaction can publish.
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
const entry: SessionEntry = {
|
||||
id,
|
||||
session,
|
||||
carrier,
|
||||
emitCtx: this.ctx,
|
||||
announced: false,
|
||||
announcing: false,
|
||||
appending: false,
|
||||
detachRequested: false,
|
||||
detach: () => { this.detachEntered(entry) },
|
||||
}
|
||||
this.store.set(id, entry)
|
||||
attachments.set(session, entry)
|
||||
let entered = true
|
||||
return () => {
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
session.onAppend = undefined
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(session.id)
|
||||
// A lifecycle listener may own the advanced detach capability. Keep the
|
||||
// entry and its publication hooks live until synchronous creation or append
|
||||
// publication unwinds, then publish the paired disposal edge.
|
||||
if (entry.announcing || entry.appending) {
|
||||
entry.detachRequested = true
|
||||
return
|
||||
}
|
||||
entry.detach()
|
||||
}
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Remove one exact entered session and emit its paired disposal when announced. */
|
||||
private detachEntered(entry: SessionEntry): void {
|
||||
entry.detachRequested = false
|
||||
// A stale capability cannot remove observers or storage belonging to a
|
||||
// later same-id lifecycle.
|
||||
/* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
|
||||
if (this.store.get(entry.id) !== entry) return
|
||||
this.store.delete(entry.id)
|
||||
attachments.delete(entry.session)
|
||||
if (entry.announced) this.emitDisposed(entry)
|
||||
}
|
||||
|
||||
/** Emit `session/created` exactly once for an {@link enter}ed session (with
|
||||
* the carrier {@link enter} captured). Separate from {@link enter} so the
|
||||
* caller can yield the detach disposer first (rollback safety — see
|
||||
* {@link enter}).
|
||||
* @param session - the entered session to announce to listeners.
|
||||
* @throws if the session is not live or its announcement already began,
|
||||
* including a reentrant call from a creation listener. */
|
||||
announce(session: Session): void {
|
||||
const entry = this.liveEntryFor(session)
|
||||
if (entry.announced || entry.announcing) {
|
||||
throw new Error(`session "${entry.id}" was already announced`)
|
||||
}
|
||||
// Mark before emit: Cordis emit may deliver to earlier listeners and then
|
||||
// throw. Rollback must still pair that partial creation with disposal, and
|
||||
// a listener cannot recursively create a second lifecycle edge.
|
||||
entry.announced = true
|
||||
const callbackArgs: unknown[] = [session]
|
||||
entry.announcing = true
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
|
||||
for (const callback of callbacks) {
|
||||
// Synchronous throws intentionally propagate and veto publication; the
|
||||
// yielded detach then emits the paired disposal edge. An async function
|
||||
// is nevertheless assignable to a void listener, so observe its returned
|
||||
// promise: rejection is too late to roll back and must be logged instead
|
||||
// of becoming unhandled.
|
||||
const returned: unknown = callback(...callbackArgs)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
entry.announcing = false
|
||||
if (entry.detachRequested && !entry.appending) entry.detach()
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit `session/created` for an {@link enter}ed session (with the carrier
|
||||
* {@link enter} captured). Separate from {@link enter} so the caller can
|
||||
* yield the detach disposer first (rollback safety — see {@link enter}).
|
||||
* @param session - the entered session to announce to listeners. */
|
||||
announce(session: Session): void {
|
||||
this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
|
||||
/** Emit the paired teardown notification with per-listener containment. */
|
||||
private emitDisposed(entry: SessionEntry): void {
|
||||
const callbackArgs: unknown[] = [entry.session]
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
|
||||
invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,26 +794,34 @@ export class SessionStore extends Service {
|
||||
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
|
||||
* scoped-dispatch invariant can pin it.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @returns resolves when every flush listener has settled; rejects if one rejects.
|
||||
* @returns resolves when every flush listener has settled; after all settle,
|
||||
* rejects with the first registered listener failure if any listener failed.
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
|
||||
const { carrier } = this.liveEntryFor(session)
|
||||
const callbackArgs: unknown[] = [session]
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
|
||||
const results = await Promise.allSettled(callbacks.map((callback) => {
|
||||
try {
|
||||
return callback(...callbackArgs)
|
||||
} catch (error: unknown) {
|
||||
// Preserve the listener's exact rejection value; flush is a caller-owned
|
||||
// failure boundary, and Cordis listeners may throw arbitrary values.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}))
|
||||
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
|
||||
/** Return the exact live session's carrier; detached/prepared objects reject. */
|
||||
private liveCarrierFor(session: Session): Scoped<Session> {
|
||||
if (this.store.get(session.id) !== session) {
|
||||
/** Return the exact live entry; detached/prepared objects reject. */
|
||||
private liveEntryFor(session: Session): SessionEntry {
|
||||
const entry = attachments.get(session)
|
||||
if (entry === undefined || this.store.get(entry.id) !== entry) {
|
||||
throw new Error(`session "${session.id}" is not live in this store`)
|
||||
}
|
||||
const carrier = this.carriers.get(session)
|
||||
// enter() installs store + carrier in one synchronous sequence; a live
|
||||
// session without one is an internal invariant violation, never fallback
|
||||
// to subject-less dispatch (that would silently cross scope boundaries).
|
||||
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
|
||||
if (carrier === undefined) {
|
||||
throw new Error(`session "${session.id}" has no dispatch carrier`)
|
||||
}
|
||||
return carrier
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,7 +830,7 @@ export class SessionStore extends Service {
|
||||
* @returns the session, or undefined when no live session has that id.
|
||||
*/
|
||||
get(id: SessionId): Session | undefined {
|
||||
return this.store.get(id)
|
||||
return this.store.get(id)?.session
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,16 +838,18 @@ export class SessionStore extends Service {
|
||||
* @returns a fresh array; mutating it does not affect the store.
|
||||
*/
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
return [...this.store.values()].map(entry => entry.session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a live child session from a turn-enclosed prefix of a live source. `boundary` is
|
||||
* an inclusive source event seq; omitted means the source's current last event.
|
||||
* Create a live child session from a turn-enclosed prefix of a live source.
|
||||
* `boundary` is an inclusive source event seq; omitted means the source's
|
||||
* current last event. A non-empty selected slice must end at `turn/end`.
|
||||
*
|
||||
* @param source - Live source session object or id.
|
||||
* @param boundary - Inclusive source event seq to fork through; omitted means the
|
||||
* source's current last event, and omitted on an empty source forks an empty child.
|
||||
* @param boundary - Inclusive source event seq to fork through; omitted means
|
||||
* the source's current last event, and omitted on an empty source forks an
|
||||
* empty child.
|
||||
* @param childSessionId - Optional child session id; omitted delegates to
|
||||
* `SessionStore`'s id policy.
|
||||
* @returns The created live child session.
|
||||
@@ -540,7 +908,7 @@ export class SessionStore extends Service {
|
||||
)
|
||||
}
|
||||
|
||||
return events.slice(0, boundary + 1).map(event => structuredClone(event))
|
||||
return events.slice(0, boundary + 1)
|
||||
}
|
||||
|
||||
private _resolveForkSource(source: SessionForkSource): Session {
|
||||
|
||||
@@ -1,22 +1,127 @@
|
||||
/**
|
||||
* JSON-serializability validation for session event data.
|
||||
* Lossless-JSON validation and snapshot materialization for session data.
|
||||
*
|
||||
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
|
||||
* `event.data` must round-trip losslessly through JSON so any persistence
|
||||
* backend can store and reload it byte-identically. This invariant belongs to
|
||||
* the log itself — `Session.append` enforces it at the source, so a
|
||||
* non-serializable event never enters `session.events` and the live log can
|
||||
* never diverge from what a backend can persist. Other public boundaries use
|
||||
* {@link snapshotJsonValue} when they must validate and detach in one pass;
|
||||
* {@link isJsonValue} remains the non-copying structural predicate.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/json
|
||||
*/
|
||||
|
||||
/**
|
||||
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
|
||||
* number, a string, an array of such values, or a plain object whose values are
|
||||
* such values. The static type companion to {@link isJsonValue} (which validates
|
||||
* the same shape at runtime). Use it to type a payload that must survive
|
||||
* session-log persistence and replay byte-identically — e.g. a tool's private
|
||||
* presentation `meta`.
|
||||
* number other than negative zero, a string, an array of such values, or a
|
||||
* plain object whose values are such values. TypeScript cannot distinguish
|
||||
* `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue}
|
||||
* enforce that last numeric detail at runtime. Use this type for a payload that
|
||||
* must survive session-log persistence and replay byte-identically — e.g. a
|
||||
* tool's private presentation `meta`.
|
||||
*/
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, booleans,
|
||||
* strings, plain arrays, and plain objects of such values.
|
||||
* Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass.
|
||||
* Each array slot or own enumerable string-keyed object value is read exactly
|
||||
* once, validated, and copied immediately. This is intentionally not
|
||||
* `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter
|
||||
* could return plain JSON to the check and an exotic class instance to the
|
||||
* clone, whose prototype `structuredClone` would erase before a later check.
|
||||
*
|
||||
* Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use
|
||||
* the ordinary `Array.prototype` (subclass instances are not plain JSON
|
||||
* containers), while null-prototype objects are accepted and normalized to
|
||||
* ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite
|
||||
* numbers, unsupported scalar types, and exotic object or array shells return
|
||||
* `undefined`. A throwing getter is a caller failure and propagates unchanged.
|
||||
*
|
||||
* @param value - the candidate value to validate and detach.
|
||||
* @returns the detached snapshot, or `undefined` when the value is not
|
||||
* losslessly JSON-serializable.
|
||||
*/
|
||||
export function snapshotJsonValue<T>(value: T): T | undefined {
|
||||
const ancestors = new Set<object>()
|
||||
|
||||
const visit = (current: unknown): JsonValue | undefined => {
|
||||
if (current === null) return null
|
||||
switch (typeof current) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return current
|
||||
case 'number':
|
||||
return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return undefined
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
if (ancestors.has(current)) return undefined
|
||||
ancestors.add(current)
|
||||
try {
|
||||
if (Array.isArray(current)) {
|
||||
if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
|
||||
const length = current.length
|
||||
const snapshot: JsonValue[] = []
|
||||
for (let index = 0; index < length; index++) {
|
||||
if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
|
||||
const item = visit(current[index])
|
||||
if (item === undefined) return undefined
|
||||
snapshot.push(item)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(current) as unknown
|
||||
if (prototype !== Object.prototype && prototype !== null) return undefined
|
||||
const snapshot: { [key: string]: JsonValue } = {}
|
||||
for (const key of Object.keys(current)) {
|
||||
const item = visit((current as Record<string, unknown>)[key])
|
||||
if (item === undefined) return undefined
|
||||
// Define the key as data so a JSON field literally named "__proto__"
|
||||
// cannot mutate the snapshot's prototype through ordinary assignment.
|
||||
Object.defineProperty(snapshot, key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
return snapshot
|
||||
} finally {
|
||||
ancestors.delete(current)
|
||||
}
|
||||
}
|
||||
|
||||
return visit(value) as T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers
|
||||
* other than negative zero, booleans, strings, plain arrays, and plain objects
|
||||
* of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which
|
||||
* JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns
|
||||
* into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) —
|
||||
* anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse
|
||||
* arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not
|
||||
* round-trip. Detects circular references (which would throw) and reports them
|
||||
* as non-serializable rather than propagating the throw.
|
||||
*
|
||||
* Scope — this is a structural plain-data predicate, not an invocation of
|
||||
* `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are
|
||||
* inspected (`Object.values`). Symbol-keyed and non-enumerable properties are
|
||||
* omitted from the durable data surface. Custom `toJSON` behavior is not
|
||||
* executed; boundaries that persist a value first materialize a new plain-data
|
||||
* record with {@link snapshotJsonValue}. Getters are invoked during this check,
|
||||
* so callers that need a stable detached value use that one-pass materializer
|
||||
* instead of checking and then rereading a side-effecting record.
|
||||
* @param value - the candidate event data to test.
|
||||
* @param seen - objects on the current descent path, for circular-reference
|
||||
* detection; the recursion threads it — callers omit it.
|
||||
@@ -29,7 +134,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
|
||||
case 'string':
|
||||
return true
|
||||
case 'number':
|
||||
return Number.isFinite(value)
|
||||
return Number.isFinite(value) && !Object.is(value, -0)
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
@@ -43,6 +148,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype) return false
|
||||
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
||||
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
||||
// lossily. Require every index 0..length-1 to be an OWN property.
|
||||
|
||||
@@ -22,6 +22,9 @@ export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
/**
|
||||
* Immutable session metadata — written once at creation and never rewritten.
|
||||
* {@link Session} enforces that contract at runtime: it validates and detaches
|
||||
* the accepted scalar fields, requires this header's id to match the session
|
||||
* id, and deep-freezes the published record.
|
||||
*
|
||||
* Kept SEPARATE from the event log deliberately: format-version, cwd, and
|
||||
* lineage are storage concerns, not conversation events, so they stay out of
|
||||
@@ -35,20 +38,20 @@ export interface SessionHeader {
|
||||
* session is created. A persistence backend rejects any other version on load
|
||||
* (no migration — see the constant).
|
||||
*/
|
||||
version: number
|
||||
readonly version: number
|
||||
/** The session's id (mirrors the {@link Session}'s id). */
|
||||
id: SessionId
|
||||
readonly id: SessionId
|
||||
/** Unix epoch milliseconds when the session was created. */
|
||||
createdAt: number
|
||||
readonly createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
cwd?: string
|
||||
readonly cwd?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
parentSession?: SessionId
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by this session —
|
||||
* the seed boundary.
|
||||
*/
|
||||
seedLength?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,9 +61,10 @@ export interface SessionHeader {
|
||||
*/
|
||||
export interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
seed?: SessionEvent[]
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Creation metadata. The store fills in `version`/`id` and defaults
|
||||
* Creation metadata. The store reads this plain record and each accepted
|
||||
* field once, then fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
|
||||
* — when reconstructing a persisted session — the original `createdAt` to
|
||||
@@ -71,7 +75,12 @@ export interface CreateSessionOptions {
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user