index.ts 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171
  1. /**
  2. * Event-sourced session service: append-only session log, in-memory store, and
  3. * the derived LLM message history. Persistence is a plugin concern (subscribe
  4. * to `session/event`, drain on `session/flush`).
  5. *
  6. * @module @deepseek-ai/dsh-session
  7. */
  8. import { Context, Service } from 'cordis'
  9. import { isAbsolute } from 'node:path'
  10. import { deepFreeze } from '@deepseek-ai/dsh-llm'
  11. import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  12. import type { Scoped } from '@deepseek-ai/dsh-scope'
  13. import type { Message } from '@deepseek-ai/dsh-llm'
  14. import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
  15. import type { TypeRTLookup } from '@deepseek-ai/dsh-type-meta'
  16. import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
  17. import { snapshotJsonValue } from './json.ts'
  18. import { deriveEventMessage, SurfaceManager } from './surface.ts'
  19. import type { SessionSurface } from './surface.ts'
  20. import { foldRequestHeader } from './request-header.ts'
  21. export * from './types.ts'
  22. export { SessionPreparation } from './preparation.ts'
  23. export type { SessionPreparationOptions } from './preparation.ts'
  24. export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
  25. export { isJsonValue, snapshotJsonValue } from './json.ts'
  26. export type { JsonValue } from './json.ts'
  27. export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
  28. export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
  29. export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
  30. export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
  31. export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
  32. export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
  33. /**
  34. * Find the latest closed turn that entered at least one model step, ignoring
  35. * balanced no-step turns produced by rejection, empty input, or cancellation.
  36. * @param events - session events, or an owned suffix, to inspect.
  37. * @returns the latest matching turn end, or `undefined`.
  38. */
  39. export function findLastMessageTurnEnd(
  40. events: readonly SessionEvent[],
  41. ): SessionEvent<'turn/end'> | undefined {
  42. const steppedTurns = new Set<number>()
  43. let latest: SessionEvent<'turn/end'> | undefined
  44. for (const event of events) {
  45. if (event.type === 'step/start') {
  46. steppedTurns.add(event.data.turn)
  47. continue
  48. }
  49. if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
  50. }
  51. return latest
  52. }
  53. declare module 'cordis' {
  54. interface Context {
  55. sessions: SessionStore
  56. }
  57. interface Events {
  58. /**
  59. * Creation announcement during session publication. A synchronous throw vetoes and rolls
  60. * back with a paired disposal; detach requested during dispatch is deferred.
  61. * A returned-promise rejection is logged but cannot retroactively veto this
  62. * synchronous boundary.
  63. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
  64. * receive only sessions entered through that agent's context.
  65. * @param session - the session just entered and announced.
  66. * @dshScopeScan unsupported
  67. * @mode emit
  68. */
  69. 'session/created'(this: Scoped<Session>, session: Session): void
  70. /**
  71. * Emitted once when an announced session leaves the store, including
  72. * publication rollback, but never for an entry whose creation announcement
  73. * did not begin. Listener failures are logged and contained.
  74. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
  75. * @param session - the session that is no longer live in the store.
  76. * @dshScopeScan unsupported
  77. * @mode emit
  78. */
  79. 'session/disposed'(this: Scoped<Session>, session: Session): void
  80. /**
  81. * Post-commit, fire-and-forget append feed. The listener snapshot resolves
  82. * before the log push, but callbacks run after it; observer failures are
  83. * logged and contained without making the committed append fail.
  84. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
  85. * receive only events from sessions entered through that agent's context.
  86. * @param session - the session whose log grew.
  87. * @param event - the appended event, exactly as recorded.
  88. * @dshScopeScan unsupported
  89. * @mode emit
  90. */
  91. 'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
  92. /**
  93. * Awaited parallel durability checkpoint: every listener runs and the
  94. * caller awaits all of them, with no waterfall veto. Scope-filtered dispatch
  95. * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
  96. * @param session - the session whose buffered events must reach durable storage.
  97. * @dshScopeScan unsupported
  98. * @mode parallel
  99. */
  100. 'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
  101. }
  102. }
  103. declare module '@deepseek-ai/dsh-type-meta' {
  104. interface TypeRTLookupMap {
  105. session: TypeRTLookup<Session, SessionId>
  106. }
  107. }
  108. /** Validate and freeze one detached creation header in place. */
  109. function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
  110. if (input === null || typeof input !== 'object' || Array.isArray(input)) {
  111. throw new Error('session header is not a plain JSON record')
  112. }
  113. const record = input as Record<string, unknown>
  114. if (record.version !== SESSION_FORMAT_VERSION) {
  115. throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
  116. }
  117. if (record.id !== id) {
  118. throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
  119. }
  120. if (typeof record.createdAt !== 'number'
  121. || !Number.isSafeInteger(record.createdAt)
  122. || record.createdAt < 0) {
  123. throw new Error('session header createdAt must be a non-negative safe integer')
  124. }
  125. if (record.cwd !== undefined) {
  126. if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
  127. if (!isAbsolute(record.cwd)) {
  128. throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
  129. }
  130. }
  131. if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
  132. throw new Error('session header parentSession must be a string')
  133. }
  134. if (record.seedLength !== undefined
  135. && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
  136. throw new Error('session header seedLength must be a non-negative safe integer')
  137. }
  138. if (record.origin !== undefined && record.origin !== 'subagent') {
  139. throw new Error('session header origin must be "subagent"')
  140. }
  141. if (record.delegationDepth !== undefined
  142. && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
  143. throw new Error('session header delegationDepth must be a non-negative safe integer')
  144. }
  145. return deepFreeze(record as unknown as SessionHeader)
  146. }
  147. /** Validate and freeze one exclusively owned persistence header in place. */
  148. function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader {
  149. if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
  150. const prototype = Reflect.getPrototypeOf(input)
  151. if (prototype !== Object.prototype && prototype !== null) {
  152. throw new Error('session header is not a plain JSON record')
  153. }
  154. }
  155. return validateSessionHeader(id, input)
  156. }
  157. /** Detach, validate, and freeze the creation metadata published by a session. */
  158. function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
  159. const input: unknown = source === undefined
  160. ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
  161. : source
  162. const snapshot = snapshotJsonValue(input)
  163. if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
  164. return validateSessionHeader(id, snapshot)
  165. }
  166. /**
  167. * Validate an exclusively owned event and deeply freeze its identified message
  168. * without copying the event. The caller transfers an object graph that no
  169. * producer retains and that shares no mutable children with another event.
  170. * Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
  171. * @param event - exclusively owned event imported across a trusted boundary.
  172. * @returns the same event object with a validated, deeply frozen message.
  173. */
  174. export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
  175. assertMessageEventShape(
  176. event,
  177. `session event at seq ${event.seq}`,
  178. )
  179. switch (event.type) {
  180. case 'user/message':
  181. deepFreeze(event.data)
  182. break
  183. case 'assistant/message':
  184. case 'tool/result':
  185. deepFreeze(event.data.message)
  186. break
  187. default:
  188. // SessionEventMap is merge-extensible; plugin-owned events carry no core message.
  189. break
  190. }
  191. return event
  192. }
  193. /**
  194. * Detach one event while preserving deep immutability for its identified message.
  195. * @param event - event imported across a query or persistence boundary.
  196. * @returns a detached event snapshot with a validated, deeply frozen message.
  197. */
  198. export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
  199. return adoptSessionEvent(structuredClone(event))
  200. }
  201. /** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
  202. function freezeRestoredObject<T extends object>(value: T): T {
  203. const pending: object[] = [value]
  204. while (pending.length > 0) {
  205. // The non-empty check proves an object remains to visit.
  206. // oxlint-disable-next-line typescript/no-non-null-assertion
  207. const current = pending.pop()!
  208. Object.freeze(current)
  209. for (const key in current) {
  210. const child = (current as Record<string, unknown>)[key]
  211. if (child !== null && typeof child === 'object') pending.push(child)
  212. }
  213. }
  214. return value
  215. }
  216. /** Validate the fixed event envelope after one-pass JSON materialization. */
  217. function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
  218. const event = value
  219. if (event['type'] === 'request/header-delta') {
  220. throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`)
  221. }
  222. for (const key in event) {
  223. switch (key) {
  224. case 'type':
  225. case 'seq':
  226. case 'time':
  227. case 'data':
  228. case 'surfaceOp':
  229. case 'sourceEventSeqs':
  230. break
  231. default:
  232. throw new Error(`seed event at index ${index} has an invalid event envelope`)
  233. }
  234. }
  235. const type = event['type']
  236. const seq = event['seq']
  237. const time = event['time']
  238. if (typeof type !== 'string'
  239. || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
  240. || typeof time !== 'number' || !Number.isSafeInteger(time)
  241. || event['data'] === undefined) {
  242. throw new Error(`seed event at index ${index} has an invalid event envelope`)
  243. }
  244. switch (type) {
  245. case 'request/header':
  246. case 'user/message':
  247. case 'assistant/message':
  248. case 'tool/result':
  249. assertCurrentLlmShape(event, index)
  250. break
  251. }
  252. }
  253. /** Reject obsolete request headers and malformed messages at the seed/load boundary. */
  254. function assertCurrentLlmShape(event: Record<string, unknown>, index: number): void {
  255. const data = event['data']
  256. const record = typeof data === 'object' && data !== null
  257. ? data as Record<string, unknown>
  258. : undefined
  259. if (event['type'] === 'request/header') {
  260. const header = record?.['header']
  261. const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header)
  262. ? header as Record<string, unknown>
  263. : undefined
  264. const config = headerRecord?.['config']
  265. if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
  266. const configRecord = config as Record<string, unknown>
  267. const reasoningEffort = configRecord['reasoningEffort']
  268. if (reasoningEffort !== undefined
  269. && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
  270. throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
  271. }
  272. assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index)
  273. }
  274. const type = event['type']
  275. if (type !== 'user/message' && type !== 'assistant/message'
  276. && type !== 'tool/result') return
  277. assertMessageEventShape(event, `seed ${type} at index ${index}`)
  278. }
  279. const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens'])
  280. /** Validate adapter-default provenance imported from a durable request header. */
  281. function assertAdapterDefaults(
  282. value: unknown,
  283. config: Record<string, unknown>,
  284. index: number,
  285. ): void {
  286. if (value === undefined) return
  287. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  288. throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
  289. }
  290. const defaults = value as Record<string, unknown>
  291. if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key))
  292. || Object.values(defaults).some(marker => marker !== true)
  293. || defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
  294. || defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
  295. throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
  296. }
  297. }
  298. /** Validate only the event-specific invariants needed to safely replay a message. */
  299. function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
  300. const type = event['type']
  301. if (type !== 'user/message' && type !== 'assistant/message'
  302. && type !== 'tool/result') return
  303. const data = event['data']
  304. const record = typeof data === 'object' && data !== null
  305. ? data as Record<string, unknown>
  306. : undefined
  307. const message = type === 'user/message' ? record : record?.['message']
  308. if (typeof message !== 'object' || message === null
  309. || typeof (message as Record<string, unknown>)['id'] !== 'string'
  310. || (message as Record<string, unknown>)['id'] === '') {
  311. throw new Error(`${subject} lacks an identified message`)
  312. }
  313. const messageRecord = message as Record<string, unknown>
  314. const expectedRole = type === 'assistant/message' ? 'assistant' : 'user'
  315. if (messageRecord['role'] !== expectedRole) {
  316. throw new Error(`${subject} message must have role "${expectedRole}"`)
  317. }
  318. const source = messageRecord['source']
  319. if (typeof source !== 'object' || source === null
  320. || typeof (source as Record<string, unknown>)['kind'] !== 'string'
  321. || (source as Record<string, unknown>)['kind'] === '') {
  322. throw new Error(`${subject} message has invalid source`)
  323. }
  324. if (!Array.isArray(messageRecord['content'])) {
  325. throw new Error(`${subject} message has invalid content`)
  326. }
  327. const sourceRecord = source as Record<string, unknown>
  328. if (type === 'assistant/message') {
  329. if (sourceRecord['kind'] !== 'model' || !hasProviderModel(sourceRecord)) {
  330. throw new Error(`${subject} message must have model source`)
  331. }
  332. return
  333. }
  334. if (type !== 'tool/result') return
  335. if (sourceRecord['kind'] !== 'tool'
  336. || typeof sourceRecord['callId'] !== 'string'
  337. || sourceRecord['callId'] === '') {
  338. throw new Error(`${subject} message must have tool source`)
  339. }
  340. const content = messageRecord['content'] as unknown[]
  341. const block = content[0]
  342. if (content.length !== 1 || typeof block !== 'object' || block === null
  343. || (block as Record<string, unknown>)['type'] !== 'tool-result'
  344. || !Array.isArray((block as Record<string, unknown>)['content'])) {
  345. throw new Error(`${subject} message must contain one tool-result block`)
  346. }
  347. if ((block as Record<string, unknown>)['toolCallId'] !== sourceRecord['callId']) {
  348. throw new Error(`${subject} message has mismatched tool call ids`)
  349. }
  350. }
  351. /** Whether an unknown value carries the current provider/model pair. */
  352. function hasProviderModel(value: unknown): boolean {
  353. if (typeof value !== 'object' || value === null) return false
  354. const pair = value as Record<string, unknown>
  355. return typeof pair['provider'] === 'string' && pair['provider'].length > 0
  356. && typeof pair['model'] === 'string' && pair['model'].length > 0
  357. }
  358. /** Reject request-header vocabulary removed with the legacy delta codec. */
  359. function assertSupportedRequestHeader(type: string, data: unknown, location: string): void {
  360. if (type === 'request/header-delta') {
  361. throw new Error(`${location} uses unsupported legacy request/header-delta format`)
  362. }
  363. if (type === 'request/header'
  364. && data !== null && typeof data === 'object' && !Array.isArray(data)
  365. && (data as Record<string, unknown>)['reason'] === 'fallback') {
  366. throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`)
  367. }
  368. }
  369. type SessionCallback = (...args: unknown[]) => unknown
  370. /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
  371. function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
  372. return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
  373. }
  374. /** Invoke one resolved observe-only listener snapshot with per-listener containment. */
  375. function invokeContainedSessionObservers(
  376. ctx: Context,
  377. name: 'session/event' | 'session/disposed',
  378. id: SessionId,
  379. args: unknown[],
  380. callbacks: SessionCallback[],
  381. ): void {
  382. for (const callback of callbacks) {
  383. try {
  384. const returned: unknown = callback(...args)
  385. void Promise.resolve(returned).catch((error: unknown) => {
  386. ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
  387. })
  388. } catch (error: unknown) {
  389. ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
  390. }
  391. }
  392. }
  393. /** All mutable lifecycle state for one exact store entry. */
  394. interface SessionEntry {
  395. readonly id: SessionId
  396. readonly session: Session
  397. readonly carrier: Scoped<Session>
  398. readonly emitCtx: Context
  399. announced: boolean
  400. announcing: boolean
  401. appending: boolean
  402. detachRequested: boolean
  403. detach(): void
  404. }
  405. /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
  406. const attachments = new WeakMap<Session, SessionEntry>()
  407. /**
  408. * An event-sourced session: an append-only log of {@link SessionEvent}s.
  409. *
  410. * Plain class (not a Service) — create live instances via
  411. * `ctx.sessions.create()` and detached instances via {@link create}.
  412. * Seeding with an existing event log replays/forks a session.
  413. * @typert object
  414. */
  415. export class Session {
  416. private log: SessionEvent[] = []
  417. /** Single incremental owner of surface acceptance and projection state. */
  418. private readonly surfaceManager = new SurfaceManager(this.log)
  419. /** The ordered surface over this session's event log. */
  420. get surface(): SessionSurface {
  421. return this.surfaceManager
  422. }
  423. /**
  424. * Detached, deep-frozen creation metadata (format version, cwd, lineage,
  425. * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
  426. * `Session` is created without a store-owned header, a minimal header is
  427. * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
  428. * `session.header` is always present. Kept out of the event log — it is a
  429. * storage concern, not replayable conversation state.
  430. */
  431. readonly header: SessionHeader
  432. /** The session identity, derived from its durable header's single copy. */
  433. get id(): SessionId {
  434. return this.header.id
  435. }
  436. /**
  437. * The first seq appended IN THIS PROCESS: the length of the constructor
  438. * seed (0 without one). Events with smaller seq values entered through
  439. * construction — replay, fork, or resume — and were never published on the
  440. * `session/event` firehose (constructor seeds do not emit), so consumers
  441. * that replay the log as a publication substitute (telemetry adoption)
  442. * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
  443. * boundary: a resumed session's constructor seed is its full stored log,
  444. * while its header keeps the original fork value — this field is the
  445. * in-process construction fact.
  446. *
  447. * Not persisted itself: a seeded session projects it into the log as the
  448. * `session/end-seed` event, which is what a consumer reading STORED history
  449. * reads. Locate the LAST such event, not necessarily one at this seq — a
  450. * seed already ending in one is not re-marked, so reopening an untouched
  451. * session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
  452. * this field in-process: it is exact before the marker reaches storage.
  453. *
  454. * When this lifecycle appends the marker, it occupies this seq before the
  455. * store attaches and therefore does not publish either. Otherwise this seq
  456. * holds an ordinary published write.
  457. */
  458. readonly firstLiveSeq: number
  459. /**
  460. * Create a detached session by validating and snapshotting borrowed seed
  461. * events and storage metadata.
  462. * @param id - session identity.
  463. * @param seed - optional borrowed replay or fork events.
  464. * @param header - optional borrowed storage metadata.
  465. * @returns a detached session.
  466. */
  467. static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
  468. return new Session(id, seed, header)
  469. }
  470. /**
  471. * Restore a detached session by taking ownership of fresh persistence values.
  472. * Storage shape, event envelopes, sequence continuity, surface transitions,
  473. * and header fields are validated before the graphs are frozen in place.
  474. * @param id - restored session identity.
  475. * @param seed - fresh detached events whose ownership is transferred.
  476. * @param header - fresh detached metadata whose ownership is transferred.
  477. * @returns a restored detached session.
  478. */
  479. static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session {
  480. return new Session(id, seed, header, 'restore')
  481. }
  482. private constructor(
  483. id: SessionId,
  484. seed?: readonly SessionEvent[],
  485. header?: SessionHeader,
  486. mode: 'snapshot' | 'restore' = 'snapshot',
  487. ) {
  488. const restoredHeader = mode === 'restore'
  489. ? validateRestoredSessionHeader(id, header)
  490. : undefined
  491. if (seed !== undefined) {
  492. // Validate the seed to the SAME invariants `append` enforces, so a
  493. // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
  494. // live log that no persistence backend could store: each event's `data`
  495. // must be JSON-serializable, and `seq` must be contiguous from 0 (the
  496. // `seq = log.length` contract the whole system relies on). Without this,
  497. // a bad seed would surface only later as a backend rejection or a silent
  498. // divergence between the live log and disk.
  499. for (const [index, source] of seed.entries()) {
  500. // The seed is a persistence/replay boundary: validate and detach the
  501. // complete event in one lossless-JSON pass.
  502. const snapshot = mode === 'restore' ? source : snapshotJsonValue(source)
  503. if (snapshot === undefined) {
  504. throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
  505. }
  506. assertSessionEventEnvelope(snapshot, index)
  507. assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`)
  508. if (snapshot.seq !== index) {
  509. throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
  510. }
  511. // A seed is accepted incrementally through the same transition as a
  512. // live append and a full-log fold. The candidate is planned before it
  513. // enters `log`, so a failure cannot partially mutate the surface.
  514. try {
  515. this.surfaceManager.validateNext(snapshot)
  516. } catch (error: unknown) {
  517. throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
  518. }
  519. this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot))
  520. }
  521. }
  522. this.firstLiveSeq = this.log.length
  523. this.header = restoredHeader ?? snapshotSessionHeader(id, header)
  524. // Appended here so the marker is already in `events` when a backend
  525. // captures the creation seed: no load-time write. Re-marking is skipped
  526. // because a cold session is resumed on first touch, so repeatedly opening
  527. // one must not grow its log per open.
  528. if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
  529. this.append('session/end-seed', {})
  530. }
  531. }
  532. /** Cached immutable public snapshot of the private append-only log. */
  533. private eventsSnapshot: readonly SessionEvent[] | undefined
  534. /**
  535. * An immutable snapshot of the append-only event log. The snapshot is reused
  536. * until the next append; a previously returned array does not grow later.
  537. * Events and their nested data are deep-frozen at acceptance, so neither a
  538. * cast nor ordinary JavaScript can rewrite durable history.
  539. */
  540. get events(): readonly SessionEvent[] {
  541. this.eventsSnapshot ??= Object.freeze([...this.log])
  542. return this.eventsSnapshot
  543. }
  544. /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
  545. get seq(): number {
  546. return this.log.length
  547. }
  548. /**
  549. * Append one typed event to the log and synchronously notify observers via
  550. * the store-owned, module-private publication hooks. The hot path never blocks
  551. * on I/O — persistence plugins buffer asynchronously. Once the event enters
  552. * the log, the append is committed: observer failures are logged and
  553. * contained per listener, so they do not change the return value or prevent
  554. * later listeners from observing the same accepted event.
  555. *
  556. * @param type - The event type (key of {@link SessionEventMap}).
  557. * @param data - The event payload; must be JSON-serializable.
  558. * @param opts - Surface metadata: `surfaceOp` controls how the event enters
  559. * the ordered surface; `sourceEventSeqs` records provenance (the seq
  560. * numbers of events this one derives from). REQUIRED for
  561. * {@link SurfaceEventType} events (every message-producing event must
  562. * declare how it joins the surface, the sole source of derived model
  563. * history) and
  564. * rejected by the compiler for non-surface types like `turn/start` or
  565. * `assistant/chunk`.
  566. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
  567. * `data` that entered the log, so reading `event.data` back sees the logged
  568. * value, never the caller's still-mutable input.
  569. * @throws if `data` or surface metadata is not losslessly JSON-serializable
  570. * (BigInt, function, symbol, undefined, negative zero, non-finite number,
  571. * circular reference, sparse array, or an exotic object such as
  572. * Map/Set/Date/class instance), or when the candidate violates the
  573. * canonical surface contract (marker shape and eligibility, unique
  574. * earlier provenance, positional replacement validity, and complete
  575. * shadowed-node coverage). One recursive pass reads, validates, and
  576. * copies each nested value once, so a stateful getter cannot supply one value
  577. * to validation and another to storage. The event log is the durable source
  578. * of truth, so a bad event fails at the append site rather than later during
  579. * a backend flush. A synchronous internal dispatch validation failure or an
  580. * append reentered while this acceptance/publication boundary is open also
  581. * rejects before the log changes.
  582. */
  583. append<T extends SessionEventType>(
  584. type: T,
  585. data: SessionEventMap[T],
  586. ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
  587. ): SessionEvent<T> {
  588. const surfaceOpts: SurfaceIntent | undefined = opts[0]
  589. const surfaceMetadata = {
  590. ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
  591. ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
  592. }
  593. const dataSnapshot = snapshotJsonValue(data)
  594. if (dataSnapshot === undefined) {
  595. throw new Error(`session event "${type}" carries non-JSON-serializable data`)
  596. }
  597. assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`)
  598. const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
  599. if (surfaceMetadataSnapshot === undefined) {
  600. throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
  601. }
  602. const entry = attachments.get(this)
  603. if (entry?.appending) {
  604. throw new Error('session append cannot reenter while another append is being published')
  605. }
  606. const event = deepFreeze({
  607. type,
  608. seq: this.log.length,
  609. time: Date.now(),
  610. data: dataSnapshot,
  611. ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
  612. } as unknown as SessionEvent<T>)
  613. this.surfaceManager.validateNext(event as SessionEvent)
  614. if (entry !== undefined) entry.appending = true
  615. try {
  616. let callbacks: SessionCallback[] | undefined
  617. const callbackArgs: unknown[] = [this, event]
  618. if (entry !== undefined) {
  619. callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
  620. }
  621. this.log.push(event as SessionEvent)
  622. this.eventsSnapshot = undefined
  623. if (callbacks !== undefined && entry !== undefined) {
  624. invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
  625. }
  626. return event
  627. } finally {
  628. if (entry !== undefined) {
  629. entry.appending = false
  630. if (entry.detachRequested && !entry.announcing) entry.detach()
  631. }
  632. }
  633. }
  634. /** Cached fold of the request-header events — see {@link requestHeader}. */
  635. private headerFold: EpochHeader | undefined
  636. /** Log position (events consumed) the header fold has reached. */
  637. private headerFoldSeq = 0
  638. /**
  639. * The {@link EpochHeader} in force after the log's last header event — the
  640. * header the NEXT request will be compared against — or undefined before
  641. * the first `request/header` snapshot. The live, incrementally-maintained
  642. * form of `foldRequestHeader(session.events)`: each header event is folded
  643. * once, when first seen, so a per-step read costs O(new events).
  644. * @returns the folded header, or undefined when no header event exists yet.
  645. */
  646. requestHeader(): EpochHeader | undefined {
  647. if (this.headerFoldSeq < this.log.length) {
  648. // Frozen on update: the fold is session state exposed by reference — a
  649. // consumer mutating it in place (instead of building a replacement)
  650. // would desync every later comparison against the log, so mutation
  651. // throws instead.
  652. this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold))
  653. this.headerFoldSeq = this.log.length
  654. }
  655. return this.headerFold
  656. }
  657. /** Cached fold of `request/context` events. */
  658. private contextFold: RequestContext | undefined
  659. private contextFoldSeq = 0
  660. /**
  661. * Return the latest resolved route metadata, or `undefined` before the first
  662. * `request/context` event. Each event is folded once.
  663. * @returns the latest immutable route metadata.
  664. */
  665. requestContext(): RequestContext | undefined {
  666. if (this.contextFoldSeq < this.log.length) {
  667. for (const event of this.log.slice(this.contextFoldSeq)) {
  668. if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
  669. }
  670. this.contextFoldSeq = this.log.length
  671. }
  672. return this.contextFold
  673. }
  674. /** The derived-message cache: frozen projections, extended per unseen node. */
  675. private derived: Message[] = []
  676. /** Surface position (nodes projected) the cache has reached. */
  677. private derivedNodes = 0
  678. /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
  679. private derivedGeneration = 0
  680. /**
  681. * Derive the LLM message history by walking the ordered sequences of
  682. * message-producing events maintained by `surfaceOp` markers. The
  683. * surface is the single source of derived history: every message-producing
  684. * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
  685. * turn boundary) is correctly absent, and a compaction `replace` deletes the
  686. * shadowed nodes from the derivation. The projection rules are
  687. * {@link deriveEventMessage}, folded per node.
  688. *
  689. * CACHED: each surface node is projected exactly once, when first seen — a
  690. * call costs O(new nodes), and a surface rewrite (a `replace`;
  691. * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
  692. * a fresh snapshot per call (later appends never grow an array a caller
  693. * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
  694. * Their content reuses the already frozen durable event data, so the cache
  695. * needs no second deep clone and consumers still cannot mutate the log.
  696. * @returns a fresh array of the shared, frozen derived history.
  697. */
  698. deriveMessages(): Message[] {
  699. const surface = this.surface
  700. const nodes = surface.nodes
  701. const generation = surface.replaceGeneration
  702. if (generation !== this.derivedGeneration) {
  703. this.derived = []
  704. this.derivedNodes = 0
  705. this.derivedGeneration = generation
  706. }
  707. for (const seq of nodes.slice(this.derivedNodes)) {
  708. // Surface sequences are built from this.log — seq is always a valid
  709. // index by construction. The non-null assertion expresses that invariant.
  710. // oxlint-disable-next-line typescript/no-non-null-assertion
  711. const msg = this.deriveEventMessage(this.log[seq]!)
  712. // A surface node is one of the five message-producing types, but an
  713. // empty-content assistant/message (a max-tokens step that hosts only
  714. // usage) derives to null and must not enter the transcript.
  715. if (msg) this.derived.push(msg)
  716. }
  717. this.derivedNodes = nodes.length
  718. return [...this.derived]
  719. }
  720. /**
  721. * Instance face of the pure per-node `deriveEventMessage` export from
  722. * `surface.ts`.
  723. * @param event - the event to project.
  724. * @returns the derived message, or null when the event produces none.
  725. */
  726. deriveEventMessage(event: SessionEvent): Message | null {
  727. return deriveEventMessage(event)
  728. }
  729. }
  730. /** A fork source: either the live session object or its live store id. */
  731. export type SessionForkSource = Session | SessionId
  732. /**
  733. * Rejection codes for session forking: the fork source id is unknown to the
  734. * live store (`SESSION_NOT_FOUND`) or names a session object that is not the
  735. * store's live instance (`SESSION_NOT_LIVE`); the requested child id is
  736. * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
  737. * existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an
  738. * open turn (`OPEN_TURN`).
  739. */
  740. export type SessionForkErrorCode =
  741. | 'SESSION_NOT_FOUND'
  742. | 'SESSION_NOT_LIVE'
  743. | 'SESSION_ALREADY_EXISTS'
  744. | 'INVALID_BOUNDARY'
  745. | 'OPEN_TURN'
  746. /** Typed error for session fork rejections. */
  747. export class SessionForkError extends Error {
  748. constructor(message: string, public readonly code: SessionForkErrorCode) {
  749. super(message)
  750. this.name = 'SessionForkError'
  751. }
  752. }
  753. /**
  754. * In-memory session store (`ctx.sessions`).
  755. *
  756. * Persistence is intentionally not implemented here — persistence plugins
  757. * subscribe to `session/event` and flush on `session/flush` / dispose.
  758. */
  759. export class SessionStore extends Service {
  760. private store = new Map<SessionId, SessionEntry>()
  761. private counter = 0
  762. constructor(ctx: Context) {
  763. super(ctx, 'sessions')
  764. ctx.inject(['typert'], (typeCtx) => {
  765. typeCtx.typert.lookups.register('session', {
  766. parameter: 'session',
  767. wire: 'sessionId',
  768. hostTypeSymbol: '@deepseek-ai/dsh-session#Session',
  769. wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
  770. resolve: sessionId => this.get(sessionId),
  771. })
  772. })
  773. }
  774. /**
  775. * Create a session owned by the calling fiber: disposing that fiber stops
  776. * event notification and removes the session from the store. `options.seed`
  777. * populates the session with a copy of those events (replay/fork);
  778. * `options.meta` attaches creation metadata (validated absolute `cwd`, seed
  779. * and parent lineage, and delegation depth) as the immutable
  780. * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
  781. *
  782. * For an agent whose session must be torn down IN ORDER with its loop (so the
  783. * loop's final events are published before the store attachment ends), do NOT use this
  784. * — fold the session lifecycle into the agent's own effect via
  785. * {@link prepare} + {@link enter} + {@link announce} (see
  786. * `dsh-agent-loop`'s creation transaction).
  787. *
  788. * @param id - the session id; omitted, the store mints `session-<n>`.
  789. * @param options - seed events and/or creation metadata for the header.
  790. * @returns the live session, already entered and announced.
  791. * @throws if a session with `id` already exists, metadata is not a plain
  792. * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
  793. * non-absolute path (storage backends key directories off it).
  794. */
  795. create(id?: SessionId, options?: CreateSessionOptions): Session {
  796. const session = this.prepare(id, options)
  797. // Single effect owned by the calling fiber. Yield the detach BEFORE
  798. // announcing so a throwing `session/created` listener rolls the attach back
  799. // (the generator effect disposes already-yielded disposers on a throw)
  800. // instead of leaking the store entry and its publication hooks.
  801. this.ctx.effect(function* (this: SessionStore) {
  802. yield this.enter(session)
  803. this.announce(session)
  804. }.bind(this), 'sessions.create()')
  805. return session
  806. }
  807. /**
  808. * Build a session WITHOUT entering it into the store — validate the id/cwd and
  809. * construct the {@link Session} (with its immutable {@link SessionHeader}).
  810. * Pairs with {@link enter} + {@link announce}: a caller that owns a composite
  811. * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
  812. * effect so a fiber unload tears the session + agent down as a single ORDERED
  813. * chain rather than as racing sibling effects — which would remove the publication hooks
  814. * before the driver's closing events commit, dropping them.
  815. *
  816. * @param id - the session id; omitted, the store mints `session-<n>`.
  817. * @param options - seed events and/or creation metadata for the header. With
  818. * `seedSource: 'persistence'`, metadata and events must be fresh detached
  819. * graphs whose ownership transfers to this call: they are validated and
  820. * frozen in place through {@link Session.fromRestore}, so the caller must
  821. * retain no mutable aliases.
  822. * @returns the constructed session, NOT yet in the store.
  823. * @throws if a session with `id` already exists, metadata is not a plain
  824. * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
  825. * non-absolute path.
  826. */
  827. prepare(id?: SessionId, options?: PrepareSessionOptions): Session {
  828. let sessionId: SessionId
  829. if (id === undefined) {
  830. do sessionId = SessionId(`session-${++this.counter}`)
  831. while (this.store.has(sessionId))
  832. } else {
  833. sessionId = SessionId(id)
  834. }
  835. if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
  836. if (options?.seedSource === 'persistence') {
  837. return Session.fromRestore(sessionId, options.seed, options.meta)
  838. }
  839. const seed = options?.seed
  840. const meta = options?.meta
  841. const header: SessionHeader = {
  842. version: SESSION_FORMAT_VERSION,
  843. id: sessionId,
  844. createdAt: meta?.createdAt ?? Date.now(),
  845. ...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
  846. ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
  847. ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
  848. ...meta?.origin === undefined ? {} : { origin: meta.origin },
  849. ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
  850. }
  851. return Session.create(sessionId, seed, header)
  852. }
  853. /**
  854. * Enter a {@link prepare}d session into the store: install the module-private
  855. * append publication hooks and add it to the store. Returns the DETACH
  856. * disposer (hooks + store removal). Does NOT emit `session/created` —
  857. * the caller yields this disposer inside its effect and THEN calls
  858. * {@link announce}, so a throwing `session/created` listener rolls the attach
  859. * back instead of leaking it.
  860. *
  861. * Re-checks the id for a duplicate: `prepare` and `enter` are public
  862. * cross-package primitives and a caller may interleave arbitrary work (or
  863. * another create) between them, so a stale prepared session must NOT overwrite
  864. * a live store entry of the same id — its detach disposer would later delete
  865. * the REAL session. The {@link create} convenience and the agent factory call
  866. * the two back-to-back so they never trip this, but the public seam cannot
  867. * assume that.
  868. *
  869. * @param session - a {@link prepare}d session not yet in the store.
  870. * @returns the detach disposer (publication hooks + store removal). When called from
  871. * a synchronous `session/created` listener, removal and disposal wait until
  872. * that creation dispatch unwinds.
  873. * @throws if a session with this id is already in the store.
  874. */
  875. enter(session: Session): () => void {
  876. const id = session.id
  877. const carrier = scopeTarget(session, scopeOf(this.ctx))
  878. // This is the authoritative collision boundary after arbitrary unpublished
  879. // preparation. Only one exact same-id transaction can publish.
  880. if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
  881. if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
  882. const entry: SessionEntry = {
  883. id,
  884. session,
  885. carrier,
  886. emitCtx: this.ctx,
  887. announced: false,
  888. announcing: false,
  889. appending: false,
  890. detachRequested: false,
  891. detach: () => { this.detachEntered(entry) },
  892. }
  893. this.store.set(id, entry)
  894. attachments.set(session, entry)
  895. let entered = true
  896. const detach = (): void => {
  897. if (!entered) return
  898. entered = false
  899. // A lifecycle listener may own the advanced detach capability. Keep the
  900. // entry and its publication hooks live until synchronous creation or append
  901. // publication unwinds, then publish the paired disposal edge.
  902. if (entry.announcing || entry.appending) {
  903. entry.detachRequested = true
  904. return
  905. }
  906. entry.detach()
  907. }
  908. return detach
  909. }
  910. /** Remove one exact entered session and emit its paired disposal when announced. */
  911. private detachEntered(entry: SessionEntry): void {
  912. entry.detachRequested = false
  913. // A stale capability cannot remove observers or storage belonging to a
  914. // later same-id lifecycle.
  915. /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
  916. if (this.store.get(entry.id) !== entry) return
  917. this.store.delete(entry.id)
  918. attachments.delete(entry.session)
  919. if (entry.announced) this.emitDisposed(entry)
  920. }
  921. /** Emit `session/created` exactly once for an {@link enter}ed session (with
  922. * the carrier {@link enter} captured). Separate from {@link enter} so the
  923. * caller can yield the detach disposer first (rollback safety — see
  924. * {@link enter}).
  925. * @param session - the entered session to announce to listeners.
  926. * @throws if the session is not live or its announcement already began,
  927. * including a reentrant call from a creation listener. */
  928. announce(session: Session): void {
  929. const entry = this.liveEntryFor(session)
  930. if (entry.announced || entry.announcing) {
  931. throw new Error(`session "${entry.id}" was already announced`)
  932. }
  933. // Mark before emit: Cordis emit may deliver to earlier listeners and then
  934. // throw. Rollback must still pair that partial creation with disposal, and
  935. // a listener cannot recursively create a second lifecycle edge.
  936. entry.announced = true
  937. const callbackArgs: unknown[] = [session]
  938. entry.announcing = true
  939. try {
  940. const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
  941. for (const callback of callbacks) {
  942. // Synchronous throws intentionally propagate and veto publication; the
  943. // yielded detach then emits the paired disposal edge. An async function
  944. // is nevertheless assignable to a void listener, so observe its returned
  945. // promise: rejection is too late to roll back and must be logged instead
  946. // of becoming unhandled.
  947. const returned: unknown = callback(...callbackArgs)
  948. void Promise.resolve(returned).catch((error: unknown) => {
  949. this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
  950. })
  951. }
  952. } finally {
  953. entry.announcing = false
  954. if (entry.detachRequested && !entry.appending) entry.detach()
  955. }
  956. }
  957. /** Emit the paired teardown notification with per-listener containment. */
  958. private emitDisposed(entry: SessionEntry): void {
  959. const callbackArgs: unknown[] = [entry.session]
  960. try {
  961. const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
  962. invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
  963. } catch (error: unknown) {
  964. this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
  965. }
  966. }
  967. /**
  968. * Dispatch the awaited `session/flush` durability checkpoint for `session`,
  969. * with the carrier captured at {@link enter}. THE flush entry point: the
  970. * store owns the carrier, so callers (the checkpoint policy's per-request
  971. * barrier, goal-session's idle checkpoint, teardown drains, and consumers
  972. * that flush themselves before reading storage) must come through here
  973. * rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
  974. * one spelling, and the scoped-dispatch invariant can pin it.
  975. * @param session - the session whose buffered events must reach durable storage.
  976. * @returns whether at least one durability listener participated, after every
  977. * listener has settled successfully.
  978. * @throws the first registered listener failure after every listener settles.
  979. */
  980. async flush(session: Session): Promise<boolean> {
  981. const { carrier } = this.liveEntryFor(session)
  982. const callbackArgs: unknown[] = [session]
  983. const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
  984. const results = await Promise.allSettled(callbacks.map((callback) => {
  985. try {
  986. return callback(...callbackArgs)
  987. } catch (error: unknown) {
  988. // Preserve the listener's exact rejection value; flush is a caller-owned
  989. // failure boundary, and Cordis listeners may throw arbitrary values.
  990. // oxlint-disable-next-line typescript/prefer-promise-reject-errors
  991. return Promise.reject(error)
  992. }
  993. }))
  994. const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
  995. if (failure !== undefined) throw failure.reason
  996. return callbacks.length > 0
  997. }
  998. /** Return the exact live entry; detached/prepared objects reject. */
  999. private liveEntryFor(session: Session): SessionEntry {
  1000. const entry = attachments.get(session)
  1001. if (entry === undefined || this.store.get(entry.id) !== entry) {
  1002. throw new Error(`session "${session.id}" is not live in this store`)
  1003. }
  1004. return entry
  1005. }
  1006. /**
  1007. * Look up a live session.
  1008. * @param id - the session id to look up.
  1009. * @returns the session, or undefined when no live session has that id.
  1010. */
  1011. get(id: SessionId): Session | undefined {
  1012. return this.store.get(id)?.session
  1013. }
  1014. /**
  1015. * All live sessions, in creation order.
  1016. * @returns a fresh array; mutating it does not affect the store.
  1017. */
  1018. list(): Session[] {
  1019. return [...this.store.values()].map(entry => entry.session)
  1020. }
  1021. /**
  1022. * Create a live child session from a stable prefix of a live source.
  1023. * `boundary` is an inclusive source event seq; omitted means the source's
  1024. * current last event. The selected slice may end with a between-turn event
  1025. * but must not end inside an open turn.
  1026. *
  1027. * @param source - Live source session object or id.
  1028. * @param boundary - Inclusive source event seq to fork through; omitted means
  1029. * the source's current last event, and omitted on an empty source forks an
  1030. * empty child.
  1031. * @param childSessionId - Optional child session id; omitted delegates to
  1032. * `SessionStore`'s id policy.
  1033. * @returns The created live child session.
  1034. */
  1035. fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {
  1036. if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
  1037. throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
  1038. }
  1039. const liveSource = this._resolveForkSource(source)
  1040. const seed = this._forkSeed(liveSource, boundary)
  1041. return this.create(childSessionId, {
  1042. seed,
  1043. meta: {
  1044. ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
  1045. parentSession: liveSource.id,
  1046. seedLength: seed.length,
  1047. },
  1048. })
  1049. }
  1050. private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
  1051. const events = session.events
  1052. const lastEvent = events.at(-1)
  1053. let boundary: number
  1054. if (requestedBoundary !== undefined) {
  1055. boundary = requestedBoundary
  1056. } else {
  1057. if (lastEvent === undefined) return []
  1058. boundary = lastEvent.seq
  1059. }
  1060. if (!Number.isSafeInteger(boundary) || boundary < 0) {
  1061. throw new SessionForkError(
  1062. `fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
  1063. 'INVALID_BOUNDARY',
  1064. )
  1065. }
  1066. if (boundary >= events.length) {
  1067. const lastSeq = events.at(-1)?.seq
  1068. throw new SessionForkError(
  1069. `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
  1070. 'INVALID_BOUNDARY',
  1071. )
  1072. }
  1073. const boundaryEvent = events[boundary]
  1074. if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
  1075. throw new SessionForkError(
  1076. `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
  1077. 'INVALID_BOUNDARY',
  1078. )
  1079. }
  1080. const lastTurnBoundary = events.slice(0, boundary + 1)
  1081. .findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
  1082. if (lastTurnBoundary?.type === 'turn/start') {
  1083. throw new SessionForkError(
  1084. `fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`,
  1085. 'OPEN_TURN',
  1086. )
  1087. }
  1088. return events.slice(0, boundary + 1)
  1089. }
  1090. private _resolveForkSource(source: SessionForkSource): Session {
  1091. if (typeof source === 'string') {
  1092. const session = this.get(source)
  1093. if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
  1094. return session
  1095. }
  1096. const live = this.get(source.id)
  1097. if (live === undefined) {
  1098. throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
  1099. }
  1100. if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
  1101. return source
  1102. }
  1103. }
  1104. export default SessionStore