index.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. /**
  2. * JSONL durable session-persistence backend. It stores a header and contiguous
  3. * events in one append-only file per session, and delegates orchestration to
  4. * {@link PersistenceCoordinator}. Its side-effect-free locator returns the
  5. * absolute per-session log target before materialization.
  6. * @module @deepseek-ai/dsh-session-persistence-jsonl
  7. */
  8. import { Context } from 'cordis'
  9. import z from 'schemastery'
  10. import { open, mkdir, readFile, readdir, link, rm, stat as fsStat, truncate } from 'node:fs/promises'
  11. import { dirname, resolve } from 'node:path'
  12. import { randomBytes } from 'node:crypto'
  13. import {
  14. SessionPersistence, PersistenceCoordinator,
  15. type PersistenceBackend, type SessionLocation, type StoredPrefix,
  16. } from '@deepseek-ai/dsh-session-persistence'
  17. import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
  18. import {
  19. encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
  20. } from './format.ts'
  21. import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
  22. /** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
  23. export interface Config {
  24. /**
  25. * Root directory for all session files. Required (no default): a default of
  26. * `process.cwd()` would scatter session files as the process's cwd changes
  27. * (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
  28. */
  29. root: string
  30. }
  31. /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
  32. function isENOENT(error: unknown): boolean {
  33. return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
  34. }
  35. /**
  36. * The JSONL persistence backend. Load as a plugin; it registers as
  37. * `ctx.sessionPersistence` and (via the coordinator) installs the write-path
  38. * listeners. Its torn-tail marker is the byte offset to truncate the log to.
  39. */
  40. export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend<number> {
  41. static inject = ['sessions']
  42. static Config: z<Config> = z.object({
  43. root: z.string().required(),
  44. })
  45. /**
  46. * Backend label for coordinator diagnostics and effects. It shadows
  47. * `Service.name` without changing the service key captured by the base
  48. * constructor.
  49. */
  50. override readonly name = 'session-persistence-jsonl'
  51. private root: string
  52. private coordinator: PersistenceCoordinator<number>
  53. constructor(ctx: Context, public config: Config) {
  54. super(ctx)
  55. // Resolve once so later process.cwd() changes cannot split one backend across roots.
  56. this.root = resolve(config.root)
  57. this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
  58. }
  59. // Each backend keeps the typed service surface beside its storage hooks;
  60. // extracting these trivial forwards would add an inheritance seam.
  61. /* jscpd:ignore-start */
  62. // --- SessionPersistence service surface (delegated to the coordinator) ---
  63. /** Resolve the absolute target path without touching the filesystem. */
  64. locate(meta: SessionHeader): SessionLocation {
  65. return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id) }
  66. }
  67. create(meta: SessionHeader): Promise<void> {
  68. return this.coordinator.create(meta)
  69. }
  70. append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
  71. return this.coordinator.append(id, events)
  72. }
  73. load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
  74. return this.coordinator.load(id)
  75. }
  76. // One method serves both public `list` and the backend hook; delegating it to
  77. // the coordinator would call this hook recursively.
  78. /* jscpd:ignore-end */
  79. // --- PersistenceBackend hooks (the file-bytes storage primitives) ---
  80. /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */
  81. async loadStored(id: SessionId): Promise<StoredPrefix<number> | undefined> {
  82. const file = await this.findLog(id)
  83. if (file === undefined) return undefined
  84. return this.readPrefix(file.path)
  85. }
  86. /**
  87. * Read a stored prefix within one cwd for HMR adoption. `undefined` names the
  88. * no-cwd bucket rather than an unknown cwd, so this never scans other buckets.
  89. */
  90. async loadLive(id: SessionId, cwd: string | undefined): Promise<StoredPrefix<number> | undefined> {
  91. const path = logPath(this.root, cwd, id)
  92. if (!await this.exists(path)) return undefined
  93. return this.readPrefix(path)
  94. }
  95. /**
  96. * Read a stored prefix and convert torn-tail state to the byte offset the
  97. * coordinator can round-trip without knowing the file format.
  98. */
  99. private async readPrefix(path: string): Promise<StoredPrefix<number>> {
  100. const buffer = await readFile(path)
  101. const { meta, events, committedBytes } = scanLog(buffer)
  102. return {
  103. meta,
  104. events,
  105. ...committedBytes < buffer.byteLength ? { tornMarker: committedBytes } : {},
  106. }
  107. }
  108. /** Durably append a batch, lazily materializing the file when not yet present. */
  109. async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> {
  110. if (isMaterialized) {
  111. await this.appendLines(meta, events)
  112. } else {
  113. await this.materialize(meta, events)
  114. }
  115. }
  116. /**
  117. * Make a crash repair durable: truncate the torn tail to `tornMarker` bytes (if
  118. * any), then append the synthetic `closers` (if any). Two fsync'd steps — the
  119. * seam does not require this to be atomic.
  120. */
  121. async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise<void> {
  122. if (tornMarker !== undefined) await this.repair(meta, tornMarker)
  123. if (closers.length > 0) await this.appendLines(meta, closers)
  124. }
  125. /** List all stored sessions' metadata (header line only — no full-log parse). */
  126. async list(): Promise<SessionHeader[]> {
  127. const metas: SessionHeader[] = []
  128. for (const dir of await this.listCwdDirs()) {
  129. for (const name of await this.listJsonl(dir)) {
  130. // Read only headers so listing scales with session count, not log size.
  131. const first = await this.readFirstLine(`${dir}/${name}`)
  132. if (first === undefined) continue // empty/half-written file
  133. const meta = parseHeaderMeta(first)
  134. if (meta === undefined) continue // not a session header
  135. metas.push(meta)
  136. }
  137. }
  138. return metas
  139. }
  140. // --- materialization / append / repair (file mechanics) ---
  141. /** Atomically write the header line + first batch (temp-write, fsync, publish). */
  142. private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
  143. const dir = sessionDir(this.root, meta.cwd)
  144. const finalPath = logPath(this.root, meta.cwd, meta.id)
  145. const content = this.initialLogContent(meta, events)
  146. /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
  147. if (process.platform === 'win32') {
  148. await this.materializeWin32(dir, finalPath, meta.id, content)
  149. } else {
  150. await this.materializePosix(dir, finalPath, meta.id, content)
  151. }
  152. }
  153. private initialLogContent(meta: SessionHeader, events: readonly SessionEvent[]): string {
  154. const header = JSON.stringify(toHeaderLine(meta))
  155. const body = events.map(eventLine).join('\n')
  156. return header + '\n' + body + '\n'
  157. }
  158. /* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
  159. private async materializePosix(dir: string, finalPath: string, id: SessionId, content: string): Promise<void> {
  160. await mkdir(this.root, { recursive: true, mode: 0o700 })
  161. await this.syncDirPosix(dirname(this.root))
  162. await mkdir(dir, { recursive: true, mode: 0o700 })
  163. await this.syncDirPosix(this.root)
  164. await this.rejectExistingLog(finalPath, id)
  165. const tmp = await this.writeSyncedTempFile(finalPath, content)
  166. // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
  167. // final path already exists, so two processes materializing the same id
  168. // concurrently cannot clobber each other. rename() would silently overwrite.
  169. let linked = false
  170. try {
  171. await link(tmp, finalPath)
  172. linked = true
  173. } finally {
  174. // Remove an unpublished temp on failure. After publication, defer cleanup
  175. // until the directory entry is durable so cleanup cannot reject a live log.
  176. /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
  177. if (!linked) await rm(tmp, { force: true })
  178. }
  179. // link() succeeded — the log is published. fsync the directory so the new
  180. // entry survives a power loss: the new link is not crash-durable until the
  181. // parent directory's metadata is synced.
  182. await this.syncDirPosix(dir)
  183. // Best-effort temp cleanup: the log is already published and durable, so a
  184. // failure to remove the (now-redundant) temp hard link must NOT reject the
  185. // append. Swallow only the rm failure; nothing else of consequence runs here.
  186. try {
  187. await rm(tmp, { force: true })
  188. } catch {
  189. /* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */
  190. }
  191. }
  192. /* v8 ignore stop */
  193. /* v8 ignore start -- native Windows coverage exercises this integration path */
  194. private async materializeWin32(dir: string, finalPath: string, id: SessionId, content: string): Promise<void> {
  195. await ensureDurableDirectoryWin32(this.root)
  196. await ensureDurableDirectoryWin32(dir)
  197. await this.rejectExistingLog(finalPath, id)
  198. const tmp = await this.writeSyncedTempFile(finalPath, content)
  199. try {
  200. await publishNewFileWin32(tmp, finalPath)
  201. } catch (error) {
  202. await rm(tmp, { force: true })
  203. throw error
  204. }
  205. }
  206. /* v8 ignore stop */
  207. private async rejectExistingLog(finalPath: string, id: SessionId): Promise<void> {
  208. // Never publish over an existing committed log: materialize is the FIRST
  209. // write of a session the backend believes is new. A file here means a
  210. // different session shares this id on disk — reject loudly. (createCore
  211. // already guards the create path, so this is unreachable-in-practice TOCTOU
  212. // defense.)
  213. /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
  214. if (await this.exists(finalPath)) {
  215. throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`)
  216. }
  217. }
  218. private async writeSyncedTempFile(finalPath: string, content: string): Promise<string> {
  219. const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
  220. const handle = await open(tmp, 'wx', 0o600)
  221. try {
  222. await handle.writeFile(content)
  223. await handle.sync()
  224. } finally {
  225. await handle.close()
  226. }
  227. return tmp
  228. }
  229. /** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
  230. /* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */
  231. private async syncDirPosix(dir: string): Promise<void> {
  232. const handle = await open(dir, 'r')
  233. try {
  234. await handle.sync()
  235. } finally {
  236. await handle.close()
  237. }
  238. }
  239. /* v8 ignore stop */
  240. /**
  241. * Append and fsync event lines. On a partial write or sync failure, restore the
  242. * previous size before rethrowing because the unchanged cursor will retry the
  243. * batch; leaving partial bytes would create duplicate sequence numbers.
  244. */
  245. private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
  246. const path = logPath(this.root, meta.cwd, meta.id)
  247. const handle = await open(path, 'a')
  248. let closed = false
  249. const closeAppendHandle = async (): Promise<void> => {
  250. if (closed) return
  251. closed = true
  252. await handle.close()
  253. }
  254. try {
  255. const { size: before } = await handle.stat()
  256. try {
  257. await handle.writeFile(events.map(eventLine).join('\n') + '\n')
  258. await handle.sync()
  259. } catch (error) {
  260. try {
  261. await closeAppendHandle()
  262. await this.rollbackAppend(path, before)
  263. } catch (rollbackError) {
  264. throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`)
  265. }
  266. throw error
  267. }
  268. } finally {
  269. await closeAppendHandle()
  270. }
  271. }
  272. private async rollbackAppend(path: string, size: number): Promise<void> {
  273. const handle = await open(path, 'r+')
  274. try {
  275. await handle.truncate(size)
  276. await handle.sync()
  277. } finally {
  278. await handle.close()
  279. }
  280. }
  281. /** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */
  282. private async repair(meta: SessionHeader, offset: number): Promise<void> {
  283. const path = logPath(this.root, meta.cwd, meta.id)
  284. await truncate(path, offset)
  285. const handle = await open(path, 'r+')
  286. try {
  287. await handle.sync()
  288. } finally {
  289. await handle.close()
  290. }
  291. }
  292. // --- discovery helpers ---
  293. /**
  294. * Read the first newline-terminated line of a file without loading the whole
  295. * file. Returns undefined if the file is empty or has no complete first line.
  296. * Reads in bounded chunks so a huge log costs only the header read.
  297. */
  298. private async readFirstLine(path: string): Promise<string | undefined> {
  299. const handle = await open(path, 'r')
  300. try {
  301. const chunks: Buffer[] = []
  302. const buf = Buffer.alloc(8192)
  303. for (;;) {
  304. const { bytesRead } = await handle.read(buf, 0, buf.length, null)
  305. if (bytesRead === 0) return undefined // EOF with no newline → no complete line
  306. const slice = buf.subarray(0, bytesRead)
  307. const nl = slice.indexOf(0x0a)
  308. if (nl !== -1) {
  309. chunks.push(slice.subarray(0, nl))
  310. return Buffer.concat(chunks).toString('utf8')
  311. }
  312. chunks.push(Buffer.from(slice))
  313. }
  314. } finally {
  315. await handle.close()
  316. }
  317. }
  318. /**
  319. * Find a session by id across cwd buckets for resume. Cwd-scoped HMR adoption
  320. * bypasses this scan so a no-cwd session cannot claim another bucket.
  321. */
  322. private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> {
  323. const target = encodeSegment(id) + '.jsonl'
  324. for (const dir of await this.listCwdDirs()) {
  325. const path = `${dir}/${target}`
  326. if (await this.exists(path)) {
  327. // Recover the cwd from the header so the caller has the session's bucket.
  328. const { meta } = scanLog(await readFile(path))
  329. return { path, cwd: meta.cwd }
  330. }
  331. }
  332. return undefined
  333. }
  334. /** The cwd-bucket directories under the root (absolute paths). */
  335. private async listCwdDirs(): Promise<string[]> {
  336. try {
  337. const entries = await readdir(this.root, { withFileTypes: true })
  338. return entries.filter(e => e.isDirectory()).map(e => `${this.root}/${e.name}`)
  339. } catch (error) {
  340. // Only an absent root means no sessions; rethrow every other I/O failure.
  341. if (isENOENT(error)) return []
  342. throw error
  343. }
  344. }
  345. private async listJsonl(dir: string): Promise<string[]> {
  346. const entries = await readdir(dir)
  347. return entries.filter(n => n.endsWith('.jsonl'))
  348. }
  349. private async exists(path: string): Promise<boolean> {
  350. try {
  351. const handle = await open(path, 'r')
  352. await handle.close()
  353. return true
  354. } catch (error) {
  355. // Only ENOENT means absent. A permission/I/O error must surface rather
  356. // than letting load or collision checks proceed under false absence.
  357. // Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify
  358. // the immediate parent so a blocked cwd bucket remains a storage fault.
  359. /* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
  360. if (isENOENT(error)) {
  361. await this.assertLogParentAllowsAbsence(path)
  362. return false
  363. }
  364. /* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */
  365. throw error
  366. }
  367. }
  368. /* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */
  369. private async assertLogParentAllowsAbsence(path: string): Promise<void> {
  370. try {
  371. const parent = dirname(path)
  372. const info = await fsStat(parent)
  373. if (info.isDirectory()) return
  374. const error = new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`) as NodeJS.ErrnoException
  375. error.code = 'ENOTDIR'
  376. error.path = parent
  377. throw error
  378. } catch (error) {
  379. if (isENOENT(error)) return
  380. throw error
  381. }
  382. }
  383. /* v8 ignore stop */
  384. }
  385. export default SessionPersistenceJsonl