index.ts 47 KB

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