index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. /**
  2. * Durable session-persistence Service Definition (`ctx.sessionPersistence`). Backends store
  3. * {@link SessionEvent}s as the event-sourced log and carry non-replayable
  4. * {@link SessionHeader} metadata separately.
  5. * @module @deepseek-ai/dsh-session-persistence
  6. */
  7. import { Context, Service } from '@deepseek-ai/cordis'
  8. import { SessionPreparation } from '@deepseek-ai/dsh-session'
  9. import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
  10. import type { SessionPersistenceRevision } from './revision.ts'
  11. // Re-export the metadata vocabulary so Consumers import it from the Service Definition.
  12. export type { SessionHeader } from '@deepseek-ai/dsh-session'
  13. export { SessionPersistenceRevision } from './revision.ts'
  14. /** Lightweight immutable source identity returned without loading a full log. */
  15. export interface SessionPersistenceSnapshot {
  16. /** Detached metadata for one materialized session. */
  17. header: SessionHeader
  18. /** Opaque source-qualified token that changes whenever this stored log changes. */
  19. revision: SessionPersistenceRevision
  20. }
  21. /** Immutable logical session prepared from persistence or a live owner. */
  22. export interface SessionInspection {
  23. /** Validated immutable session metadata. */
  24. readonly meta: SessionHeader
  25. /** Validated contiguous logical event log. */
  26. readonly events: readonly SessionEvent[]
  27. }
  28. /** A backend's own raw artifact text for one session, verbatim. */
  29. export interface SessionRawArtifact {
  30. /** The session header parsed from the artifact's own first line. */
  31. readonly meta: SessionHeader
  32. /** The artifact's base filename on disk, without any physical encoding suffix. */
  33. readonly filename: string
  34. /** The artifact's full text content, decoded from the backend's physical encoding. */
  35. readonly content: string
  36. }
  37. // The backend-agnostic write-path orchestration first-party backends compose.
  38. export {
  39. DEFAULT_PREPARED_SESSION_CACHE_SIZE,
  40. DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
  41. MAX_WRITE_BATCH_DELAY_MS,
  42. PersistenceCoordinator,
  43. SessionFormatUnsupportedError,
  44. SessionPersistenceCorruptionError,
  45. sessionFormatVersionRefusal,
  46. } from './coordinator.ts'
  47. export type {
  48. PersistenceBackend,
  49. PersistenceCoordinatorOptions,
  50. StoredPrefix,
  51. StoredSuffix,
  52. } from './coordinator.ts'
  53. declare module '@deepseek-ai/cordis' {
  54. interface Context {
  55. sessionPersistence: SessionPersistence
  56. }
  57. }
  58. /**
  59. * A backend-resolved, per-session local artifact location. The path is an
  60. * absolute target path and can name an artifact that has not materialized yet.
  61. * Consumers must treat it as a location hint, never as an authorization token.
  62. */
  63. export interface SessionLocation {
  64. /** Backend-specific artifact kind, for example `jsonl`. */
  65. readonly kind: string
  66. /** Absolute path to this session's backend-owned artifact. */
  67. readonly path: string
  68. }
  69. /**
  70. * Durable append-only session storage. Implementations preserve contiguous,
  71. * losslessly JSON-serializable events; {@link append} resolves only after
  72. * durability, and {@link load} balances a complete interrupted tail without
  73. * rewriting committed events.
  74. */
  75. export abstract class SessionPersistence extends Service {
  76. constructor(ctx: Context) {
  77. super(ctx, 'sessionPersistence')
  78. }
  79. /**
  80. * Resolve this backend's independent local artifact for a session without
  81. * reading, creating, flushing, or otherwise materializing it. Backends such
  82. * as SQLite that do not own one artifact per session return `undefined`.
  83. * @param meta - the immutable session header whose artifact is requested.
  84. * @returns the backend-specific absolute location, when one exists.
  85. */
  86. abstract locate(meta: SessionHeader): SessionLocation | undefined
  87. /**
  88. * Whether this backend exposes one verbatim raw artifact per session.
  89. * A backend that declares `true` must override {@link readRaw}.
  90. */
  91. abstract readonly supportsRawArtifacts: boolean
  92. /**
  93. * Read a session's backend-owned artifact text verbatim — the exact durable
  94. * bytes the backend wrote (decoded from its physical encoding, e.g. a
  95. * decompressed JSONL). The returned `content` is the raw text, not a
  96. * reconstruction from parsed events, so it preserves backend-specific
  97. * serialization (chunk packing, key order, line breaks). Callers first test
  98. * {@link supportsRawArtifacts}; `undefined` then means only that the requested
  99. * session has no materialized artifact.
  100. * @param _id - the persisted session to read (unused by the default: no
  101. * per-session artifact).
  102. * @param signal - optional cancellation for backend read work.
  103. * @returns the raw artifact plus its parsed header, or `undefined` when the
  104. * session is absent.
  105. * @throws when this backend does not expose per-session raw artifacts.
  106. */
  107. readRaw(_id: SessionId, signal?: AbortSignal): Promise<SessionRawArtifact | undefined> {
  108. if (signal?.aborted === true) {
  109. return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
  110. }
  111. return Promise.reject(new Error('this session persistence backend does not expose raw artifacts'))
  112. }
  113. /**
  114. * Register a new session's metadata. A backend MAY defer the physical write
  115. * until the first {@link append} (lazy materialization), in which case a
  116. * created-but-never-appended session is absent from {@link list}
  117. * — abandoned sessions leave nothing behind.
  118. * @param meta - the immutable header (id, version, cwd, lineage) to record.
  119. */
  120. abstract create(meta: SessionHeader): Promise<void>
  121. /**
  122. * Durably persist a batch of events. Honors the append-only and contiguous-
  123. * seq contracts: the first event's `seq` MUST equal the stored next-seq
  124. * (after `load` has durably closed any interrupted turn). Rejects non-JSON-
  125. * serializable `event.data` with an error naming the offending event type.
  126. * @param id - the session the batch belongs to.
  127. * @param events - the contiguous batch to persist, in seq order.
  128. */
  129. abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
  130. /**
  131. * Prepare the exact unpublished Session used by resume. Implementations may
  132. * reuse object graphs retained by an earlier {@link inspect} after confirming
  133. * their durable revision is still current; disposal releases an unpublished
  134. * reservation. Revision retries require the durable log to remain unchanged
  135. * for one read/check round trip; continuous external writers may delay completion.
  136. * @param id - persisted session to prepare.
  137. * @param signal - optional cancellation for preparation work.
  138. * @returns one owned unpublished Session preparation.
  139. */
  140. async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> {
  141. signal?.throwIfAborted()
  142. const loaded = await this.load(id)
  143. signal?.throwIfAborted()
  144. const sessions = this.ctx.get('sessions')
  145. if (sessions === undefined) {
  146. throw new Error('cannot prepare a session: SessionStore is not configured')
  147. }
  148. return SessionPreparation.create(sessions.prepare(id, {
  149. seed: loaded.events.map(event => structuredClone(event)),
  150. meta: structuredClone(loaded.meta),
  151. seedSource: 'persistence',
  152. }))
  153. }
  154. /**
  155. * Load an immutable balanced logical view and commit any required cold
  156. * recovery. A complete interrupted final turn is preserved and durably
  157. * closed with missing tool errors plus any open step and turn boundaries;
  158. * only a torn final record is discarded. Unknown versions and corruption in
  159. * the committed prefix reject. Implementations MUST NOT crash-repair an
  160. * identity still bound to a live Session: a balanced live log may return as a
  161. * durable snapshot, while an open live turn rejects. Returned values may be
  162. * shared with immutable live or prepared state and must not be mutated.
  163. * Revision-based implementations may wait for one stable read/check round trip.
  164. * @param id - the persisted session to reload.
  165. * @returns the header and a log ending on a balanced `turn/end`.
  166. */
  167. abstract load(id: SessionId): Promise<SessionInspection>
  168. /**
  169. * Inspect an immutable logical session without committing recovery or
  170. * publishing it. A cold complete interrupted turn receives synthetic closers
  171. * in memory and a torn physical tail remains untouched. An already-live
  172. * Session instead yields its current immutable snapshot, which may contain an
  173. * open turn and its `session/end-seed` boundary. Coordinator-backed
  174. * implementations retain the exact cold unpublished Session for bounded
  175. * reuse by a later {@link prepare}. A stale ready source is reloaded; a source
  176. * already committing or reserved for resume remains exclusive, and inspection
  177. * may borrow its immutable view. Callers borrow only the immutable header and
  178. * log. Continuous external writers may delay revision convergence.
  179. * @param id - the persisted session to inspect.
  180. * @param signal - optional cancellation for queued and backend read work.
  181. * @returns the validated header and current logical event log.
  182. */
  183. abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>
  184. /**
  185. * Read the stored events from `fromSeq` onward — the read-from-seq
  186. * primitive for read models that resume from a watermark (e.g. a persisted
  187. * projection cache folding only the tail past its checkpoint). Unlike
  188. * {@link inspect}, it is a detached physical suffix read: no preparation
  189. * cache, torn-tail truncation, synthetic closers, or coordinator-state
  190. * publication. Only events from the valid contiguous stored prefix are
  191. * returned, so a torn fragment never reaches the caller. `fromSeq` at or
  192. * beyond the stored prefix returns an empty event list (never an error).
  193. * Backends whose medium can seek by seq
  194. * (SQLite) read only the suffix; sequential media (JSONL, both encodings)
  195. * still parse the whole artifact and skip forward — the primitive bounds
  196. * what is RETURNED and refolded, not every backend's physical read.
  197. * @param id - the persisted session to read.
  198. * @param fromSeq - first event seq to include; a non-negative safe integer.
  199. * @param signal - optional cancellation for queued and backend read work.
  200. * @returns the header and the stored events with `seq >= fromSeq`.
  201. */
  202. abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal):
  203. Promise<{ meta: SessionHeader; events: SessionEvent[] }>
  204. /**
  205. * Lightweight listing from metadata, without a full-log parse.
  206. * @param signal - optional cancellation for backend listing work.
  207. * @returns one header per materialized session.
  208. */
  209. abstract list(signal?: AbortSignal): Promise<SessionHeader[]>
  210. /**
  211. * List materialized sessions with cheap per-log change tokens.
  212. *
  213. * Repeated observations of an unchanged log return the same revision. A
  214. * successful mutating {@link load} repair changes the next listed revision.
  215. * Revisions also distinguish independently backed stores so backend-local
  216. * counters cannot compare equal across different persistence sources.
  217. * @param signal - optional cancellation for backend snapshot-listing work.
  218. * @returns one header and opaque revision per materialized session without loading full logs.
  219. */
  220. abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>
  221. }
  222. export default SessionPersistence