From 4e295221f352b4ca507e813a2c410269d5a6e29a Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 19:52:47 +0800 Subject: [PATCH] refactor(persistence): group sessions in project directories --- ...7-24-project-session-directories.i18n.yaml | 6 + .../2026-07-24-project-session-directories.md | 48 +++++++ ...26-07-24-project-session-directories.zh.md | 48 +++++++ docs/core-data-structures/persistence.md | 2 +- .../session-persistence-jsonl/README.md | 17 ++- .../session-persistence-jsonl/src/format.ts | 67 ++++++++-- .../session-persistence-jsonl/src/index.ts | 90 ++++++++----- .../tests/jsonl.spec.ts | 122 +++++++++++++----- .../tests/zstd.spec.ts | 29 +++-- packages/support/acp-snapshot/src/harness.ts | 42 +++--- .../tests/fixtures/fake-acp-agent.ts | 6 +- .../record-suite/rec-child/behavior.json | 4 +- .../record-suite/rec-pin/behavior.json | 2 +- .../suite/authored-error/behavior.json | 2 +- .../fixtures/suite/blocked-log/behavior.json | 2 +- .../fixtures/suite/pin-turn/behavior.json | 2 +- .../fixtures/suite/plain-turn/behavior.json | 4 +- .../acp-snapshot/tests/harness.spec.ts | 16 +-- 18 files changed, 366 insertions(+), 143 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-24-project-session-directories.md create mode 100644 .agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml new file mode 100644 index 0000000000..f6cd03ddfd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-project-session-directories.md: f65045419d5c749525ebdefcd1875dfc8ea69182 +2026-07-24-project-session-directories.zh.md: 1b4320d925c85b9b42a6c3ec9ee4ec52f4600786 diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md new file mode 100644 index 0000000000..f65045419d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md @@ -0,0 +1,48 @@ +# Agent Note: Project-grouped session directories + +Status: implemented + +English | [中文](2026-07-24-project-session-directories.zh.md) + +## Problem + +A persistence root may be local to one project, shared by several projects, temporary, or centralized. The hashed cwd buckets kept all deployments functional but made a shared root difficult to navigate because a developer could not recognize a project from its directory name. + +Each JSONL session also occupied one file directly inside the project bucket. That shape had no ownership directory for additional session artifacts such as metadata, attachments, spill files, or coordination state. + +## Decision + +The JSONL backend stores sessions under a readable project key and gives every session its own directory: + +```text +/ + ----/ + / + session.jsonl.zstd +``` + +Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable prefix is bounded to keep the component within filesystem limits. A short SHA-256 suffix distinguishes project paths whose readable forms collide or truncate alike. + +The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure. + +The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change. + +Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `/.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration. + +## Alternatives considered + +**Keep opaque cwd hashes.** This preserved short names but defeated the requested navigation by project path when several projects share a persistence root. + +**Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts. + +**Replace separators without a collision suffix.** This is readable but lossy: paths containing literal `-` can collide with paths where `-` represents a separator. Retaining a short hash suffix preserves readable navigation without merging distinct projects. + +**Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not. + +**Load both flat and directory layouts.** Rejected under the pre-release no-compatibility stance. One accepted layout keeps identity checks and discovery deterministic. + +## Consequences + +Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path. + +Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix plus their distinguishing hash, and moving a project still selects a different directory because the absolute cwd remains part of storage identity. diff --git a/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md new file mode 100644 index 0000000000..1b4320d925 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-project-session-directories.zh.md @@ -0,0 +1,48 @@ +# Agent Note: 按项目分组的会话目录 + +Status: implemented + +[English](2026-07-24-project-session-directories.md) | 中文 + +## 问题 + +持久化根目录可以只供一个项目使用,也可以由多个项目共享,还可以是临时目录或集中式目录。对 cwd 进行哈希得到的分桶目录能适用于所有这些部署方式,但开发者无法从目录名辨认项目,因此共享根目录难以浏览。 + +每个 JSONL 会话也直接以单个文件的形式放在项目分桶目录中。这种布局没有为元数据、附件、溢写文件或协调状态等其他会话产物提供归属目录。 + +## 决策 + +JSONL 后端按可读的项目键存储会话,并为每个会话提供独立目录: + +```text +/ + ----/ + / + session.jsonl.zstd +``` + +原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读前缀则限制长度,以确保目录项不超过文件系统限制。短 SHA-256 后缀用于区分可读形式发生冲突或被截断成相同形式的项目路径。 + +根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。 + +编码后的会话 id 用于命名归属目录,而不是 transcript(文本记录)文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。 + +延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `/.jsonl*` 产物;预发布格式不提供自动数据迁移。 + +## 考虑过的替代方案 + +**保留不透明的 cwd 哈希。** 这可以保持目录名简短,但当多个项目共享一个持久化根目录时,无法满足按项目路径浏览的需求。 + +**把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。 + +**替换分隔符但不添加冲突后缀。** 这种方式可读但有损:路径中的字面 `-` 可能与用 `-` 表示分隔符的路径发生冲突。保留短哈希后缀,既能让不同项目保持区分,又不会牺牲可读的浏览体验。 + +**强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。 + +**同时加载扁平布局和目录布局。** 按照预发布阶段不提供兼容性的原则,不予采纳。只接受一种布局,可以让身份检查和发现过程保持确定性。 + +## 后果 + +共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。 + +项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀和用于区分的哈希;移动项目仍会选择不同的目录,因为绝对 cwd 仍是存储身份的一部分。 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f45eb0417a..12cfc31125 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -18,7 +18,7 @@ Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id ## `SessionLocation` — optional per-session artifact target -`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. +`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. ```ts type-equiv /** diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index bf86bf8633..8bd704f1e2 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,14 +6,16 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` / - cwd-/ # per-project bucket (or _no-cwd/ when no cwd) - .jsonl.zstd # default: checksummed header frame + append frames - .jsonl # only with compression: 'none' + ----/ # readable project directory (or _no-cwd/) + / # session-owned directory + session.jsonl.zstd # default: checksummed header frame + append frames + session.jsonl # only with compression: 'none' ``` - The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`). - A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically. -- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). +- The project directory keeps the normalized cwd readable for navigation and adds a short SHA-256 suffix so paths that normalize alike remain distinct. Its readable prefix is bounded for filesystem component limits. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. +- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename. ## Config @@ -23,17 +25,17 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence | `packChunks` | `boolean` (default `false`) | Write delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Off, the written logical layout is byte-identical to the pre-packing format; reading packed rows works regardless of this switch. Off by default while the snapshot goldens stay one-event-per-line — recording with packing on rewrites every fixture `session.jsonl`. | | `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. | -`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix. +`locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix. ## Physical encoding The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation. -A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. There is no migration, mixed-root fallback, or dual write. +A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. There is no migration, mixed-root fallback, or dual write. ## Durability and crash semantics -- **Bound storage identity.** Lookup requires one matching encoded filename across the cwd buckets, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append. +- **Bound storage identity.** Lookup requires one matching session directory across the readable project directories, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected transcript path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append. - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. @@ -64,6 +66,7 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr ## Known Limitations and Deferred Work - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. +- **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). - **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 2a34a1ce80..bb55f5e00d 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -2,7 +2,7 @@ * 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 @@ -120,24 +120,65 @@ 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, collision-resistant directory key for a project path. + * Filesystem separators and drive separators become `-`; unsafe code units use + * the same `~XXXX` escape as session ids. The readable prefix is bounded for + * filesystem component limits, and the hash suffix keeps distinct or truncated + * paths separate. + * @param cwd - the session's project directory. + * @returns a single filesystem-safe project directory name. */ -export function sessionDir(root: string, cwd: string | undefined): string { - if (cwd === undefined) return join(root, '_no-cwd') +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 hash = createHash('sha256').update(cwd).digest('hex').slice(0, 12) - return join(root, `cwd-${hash}`) + const slug = readable.replace(/^-+/, '') || 'root' + return `--${slug.slice(0, 200)}--${hash}` +} + +/** + * 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') + 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. @@ -148,7 +189,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)}`) } /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 629c0e3ff1..ad58cfe145 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -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' @@ -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): Promise | undefined> { await this.ensureRootEncoding() const path = await this.findLog(id) @@ -278,9 +278,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] const ids = new Set() - for (const dir of await this.listCwdDirs()) { - for (const name of await this.listArtifactNames(dir)) { - const path = join(dir, name) + for (const project of await this.listProjectDirs()) { + for (const dir of await this.listSessionDirs(project)) { + const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`) + if (await this.exists(opposite)) throw this.encodingMismatch(opposite) + const path = join(dir, `session${logSuffix(this.compression)}`) + if (!await this.exists(path)) continue // Read only headers so listing scales with session count, not log size. const first = this.compression === 'zstd' ? await this.readFirstZstdLine(path) @@ -290,7 +293,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (meta === undefined) continue // not a session header this.assertStoredIdentity(path, meta) 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 }) @@ -303,20 +306,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 { - 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, @@ -324,8 +329,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi ): Promise { 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 @@ -358,12 +365,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 { await ensureDurableDirectoryWin32(this.root) + await ensureDurableDirectoryWin32(project) await ensureDurableDirectoryWin32(dir) await this.rejectExistingLog(finalPath, id) const tmp = await this.writeSyncedTempFile(finalPath, content) @@ -541,19 +550,19 @@ 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): Promise { - const target = encodeSegment(id) + logSuffix(this.compression) - const oppositeTarget = encodeSegment(id) + logSuffix(this.oppositeCompression()) const matches: string[] = [] - for (const dir of await this.listCwdDirs()) { - const path = join(dir, target) - const opposite = join(dir, oppositeTarget) + for (const project of await this.listProjectDirs()) { + await this.rejectLegacyFlatArtifact(project, id) + const dir = join(project, encodeSegment(id)) + const path = join(dir, `session${logSuffix(this.compression)}`) + const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`) if (await this.exists(opposite)) throw this.encodingMismatch(opposite) if (await this.exists(path)) 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`) } return matches[0] } @@ -580,12 +589,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi 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}"`) + throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`) } } - /** The cwd-bucket directories under the root (absolute paths). */ - private async listCwdDirs(): Promise { + /** The human-readable project directories under the configured root. */ + private async listProjectDirs(): Promise { try { const entries = await readdir(this.root, { withFileTypes: true }) return entries.filter(e => e.isDirectory()).map(e => join(this.root, e.name)) @@ -596,13 +605,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - private async listArtifactNames(dir: string): Promise { - const entries = await readdir(dir) - 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)) + /** List session-owned directories and reject the obsolete flat-file layout. */ + private async listSessionDirs(project: string): Promise { + const entries = await readdir(project, { withFileTypes: true }) + 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. */ @@ -612,11 +621,19 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } private async checkRootEncoding(): Promise { - 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): Promise { + const encoded = encodeSegment(id) + for (const compression of ['zstd', 'none'] as const) { + const path = join(project, encoded + logSuffix(compression)) + if (await this.exists(path)) throw this.legacyLayout(path) } } @@ -637,6 +654,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 { try { const handle = await open(path, 'r') @@ -646,7 +670,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) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 2b49b7d55b..0c46afc6b8 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -6,7 +6,9 @@ import { isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts' +import { + encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, toHeaderLine, +} from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -125,6 +127,18 @@ describe('SessionPersistenceJsonl: format helpers', () => { expect(() => encodeSegment('')).toThrow(/empty/) }) + it('projectKey keeps the path readable and disambiguates normalized collisions', () => { + expect(projectKey('/Users/qyj/work/deepseek-harness')).toMatch( + /^--Users-qyj-work-deepseek-harness--[a-f0-9]{12}$/, + ) + expect(projectKey('/a/b-c')).not.toBe(projectKey('/a-b/c')) + expect(projectKey('C:\\work\\agent')).toMatch(/^--C-work-agent--[a-f0-9]{12}$/) + expect(projectKey('/开发/~agent')).toMatch(/^--~5F00~53D1-~007Eagent--[a-f0-9]{12}$/) + expect(projectKey('/')).toMatch(/^--root--[a-f0-9]{12}$/) + expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(216) + expect(() => projectKey('')).toThrow(/empty project path/) + }) + it('resolves a relative custom root before locating a session', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() @@ -161,15 +175,15 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await ctx.sessionPersistence.create(m) // locate() is a pure target-path calculation: neither it nor create() // materializes a file before the first append. - const dir = sessionDir(root, '/work') + const dir = sessionDir(root, '/work', m.id) await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow() expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) await ctx.sessionPersistence.append(m.id, oneTurnLog()) // now materialized + expect((await stat(dir)).isDirectory()).toBe(true) expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true) expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) - void dir }) it('keeps the same location on resume and gives a fork its own location', async () => { @@ -268,7 +282,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { const m = meta('legacy-header-delta', '/legacy') const path = rawLogPath(root, m.cwd, m.id) - await mkdir(sessionDir(root, m.cwd), { recursive: true }) + await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), @@ -283,7 +297,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { const m = meta('legacy-header-fallback', '/legacy') const path = rawLogPath(root, m.cwd, m.id) - await mkdir(sessionDir(root, m.cwd), { recursive: true }) + await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), JSON.stringify({ @@ -693,7 +707,7 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () => const log = chunkRunLog() // First turn written line-per-event by an unpacked-config writer (an old // file, hand-planted so this packed-config backend adopts it on load). - await mkdir(sessionDir(root, '/work'), { recursive: true }) + await mkdir(sessionDir(root, '/work', m.id), { recursive: true }) await writeFile(rawLogPath(root, '/work', m.id), [ JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }), ...log.map(e => JSON.stringify(e)), @@ -789,12 +803,12 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow() }) - it('list discovers sessions across multiple cwd buckets', async () => { + it('list discovers sessions across multiple project directories', async () => { await ctx.sessionPersistence.create(meta('p1', '/projA')) await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog()) await ctx.sessionPersistence.create(meta('p2', '/projB')) await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog()) - await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd bucket + await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd project directory await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog()) const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort() @@ -805,18 +819,60 @@ describe('SessionPersistenceJsonl: edge cases', () => { expect(await ctx.sessionPersistence.list()).toEqual([]) }) - it('list skips empty and non-header .jsonl files (metadata-only read)', async () => { + it('keeps the transcript in an extensible session-owned directory', async () => { + const m = meta('owned-directory', '/project') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const dir = sessionDir(root, m.cwd, m.id) + await writeFile(join(dir, 'metadata.json'), '{}\n') + await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n') + await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true }) + + expect(await readdir(dir)).toEqual(expect.arrayContaining(['metadata.json', 'session.jsonl'])) + expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id) + expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog()) + }) + + it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => { + const m = meta('legacy-flat', '/legacy') + const project = projectDir(root, m.cwd) + const path = join(project, `${encodeSegment(m.id)}.jsonl`) + await mkdir(project, { recursive: true }) + await writeFile(path, [ + JSON.stringify(toHeaderLine(m)), + ...oneTurnLog().map(event => JSON.stringify(event)), + '', + ].join('\n')) + + await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/) + }) + + it('rejects a compressed obsolete flat-file artifact during targeted lookup', async () => { + const m = meta('legacy-compressed-flat', '/legacy') + const project = projectDir(root, m.cwd) + expect(await ctx.sessionPersistence.list()).toEqual([]) + await mkdir(project, { recursive: true }) + await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy') + + await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/) + }) + + it('list skips empty and non-header session logs (metadata-only read)', async () => { // A real session… await ctx.sessionPersistence.create(meta('real', '/p')) await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog()) - // …alongside two junk files in the _no-cwd bucket: an EMPTY file (readFirstLine - // returns undefined) and a file whose first line is not a session header - // (parseHeaderMeta returns undefined). Both are skipped, not listed. - const bucket = join(root, '_no-cwd') - await mkdir(bucket, { recursive: true }) - await writeFile(join(bucket, 'empty.jsonl'), '') - await writeFile(join(bucket, 'notheader.jsonl'), '{"type":"turn/start"}\n') - await writeFile(join(bucket, 'badjson.jsonl'), 'not json at all\n') + // …alongside junk session directories whose fixed transcript is empty or + // lacks a header. Both remain unmaterialized and are skipped. + for (const [id, content] of [ + ['empty', ''], + ['notheader', '{"type":"turn/start"}\n'], + ['badjson', 'not json at all\n'], + ] as const) { + const path = rawLogPath(root, undefined, SessionId(id)) + await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true }) + await writeFile(path, content) + } const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort() expect(ids).toEqual(['real']) @@ -825,10 +881,10 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('list reads a header line longer than the 8KB read chunk', async () => { // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving // `readFirstLine` accumulates chunks before `list()` parses it. - const bucket = join(root, '_no-cwd') - await mkdir(bucket, { recursive: true }) + const id = SessionId('big') + await mkdir(sessionDir(root, undefined, id), { recursive: true }) const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) }) - await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n') + await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n') const ids = (await ctx.sessionPersistence.list()).map(x => x.id) expect(ids).toContain('big') }) @@ -839,30 +895,30 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx.sessionPersistence.append(m.id, oneTurnLog()) await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' }) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd belong at/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/) }) it('list rejects a session header whose id cannot name a storage path', async () => { - const bucket = sessionDir(root, undefined) - await mkdir(bucket, { recursive: true }) - await writeFile(join(bucket, 'invalid-id.jsonl'), JSON.stringify({ + const dir = join(projectDir(root, undefined), 'invalid-id') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'session.jsonl'), JSON.stringify({ type: 'session', version: 0, id: '', createdAt: 1, delegationDepth: 0, }) + '\n') await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/) }) - it('load and list reject one id materialized in multiple cwd buckets', async () => { + it('load and list reject one id materialized in multiple project directories', async () => { const id = SessionId('duplicate') for (const cwd of ['/a', '/b']) { const m = meta(id, cwd) - await mkdir(sessionDir(root, cwd), { recursive: true }) + await mkdir(sessionDir(root, cwd, id), { recursive: true }) const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n' await writeFile(rawLogPath(root, cwd, id), content) } - await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple cwd buckets/) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple cwd buckets/) + await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple project directories/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/) }) it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => { @@ -985,12 +1041,12 @@ describe('SessionPersistenceJsonl: edge cases', () => { await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/) }) - it('materialization surfaces a cwd-bucket storage fault', async () => { + it('materialization surfaces a project-directory storage fault', async () => { const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) - await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE + await writeFile(projectDir(root, cwd), 'x') // project path is now a file let s!: Session await ctx2.plugin(Object.assign((inner: Context) => { s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) @@ -1038,14 +1094,14 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) - it('createCore rejects an id already on disk under a DIFFERENT cwd bucket', async () => { + it('createCore rejects an id already on disk under a different project directory', async () => { // Persist the id under cwd A. const a = meta('dup-id', '/projA') await ctx.sessionPersistence.create(a) await ctx.sessionPersistence.append(a.id, oneTurnLog()) // A fresh backend creating the SAME id under cwd B must still refuse: load - // identifies by id across all buckets, so a second log would make resume - // nondeterministic. create scans every bucket, not just meta.cwd's. + // identifies by id across all projects, so a second log would make resume + // nondeterministic. create scans every project, not just meta.cwd's. const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts index fcadac1f04..a91b51ec9d 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -391,15 +391,21 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => { const root = await freshRoot() - const bucket = sessionDir(root, undefined) - await mkdir(bucket, { recursive: true }) - await writeFile(join(bucket, 'empty.jsonl.zstd'), '') - await writeFile(join(bucket, 'partial.jsonl.zstd'), MAGIC) - await writeFile(join(bucket, 'not-header.jsonl.zstd'), await compressZstdFrame('{"type":"turn/start"}\n')) + for (const [id, content] of [ + ['empty', Buffer.alloc(0)], + ['partial', MAGIC], + ['not-header', await compressZstdFrame('{"type":"turn/start"}\n')], + ] as const) { + const sessionId = SessionId(id) + await mkdir(sessionDir(root, undefined, sessionId), { recursive: true }) + await writeFile(logPath(root, undefined, sessionId, 'zstd'), content) + } const ctx = await mount(root) expect(await ctx.sessionPersistence.list()).toEqual([]) - await writeFile(join(bucket, 'two-lines.jsonl.zstd'), await compressZstdFrame([ + const twoLinesId = SessionId('two-lines') + await mkdir(sessionDir(root, undefined, twoLinesId), { recursive: true }) + await writeFile(logPath(root, undefined, twoLinesId, 'zstd'), await compressZstdFrame([ JSON.stringify(toHeaderLine(meta('two-lines'))), JSON.stringify({ type: 'turn/start' }), '', @@ -411,8 +417,9 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { it('rejects missing, empty, and checksum-corrupt header frames on targeted reads', async () => { const root = await freshRoot() - const bucket = sessionDir(root, undefined) - await mkdir(bucket, { recursive: true }) + for (const id of ['partial-only', 'empty-header', 'bad-checksum']) { + await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true }) + } await writeFile(logPath(root, undefined, SessionId('partial-only'), 'zstd'), MAGIC) await writeFile(logPath(root, undefined, SessionId('empty-header'), 'zstd'), await compressZstdFrame('')) const corruptHeader = Buffer.from(await compressZstdFrame(`${JSON.stringify(toHeaderLine(meta('bad-checksum')))}\n`)) @@ -453,7 +460,7 @@ describe('SessionPersistenceJsonl: encoding selection', () => { expect(await ctx.sessionPersistence.list()).toEqual([]) const loadHeader = meta('late-raw-load', '/late') - await mkdir(sessionDir(root, loadHeader.cwd), { recursive: true }) + await mkdir(sessionDir(root, loadHeader.cwd, loadHeader.id), { recursive: true }) await writeFile(logPath(root, loadHeader.cwd, loadHeader.id, 'none'), [ JSON.stringify(toHeaderLine(loadHeader)), ...oneTurnLog().map(e => JSON.stringify(e)), @@ -471,13 +478,13 @@ describe('SessionPersistenceJsonl: encoding selection', () => { await ctx.sessionPersistence.list() const header = meta('late-raw-materialize', '/late') await ctx.sessionPersistence.create(header) - await mkdir(sessionDir(root, header.cwd), { recursive: true }) + await mkdir(sessionDir(root, header.cwd, header.id), { recursive: true }) await writeFile(logPath(root, header.cwd, header.id, 'none'), [ JSON.stringify(toHeaderLine(header)), ...oneTurnLog().map(e => JSON.stringify(e)), '', ].join('\n')) await expect(ctx.sessionPersistence.append(header.id, oneTurnLog())).rejects.toThrow(/uses \.jsonl/) - expect((await readdir(sessionDir(root, header.cwd))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false) + expect((await readdir(sessionDir(root, header.cwd, header.id))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false) }) }) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 2821969457..d2d861ed1a 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -563,39 +563,29 @@ function latestTurnIsClosed(content: string): boolean { * `parentSession`) leads, then each subagent child by ascending `createdAt`. * * Snapshot configs select the JSONL backend's raw mode, which lays sessions - * out as `//.jsonl` (one bucket per cwd). A - * parent and its same-cwd in-process child land in the SAME bucket, so - * collecting all files across all buckets catches both. Returns `[]` if no log - * was produced (a no-session scenario). + * out as `///session.jsonl`. Recursive collection + * catches the primary and every child session. Returns `[]` if no log was + * produced (a no-session scenario). */ async function harvestSessionLogs(root: string): Promise { - let cwdDirs: string[] + let files: string[] try { - cwdDirs = await readdir(root) + files = await readdir(root, { recursive: true }) } catch { return [] } const logs: HarvestedLog[] = [] - for (const dir of cwdDirs) { - const sub = join(root, dir) - let files: string[] - try { - files = await readdir(sub) - } catch { - continue - } - for (const f of files) { - if (!f.endsWith('.jsonl')) continue - const content = await readFile(join(sub, f), 'utf8') - const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}' - const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown } - logs.push({ - id: typeof header.id === 'string' ? header.id : '', - createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0, - ...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {}, - content, - }) - } + for (const file of files) { + if (basename(file) !== 'session.jsonl') continue + const content = await readFile(join(root, file), 'utf8') + const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}' + const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown } + logs.push({ + id: typeof header.id === 'string' ? header.id : '', + createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0, + ...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {}, + content, + }) } // Primary (no parentSession) first, then children by ascending createdAt. A // scenario has exactly one top-level session. In the synchronous cut sibling diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index df3bb0b970..570a783a13 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -23,9 +23,9 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' -/** One scripted session log: a file path under the sessions root plus its JSONL lines. */ +/** One scripted session log: a transcript path under the sessions root plus its JSONL lines. */ interface ScriptedLog { - /** Path relative to `$DSH_SNAPSHOT_SESSIONS_ROOT`, e.g. `bucket/a.jsonl` (an empty dir segment is invalid). */ + /** Path relative to `$DSH_SNAPSHOT_SESSIONS_ROOT`, e.g. `project/session/session.jsonl`. */ file: string /** * The JSONL records. String templates `{{CWD}}` and `{{SID}}` are replaced @@ -69,7 +69,7 @@ interface Behavior { logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ strayRootFile?: boolean - /** Leave a stray non-`.jsonl` file inside a bucket (harvest must skip it). */ + /** Leave a stray non-transcript file inside a project directory (harvest must skip it). */ strayBucketFile?: boolean /** Delete the sessions root entirely (harvest must yield no logs). */ deleteSessionsRoot?: boolean diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json index fd06978be1..d98afb4865 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json @@ -1,11 +1,11 @@ { "prompt": "respond", "logs": [ - { "file": "b/parent.jsonl", "lines": [ + { "file": "b/parent/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]}, - { "file": "b/child.jsonl", "lines": [ + { "file": "b/child/session.jsonl", "lines": [ { "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, { "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json index b0ed5f1a3f..7fffecf747 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json @@ -1,7 +1,7 @@ { "prompt": "respond", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 600, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 4, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json index 991de99fd6..fd843a3a08 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json @@ -1,7 +1,7 @@ { "prompt": "error", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 500, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "turn/end", "seq": 1, "time": 9, "data": { "error": "model exploded" } } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json index 209159da7d..3c8ffc0b86 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json @@ -1,7 +1,7 @@ { "prompt": "error", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 400, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "hook/result", "seq": 1, "time": 8, "data": { "decision": "block", "durationMs": 37 } } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json index ad4c368e49..4de8f25b7e 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json @@ -1,7 +1,7 @@ { "prompt": "respond", "logs": [{ - "file": "b/main.jsonl", + "file": "b/main/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json index 8903d0360e..e00ca3ff28 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json @@ -2,12 +2,12 @@ "prompt": "respond", "echoWorkspace": true, "logs": [ - { "file": "b/parent.jsonl", "lines": [ + { "file": "b/parent/session.jsonl", "lines": [ { "type": "session", "id": "{{SID}}", "createdAt": 200, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 5, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, { "type": "assistant/chunk", "seq": 1, "time": 5, "data": { "turn": 1, "step": 1, "chunk": { "type": "text-delta", "index": 0, "text": "hi" } } } ]}, - { "file": "b/child.jsonl", "lines": [ + { "file": "b/child/session.jsonl", "lines": [ { "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, { "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]} diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index a87e72d3d4..b1981abc9d 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -378,7 +378,7 @@ describe('runScenario', () => { const { fixtureFile } = await scenario({ permissionProbe: true, logs: [{ - file: 'bucket/main.jsonl', + file: 'project/main/session.jsonl', lines: [ { type: 'session', id: '{{SID}}', createdAt: 42, cwd: '{{CWD}}' }, { type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } }, @@ -566,7 +566,7 @@ describe('runScenario', () => { prompt: 'hang-until-cancel', persistLogsOnCancel: true, logs: [{ - file: 'bucket/session.jsonl', + file: 'project/main/session.jsonl', lines: [ { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } }, @@ -591,7 +591,7 @@ describe('runScenario', () => { prompt: 'hang-until-cancel', persistLogsOnCancel: true, logs: [{ - file: 'bucket/session.jsonl', + file: 'project/main/session.jsonl', lines: [ { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, @@ -776,11 +776,11 @@ describe('runScenario', () => { // File names chosen so readdir feeds the sort children-first AND // parent-in-the-middle: the comparator then sees a parent on both // sides of a pair, plus the same-createdAt (localeCompare) tiebreak. - { file: 'b1/aa-child-c.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, - { file: 'b1/bb-parent.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] }, - { file: 'b1/cc-child-a.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, + { file: 'b1/aa-child-c/session.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, + { file: 'b1/bb-parent/session.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] }, + { file: 'b1/cc-child-a/session.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] }, // Missing id/createdAt fall back to ''/0; earliest child by createdAt. - { file: 'b2/orphan-fields.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] }, + { file: 'b2/orphan/session.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] }, ], }) const result = await runScenario( @@ -797,7 +797,7 @@ describe('runScenario', () => { }) it('treats an empty log file as a header-less primary with default fields', { timeout: 20_000 }, async () => { - const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty.jsonl', lines: [] }] }) + const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty/session.jsonl', lines: [] }] }) const result = await runScenario( { steps: boot }, { agent: AGENT, mode: 'replay', fixtureFile },