Merge remote-tracking branch 'origin/master' into session-query-tool
# Conflicts: # docs/core-data-structures/persistence.i18n.yaml # packages/session-persistence/session-persistence-jsonl/src/index.ts
This commit is contained in:
@@ -2,13 +2,12 @@
|
||||
* On-disk format helpers for the JSONL session-persistence backend: path
|
||||
* sanitization (a {@link SessionId} is an unvalidated branded string, so it
|
||||
* MUST be encoded before use in a path — no traversal, no collision), the
|
||||
* per-cwd directory layout, header-line (de)serialization, and the
|
||||
* per-project/session directory layout, header-line (de)serialization, and the
|
||||
* truncation-repair offset computation.
|
||||
*
|
||||
* @module dsh-session-persistence-jsonl/format
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
|
||||
@@ -123,24 +122,64 @@ export function encodeSegment(raw: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory a session's files live in: the configured root, then a per-cwd
|
||||
* subdirectory so sessions group by project. The cwd subdir is a stable hash of
|
||||
* the cwd (short, collision-resistant, filesystem-safe); sessions without a
|
||||
* cwd go in a shared `_no-cwd` bucket.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory; `undefined` selects the shared `_no-cwd` bucket.
|
||||
* @returns the per-cwd bucket directory path under `root`.
|
||||
* Build the readable directory key for a project path.
|
||||
* Filesystem separators and drive separators become `-`; unsafe code units use
|
||||
* the same `~XXXX` escape as session ids. The key is bounded for filesystem
|
||||
* component limits. Separator replacement and truncation are intentionally
|
||||
* lossy, following the common human-navigable project-directory convention.
|
||||
* @param cwd - the session's project directory.
|
||||
* @returns a single filesystem-safe project directory name.
|
||||
*/
|
||||
export function sessionDir(root: string, cwd: string | undefined): string {
|
||||
export function projectKey(cwd: string): string {
|
||||
if (cwd.length === 0) throw new Error('cannot encode an empty project path')
|
||||
let readable = ''
|
||||
let separatorRun = false
|
||||
for (let i = 0; i < cwd.length; i++) {
|
||||
const code = cwd.charCodeAt(i)
|
||||
const ch = String.fromCharCode(code)
|
||||
if (ch === '/' || ch === '\\' || ch === ':') {
|
||||
if (!separatorRun) readable += '-'
|
||||
separatorRun = true
|
||||
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
||||
readable += ch
|
||||
separatorRun = false
|
||||
} else {
|
||||
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
||||
separatorRun = false
|
||||
}
|
||||
}
|
||||
const slug = readable.replace(/^-+/, '') || 'root'
|
||||
return `--${slug.slice(0, 251)}--`
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured root's human-navigable project directory. A configured root
|
||||
* may be local or shared; this grouping does not prescribe its deployment.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory; `undefined` selects `_no-cwd`.
|
||||
* @returns the project directory path under `root`.
|
||||
*/
|
||||
export function projectDir(root: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined) return join(root, '_no-cwd')
|
||||
const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 12)
|
||||
return join(root, `cwd-${hash}`)
|
||||
return join(root, projectKey(cwd))
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory owned by one session and available for future session-local
|
||||
* artifacts.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory.
|
||||
* @param id - the session id, encoded to one safe path segment.
|
||||
* @returns the session directory beneath its project directory.
|
||||
*/
|
||||
export function sessionDir(root: string, cwd: string | undefined, id: SessionId): string {
|
||||
return join(projectDir(root, cwd), encodeSegment(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The append-only event-log file path for a session.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`).
|
||||
* @param cwd - the session's project directory (`undefined` → `_no-cwd`).
|
||||
* @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
|
||||
* @param compression - physical artifact encoding and filename suffix.
|
||||
* @returns the session's configured JSONL artifact path.
|
||||
@@ -151,7 +190,7 @@ export function logPath(
|
||||
id: SessionId,
|
||||
compression: JsonlCompression,
|
||||
): string {
|
||||
return join(sessionDir(root, cwd), `${encodeSegment(id)}${logSuffix(compression)}`)
|
||||
return join(sessionDir(root, cwd, id), `session${logSuffix(compression)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { open, mkdir, readFile, readdir, link, rm, stat, truncate } from 'node:fs/promises'
|
||||
import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
|
||||
encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, toHeaderLine,
|
||||
type JsonlCompression,
|
||||
} from './format.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
|
||||
@@ -40,9 +40,9 @@ 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. An
|
||||
* existing root must be a readable directory; an absent root is created on
|
||||
* first materialization.
|
||||
* (bash calls, subprocesses). Sessions group under human-readable project
|
||||
* directories, then per-session directories. An existing root must be a
|
||||
* readable directory; an absent root is created on first materialization.
|
||||
*/
|
||||
root: string
|
||||
/**
|
||||
@@ -141,7 +141,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
/* jscpd:ignore-end */
|
||||
// --- PersistenceBackend hooks (the file-bytes storage primitives) ---
|
||||
|
||||
/** Read a stored prefix by id across all cwd buckets when cwd is unknown. */
|
||||
/** Read a stored prefix by id across all project directories when cwd is unknown. */
|
||||
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
|
||||
signal?.throwIfAborted()
|
||||
await this.ensureRootEncoding()
|
||||
@@ -178,7 +178,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
this.assertStoredIdentity(path, prefix.meta, expectedId)
|
||||
await this.assertStoredIdentity(path, prefix.meta, expectedId, signal)
|
||||
signal?.throwIfAborted()
|
||||
return prefix
|
||||
}
|
||||
|
||||
@@ -313,10 +314,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
signal?.throwIfAborted()
|
||||
const artifacts: Array<{ header: SessionHeader; path: string }> = []
|
||||
const ids = new Set<SessionId>()
|
||||
for (const dir of await this.listCwdDirs(signal)) {
|
||||
for (const name of await this.listArtifactNames(dir, signal)) {
|
||||
for (const project of await this.listProjectDirs(signal)) {
|
||||
signal?.throwIfAborted()
|
||||
for (const dir of await this.listSessionDirs(project, signal)) {
|
||||
signal?.throwIfAborted()
|
||||
const path = join(dir, name)
|
||||
const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`)
|
||||
const oppositeExists = await this.exists(opposite)
|
||||
signal?.throwIfAborted()
|
||||
if (oppositeExists) throw this.encodingMismatch(opposite)
|
||||
const path = join(dir, `session${logSuffix(this.compression)}`)
|
||||
const pathExists = await this.exists(path)
|
||||
signal?.throwIfAborted()
|
||||
if (!pathExists) continue
|
||||
// Read only headers so listing scales with session count, not log size.
|
||||
const first = this.compression === 'zstd'
|
||||
? await this.readFirstZstdLine(path, signal)
|
||||
@@ -325,14 +334,16 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
if (first === undefined) continue // empty/half-written file
|
||||
const meta = parseHeaderMeta(first)
|
||||
if (meta === undefined) continue // not a session header
|
||||
this.assertStoredIdentity(path, meta)
|
||||
await this.assertStoredIdentity(path, meta, undefined, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (ids.has(meta.id)) {
|
||||
throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple cwd buckets`)
|
||||
throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`)
|
||||
}
|
||||
ids.add(meta.id)
|
||||
artifacts.push({ header: meta, path })
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return artifacts
|
||||
}
|
||||
|
||||
@@ -340,20 +351,22 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
/** 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 project = projectDir(this.root, meta.cwd)
|
||||
const dir = sessionDir(this.root, meta.cwd, meta.id)
|
||||
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression)
|
||||
await this.rejectOppositeArtifact(meta.cwd, meta.id)
|
||||
const content = await this.encodeMaterialization(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)
|
||||
await this.materializeWin32(project, dir, finalPath, meta.id, content)
|
||||
} else {
|
||||
await this.materializePosix(dir, finalPath, meta.id, content)
|
||||
await this.materializePosix(project, dir, finalPath, meta.id, content)
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
|
||||
private async materializePosix(
|
||||
project: string,
|
||||
dir: string,
|
||||
finalPath: string,
|
||||
id: SessionId,
|
||||
@@ -361,8 +374,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
): Promise<void> {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(dirname(this.root))
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await mkdir(project, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(this.root)
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(project)
|
||||
await this.rejectExistingLog(finalPath, id)
|
||||
const tmp = await this.writeSyncedTempFile(finalPath, content)
|
||||
// Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
|
||||
@@ -395,12 +410,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
/* v8 ignore start -- native Windows coverage exercises this integration path */
|
||||
private async materializeWin32(
|
||||
project: string,
|
||||
dir: string,
|
||||
finalPath: string,
|
||||
id: SessionId,
|
||||
content: Buffer | string,
|
||||
): Promise<void> {
|
||||
await ensureDurableDirectoryWin32(this.root)
|
||||
await ensureDurableDirectoryWin32(project)
|
||||
await ensureDurableDirectoryWin32(dir)
|
||||
await this.rejectExistingLog(finalPath, id)
|
||||
const tmp = await this.writeSyncedTempFile(finalPath, content)
|
||||
@@ -594,21 +611,27 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the unique physical log for an id across every cwd bucket. */
|
||||
/** Find the unique physical log for an id across every project directory. */
|
||||
private async findLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> {
|
||||
const target = encodeSegment(id) + logSuffix(this.compression)
|
||||
const oppositeTarget = encodeSegment(id) + logSuffix(this.oppositeCompression())
|
||||
const matches: string[] = []
|
||||
for (const dir of await this.listCwdDirs(signal)) {
|
||||
for (const project of await this.listProjectDirs(signal)) {
|
||||
signal?.throwIfAborted()
|
||||
const path = join(dir, target)
|
||||
const opposite = join(dir, oppositeTarget)
|
||||
if (await this.exists(opposite)) throw this.encodingMismatch(opposite)
|
||||
if (await this.exists(path)) matches.push(path)
|
||||
await this.rejectLegacyFlatArtifact(project, id, signal)
|
||||
signal?.throwIfAborted()
|
||||
const dir = join(project, encodeSegment(id))
|
||||
const path = join(dir, `session${logSuffix(this.compression)}`)
|
||||
const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`)
|
||||
const oppositeExists = await this.exists(opposite)
|
||||
signal?.throwIfAborted()
|
||||
if (oppositeExists) throw this.encodingMismatch(opposite)
|
||||
const pathExists = await this.exists(path)
|
||||
signal?.throwIfAborted()
|
||||
if (pathExists) matches.push(path)
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`duplicate JSONL session id "${id}" appears in multiple cwd buckets`)
|
||||
throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
@@ -623,7 +646,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** Reject metadata that does not identify the selected physical log. */
|
||||
private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void {
|
||||
private async assertStoredIdentity(
|
||||
path: string,
|
||||
meta: SessionHeader,
|
||||
expectedId?: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
if (expectedId !== undefined && meta.id !== expectedId) {
|
||||
throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`)
|
||||
}
|
||||
@@ -633,13 +662,34 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
} catch (error) {
|
||||
throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error })
|
||||
}
|
||||
if (path !== expectedPath) {
|
||||
throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd belong at "${expectedPath}"`)
|
||||
if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) {
|
||||
throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two path spellings resolve to the same physical file. This admits
|
||||
* case aliases on case-insensitive filesystems without weakening identity
|
||||
* checks on case-sensitive stores.
|
||||
*/
|
||||
private async sameFile(path: string, expectedPath: string, signal?: AbortSignal): Promise<boolean> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)])
|
||||
signal?.throwIfAborted()
|
||||
return actual === expected
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
/* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */
|
||||
if (isENOENT(error)) return false
|
||||
/* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The cwd-bucket directories under the root (absolute paths). */
|
||||
private async listCwdDirs(signal?: AbortSignal): Promise<string[]> {
|
||||
/** The human-readable project directories under the configured root. */
|
||||
private async listProjectDirs(signal?: AbortSignal): Promise<string[]> {
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const entries = await readdir(this.root, { withFileTypes: true })
|
||||
@@ -652,15 +702,15 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
}
|
||||
|
||||
private async listArtifactNames(dir: string, signal?: AbortSignal): Promise<string[]> {
|
||||
/** List session-owned directories and reject the obsolete flat-file layout. */
|
||||
private async listSessionDirs(project: string, signal?: AbortSignal): Promise<string[]> {
|
||||
signal?.throwIfAborted()
|
||||
const entries = await readdir(dir)
|
||||
const entries = await readdir(project, { withFileTypes: true })
|
||||
signal?.throwIfAborted()
|
||||
const oppositeSuffix = logSuffix(this.oppositeCompression())
|
||||
const incompatible = entries.find(name => name.endsWith(oppositeSuffix))
|
||||
if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`)
|
||||
const suffix = logSuffix(this.compression)
|
||||
return entries.filter(name => name.endsWith(suffix))
|
||||
const legacy = entries.find(entry =>
|
||||
entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zstd')))
|
||||
if (legacy !== undefined) throw this.legacyLayout(join(project, legacy.name))
|
||||
return entries.filter(entry => entry.isDirectory()).map(entry => join(project, entry.name))
|
||||
}
|
||||
|
||||
/** Reject a root that already belongs to the other physical encoding. */
|
||||
@@ -670,11 +720,26 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
private async checkRootEncoding(): Promise<void> {
|
||||
const oppositeSuffix = logSuffix(this.oppositeCompression())
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
const entries = await readdir(dir)
|
||||
const incompatible = entries.find(name => name.endsWith(oppositeSuffix))
|
||||
if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`)
|
||||
for (const project of await this.listProjectDirs()) {
|
||||
for (const dir of await this.listSessionDirs(project)) {
|
||||
const incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`)
|
||||
if (await this.exists(incompatible)) throw this.encodingMismatch(incompatible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectLegacyFlatArtifact(
|
||||
project: string,
|
||||
id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
const encoded = encodeSegment(id)
|
||||
for (const compression of ['zstd', 'none'] as const) {
|
||||
const path = join(project, encoded + logSuffix(compression))
|
||||
const artifactExists = await this.exists(path)
|
||||
signal?.throwIfAborted()
|
||||
if (artifactExists) throw this.legacyLayout(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,6 +760,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
)
|
||||
}
|
||||
|
||||
private legacyLayout(path: string): Error {
|
||||
return new Error(
|
||||
`session artifact ${JSON.stringify(path)} uses the unsupported flat-file layout; `
|
||||
+ 'use a separate root or move it into a project/session directory before loading',
|
||||
)
|
||||
}
|
||||
|
||||
private async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const handle = await open(path, 'r')
|
||||
@@ -704,7 +776,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
// 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.
|
||||
// the immediate parent so a blocked session directory 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)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { basename, join, parse, resolve, toNamespacedPath } from 'node:path'
|
||||
import { join, parse, resolve, toNamespacedPath } from 'node:path'
|
||||
|
||||
type MoveFileExW = (existing: string, replacement: string, flags: number) => number
|
||||
type GetLastError = () => number
|
||||
@@ -139,7 +139,9 @@ export async function ensureDurableDirectoryWin32(target: string): Promise<void>
|
||||
}
|
||||
|
||||
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
|
||||
const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`))
|
||||
// Keep the staging component independent of the target basename so a legal
|
||||
// 255-byte target component does not make mkdtemp's sibling name too long.
|
||||
const staging = await mkdtemp(join(parent, '.dsh-mkdir-'))
|
||||
try {
|
||||
await publishNewFileWin32(staging, target)
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user