| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- /**
- * JSONL durable session-persistence backend. It stores a header and contiguous
- * events in one append-only file per session, and delegates orchestration to
- * {@link PersistenceCoordinator}. Its side-effect-free locator returns the
- * absolute per-session log target before materialization.
- * @module @deepseek-ai/dsh-session-persistence-jsonl
- */
- import { Context } from 'cordis'
- import z from 'schemastery'
- import { open, mkdir, readFile, readdir, link, rm, stat as fsStat, truncate } from 'node:fs/promises'
- import { dirname, resolve } from 'node:path'
- import { randomBytes } from 'node:crypto'
- import {
- SessionPersistence, PersistenceCoordinator,
- type PersistenceBackend, type SessionLocation, type StoredPrefix,
- } from '@deepseek-ai/dsh-session-persistence'
- import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
- import {
- encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
- } from './format.ts'
- import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
- /** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
- export interface Config {
- /**
- * Root directory for all session files. Required (no default): a default of
- * `process.cwd()` would scatter session files as the process's cwd changes
- * (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
- */
- root: string
- }
- /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
- function isENOENT(error: unknown): boolean {
- return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
- }
- /**
- * The JSONL persistence backend. Load as a plugin; it registers as
- * `ctx.sessionPersistence` and (via the coordinator) installs the write-path
- * listeners. Its torn-tail marker is the byte offset to truncate the log to.
- */
- export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend<number> {
- static inject = ['sessions']
- static Config: z<Config> = z.object({
- root: z.string().required(),
- })
- /**
- * Backend label for coordinator diagnostics and effects. It shadows
- * `Service.name` without changing the service key captured by the base
- * constructor.
- */
- override readonly name = 'session-persistence-jsonl'
- private root: string
- private coordinator: PersistenceCoordinator<number>
- constructor(ctx: Context, public config: Config) {
- super(ctx)
- // Resolve once so later process.cwd() changes cannot split one backend across roots.
- this.root = resolve(config.root)
- this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
- }
- // Each backend keeps the typed service surface beside its storage hooks;
- // extracting these trivial forwards would add an inheritance seam.
- /* jscpd:ignore-start */
- // --- SessionPersistence service surface (delegated to the coordinator) ---
- /** Resolve the absolute target path without touching the filesystem. */
- locate(meta: SessionHeader): SessionLocation {
- return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id) }
- }
- create(meta: SessionHeader): Promise<void> {
- return this.coordinator.create(meta)
- }
- append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
- return this.coordinator.append(id, events)
- }
- load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
- return this.coordinator.load(id)
- }
- // One method serves both public `list` and the backend hook; delegating it to
- // the coordinator would call this hook recursively.
- /* jscpd:ignore-end */
- // --- PersistenceBackend hooks (the file-bytes storage primitives) ---
- /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */
- async loadStored(id: SessionId): Promise<StoredPrefix<number> | undefined> {
- const file = await this.findLog(id)
- if (file === undefined) return undefined
- return this.readPrefix(file.path)
- }
- /**
- * Read a stored prefix within one cwd for HMR adoption. `undefined` names the
- * no-cwd bucket rather than an unknown cwd, so this never scans other buckets.
- */
- async loadLive(id: SessionId, cwd: string | undefined): Promise<StoredPrefix<number> | undefined> {
- const path = logPath(this.root, cwd, id)
- if (!await this.exists(path)) return undefined
- return this.readPrefix(path)
- }
- /**
- * Read a stored prefix and convert torn-tail state to the byte offset the
- * coordinator can round-trip without knowing the file format.
- */
- private async readPrefix(path: string): Promise<StoredPrefix<number>> {
- const buffer = await readFile(path)
- const { meta, events, committedBytes } = scanLog(buffer)
- return {
- meta,
- events,
- ...committedBytes < buffer.byteLength ? { tornMarker: committedBytes } : {},
- }
- }
- /** Durably append a batch, lazily materializing the file when not yet present. */
- async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> {
- if (isMaterialized) {
- await this.appendLines(meta, events)
- } else {
- await this.materialize(meta, events)
- }
- }
- /**
- * Make a crash repair durable: truncate the torn tail to `tornMarker` bytes (if
- * any), then append the synthetic `closers` (if any). Two fsync'd steps — the
- * seam does not require this to be atomic.
- */
- async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise<void> {
- if (tornMarker !== undefined) await this.repair(meta, tornMarker)
- if (closers.length > 0) await this.appendLines(meta, closers)
- }
- /** List all stored sessions' metadata (header line only — no full-log parse). */
- async list(): Promise<SessionHeader[]> {
- const metas: SessionHeader[] = []
- for (const dir of await this.listCwdDirs()) {
- for (const name of await this.listJsonl(dir)) {
- // Read only headers so listing scales with session count, not log size.
- const first = await this.readFirstLine(`${dir}/${name}`)
- if (first === undefined) continue // empty/half-written file
- const meta = parseHeaderMeta(first)
- if (meta === undefined) continue // not a session header
- metas.push(meta)
- }
- }
- return metas
- }
- // --- materialization / append / repair (file mechanics) ---
- /** Atomically write the header line + first batch (temp-write, fsync, publish). */
- private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
- const dir = sessionDir(this.root, meta.cwd)
- const finalPath = logPath(this.root, meta.cwd, meta.id)
- const content = this.initialLogContent(meta, events)
- /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
- if (process.platform === 'win32') {
- await this.materializeWin32(dir, finalPath, meta.id, content)
- } else {
- await this.materializePosix(dir, finalPath, meta.id, content)
- }
- }
- private initialLogContent(meta: SessionHeader, events: readonly SessionEvent[]): string {
- const header = JSON.stringify(toHeaderLine(meta))
- const body = events.map(eventLine).join('\n')
- return header + '\n' + body + '\n'
- }
- /* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
- private async materializePosix(dir: string, finalPath: string, id: SessionId, content: string): Promise<void> {
- await mkdir(this.root, { recursive: true, mode: 0o700 })
- await this.syncDirPosix(dirname(this.root))
- await mkdir(dir, { recursive: true, mode: 0o700 })
- await this.syncDirPosix(this.root)
- await this.rejectExistingLog(finalPath, id)
- const tmp = await this.writeSyncedTempFile(finalPath, content)
- // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
- // final path already exists, so two processes materializing the same id
- // concurrently cannot clobber each other. rename() would silently overwrite.
- let linked = false
- try {
- await link(tmp, finalPath)
- linked = true
- } finally {
- // Remove an unpublished temp on failure. After publication, defer cleanup
- // until the directory entry is durable so cleanup cannot reject a live log.
- /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
- if (!linked) await rm(tmp, { force: true })
- }
- // link() succeeded — the log is published. fsync the directory so the new
- // entry survives a power loss: the new link is not crash-durable until the
- // parent directory's metadata is synced.
- await this.syncDirPosix(dir)
- // Best-effort temp cleanup: the log is already published and durable, so a
- // failure to remove the (now-redundant) temp hard link must NOT reject the
- // append. Swallow only the rm failure; nothing else of consequence runs here.
- try {
- await rm(tmp, { force: true })
- } catch {
- /* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */
- }
- }
- /* v8 ignore stop */
- /* v8 ignore start -- native Windows coverage exercises this integration path */
- private async materializeWin32(dir: string, finalPath: string, id: SessionId, content: string): Promise<void> {
- await ensureDurableDirectoryWin32(this.root)
- await ensureDurableDirectoryWin32(dir)
- await this.rejectExistingLog(finalPath, id)
- const tmp = await this.writeSyncedTempFile(finalPath, content)
- try {
- await publishNewFileWin32(tmp, finalPath)
- } catch (error) {
- await rm(tmp, { force: true })
- throw error
- }
- }
- /* v8 ignore stop */
- private async rejectExistingLog(finalPath: string, id: SessionId): Promise<void> {
- // Never publish over an existing committed log: materialize is the FIRST
- // write of a session the backend believes is new. A file here means a
- // different session shares this id on disk — reject loudly. (createCore
- // already guards the create path, so this is unreachable-in-practice TOCTOU
- // defense.)
- /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
- if (await this.exists(finalPath)) {
- throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`)
- }
- }
- private async writeSyncedTempFile(finalPath: string, content: string): Promise<string> {
- const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
- const handle = await open(tmp, 'wx', 0o600)
- try {
- await handle.writeFile(content)
- await handle.sync()
- } finally {
- await handle.close()
- }
- return tmp
- }
- /** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
- /* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */
- private async syncDirPosix(dir: string): Promise<void> {
- const handle = await open(dir, 'r')
- try {
- await handle.sync()
- } finally {
- await handle.close()
- }
- }
- /* v8 ignore stop */
- /**
- * Append and fsync event lines. On a partial write or sync failure, restore the
- * previous size before rethrowing because the unchanged cursor will retry the
- * batch; leaving partial bytes would create duplicate sequence numbers.
- */
- private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
- const path = logPath(this.root, meta.cwd, meta.id)
- const handle = await open(path, 'a')
- let closed = false
- const closeAppendHandle = async (): Promise<void> => {
- if (closed) return
- closed = true
- await handle.close()
- }
- try {
- const { size: before } = await handle.stat()
- try {
- await handle.writeFile(events.map(eventLine).join('\n') + '\n')
- await handle.sync()
- } catch (error) {
- try {
- await closeAppendHandle()
- await this.rollbackAppend(path, before)
- } catch (rollbackError) {
- throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`)
- }
- throw error
- }
- } finally {
- await closeAppendHandle()
- }
- }
- private async rollbackAppend(path: string, size: number): Promise<void> {
- const handle = await open(path, 'r+')
- try {
- await handle.truncate(size)
- await handle.sync()
- } finally {
- await handle.close()
- }
- }
- /** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */
- private async repair(meta: SessionHeader, offset: number): Promise<void> {
- const path = logPath(this.root, meta.cwd, meta.id)
- await truncate(path, offset)
- const handle = await open(path, 'r+')
- try {
- await handle.sync()
- } finally {
- await handle.close()
- }
- }
- // --- discovery helpers ---
- /**
- * Read the first newline-terminated line of a file without loading the whole
- * file. Returns undefined if the file is empty or has no complete first line.
- * Reads in bounded chunks so a huge log costs only the header read.
- */
- private async readFirstLine(path: string): Promise<string | undefined> {
- const handle = await open(path, 'r')
- try {
- const chunks: Buffer[] = []
- const buf = Buffer.alloc(8192)
- for (;;) {
- const { bytesRead } = await handle.read(buf, 0, buf.length, null)
- if (bytesRead === 0) return undefined // EOF with no newline → no complete line
- const slice = buf.subarray(0, bytesRead)
- const nl = slice.indexOf(0x0a)
- if (nl !== -1) {
- chunks.push(slice.subarray(0, nl))
- return Buffer.concat(chunks).toString('utf8')
- }
- chunks.push(Buffer.from(slice))
- }
- } finally {
- await handle.close()
- }
- }
- /**
- * Find a session by id across cwd buckets for resume. Cwd-scoped HMR adoption
- * bypasses this scan so a no-cwd session cannot claim another bucket.
- */
- private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> {
- const target = encodeSegment(id) + '.jsonl'
- for (const dir of await this.listCwdDirs()) {
- const path = `${dir}/${target}`
- if (await this.exists(path)) {
- // Recover the cwd from the header so the caller has the session's bucket.
- const { meta } = scanLog(await readFile(path))
- return { path, cwd: meta.cwd }
- }
- }
- return undefined
- }
- /** The cwd-bucket directories under the root (absolute paths). */
- private async listCwdDirs(): Promise<string[]> {
- try {
- const entries = await readdir(this.root, { withFileTypes: true })
- return entries.filter(e => e.isDirectory()).map(e => `${this.root}/${e.name}`)
- } catch (error) {
- // Only an absent root means no sessions; rethrow every other I/O failure.
- if (isENOENT(error)) return []
- throw error
- }
- }
- private async listJsonl(dir: string): Promise<string[]> {
- const entries = await readdir(dir)
- return entries.filter(n => n.endsWith('.jsonl'))
- }
- private async exists(path: string): Promise<boolean> {
- try {
- const handle = await open(path, 'r')
- await handle.close()
- return true
- } catch (error) {
- // Only ENOENT means absent. A permission/I/O error must surface rather
- // than letting load or collision checks proceed under false absence.
- // Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify
- // the immediate parent so a blocked cwd bucket remains a storage fault.
- /* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
- if (isENOENT(error)) {
- await this.assertLogParentAllowsAbsence(path)
- return false
- }
- /* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */
- throw error
- }
- }
- /* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */
- private async assertLogParentAllowsAbsence(path: string): Promise<void> {
- try {
- const parent = dirname(path)
- const info = await fsStat(parent)
- if (info.isDirectory()) return
- const error = new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`) as NodeJS.ErrnoException
- error.code = 'ENOTDIR'
- error.path = parent
- throw error
- } catch (error) {
- if (isENOENT(error)) return
- throw error
- }
- }
- /* v8 ignore stop */
- }
- export default SessionPersistenceJsonl
|