Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
251 lines
8.8 KiB
TypeScript
251 lines
8.8 KiB
TypeScript
/**
|
|
* Package-owned relational invariants for the session event log. Load this
|
|
* companion beside `@deepseek-ai/dsh-invariants` to enable the checks.
|
|
*
|
|
* @module @deepseek-ai/dsh-session/invariant
|
|
*/
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
import type { CallId } from '@deepseek-ai/dsh-llm'
|
|
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { TOOL_NOT_STARTED } from './repair.ts'
|
|
|
|
const PACKAGE_NAME = '@deepseek-ai/dsh-session'
|
|
|
|
/** Cordis companion plugin name. */
|
|
export const name = 'session-invariant'
|
|
/** Service required before the companion can reserve package ownership. */
|
|
export const inject = ['invariants']
|
|
|
|
/** Per-session bookkeeping for relational log checks. */
|
|
interface SessionTrace {
|
|
lastSeq: number
|
|
openTurn: number | null
|
|
openStep: number | null
|
|
nextTurn: number
|
|
nextStep: number
|
|
pendingCalls: Set<CallId>
|
|
}
|
|
|
|
/** One accepted event's deferred mutation of a committed session trace. */
|
|
interface SessionTraceTransition {
|
|
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
|
|
pendingCalls:
|
|
| { kind: 'none' }
|
|
| { kind: 'add' | 'delete'; callId: CallId }
|
|
| { kind: 'clear' }
|
|
}
|
|
|
|
/** Assert that a step-scoped event names the currently open turn and step. */
|
|
function requireOpenStep(
|
|
trace: SessionTrace,
|
|
kind: string,
|
|
turn: number,
|
|
step: number,
|
|
fail: InvariantFailure,
|
|
): void {
|
|
if (trace.openTurn !== turn || trace.openStep !== step) {
|
|
fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`)
|
|
}
|
|
}
|
|
|
|
/** Validate one candidate event without mutating the committed trace. */
|
|
function validateEvent(
|
|
trace: SessionTrace,
|
|
event: SessionEvent,
|
|
fail: InvariantFailure,
|
|
): SessionTraceTransition {
|
|
if (event.seq <= trace.lastSeq) {
|
|
fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
|
|
}
|
|
let openTurn = trace.openTurn
|
|
let openStep = trace.openStep
|
|
let nextTurn = trace.nextTurn
|
|
let nextStep = trace.nextStep
|
|
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
|
|
|
|
// Context and plugin-owned log-only events may be appended between model
|
|
// executions. Core execution events retain their explicit turn relations.
|
|
switch (event.type) {
|
|
case 'turn/start': {
|
|
if (trace.openTurn !== null) {
|
|
fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
|
|
}
|
|
if (event.data.turn !== trace.nextTurn) {
|
|
fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
|
|
}
|
|
openTurn = event.data.turn
|
|
nextStep = 1
|
|
break
|
|
}
|
|
case 'turn/end': {
|
|
if (trace.openTurn !== event.data.turn) {
|
|
fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
|
|
}
|
|
if (trace.openStep !== null) {
|
|
fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
|
|
}
|
|
openTurn = null
|
|
nextTurn += 1
|
|
break
|
|
}
|
|
case 'step/start': {
|
|
if (trace.openTurn !== event.data.turn) {
|
|
fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
|
|
}
|
|
if (trace.openStep !== null) {
|
|
fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
|
|
}
|
|
if (event.data.step !== trace.nextStep) {
|
|
fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
|
|
}
|
|
openStep = event.data.step
|
|
break
|
|
}
|
|
case 'step/end': {
|
|
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
|
|
pendingCalls = { kind: 'clear' }
|
|
openStep = null
|
|
nextStep += 1
|
|
break
|
|
}
|
|
case 'assistant/chunk': {
|
|
requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail)
|
|
break
|
|
}
|
|
case 'assistant/message': {
|
|
requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail)
|
|
break
|
|
}
|
|
case 'tool/call': {
|
|
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail)
|
|
pendingCalls = { kind: 'add', callId: event.data.callId }
|
|
break
|
|
}
|
|
case 'tool/result': {
|
|
// Session has already validated a content rewrite that cites its replaced event.
|
|
// It is durable turn work, not a second execution of the original call.
|
|
if (event.surfaceOp !== 'append') {
|
|
if (trace.openTurn === null) {
|
|
fail('tool/result surface replacement appended outside any open turn')
|
|
}
|
|
break
|
|
}
|
|
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
|
|
const callId = event.data.message.source.callId
|
|
const syntheticNotStarted = event.data.message.content[0].isError === true && event.data.error?.code === TOOL_NOT_STARTED
|
|
if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) {
|
|
fail(`tool/result for ${callId} with no prior tool/call in this step`)
|
|
}
|
|
pendingCalls = { kind: 'delete', callId }
|
|
break
|
|
}
|
|
case 'user/message':
|
|
break
|
|
case 'session/end-seed':
|
|
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
|
|
break
|
|
case 'todo/write':
|
|
case 'request/header':
|
|
case 'request/context': {
|
|
if (trace.openTurn === null) {
|
|
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
|
|
}
|
|
break
|
|
}
|
|
default:
|
|
// Merge-extensible event relations belong to their owning plugin.
|
|
break
|
|
}
|
|
return {
|
|
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
|
|
pendingCalls,
|
|
}
|
|
}
|
|
|
|
/** Apply one already-validated transition after its event commits. */
|
|
function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
|
|
Object.assign(trace, transition.scalars)
|
|
switch (transition.pendingCalls.kind) {
|
|
case 'none':
|
|
break
|
|
case 'add':
|
|
trace.pendingCalls.add(transition.pendingCalls.callId)
|
|
break
|
|
case 'delete':
|
|
trace.pendingCalls.delete(transition.pendingCalls.callId)
|
|
break
|
|
case 'clear':
|
|
trace.pendingCalls.clear()
|
|
break
|
|
/* v8 ignore next -- validateEvent produces this closed transition union */
|
|
default:
|
|
assertNever(transition.pendingCalls, 'session trace pending-call transition')
|
|
}
|
|
}
|
|
|
|
/** Install the session contribution into its child registration fiber. */
|
|
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
|
const traces = new WeakMap<Session, SessionTrace>()
|
|
const stagedTransitions = new WeakMap<SessionEvent, {
|
|
session: Session
|
|
trace: SessionTrace
|
|
transition: SessionTraceTransition
|
|
}>()
|
|
|
|
const freshTrace = (): SessionTrace => ({
|
|
lastSeq: -1,
|
|
openTurn: null,
|
|
openStep: null,
|
|
nextTurn: 1,
|
|
nextStep: 1,
|
|
pendingCalls: new Set(),
|
|
})
|
|
|
|
const seedSession = (session: Session): SessionTrace => {
|
|
const trace = freshTrace()
|
|
traces.set(session, trace)
|
|
for (const event of session.events) {
|
|
applyTransition(trace, validateEvent(trace, event, fail))
|
|
}
|
|
return trace
|
|
}
|
|
|
|
/* v8 ignore next -- session/event always follows list() or session/created seeding */
|
|
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
|
|
|
|
for (const session of ctx.sessions.list()) seedSession(session)
|
|
|
|
ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
|
|
|
|
ctx.on('session/event', (session, event) => {
|
|
const staged = stagedTransitions.get(event)
|
|
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
|
if (staged === undefined || staged.session !== session) {
|
|
return fail('session/event reached publication without matching pre-commit validation')
|
|
}
|
|
stagedTransitions.delete(event)
|
|
applyTransition(staged.trace, staged.transition)
|
|
}, { global: true })
|
|
|
|
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
|
if (eventName !== 'session/event') return
|
|
const [session, event] = args as [Session, SessionEvent]
|
|
const trace = traceFor(session)
|
|
const transition = validateEvent(trace, event, fail)
|
|
// A later dispatch listener may veto. Validation is pure, so abandoning
|
|
// this weakly keyed transition does not advance or retain the session.
|
|
stagedTransitions.set(event, { session, trace, transition })
|
|
}, { global: true })
|
|
}, { inject: ['sessions'] })
|
|
|
|
/**
|
|
* Register the session 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))
|