Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows

Master removed the stdio demo (#702c8cc30) — accept the deletion; this
branch's packChunks passthrough survives in acp-demo (auto-merged), and
cli-demo/tui-demo arrived from master without one (the follow-up snapshot
PR decides which demos expose the switch). Generated catalogs regenerated
over merged sources; the hand-written session.md durability paragraph
re-weaves this branch's lossless-encoding wording with master's invariant-
companion sentence.
This commit is contained in:
kingwl
2026-07-22 15:46:12 +08:00
1242 changed files with 57560 additions and 15291 deletions

View File

@@ -33,7 +33,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
## Durability and crash semantics
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **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.** Committed events (at or below a flushed `turn/end`) 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.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
@@ -64,5 +64,4 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
- **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).
- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated.
- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend.
- **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive.
- **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.

View File

@@ -11,25 +11,33 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"koffi": "^3.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -8,7 +8,7 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises'
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 {
@@ -21,6 +21,7 @@ import {
type JsonlCompression,
} from './format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
export type { JsonlCompression } from './format.ts'
@@ -92,9 +93,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
private coordinator: PersistenceCoordinator<JsonlTornMarker>
private rootEncodingCheck: Promise<void> | undefined
/** Runtime host platform used to decide whether directory sync is supported. */
readonly internals: { platform: NodeJS.Platform } = { platform: process.platform }
constructor(ctx: Context, public config: Config) {
super(ctx)
// Resolve once so later process.cwd() changes cannot split one backend across roots.
@@ -268,32 +266,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
// --- materialization / append / repair (file mechanics) ---
/** Atomically write the header line + first batch (temp-write, fsync, collision-safe hard-link publish). */
/** 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)
await mkdir(this.root, { recursive: true, mode: 0o700 })
await this.syncDir(dirname(this.root))
await mkdir(dir, { recursive: true, mode: 0o700 })
await this.syncDir(this.root)
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression)
// Materialization is the first write; an existing log is an id collision.
/* 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 "${meta.id}": a log already exists on disk (load/resume it instead)`)
}
await this.rejectOppositeArtifact(meta.cwd, meta.id)
const content = await this.encodeMaterialization(meta, events)
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()
/* 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)
}
// Publish with link()+unlink(): unlike rename(), link fails if another
// process materialized the same id first.
}
/* 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: Buffer | 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)
@@ -304,16 +306,64 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
/* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
if (!linked) await rm(tmp, { force: true })
}
// The published link becomes crash-durable only after its directory fsync.
await this.syncDir(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.
// 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: Buffer | 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: Buffer | 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
}
/** Encode the header and first batch without combining their frame boundaries. */
private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise<Buffer | string> {
@@ -331,22 +381,17 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.compression === 'zstd' ? compressZstdFrame(body) : body
}
/** fsync a directory when the host exposes that durability primitive. */
private async syncDir(dir: string): Promise<void> {
/** 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 {
try {
await handle.sync()
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | null)?.code
// Node opens directories on Windows but its fsync binding rejects them.
// File-content fsync remains mandatory; only this unsupported primitive is skipped.
if (this.internals.platform !== 'win32' || code !== 'EPERM') throw error
}
await handle.sync()
} finally {
await handle.close()
}
}
/* v8 ignore stop */
/**
* Append and fsync event lines. On a partial write or sync failure, restore the
@@ -357,17 +402,37 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
const content = await this.encodeEventBatch(events)
const path = logPath(this.root, meta.cwd, meta.id, this.compression)
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(content)
await handle.sync()
} catch (error) {
// Roll back whatever bytes landed so a retry starts from a clean EOF.
await handle.truncate(before)
await handle.sync()
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()
}
@@ -519,13 +584,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
await handle.close()
return true
} catch (error) {
// Only ENOENT means absent. A permission/I/O error must surface, not be
// collapsed to `false` — otherwise load() reports "not found" and collision
// checks proceed under a false absence assumption.
if (isENOENT(error)) return false
// 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

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-jsonl`.
* @module @deepseek-ai/dsh-session-persistence-jsonl/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl'
/** Cordis companion plugin name. */
export const name = 'session-persistence-jsonl-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
* this package exposes no continuously observable in-process relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,150 @@
/**
* Windows durable namespace helpers for the JSONL backend.
*
* POSIX publishes a newly-created log by creating a directory entry and then
* fsyncing the parent directory. Windows does not expose that parent-directory
* fsync contract through Node, so the Windows path uses the native durable
* namespace primitive instead: create a staging object in the target directory
* and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
* replacement or cross-volume copy fallback.
*
* @module dsh-session-persistence-jsonl/win32
*/
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { basename, join, parse, resolve, toNamespacedPath } from 'node:path'
type MoveFileExW = (existing: string, replacement: string, flags: number) => number
type GetLastError = () => number
interface Win32Bindings {
moveFileExW: MoveFileExW
getLastError: GetLastError
}
interface Win32ErrnoException extends NodeJS.ErrnoException {
win32Code: number
dest: string
}
const MOVEFILE_WRITE_THROUGH = 0x00000008
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
const ERROR_NOT_SAME_DEVICE = 17
const ERROR_FILE_EXISTS = 80
const ERROR_INVALID_NAME = 123
const ERROR_ALREADY_EXISTS = 183
let bindings: Win32Bindings | undefined
/** Load the small Win32 surface lazily so non-Windows processes never load Koffi. */
async function win32(): Promise<Win32Bindings> {
if (bindings !== undefined) return bindings
const koffi = (await import('koffi')).default
const kernel32 = koffi.load('kernel32.dll')
bindings = {
moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW,
getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError,
}
return bindings
}
function errnoCode(win32Code: number): string {
switch (win32Code) {
case ERROR_FILE_NOT_FOUND:
case ERROR_PATH_NOT_FOUND:
return 'ENOENT'
case ERROR_ACCESS_DENIED:
return 'EACCES'
case ERROR_NOT_SAME_DEVICE:
return 'EXDEV'
case ERROR_FILE_EXISTS:
case ERROR_ALREADY_EXISTS:
return 'EEXIST'
case ERROR_INVALID_NAME:
return 'EINVAL'
default:
return 'EIO'
}
}
function win32Error(syscall: string, win32Code: number, path: string, dest: string): Win32ErrnoException {
const code = errnoCode(win32Code)
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`) as Win32ErrnoException
error.code = code
error.errno = win32Code
error.syscall = syscall
error.path = path
error.dest = dest
error.win32Code = win32Code
return error
}
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
async function assertDirectory(path: string): Promise<boolean> {
try {
const info = await stat(path)
if (info.isDirectory()) return true
const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException
error.code = 'ENOTDIR'
error.path = path
throw error
} catch (error) {
if (isENOENT(error)) return false
throw error
}
}
/**
* Publish `existing` at `replacement` with Windows write-through rename
* semantics. The destination must not already exist; the move must stay within
* the volume (no copy fallback flag is set).
* @param existing - the synced staging path to move.
* @param replacement - the final path, which must not already exist.
*/
export async function publishNewFileWin32(existing: string, replacement: string): Promise<void> {
const api = await win32()
const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH)
if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
}
/**
* Create `target` and its missing ancestors with durable Windows namespace
* publication. Each missing directory is first created as a random staging
* sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
* with another creator are accepted only after verifying the winner is a
* directory.
* @param target - the absolute directory path to create durably when absent.
*/
export async function ensureDurableDirectoryWin32(target: string): Promise<void> {
const absolute = resolve(target)
const root = parse(absolute).root
await assertDirectory(root)
const segments = absolute.slice(root.length).split(/[\\/]+/).filter(part => part.length > 0)
let current = root
for (const segment of segments) {
const next = join(current, segment)
if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next)
current = next
}
}
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`))
try {
await publishNewFileWin32(staging, target)
} catch (error) {
await rm(staging, { recursive: true, force: true })
if (isEEXIST(error) && await assertDirectory(target)) return
throw error
}
}

View File

@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -21,17 +20,15 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader {
return header
}
async function expectParallelFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
try {
await promise
} catch (error) {
expect(error).toBeInstanceOf(AggregateError)
const [cause] = (error as AggregateError).errors as unknown[]
expect(cause).toBeInstanceOf(Error)
expect((cause as Error).message).toMatch(message)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toMatch(message)
return
}
throw new Error('expected parallel flush to reject')
throw new Error('expected flush to reject')
}
async function freshRoot(): Promise<string> {
@@ -49,21 +46,6 @@ afterEach(async () => {
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
})
async function rejectDirectorySync(code: string): Promise<void> {
const handle = await open(root, 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) {
if ((await this.stat()).isDirectory()) {
const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException
error.code = code
throw error
}
return realSync.call(this)
})
}
function appendClosedTurn(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
@@ -257,7 +239,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
appendClosedTurn(source)
const child = ctx.sessions.fork(source, undefined, SessionId('persist-child'))
await ctx.parallel('session/flush', child)
await ctx.sessions.flush(child)
const loaded = await ctx.sessionPersistence.load(child.id)
expect(loaded.events).toEqual(source.events)
@@ -360,26 +342,43 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => {
await rejectDirectorySync('EPERM')
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = 'win32'
const m = meta('windows-directory-sync')
it('reports both the append failure and a failed rollback', async () => {
const m = meta('rollback-failure')
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
})
await ctx.sessionPersistence.append(m.id, oneTurnLog())
it.each([
['linux', 'EPERM'],
['win32', 'EIO'],
] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => {
await rejectDirectorySync(code)
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = platform
const m = meta(`directory-sync-${platform}-${code}`)
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code })
const path = rawLogPath(root, undefined, m.id)
const handle = await (await import('node:fs/promises')).open(path, 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
let failed = false
const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
return realSync.call(this)
})
const backend = ctx.sessionPersistence as unknown as {
rollbackAppend: (path: string, size: number) => Promise<void>
}
const realRollback = backend.rollbackAppend.bind(backend)
backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
try {
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
] as SessionEvent[])
throw new Error('expected append to reject')
} catch (error) {
expect(error).toBeInstanceOf(AggregateError)
const aggregate = error as AggregateError
expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
expect(aggregate.errors).toHaveLength(2)
expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
} finally {
backend.rollbackAppend = realRollback
syncSpy.mockRestore()
}
})
it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
@@ -436,12 +435,14 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
const a = ctx.sessions.create(SessionId('sa'))
const b = ctx.sessions.create(SessionId('sb'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', a)
await ctx.parallel('session/flush', b)
await ctx.sessions.flush(a)
await ctx.sessions.flush(b)
const la = await ctx.sessionPersistence.load(SessionId('sa'))
const lb = await ctx.sessionPersistence.load(SessionId('sb'))
@@ -750,7 +751,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
}, { inject: ['sessions'] }))
// Drain A, then dispose ITS fiber (the live session A is gone) while the
// backend stays loaded.
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
await sessFiberA.dispose()
// A new Session object reuses the id. Object-keyed initialization must run independently,
@@ -818,7 +819,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}, { inject: ['sessions'] }))
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
await firstFiber.dispose()
let second!: Session
@@ -929,18 +930,19 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
const session = ctx2.sessions.create(SessionId('flush-fail'))
// A full turn lands in the write-behind buffer.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Make the durable materialize fail on the next flush.
const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
const origMat = backend.materialize.bind(backend)
backend.materialize = () => Promise.reject(new Error('disk full'))
await expectParallelFlushError(ctx2.parallel('session/flush', session), /disk full/)
await expectFlushError(ctx2.sessions.flush(session), /disk full/)
// The events are STILL buffered (not silently dropped): a retry persists them.
backend.materialize = origMat
await ctx2.parallel('session/flush', session)
await ctx2.sessions.flush(session)
const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2])
await ctx2.fiber.dispose()
})

