Merge remote-tracking branch 'origin/master' into mergebot/pr1667

This commit is contained in:
imccyu
2026-08-06 11:42:14 +08:00
92 changed files with 4320 additions and 735 deletions

View File

@@ -13,13 +13,15 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { deriveEventMessage, SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
export * from './types.ts'
export { SessionPreparation } from './preparation.ts'
export type { SessionPreparationOptions } from './preparation.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
@@ -143,6 +145,17 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
return deepFreeze(record as unknown as SessionHeader)
}
/** Validate and freeze one exclusively owned persistence header in place. */
function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader {
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
const prototype = Reflect.getPrototypeOf(input)
if (prototype !== Object.prototype && prototype !== null) {
throw new Error('session header is not a plain JSON record')
}
}
return validateSessionHeader(id, input)
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
@@ -190,23 +203,58 @@ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
return adoptSessionEvent(structuredClone(event))
}
/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
function freezeRestoredObject<T extends object>(value: T): T {
const pending: object[] = [value]
while (pending.length > 0) {
// The non-empty check proves an object remains to visit.
// oxlint-disable-next-line typescript/no-non-null-assertion
const current = pending.pop()!
Object.freeze(current)
for (const key in current) {
const child = (current as Record<string, unknown>)[key]
if (child !== null && typeof child === 'object') pending.push(child)
}
}
return value
}
/** 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
if (event['type'] === 'request/header-delta') {
throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`)
}
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')) {
for (const key in event) {
switch (key) {
case 'type':
case 'seq':
case 'time':
case 'data':
case 'surfaceOp':
case 'sourceEventSeqs':
break
default:
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
}
const type = event['type']
const seq = event['seq']
const time = event['time']
if (typeof type !== 'string'
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|| event['data'] === undefined) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
assertCurrentLlmShape(event, index)
switch (type) {
case 'request/header':
case 'user/message':
case 'assistant/message':
case 'tool/result':
assertCurrentLlmShape(event, index)
break
}
}
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
@@ -236,6 +284,8 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
assertMessageEventShape(event, `seed ${type} at index ${index}`)
}
const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens'])
/** Validate adapter-default provenance imported from a durable request header. */
function assertAdapterDefaults(
value: unknown,
@@ -247,8 +297,7 @@ function assertAdapterDefaults(
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
}
const defaults = value as Record<string, unknown>
const allowed = new Set(['reasoningEffort', 'maxTokens'])
if (Object.keys(defaults).some(key => !allowed.has(key))
if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key))
|| Object.values(defaults).some(marker => marker !== true)
|| defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
|| defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
@@ -442,7 +491,28 @@ export class Session {
return new Session(id, seed, header)
}
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
/**
* Restore a detached session by taking ownership of fresh persistence values.
* Storage shape, event envelopes, sequence continuity, surface transitions,
* and header fields are validated before the graphs are frozen in place.
* @param id - restored session identity.
* @param seed - fresh detached events whose ownership is transferred.
* @param header - fresh detached metadata whose ownership is transferred.
* @returns a restored detached session.
*/
static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session {
return new Session(id, seed, header, 'restore')
}
private constructor(
id: SessionId,
seed?: readonly SessionEvent[],
header?: SessionHeader,
mode: 'snapshot' | 'restore' = 'snapshot',
) {
const restoredHeader = mode === 'restore'
? validateRestoredSessionHeader(id, header)
: undefined
if (seed !== undefined) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
@@ -454,7 +524,7 @@ export class Session {
for (const [index, source] of seed.entries()) {
// The seed is a persistence/replay boundary: validate and detach the
// complete event in one lossless-JSON pass.
const snapshot = snapshotJsonValue(source)
const snapshot = mode === 'restore' ? source : snapshotJsonValue(source)
if (snapshot === undefined) {
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
}
@@ -471,11 +541,11 @@ export class Session {
} catch (error: unknown) {
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
}
this.log.push(deepFreeze(snapshot))
this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot))
}
}
this.firstLiveSeq = this.log.length
this.header = snapshotSessionHeader(id, header)
this.header = restoredHeader ?? snapshotSessionHeader(id, header)
// Appended here so the marker is already in `events` when a backend
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
@@ -779,13 +849,17 @@ export class SessionStore extends Service {
* before the driver's closing events commit, dropping them.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @param options - seed events and/or creation metadata for the header. With
* `seedSource: 'persistence'`, metadata and events must be fresh detached
* graphs whose ownership transfers to this call: they are validated and
* frozen in place through {@link Session.fromRestore}, so the caller must
* retain no mutable aliases.
* @returns the constructed session, NOT yet in the store.
* @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 {
prepare(id?: SessionId, options?: PrepareSessionOptions): Session {
let sessionId: SessionId
if (id === undefined) {
do sessionId = SessionId(`session-${++this.counter}`)
@@ -794,6 +868,9 @@ export class SessionStore extends Service {
sessionId = SessionId(id)
}
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
if (options?.seedSource === 'persistence') {
return Session.fromRestore(sessionId, options.seed, options.meta)
}
const seed = options?.seed
const meta = options?.meta
const header: SessionHeader = {

View File

@@ -0,0 +1,49 @@
/**
* Ownership of one unpublished Session before registry publication.
* @module @deepseek-ai/dsh-session/preparation
*/
import type { Session } from './index.ts'
/** Options for a preparation whose provider retains unpublished state. */
export interface SessionPreparationOptions {
/** Release provider-owned state when the Session was not published. */
readonly release?: () => void
}
/**
* One exact unpublished Session and the provider state that keeps it usable.
* Disposal is synchronous and idempotent. Providers decide whether release
* returns the Session to a cache or discards it; publication may consume that
* state before disposal, making the callback a no-op.
*/
export class SessionPreparation implements Disposable {
private released = false
/** The exact Session to use for setup and publication. */
readonly session: Session
private constructor(
session: Session,
private readonly options: SessionPreparationOptions,
) {
this.session = session
}
/**
* Wrap an unpublished Session in one preparation lifetime.
* @param session - exact unpublished Session.
* @param options - optional provider release behavior.
* @returns a preparation disposed after publication or rollback.
*/
static create(session: Session, options?: SessionPreparationOptions): SessionPreparation {
return new SessionPreparation(session, options ?? {})
}
/** Release provider state once when this preparation leaves its caller. */
[Symbol.dispose](): void {
if (this.released) return
this.released = true
this.options.release?.()
}
}

View File

@@ -355,6 +355,14 @@ function applySurfaceEvent(
baseSeq: number,
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
return applySurfacePlan(state, plan)
}
/** Commit one previously validated surface transition. */
function applySurfacePlan(
state: SurfaceFoldState,
plan: SurfacePlan | undefined,
): SurfaceFoldReplacement | undefined {
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -392,6 +400,8 @@ export class SurfaceManager implements SessionSurface {
private _state = createFoldState()
/** Last processed absolute seq. */
private _lastProcessedSeq: number
/** Candidate already validated by `validateNext`, pending exact log admission. */
private _pendingPlan: { event: SessionEvent; expectedSeq: number; plan: SurfacePlan | undefined } | undefined
/**
* @param log - Contiguous complete log or loaded event window.
@@ -410,13 +420,12 @@ export class SurfaceManager implements SessionSurface {
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
planSurfaceEvent(
this._state,
const expectedSeq = this.baseSeq + this.log.length
this._pendingPlan = {
event,
this.baseSeq + this.log.length,
this.log,
this.baseSeq,
)
expectedSeq,
plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq),
}
}
/** Monotonic count of folded positional replacements. */
@@ -437,7 +446,14 @@ export class SurfaceManager implements SessionSurface {
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
const index = seq - this.baseSeq
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq)
const event = this.log[index]!
const pending = this._pendingPlan
if (pending?.event === event && pending.expectedSeq === seq) {
applySurfacePlan(this._state, pending.plan)
} else {
applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq)
}
if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined
this._lastProcessedSeq = seq
}
}

View File

@@ -93,6 +93,24 @@ export interface CreateSessionOptions {
}
}
/**
* Fresh storage values transferred to {@link SessionStore.prepare} without a
* second serialization copy. Callers retain no mutable aliases.
*/
export interface RestoredSessionOptions {
/** Fresh detached storage events to validate and freeze in place. */
readonly seed: SessionEvent[]
/** Fresh detached storage metadata to validate and freeze in place. */
readonly meta: SessionHeader
/** Select the persistence ownership-transfer path. */
readonly seedSource: 'persistence'
}
/** Inputs accepted while constructing an unpublished Session. */
export type PrepareSessionOptions =
| (CreateSessionOptions & { readonly seedSource?: undefined })
| RestoredSessionOptions
/** Why an active agent driver was cancelled. */
export type AgentCancelCause =
| { readonly kind: 'user' }