index.ts 55 KB

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