index.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  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 { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
  14. import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
  15. import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
  16. import { isJsonValue } from './json.ts'
  17. import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
  18. import { foldRequestHeader } from './request-header.ts'
  19. export * from './types.ts'
  20. export { isJsonValue } from './json.ts'
  21. export type { JsonValue } from './json.ts'
  22. export { interruptedTurnClosers } from './repair.ts'
  23. export type { SurfaceNode } from './surface.ts'
  24. export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
  25. export { isToolPairingBalanced } from './tool-pairing.ts'
  26. export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
  27. declare module 'cordis' {
  28. interface Context {
  29. sessions: SessionStore
  30. }
  31. interface Events {
  32. /**
  33. * A session was created in the store.
  34. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
  35. * session's owner scope, captured when the session was ENTERED (an agent's
  36. * session is entered through `agent.ctx`, so its events dispatch in that
  37. * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
  38. * subject-less). A listener registered through `agent.ctx` hears only that
  39. * agent's sessions; a plain plugin listener hears every session.
  40. * @param session - the session just entered and announced.
  41. * @mode emit
  42. */
  43. 'session/created'(this: Scoped<Session>, session: Session): void
  44. /**
  45. * An event was appended to a session log (sync, fire-and-forget). This is
  46. * the per-append feed a UI or invariant plugin tails.
  47. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
  48. * session's owner scope, captured when the session was ENTERED (an agent's
  49. * session is entered through `agent.ctx`, so its events dispatch in that
  50. * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
  51. * subject-less). A listener registered through `agent.ctx` hears only that
  52. * agent's sessions; a plain plugin listener hears every session.
  53. * @param session - the session whose log grew.
  54. * @param event - the appended event, exactly as recorded.
  55. * @mode emit
  56. */
  57. 'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
  58. /**
  59. * Awaited durability checkpoint. The agent loop awaits
  60. * `ctx.sessions.flush(session)` at every turn end; persistence
  61. * plugins (JSONL, SQLite) drain their write-behind buffers here and on
  62. * fiber dispose. Awaited (parallel), not a waterfall: every listener runs
  63. * and the caller waits for all of them, but none can veto. Dispatch it
  64. * through {@link SessionStore.flush} — the store owns the carrier — never
  65. * via a raw `ctx.parallel`.
  66. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
  67. * session's owner scope, captured when the session was ENTERED (an agent's
  68. * session is entered through `agent.ctx`, so its events dispatch in that
  69. * agent's scope; a bare `sessions.create()` from a plain plugin dispatches
  70. * subject-less). A listener registered through `agent.ctx` hears only that
  71. * agent's sessions; a plain plugin listener hears every session.
  72. * @param session - the session whose buffered events must reach durable storage.
  73. * @mode parallel
  74. */
  75. 'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
  76. }
  77. }
  78. /**
  79. * Renders a `context/message` or `steering/message` event as a tagged
  80. * synthetic user-role message (the system-reminder pattern: zero adapter
  81. * burden, models distinguish it from real user prompts by the envelope).
  82. *
  83. * Live-adapter review has validated the tagged-envelope rendering against
  84. * current DeepSeek behavior; provider-specific mismatches belong in that
  85. * adapter, not in the canonical session vocabulary.
  86. */
  87. function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
  88. const open = `<${tag} source=${JSON.stringify(source.kind)}>`
  89. const close = `</${tag}>`
  90. return [
  91. { type: 'text', text: open },
  92. ...content,
  93. { type: 'text', text: close },
  94. ]
  95. }
  96. /**
  97. * An event-sourced session: an append-only log of {@link SessionEvent}s.
  98. *
  99. * Plain class (not a Service) — create instances via `ctx.sessions.create()`.
  100. * Seeding with an existing event log replays/forks a session.
  101. */
  102. export class Session {
  103. private log: SessionEvent[] = []
  104. /** Set by the store so appends are observable; undefined when detached. */
  105. onAppend: ((event: SessionEvent) => void) | undefined
  106. /**
  107. * Derived surface — a cached linked list of message-producing events.
  108. * Lazily rebuilt from `surfaceOp` markers in the log; processes only new
  109. * events (delta) on each access — the log is append-only, so prior events
  110. * never change.
  111. * `append`. Undefined until first accessed (including after fork/seed).
  112. */
  113. private _surface: SurfaceManager | undefined
  114. /** The surface linked list over this session's event log. */
  115. get surface(): SurfaceManager {
  116. if (!this._surface) this._surface = new SurfaceManager(this.log)
  117. return this._surface
  118. }
  119. /**
  120. * Immutable creation metadata (format version, cwd, lineage, seed boundary).
  121. * Supplied by the store via `ctx.sessions.create()`. When a `Session` is
  122. * constructed bare (tests, ad-hoc replay), a minimal header is synthesized
  123. * (stamped with the current {@link SESSION_FORMAT_VERSION}) so
  124. * `session.header` is always present. Kept out of the event log — it is a
  125. * storage concern, not replayable conversation state.
  126. */
  127. readonly header: SessionHeader
  128. constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
  129. if (seed) {
  130. // Validate the seed to the SAME invariants `append` enforces, so a
  131. // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
  132. // live log that no persistence backend could store: each event's `data`
  133. // must be JSON-serializable, and `seq` must be contiguous from 0 (the
  134. // `seq = log.length` contract the whole system relies on). Without this,
  135. // a bad seed would surface only later as a backend rejection or a silent
  136. // divergence between the live log and disk.
  137. seed.forEach((event, index) => {
  138. if (event.seq !== index) {
  139. throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
  140. }
  141. if (!isJsonValue(event.data)) {
  142. throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
  143. }
  144. // Surface-eligible events MUST carry a surfaceOp marker — the surface is
  145. // the sole source of derived history, so a marker-less message event
  146. // would load fine yet vanish from deriveMessages(). `append` enforces
  147. // this at compile time via its typed overload; a seed arrives as raw
  148. // SessionEvent[] (replay/fork/load), bypassing that, so re-check at
  149. // runtime here rather than silently resuming with empty history.
  150. if (isSurfaceEligibleType(event.type)
  151. && (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) {
  152. throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`)
  153. }
  154. })
  155. // Deep-clone each seed event, NOT just the array: the seed events and
  156. // their `data` are still owned by the caller (or the source session of a
  157. // fork), so keeping the references would let a post-create mutation of the
  158. // original rewrite this session's durable log — or reintroduce a
  159. // non-JSON-serializable value AFTER the validation above. Snapshotting at
  160. // the boundary makes `session.events` independent and keeps it equal to
  161. // what was validated. Serializability is guaranteed by the check above, so
  162. // structuredClone can never hit a non-cloneable value here.
  163. this.log = seed.map(event => structuredClone(event))
  164. }
  165. this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
  166. }
  167. /**
  168. * The append-only event log, exposed live by reference (readonly-typed, not
  169. * a snapshot): later appends are visible through the same array.
  170. */
  171. get events(): readonly SessionEvent[] {
  172. return this.log
  173. }
  174. /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
  175. get seq(): number {
  176. return this.log.length
  177. }
  178. /**
  179. * Append one typed event to the log and synchronously notify observers via
  180. * `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
  181. * asynchronously.
  182. *
  183. * @param type - The event type (key of {@link SessionEventMap}).
  184. * @param data - The event payload; must be JSON-serializable.
  185. * @param opts - Surface metadata: `surfaceOp` controls how the event enters
  186. * the surface linked list; `sourceEventSeqs` records provenance (the seq
  187. * numbers of events this one derives from). REQUIRED for
  188. * {@link SurfaceEventType} events (every message-producing event must
  189. * declare how it joins the surface, the sole source of derived history) and
  190. * rejected by the compiler for non-surface types like `turn/start` or
  191. * `assistant/chunk`.
  192. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
  193. * `data` that entered the log, so reading `event.data` back sees the logged
  194. * value, never the caller's still-mutable input.
  195. * @throws if `data` is not losslessly JSON-serializable (BigInt, function,
  196. * symbol, undefined, non-finite number, circular ref, or an exotic object
  197. * like Map/Set/Date). The event log is the durable source of truth, so this
  198. * invariant is enforced at the source — a bad event never enters the log,
  199. * keeping `session.events` always equal to what a backend can persist. The
  200. * throw surfaces at the buggy caller's append site, not asynchronously in a
  201. * backend flush.
  202. */
  203. append<T extends SessionEventType>(
  204. type: T,
  205. data: SessionEventMap[T],
  206. ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
  207. ): SessionEvent<T> {
  208. if (!isJsonValue(data)) {
  209. throw new Error(`session event "${type}" carries non-JSON-serializable data`)
  210. }
  211. const surfaceOpts: SurfaceIntent | undefined = opts[0]
  212. // Surface-eligible events MUST carry a surfaceOp marker — the surface is the
  213. // sole source of derived history, so a marker-less message event would be
  214. // logged yet vanish from deriveMessages(). The typed `opts` overload makes
  215. // the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
  216. // when `T` widens to the SessionEventType union (a caller iterating raw
  217. // events: `for (const e of log) append(e.type, e.data)`), the conditional
  218. // rest collapses to optional and the compiler stops enforcing it. Re-check
  219. // at runtime so that loophole can't silently drop history.
  220. if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) {
  221. throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
  222. }
  223. // Snapshot `data` into the log, NOT the caller's reference: the validation
  224. // above proves it is JSON-serializable AT THIS MOMENT, but the caller still
  225. // owns the object and could mutate it afterwards (before a persistence
  226. // flush, or permanently in the in-memory history) — making `session.events`
  227. // diverge from the value that passed validation, or reintroducing a
  228. // non-serializable value. Cloning here keeps the log equal to what was
  229. // validated. structuredClone is safe because serializability was just
  230. // checked. The returned event carries the SAME snapshot, so a caller reading
  231. // back `event.data` sees the logged value, not its own mutable input.
  232. //
  233. // Surface metadata is snapshot separately: sourceEventSeqs (number[] —
  234. // primitives, so array spread is a complete copy) and surfaceOp (a string
  235. // primitive, or cloned if it's a replace object).
  236. // Build the event shape with conditional surface fields via spreading.
  237. // The result is cast through `unknown` because the conditional spreads
  238. // produce an intersection type that the assignability checker can't
  239. // narrow to a specific discriminated-union member when T is generic.
  240. // This is a safe internal boundary: data was validated above, and
  241. // surface metadata was snapshot from primitive/clone-safe values.
  242. const event = {
  243. type,
  244. seq: this.log.length,
  245. time: Date.now(),
  246. data: structuredClone(data),
  247. ...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {},
  248. ...surfaceOpts?.surfaceOp !== undefined ? {
  249. surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp),
  250. } : {},
  251. } as unknown as SessionEvent<T>
  252. this.log.push(event as unknown as SessionEvent)
  253. this.onAppend?.(event as unknown as SessionEvent)
  254. return event
  255. }
  256. /** Cached fold of the request-header events — see {@link requestHeader}. */
  257. private headerFold: EpochHeader | undefined
  258. /** Log position (events consumed) the header fold has reached. */
  259. private headerFoldSeq = 0
  260. /**
  261. * The {@link EpochHeader} in force after the log's last header event — the
  262. * header the NEXT request will be compared against — or undefined before
  263. * the first `request/header` snapshot. The live, incrementally-maintained
  264. * form of `foldRequestHeader(session.events)`: each header event is folded
  265. * once, when first seen, so a per-step read costs O(new events).
  266. * @returns the folded header, or undefined when no header event exists yet.
  267. */
  268. requestHeader(): EpochHeader | undefined {
  269. if (this.headerFoldSeq < this.log.length) {
  270. // Frozen on update: the fold is session state exposed by reference — a
  271. // consumer mutating it in place (instead of building a replacement)
  272. // would desync every later comparison against the log, so mutation
  273. // throws instead.
  274. this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold))
  275. this.headerFoldSeq = this.log.length
  276. }
  277. return this.headerFold
  278. }
  279. /** The derived-message cache: frozen projections, extended per unseen node. */
  280. private derived: Message[] = []
  281. /** Surface position (nodes projected) the cache has reached. */
  282. private derivedNodes = 0
  283. /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
  284. private derivedGeneration = 0
  285. /**
  286. * Derive the LLM message history by walking the session surface — the linked
  287. * list of message-producing events maintained by `surfaceOp` markers. The
  288. * surface is the single source of derived history: every message-producing
  289. * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
  290. * turn boundary) is correctly absent, and a compaction `replace` deletes the
  291. * shadowed nodes from the derivation. The projection rules are
  292. * {@link deriveEventMessage}, folded per node.
  293. *
  294. * CACHED: each surface node is projected exactly once, when first seen — a
  295. * call costs O(new nodes), and a surface rewrite (a `replace`;
  296. * {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
  297. * a fresh snapshot per call (later appends never grow an array a caller
  298. * already holds); the `Message` objects in it are SHARED and **deep-frozen**
  299. * — cloned once off the log at projection time, so consumers can never
  300. * mutate logged data, and mutation attempts throw instead of silently
  301. * diverging replay from history.
  302. * @returns a fresh array of the shared, frozen derived history.
  303. */
  304. deriveMessages(): Message[] {
  305. const nodes = this.surface.nodes
  306. const generation = this.surface.replaceGeneration
  307. if (generation !== this.derivedGeneration) {
  308. this.derived = []
  309. this.derivedNodes = 0
  310. this.derivedGeneration = generation
  311. }
  312. for (const node of nodes.slice(this.derivedNodes)) {
  313. // Surface nodes are built from this.log — node.seq is always a valid
  314. // index by construction. The non-null assertion expresses that invariant.
  315. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  316. const msg = this.deriveEventMessage(this.log[node.seq]!)
  317. // A surface node is one of the five message-producing types, but an
  318. // empty-content assistant/message (a max-tokens step that hosts only
  319. // usage) derives to null and must not enter the transcript.
  320. if (msg) this.derived.push(deepFreeze(msg))
  321. }
  322. this.derivedNodes = nodes.length
  323. return [...this.derived]
  324. }
  325. /**
  326. * Project a single event into the LLM message it derives to, or null when
  327. * it produces none — a non-surface event (chunk, boundary, log-only record)
  328. * or an empty-content assistant/message (which exists only to host usage).
  329. * The per-node pure function {@link deriveMessages} folds over the surface;
  330. * an external reconstructor (or the dev invariant) folds the same function
  331. * over a log prefix's surface to rebuild the exact messages any request was
  332. * built from (the reconstructability RFC). The returned `content` is
  333. * deep-cloned off the logged event: the log is append-only by contract, so
  334. * no live reference to logged data leaves this boundary.
  335. * @param event - the event to project.
  336. * @returns the derived message, or null when the event produces none.
  337. */
  338. deriveEventMessage(event: SessionEvent): Message | null {
  339. // Intentionally non-exhaustive: only message-producing events derive
  340. // history; turn/step boundaries, chunks, usage, and errors are
  341. // trace/replay data.
  342. switch (event.type) {
  343. case 'user/message': {
  344. return { role: 'user', content: structuredClone(event.data.content) }
  345. }
  346. case 'assistant/message': {
  347. // Skip an empty-content assistant/message: it exists only to host a
  348. // max-tokens step's usage and must not inject a content-less assistant
  349. // turn into the provider transcript.
  350. if (event.data.content.length === 0) return null
  351. return { role: 'assistant', content: structuredClone(event.data.content) }
  352. }
  353. case 'tool/result': {
  354. const { callId, content, isError } = event.data
  355. return {
  356. role: 'user',
  357. content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
  358. }
  359. }
  360. case 'context/message': {
  361. const { content, source } = event.data
  362. return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
  363. }
  364. case 'steering/message': {
  365. const { content, source } = event.data
  366. return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
  367. }
  368. default:
  369. // A non-surface event (boundary, chunk, log-only record) projects to
  370. // no message. Merge-extensible union: no assertNever here.
  371. return null
  372. }
  373. }
  374. }
  375. /** A fork source: either the live session object or its live store id. */
  376. export type SessionForkSource = Session | SessionId
  377. /**
  378. * Rejection codes for session forking: the fork source id is unknown to the
  379. * live store (`SESSION_NOT_FOUND`) or names a session object that is not the
  380. * store's live instance (`SESSION_NOT_LIVE`); the requested child id is
  381. * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
  382. * existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
  383. * `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
  384. */
  385. export type SessionForkErrorCode =
  386. | 'SESSION_NOT_FOUND'
  387. | 'SESSION_NOT_LIVE'
  388. | 'SESSION_ALREADY_EXISTS'
  389. | 'INVALID_BOUNDARY'
  390. | 'OPEN_TURN'
  391. /** Typed error for session fork rejections. */
  392. export class SessionForkError extends Error {
  393. constructor(message: string, public readonly code: SessionForkErrorCode) {
  394. super(message)
  395. this.name = 'SessionForkError'
  396. }
  397. }
  398. /**
  399. * In-memory session store (`ctx.sessions`).
  400. *
  401. * Persistence is intentionally not implemented here — persistence plugins
  402. * subscribe to `session/event` and flush on `session/flush` / dispose.
  403. */
  404. export class SessionStore extends Service {
  405. private store = new Map<SessionId, Session>()
  406. /**
  407. * Each live session's dispatch carrier, captured at {@link enter} from the
  408. * ENTERING context's scope tag (an agent session is entered through
  409. * `agent.ctx` ⇒ its events dispatch in that agent's scope; a bare session ⇒
  410. * subject-less carrier). WeakMap so a detached session drops its carrier
  411. * with the entry.
  412. */
  413. private carriers = new WeakMap<Session, Scoped<Session>>()
  414. private counter = 0
  415. constructor(ctx: Context) {
  416. super(ctx, 'sessions')
  417. }
  418. /**
  419. * Create a session owned by the calling fiber: disposing that fiber stops
  420. * event notification and removes the session from the store. `options.seed`
  421. * populates the session with a copy of those events (replay/fork);
  422. * `options.meta` attaches creation metadata (validated absolute `cwd`,
  423. * `parentSession` lineage) as the immutable {@link SessionHeader} (the store
  424. * fills `version`/`id`/`createdAt`).
  425. *
  426. * For an agent whose session must be torn down IN ORDER with its loop (so the
  427. * loop's final flush is captured before `onAppend` detaches), do NOT use this
  428. * — fold the session lifecycle into the agent's own effect via
  429. * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
  430. * `startOwned`).
  431. *
  432. * @param id - the session id; omitted, the store mints `session-<n>`.
  433. * @param options - seed events and/or creation metadata for the header.
  434. * @returns the live session, already entered and announced.
  435. * @throws if a session with `id` already exists, or if `meta.cwd` is a
  436. * non-absolute path (storage backends key directories off it).
  437. */
  438. create(id?: SessionId, options?: CreateSessionOptions): Session {
  439. const session = this.prepare(id, options)
  440. // Single effect owned by the calling fiber. Yield the detach BEFORE
  441. // announcing so a throwing `session/created` listener rolls the attach back
  442. // (the generator effect disposes already-yielded disposers on a throw)
  443. // instead of leaking the store entry + onAppend.
  444. this.ctx.effect(function* (this: SessionStore) {
  445. yield this.enter(session)
  446. this.announce(session)
  447. }.bind(this), 'sessions.create()')
  448. return session
  449. }
  450. /**
  451. * Build a session WITHOUT entering it into the store — validate the id/cwd and
  452. * construct the {@link Session} (with its immutable {@link SessionHeader}).
  453. * Pairs with {@link enter} + {@link announce}: a caller that owns a composite
  454. * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
  455. * effect so a fiber unload tears the session + agent down as a single ORDERED
  456. * chain rather than as racing sibling effects — which would detach `onAppend`
  457. * before the loop's closing `session/flush`, dropping the closing events.
  458. *
  459. * @param id - the session id; omitted, the store mints `session-<n>`.
  460. * @param options - seed events and/or creation metadata for the header.
  461. * @returns the constructed session, NOT yet in the store.
  462. * @throws if a session with `id` already exists, or if `meta.cwd` is a
  463. * non-absolute path.
  464. */
  465. prepare(id?: SessionId, options?: CreateSessionOptions): Session {
  466. const sessionId = SessionId(id ?? `session-${++this.counter}`)
  467. if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
  468. const cwd = options?.meta?.cwd
  469. if (cwd !== undefined && !isAbsolute(cwd)) {
  470. throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
  471. }
  472. const header: SessionHeader = {
  473. version: SESSION_FORMAT_VERSION,
  474. id: sessionId,
  475. createdAt: options?.meta?.createdAt ?? Date.now(),
  476. ...cwd !== undefined ? { cwd } : {},
  477. ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
  478. ...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {},
  479. }
  480. return new Session(sessionId, options?.seed, header)
  481. }
  482. /**
  483. * Enter a {@link prepare}d session into the store: wire `onAppend` →
  484. * `session/event` and add it to the store. Returns the DETACH disposer
  485. * (`onAppend = undefined` + store removal). Does NOT emit `session/created` —
  486. * the caller yields this disposer inside its effect and THEN calls
  487. * {@link announce}, so a throwing `session/created` listener rolls the attach
  488. * back instead of leaking it.
  489. *
  490. * Re-checks the id for a duplicate: `prepare` and `enter` are public
  491. * cross-package primitives and a caller may interleave arbitrary work (or
  492. * another create) between them, so a stale prepared session must NOT overwrite
  493. * a live store entry of the same id — its detach disposer would later delete
  494. * the REAL session. The {@link create} convenience and the agent factory call
  495. * the two back-to-back so they never trip this, but the public seam cannot
  496. * assume that.
  497. *
  498. * @param session - a {@link prepare}d session not yet in the store.
  499. * @returns the detach disposer (`onAppend = undefined` + store removal).
  500. * @throws if a session with this id is already in the store.
  501. */
  502. enter(session: Session): () => void {
  503. if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
  504. // The carrier is decided HERE, once, from the ENTERING context's scope tag
  505. // (`this.ctx` is the caller's context — the tracker mechanism): every
  506. // session/created|event|flush dispatch for this session uses it, so the
  507. // session's whole event feed is scope-filtered consistently. The base is
  508. // the session itself (scoped listeners' `this` is the session).
  509. const carrier = scopeTarget(session, scopeOf(this.ctx))
  510. this.carriers.set(session, carrier)
  511. const emitCtx = this.ctx
  512. session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
  513. this.store.set(session.id, session)
  514. let entered = true
  515. return () => {
  516. if (!entered) return
  517. entered = false
  518. session.onAppend = undefined
  519. this.carriers.delete(session)
  520. this.store.delete(session.id)
  521. }
  522. }
  523. /** Emit `session/created` for an {@link enter}ed session (with the carrier
  524. * {@link enter} captured). Separate from {@link enter} so the caller can
  525. * yield the detach disposer first (rollback safety — see {@link enter}).
  526. * @param session - the entered session to announce to listeners. */
  527. announce(session: Session): void {
  528. this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
  529. }
  530. /**
  531. * Dispatch the awaited `session/flush` durability checkpoint for `session`,
  532. * with the carrier captured at {@link enter}. THE flush entry point: the
  533. * store owns the carrier, so callers (the loop's turn-end checkpoint, idle
  534. * injection, teardown drains) must come through here rather than dispatch a
  535. * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
  536. * scoped-dispatch invariant can pin it.
  537. * @param session - the session whose buffered events must reach durable storage.
  538. * @returns resolves when every flush listener has settled; rejects if one rejects.
  539. */
  540. async flush(session: Session): Promise<void> {
  541. await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
  542. }
  543. /** Return the exact live session's carrier; detached/prepared objects reject. */
  544. private liveCarrierFor(session: Session): Scoped<Session> {
  545. if (this.store.get(session.id) !== session) {
  546. throw new Error(`session "${session.id}" is not live in this store`)
  547. }
  548. const carrier = this.carriers.get(session)
  549. // enter() installs store + carrier in one synchronous sequence; a live
  550. // session without one is an internal invariant violation, never fallback
  551. // to subject-less dispatch (that would silently cross scope boundaries).
  552. /* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
  553. if (carrier === undefined) {
  554. throw new Error(`session "${session.id}" has no dispatch carrier`)
  555. }
  556. return carrier
  557. }
  558. /**
  559. * Look up a live session.
  560. * @param id - the session id to look up.
  561. * @returns the session, or undefined when no live session has that id.
  562. */
  563. get(id: SessionId): Session | undefined {
  564. return this.store.get(id)
  565. }
  566. /**
  567. * All live sessions, in creation order.
  568. * @returns a fresh array; mutating it does not affect the store.
  569. */
  570. list(): Session[] {
  571. return [...this.store.values()]
  572. }
  573. /**
  574. * Create a live child session from a turn-enclosed prefix of a live source.
  575. * `boundary` is an inclusive source event seq; omitted means the source's
  576. * current last event. A non-empty selected slice must end at `turn/end`.
  577. *
  578. * @param source - Live source session object or id.
  579. * @param boundary - Inclusive source event seq to fork through; omitted means
  580. * the source's current last event, and omitted on an empty source forks an
  581. * empty child.
  582. * @param childSessionId - Optional child session id; omitted delegates to
  583. * `SessionStore`'s id policy.
  584. * @returns The created live child session.
  585. */
  586. fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {
  587. if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
  588. throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
  589. }
  590. const liveSource = this._resolveForkSource(source)
  591. const seed = this._forkSeed(liveSource, boundary)
  592. return this.create(childSessionId, {
  593. seed,
  594. meta: {
  595. ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
  596. parentSession: liveSource.id,
  597. seedLength: seed.length,
  598. },
  599. })
  600. }
  601. private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
  602. const events = session.events
  603. const lastEvent = events.at(-1)
  604. let boundary: number
  605. if (requestedBoundary !== undefined) {
  606. boundary = requestedBoundary
  607. } else {
  608. if (lastEvent === undefined) return []
  609. boundary = lastEvent.seq
  610. }
  611. if (!Number.isSafeInteger(boundary) || boundary < 0) {
  612. throw new SessionForkError(
  613. `fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
  614. 'INVALID_BOUNDARY',
  615. )
  616. }
  617. if (boundary >= events.length) {
  618. const lastSeq = events.at(-1)?.seq
  619. throw new SessionForkError(
  620. `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
  621. 'INVALID_BOUNDARY',
  622. )
  623. }
  624. const boundaryEvent = events[boundary]
  625. if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
  626. throw new SessionForkError(
  627. `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
  628. 'INVALID_BOUNDARY',
  629. )
  630. }
  631. if (boundaryEvent.type !== 'turn/end') {
  632. throw new SessionForkError(
  633. `fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
  634. 'OPEN_TURN',
  635. )
  636. }
  637. return events.slice(0, boundary + 1).map(event => structuredClone(event))
  638. }
  639. private _resolveForkSource(source: SessionForkSource): Session {
  640. if (typeof source === 'string') {
  641. const session = this.get(source)
  642. if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
  643. return session
  644. }
  645. const live = this.get(source.id)
  646. if (live === undefined) {
  647. throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
  648. }
  649. if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
  650. return source
  651. }
  652. }
  653. export default SessionStore