index.ts 47 KB

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