View File

@@ -0,0 +1,169 @@
/**
* Unit tests for the Windows durable namespace helper with a mocked kernel32
* binding. The real JSONL suite exercises the helper on native Windows; these
* tests keep the Win32 error mapping and race handling covered on every host.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const MOVEFILE_WRITE_THROUGH = 0x00000008
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
const ERROR_NOT_SAME_DEVICE = 17
const ERROR_FILE_EXISTS = 80
const ERROR_INVALID_NAME = 123
const ERROR_ALREADY_EXISTS = 183
type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => number
const roots: string[] = []
function stripNamespace(path: string): string {
if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice('\\\\?\\UNC\\'.length)}`
if (path.startsWith('\\\\?\\')) return path.slice('\\\\?\\'.length)
return path
}
async function tempRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-win32-'))
roots.push(dir)
return dir
}
async function importWithMove(moveFileExW: MoveFileExW): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => {
let lastError = 0
const setLastError = (code: number): void => { lastError = code }
const move: MoveFileExW = (existing, replacement, flags, setError) => {
const ok = moveFileExW(existing, replacement, flags, setError)
lastError = ok === 0 ? lastError : 0
return ok
}
return {
default: {
load: () => ({
func: (_convention: string, name: string, result: string) => {
if (name === 'MoveFileExW') return (existing: string, replacement: string, flags: number) => {
expect(result).toBe('int')
const ok = move(existing, replacement, flags, setLastError)
return ok
}
return () => lastError
},
}),
},
}
})
return import('../src/win32.ts')
}
async function importWithError(code: number): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => ({
default: {
load: () => ({
func: (_convention: string, name: string) => {
if (name === 'MoveFileExW') return () => 0
return () => code
},
}),
},
}))
return import('../src/win32.ts')
}
async function importWithFilesystemMove(): Promise<typeof import('../src/win32.ts')> {
return importWithMove((existing, replacement, flags, setLastError) => {
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
const from = stripNamespace(existing)
const to = stripNamespace(replacement)
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
renameSync(from, to)
return 1
})
}
afterEach(async () => {
vi.doUnmock('koffi')
vi.resetModules()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
describe('Windows durable namespace helpers', () => {
it('publishes a new file with write-through MoveFileExW semantics', async () => {
const { publishNewFileWin32 } = await importWithFilesystemMove()
const root = await tempRoot()
const tmp = join(root, 'log.tmp')
const final = join(root, 'log.jsonl')
await writeFile(tmp, 'content')
await publishNewFileWin32(tmp, final)
expect(existsSync(tmp)).toBe(false)
expect(readFileSync(final, 'utf8')).toBe('content')
})
it('maps Win32 publish failures to Node-style errno codes', async () => {
const cases = [
[ERROR_FILE_NOT_FOUND, 'ENOENT'],
[ERROR_PATH_NOT_FOUND, 'ENOENT'],
[ERROR_ACCESS_DENIED, 'EACCES'],
[ERROR_NOT_SAME_DEVICE, 'EXDEV'],
[ERROR_FILE_EXISTS, 'EEXIST'],
[ERROR_ALREADY_EXISTS, 'EEXIST'],
[ERROR_INVALID_NAME, 'EINVAL'],
[9999, 'EIO'],
] as const
for (const [win32Code, code] of cases) {
const { publishNewFileWin32 } = await importWithError(win32Code)
await expect(publishNewFileWin32('from', 'to')).rejects.toMatchObject({ code, win32Code, path: 'from', dest: 'to' })
}
})
it('creates missing directories through staging siblings and tolerates an already-created race', async () => {
const root = await tempRoot()
const raced = join(root, 'raced')
const { ensureDurableDirectoryWin32 } = await importWithMove((existing, replacement, flags, setLastError) => {
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
const from = stripNamespace(existing)
const to = stripNamespace(replacement)
if (to === raced) {
mkdirSync(to)
setLastError(ERROR_ALREADY_EXISTS)
return 0
}
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
renameSync(from, to)
return 1
})
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
expect(existsSync(join(root, 'a', 'b'))).toBe(true)
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
await ensureDurableDirectoryWin32(raced)
expect(existsSync(raced)).toBe(true)
})
it('surfaces directory publication failures other than an existing-target race', async () => {
const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED)
const root = await tempRoot()
await expect(ensureDurableDirectoryWin32(join(root, 'denied'))).rejects.toMatchObject({ code: 'EACCES' })
})
it('rejects a non-directory component instead of treating it as missing', async () => {
const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove()
const root = await tempRoot()
const blocked = join(root, 'blocked')
writeFileSync(blocked, 'x')
await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
})
})

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -11,17 +11,23 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -30,6 +36,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence-sqlite`.
* @module @deepseek-ai/dsh-session-persistence-sqlite/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite'
/** Cordis companion plugin name. */
export const name = 'session-persistence-sqlite-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
* this package exposes no continuously observable in-process relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -14,17 +14,15 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
async function expectParallelFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
try {
await promise
} catch (error) {
expect(error).toBeInstanceOf(AggregateError)
const [cause] = (error as AggregateError).errors as unknown[]
expect(cause).toBeInstanceOf(Error)
expect((cause as Error).message).toMatch(message)
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toMatch(message)
return
}
throw new Error('expected parallel flush to reject')
throw new Error('expected flush to reject')
}
async function freshDbPath(): Promise<string> {
@@ -478,7 +476,9 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const walPath = await freshDbPath()
const bWal = await backend(walPath)
await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
const probe = openDatabase(walPath, 'wal')
expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
probe.close()
await bWal.dispose()
const deletePath = await freshDbPath()
@@ -502,7 +502,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const b1 = await backend(path)
const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
appendLog(s1, oneTurnLog())
await b1.ctx.parallel('session/flush', s1)
await b1.ctx.sessions.flush(s1)
await b1.dispose()
// A fresh context with an UNRELATED live session reusing the id meets a
@@ -513,9 +513,9 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('hmr-collide'))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await ctx.plugin(SessionPersistenceSqlite, { path })
await expectParallelFlushError(ctx.parallel('session/flush', session), /id collision/)
await expectFlushError(ctx.sessions.flush(session), /id collision/)
await ctx.fiber.dispose()
})
})
@@ -567,18 +567,20 @@ describe('surface field round-trip', () => {
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const session = ctx.sessions.create(SessionId('roundtrip-surface'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [0] })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
expect(loaded.events).toHaveLength(4)
const um = loaded.events[1]!
expect(loaded.events).toHaveLength(6)
const um = loaded.events[2]!
expect((um as SurfaceEvent).surfaceOp).toBe('append')
expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined()
const am = loaded.events[2]!
const am = loaded.events[3]!
expect((am as SurfaceEvent).surfaceOp).toBe('append')
expect((am as SurfaceEvent).sourceEventSeqs).toEqual([0])
expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2])
await fiber.dispose()
})
@@ -590,7 +592,7 @@ describe('surface field round-trip', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('steering/message', { turn: 1, content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')
expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -11,21 +11,29 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`.
* @module @deepseek-ai/dsh-session-persistence/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence'
/** Cordis companion plugin name. */
export const name = 'session-persistence-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
* this package exposes no continuously observable in-process relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -11,6 +11,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { meta, oneTurnLog, appendLog } from './contract.ts'
@@ -76,7 +77,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } })
send(session, oneTurnLog())
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('live'))
expect(loaded.events).toHaveLength(6)
@@ -96,7 +97,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } })
send(session, oneTurnLog())
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('forked-child'))
expect(loaded.meta.seedLength).toBe(3)
@@ -132,17 +133,17 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(() => {
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
}).toThrow(TypeError)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
const first = loaded.events[0]
expect(first?.type === 'user/message' && (first.data.content[0] as { text: string }).text).toBe('original')
const message = loaded.events.find(event => event.type === 'user/message')
expect(message?.type === 'user/message' && (message.data.content[0] as { text: string }).text).toBe('original')
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -187,7 +188,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
expect(loaded.events).toEqual(seed)
// A flush with no NEW events must not double-write.
await ctx.parallel('session/flush', forked)
await ctx.sessions.flush(forked)
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
expect(reloaded.events).toEqual(seed)
} finally {
@@ -203,7 +204,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } })
send(s1, oneTurnLog())
await first.ctx.parallel('session/flush', s1)
await first.ctx.sessions.flush(s1)
} finally {
await first.fiber.dispose()
}
@@ -215,7 +216,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await second.ctx.sessions.flush(s2) // let onCreated adopt
s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await second.ctx.parallel('session/flush', s2)
await second.ctx.sessions.flush(s2)
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
@@ -233,13 +234,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.plugin(SessionStore)
// A session exists BEFORE the persistence plugin is applied.
const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const fiber = await fix.mount(ctx)
try {
// The plugin seeded it on apply; a subsequent flush persists its events.
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing'))
expect(loaded.events.length).toBeGreaterThanOrEqual(2)
} finally {
@@ -254,6 +256,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.plugin(SessionStore)
const fiber = await fix.mount(ctx)
const session = await liveSessionInFiber(ctx, 'drain', WORK)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// No explicit flush — dispose must drain.
@@ -282,7 +285,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
// session stays live. The new instance has no coordinator state but must adopt the
@@ -292,7 +295,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow()
await expect(ctx.sessions.flush(session)).resolves.not.toThrow()
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
@@ -312,7 +315,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const backend1 = await fix.mount(ctx)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
// flushing turn 2: it is now ONLY in the live session's events; the new
@@ -324,7 +327,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Instance 2 adopts the stored prefix (turn 1) and MUST also persist the
// live suffix (turn 2) carried in the session's events.
await fix.mount(ctx)
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3])
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
@@ -343,7 +346,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const first = await fix.mount(ctx)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Crash-tail a torn fragment past the (open) committed turn, then reload.
await first.dispose()
@@ -353,7 +356,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// end. Adoption must truncate the torn tail but NOT synthesize closers.
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
@@ -373,7 +376,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
send(s1, oneTurnLog())
await first.ctx.parallel('session/flush', s1)
await first.ctx.sessions.flush(s1)
} finally {
await first.fiber.dispose()
}
@@ -413,7 +416,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', reuse)
await ctx.sessions.flush(reuse)
const loaded = await ctx.sessionPersistence.load(SessionId('abandoned'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
} finally {
@@ -459,14 +462,15 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Re-emit session/created for the SAME live session (idempotent initFor).
ctx.emit('session/created', session)
await ctx.parallel('session/flush', session)
ctx.emit(scopeTarget(session, undefined), 'session/created', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('idem'))
expect(loaded.events).toHaveLength(2) // not doubled
expect(loaded.events).toHaveLength(3) // not doubled
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -711,11 +715,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// async onCreated init has necessarily set state (exercises the
// state-undefined cursor path).
const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
expect(loaded.events).toHaveLength(2)
expect(loaded.events).toHaveLength(3)
} finally {
await fiber.dispose()
await fix.cleanup()

View File

@@ -16,6 +16,9 @@
},
{
"path": "../../core/session"
},
{
"path": "../../support/invariants"
}
]
}