simplify(seams): prune dead methods from the persistence and bash seams
Two capability seams carried abstract methods no production consumer calls. A method no consumer programs against is not a seam — it is speculative surface every implementation must still provide and test. - SessionPersistence: remove has() and delete(), the coordinator's has/delete/deleteCore, and the PersistenceBackend.deleteStored hook (with its jsonl + sqlite + in-spec memory-stub impls). Surviving service surface: create/append/load/list. Production uses only load() (resume) and list() (ACP session/list). - BashExecutor: remove get(id) and list(), the abstract decls and the LocalBashExecutor impls. The internal tasks map survives (it backs ownerOf/readOutput/kill); get/list were pure public accessors over it with no shipping caller and no bash_list tool. - Migrate tests that reached through ctx.bash.get(id) to the public completion seam: a doneFor(id) helper over onTaskDone awaits a task by id, and the HMR-reload ownership test now proves task survival through A's own bash_output ([status: running]) plus ownerOf + B-rejection — a stronger through-the-tool assertion than the removed lookup peek. - Update seam READMEs (six -> four service methods, drop the deleteStored hook and the get/list row) and the two implemented persistence RFCs in place. Implements docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
This commit is contained in:
@@ -176,20 +176,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: string): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: string): string | undefined {
|
||||
// Unknown id and known-but-ownerless both read as undefined — the consumer
|
||||
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
|
||||
return this.tasks.get(id)?.owner
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: string): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
|
||||
@@ -98,8 +98,6 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 0.2; echo done' }))
|
||||
expect(Date.now() - before).toBeLessThan(150)
|
||||
expect(task.status).toBe('running')
|
||||
expect(bash.get(task.id)).toBe(task)
|
||||
expect(bash.list()).toContain(task)
|
||||
await task.done
|
||||
expect(task.status).toBe('completed')
|
||||
expect(task.exitCode).toBe(0)
|
||||
@@ -237,7 +235,6 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
await running.done
|
||||
expect(finished.status).toBe('completed')
|
||||
expect(running.signal).toBe('SIGTERM')
|
||||
expect(bash.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('disposing the executor fiber kills running tasks (no orphans)', async () => {
|
||||
@@ -249,14 +246,13 @@ describe('LocalBashExecutor background tasks', () => {
|
||||
bash.onTaskDone(listener)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 60' }))
|
||||
const running = bash.get(task.id)!
|
||||
const running = task
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Grab the pid before dispose clears the registry.
|
||||
const pid = (running as unknown as { running: { pid: number } }).running.pid
|
||||
await fiber.dispose()
|
||||
await waitGone(pid)
|
||||
expect(bash.list()).toEqual([])
|
||||
// Listener silenced by base-class teardown — no late notifications.
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
|---|---|
|
||||
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
|
||||
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
|
||||
| `get(id)` / `list()` | Task lookup. |
|
||||
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
|
||||
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
|
||||
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
|
||||
|
||||
@@ -85,9 +85,6 @@ export abstract class BashExecutor extends Service {
|
||||
/** Start a background task and return its handle immediately. */
|
||||
abstract start(spec: BashExecSpec): BashTask
|
||||
|
||||
/** Look up a background task by id. */
|
||||
abstract get(id: string): BashTask | undefined
|
||||
|
||||
/**
|
||||
* The opaque OWNER token recorded for a background task at {@link start}
|
||||
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
|
||||
@@ -103,9 +100,6 @@ export abstract class BashExecutor extends Service {
|
||||
*/
|
||||
abstract ownerOf(id: string): string | undefined
|
||||
|
||||
/** All tracked background tasks (insertion order). */
|
||||
abstract list(): BashTask[]
|
||||
|
||||
/** Read output produced since the previous read. Throws for unknown ids. */
|
||||
abstract readOutput(id: string): BashTaskRead
|
||||
|
||||
|
||||
@@ -44,18 +44,10 @@ class StubExecutor extends BashExecutor {
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: string): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: string): string | undefined {
|
||||
return this.owners.get(id)
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: string): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
@@ -88,8 +80,6 @@ describe('BashExecutor service seam', () => {
|
||||
it('registers as ctx.bash and serves the abstract API', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 1' }))
|
||||
expect(bash.get(task.id)).toBe(task)
|
||||
expect(bash.list()).toEqual([task])
|
||||
expect(bash.kill(task.id)).toBe(true)
|
||||
expect(bash.kill(task.id)).toBe(false)
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
|
||||
@@ -8,6 +8,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { BashTask } from '@deepseek-ai/dsh-bash'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -143,13 +144,18 @@ describe('bash tool through the agent loop', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
// Capture the single background task's completion. Registered BEFORE send so
|
||||
// a fast task (echo) can't finish before the listener is attached; onTaskDone
|
||||
// delivers the task object once it completes (completion may race turn end).
|
||||
const taskDone = new Promise<BashTask>((resolve) => {
|
||||
const dispose = ctx.bash.onTaskDone((task) => { dispose(); resolve(task) })
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Wait for the background task itself (completion may race turn end).
|
||||
const task = ctx.bash.get(taskId)
|
||||
if (!task) throw new Error(`task ${taskId} not registered`)
|
||||
await task.done
|
||||
await taskDone
|
||||
|
||||
const log = events(agent)
|
||||
const firstResult = findEvent(log, 'tool/result')
|
||||
|
||||
@@ -66,6 +66,23 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the background task with `id` completes. The task is started
|
||||
* indirectly (via `ctx.tools.execute`), so `start()`'s return is not accessible
|
||||
* here; the executor's `onTaskDone` listener delivers the SAME task object on
|
||||
* completion, which is the surviving seam for awaiting a task by id.
|
||||
*/
|
||||
function doneFor(ctx: Context, id: string): Promise<BashTask> {
|
||||
return new Promise<BashTask>((resolve) => {
|
||||
const dispose = ctx.bash.onTaskDone((task) => {
|
||||
if (task.id === id) {
|
||||
dispose()
|
||||
resolve(task)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
class LossyReadBashExecutor extends BashExecutor {
|
||||
private readonly task: BashTask = {
|
||||
id: 'bash-lossy',
|
||||
@@ -94,18 +111,10 @@ class LossyReadBashExecutor extends BashExecutor {
|
||||
return this.task
|
||||
}
|
||||
|
||||
get(id: string): BashTask | undefined {
|
||||
return id === this.task.id ? this.task : undefined
|
||||
}
|
||||
|
||||
ownerOf(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [this.task]
|
||||
}
|
||||
|
||||
readOutput(id: string): BashTaskRead {
|
||||
if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
|
||||
return { task: this.task, delta: 'tail', lossy: true }
|
||||
@@ -286,7 +295,7 @@ describe('background tools', () => {
|
||||
expect(text(first)).toContain('first')
|
||||
expect(text(first)).toContain('[status: running]')
|
||||
|
||||
await ctx.bash.get(id)!.done
|
||||
await doneFor(ctx, id)
|
||||
const second = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(second)).toContain('second')
|
||||
expect(text(second)).not.toContain('first')
|
||||
@@ -306,7 +315,7 @@ describe('background tools', () => {
|
||||
|
||||
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
await ctx.bash.get(id)!.done
|
||||
await doneFor(ctx, id)
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
expect(text(read)).toContain('[some output was dropped from memory; full output: ')
|
||||
})
|
||||
@@ -329,7 +338,7 @@ describe('background tools', () => {
|
||||
|
||||
const killed = await call(ctx, 'bash_kill', { task_id: id })
|
||||
expect(text(killed)).toBe(`killed background task ${id}`)
|
||||
await ctx.bash.get(id)!.done
|
||||
await doneFor(ctx, id)
|
||||
|
||||
const again = await call(ctx, 'bash_kill', { task_id: id })
|
||||
expect(text(again)).toBe(`task ${id} had already finished`)
|
||||
@@ -373,7 +382,7 @@ describe('background tools', () => {
|
||||
agent,
|
||||
})
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
await ctx.bash.get(id)!.done
|
||||
await doneFor(ctx, id)
|
||||
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
const [content, options] = inject.mock.calls[0] as [
|
||||
@@ -396,7 +405,7 @@ describe('background tools', () => {
|
||||
agent,
|
||||
})
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
await expect(doneFor(ctx, id)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
|
||||
@@ -415,7 +424,7 @@ describe('background tools', () => {
|
||||
agent,
|
||||
})
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
await ctx.bash.get(id)!.done
|
||||
await doneFor(ctx, id)
|
||||
// notifyTaskDone caught and logged the rethrown error.
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
|
||||
@@ -443,7 +452,7 @@ describe('background tools', () => {
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
// Unregister the agent BEFORE the task completes (simulate disconnect).
|
||||
unregisterFakeAgents(ctx)
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
await expect(doneFor(ctx, id)).resolves.toBeDefined()
|
||||
expect(inject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -451,7 +460,7 @@ describe('background tools', () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
||||
await expect(doneFor(ctx, id)).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -534,7 +543,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
const b = fakeAgent('sess-b')
|
||||
const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
await ctx.bash.get(id)!.done
|
||||
await doneFor(ctx, id)
|
||||
// Completion does NOT clear ownership: B is still rejected, A still allowed.
|
||||
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
||||
expect(readByB.isError).toBe(true)
|
||||
@@ -567,7 +576,9 @@ describe('background task ownership (cross-session isolation)', () => {
|
||||
// token) survive.
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.bash.get(id)?.status).toBe('running')
|
||||
// The task survived the reload, still running and still owned by A — proven
|
||||
// via A's own bash_output (reports running status) and the surviving owner token.
|
||||
expect(text(await callAs(ctx, a, 'bash_output', { task_id: id }))).toContain('[status: running]')
|
||||
expect(ctx.bash.ownerOf(id)).toBe('sess-a')
|
||||
|
||||
// After reload, ownership is INTACT → B is STILL rejected.
|
||||
@@ -675,10 +686,10 @@ describe('status lines', () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const task = ctx.bash.get(id)!
|
||||
const done = doneFor(ctx, id)
|
||||
|
||||
await call(ctx, 'bash_kill', { task_id: id })
|
||||
await task.done
|
||||
const task = await done
|
||||
// Simulate the variant where the close event carried no signal.
|
||||
task.signal = null
|
||||
const read = await call(ctx, 'bash_output', { task_id: id })
|
||||
@@ -689,8 +700,7 @@ describe('status lines', () => {
|
||||
const ctx = await setup()
|
||||
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
||||
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
||||
const task = ctx.bash.get(id)!
|
||||
await task.done
|
||||
const task = await doneFor(ctx, id)
|
||||
// Defensive: completed tasks always carry an exit code in practice; the
|
||||
// ?? 0 fallback covers task shapes from other executor implementations.
|
||||
task.exitCode = null
|
||||
|
||||
@@ -21,7 +21,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
|
||||
## Durability and crash semantics
|
||||
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`.
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. 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 appends are line appends at EOF + `fsync`.
|
||||
- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md).
|
||||
- **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `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.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* (the `session/event` → buffer → `session/flush` drain, per-session
|
||||
* serialization, write cursors, fork-seed persistence, HMR live-adoption,
|
||||
* crash-repair sequencing, dispose quiescence) lives in the backend-agnostic
|
||||
* {@link PersistenceCoordinator} this class composes. The six public
|
||||
* {@link PersistenceCoordinator} this class composes. The four public
|
||||
* {@link SessionPersistence} methods delegate to the coordinator.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-persistence-jsonl
|
||||
@@ -101,14 +101,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
return this.coordinator.load(id)
|
||||
}
|
||||
|
||||
has(id: SessionId): Promise<boolean> {
|
||||
return this.coordinator.has(id)
|
||||
}
|
||||
|
||||
delete(id: SessionId): Promise<void> {
|
||||
return this.coordinator.delete(id)
|
||||
}
|
||||
|
||||
// `list` is BOTH the public service method and the PersistenceBackend hook —
|
||||
// one method, the bucket walk below. The coordinator adds no orchestration for
|
||||
// listing (no per-id serialization, no cursor), so it would just call back into
|
||||
@@ -180,12 +172,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
if (closers.length > 0) await this.appendLines(meta, closers)
|
||||
}
|
||||
|
||||
/** Remove a session's log file (the coordinator clears its in-memory state). */
|
||||
async deleteStored(id: SessionId): Promise<void> {
|
||||
const file = await this.findLog(id)
|
||||
if (file) await rm(file.path, { force: true })
|
||||
}
|
||||
|
||||
/** List all stored sessions' metadata (header line only — no full-log parse). */
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
const metas: SessionHeader[] = []
|
||||
@@ -341,9 +327,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
/**
|
||||
* Find a session's log file by id across ALL cwd buckets — the any-cwd scan
|
||||
* for `loadStored`/`deleteStored` (resume and removal identify a session by id
|
||||
* alone). The cwd-scoped lookup (`loadLive`) does NOT use this; it goes
|
||||
* straight to `logPath(cwd)` so a no-cwd session can't match a real-cwd bucket.
|
||||
* for `loadStored` (resume identifies a session by id alone). The cwd-scoped
|
||||
* lookup (`loadLive`) does NOT use this; it goes straight to `logPath(cwd)` so
|
||||
* a no-cwd session can't match a real-cwd bucket.
|
||||
*/
|
||||
private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> {
|
||||
const target = encodeSegment(id) + '.jsonl'
|
||||
|
||||
@@ -105,12 +105,12 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
// nothing on disk yet
|
||||
const dir = sessionDir(root, '/work')
|
||||
await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow()
|
||||
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
|
||||
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(logPath(root, '/work', m.id))).isFile()).toBe(true)
|
||||
expect(await ctx.sessionPersistence.has(m.id)).toBe(true)
|
||||
expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
|
||||
void dir
|
||||
})
|
||||
|
||||
@@ -448,19 +448,6 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
expect(ids).toContain('big')
|
||||
})
|
||||
|
||||
it('has() finds a session on disk under an unknown cwd (cross-bucket scan)', async () => {
|
||||
const m = meta('scan-me', '/somewhere')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
// A fresh backend with no in-memory state → has() must scan disk buckets.
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
expect(await ctx2.sessionPersistence.has(m.id)).toBe(true)
|
||||
expect(await ctx2.sessionPersistence.has(SessionId('absent'))).toBe(false)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
|
||||
// Session A materializes a log under id "reuse".
|
||||
const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
@@ -579,20 +566,23 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exists() surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
|
||||
// Same contract on the existence path: a non-ENOENT error from the per-id
|
||||
// open() must surface, not be collapsed to "not found" (which would let a
|
||||
// collision check proceed under a false absence assumption). A LAZY session
|
||||
// (created, never appended) keeps its cwd in state, so has() reaches
|
||||
// loadLive(id, cwd) → exists(logPath). Make that cwd's bucket DIRECTORY a
|
||||
// regular file: open()ing `bucket/<id>.jsonl` under it then fails ENOTDIR.
|
||||
it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
|
||||
// A non-ENOENT error from the per-id open() must surface, not be collapsed to
|
||||
// "not found" (which would let live-adoption proceed under a false absence
|
||||
// assumption). A live session's onCreated reaches loadLive(id, cwd) →
|
||||
// exists(logPath). Make that cwd's bucket DIRECTORY a regular file: open()ing
|
||||
// `bucket/<id>.jsonl` under it then fails ENOTDIR.
|
||||
const cwd = '/x'
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
await ctx2.sessionPersistence.create(meta('exists-fault', cwd)) // lazy: no bucket yet
|
||||
await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
|
||||
await expect(ctx2.sessionPersistence.has(SessionId('exists-fault'))).rejects.toThrow(/ENOTDIR/)
|
||||
const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
|
||||
let s!: Session
|
||||
await ctx2.plugin(Object.assign((inner: Context) => {
|
||||
s = inner.sessions.create('exists-fault', { meta: { cwd } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -687,7 +677,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
circ.self = circ
|
||||
await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/)
|
||||
// The session was never materialized by any of the rejected appends.
|
||||
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
|
||||
expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
|
||||
})
|
||||
|
||||
it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => {
|
||||
@@ -695,7 +685,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx.sessionPersistence.create(m)
|
||||
const ev = [{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] } } }] as unknown as SessionEvent[]
|
||||
await ctx.sessionPersistence.append(m.id, ev)
|
||||
expect(await ctx.sessionPersistence.has(m.id)).toBe(true)
|
||||
expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
|
||||
})
|
||||
|
||||
it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
|
||||
|
||||
@@ -13,8 +13,8 @@ The repo targets Node ≥ 24 (the root `engines` field), which includes the stab
|
||||
## Contract semantics over rows
|
||||
|
||||
- **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.)
|
||||
- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `has()`/`list()` (which report exactly the sessions that have a row).
|
||||
- **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `has()`/`list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`.
|
||||
- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row).
|
||||
- **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`.
|
||||
|
||||
## Configuration (schemastery)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Like the JSONL backend it supplies ONLY the storage primitives (the
|
||||
* {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside
|
||||
* transactions); all the write-path orchestration lives in the backend-agnostic
|
||||
* {@link PersistenceCoordinator} this class composes. The six public
|
||||
* {@link PersistenceCoordinator} this class composes. The four public
|
||||
* {@link SessionPersistence} methods delegate to the coordinator.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-persistence-sqlite
|
||||
@@ -99,14 +99,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.coordinator.load(id)
|
||||
}
|
||||
|
||||
has(id: SessionId): Promise<boolean> {
|
||||
return this.coordinator.has(id)
|
||||
}
|
||||
|
||||
delete(id: SessionId): Promise<void> {
|
||||
return this.coordinator.delete(id)
|
||||
}
|
||||
|
||||
// `list` is BOTH the public service method and the PersistenceBackend hook —
|
||||
// one method (the SELECT below). The coordinator adds no orchestration for
|
||||
// listing, so routing it through the coordinator would just recurse. Defined
|
||||
@@ -203,12 +195,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a session's row (ON DELETE CASCADE drops its events). */
|
||||
async deleteStored(id: SessionId): Promise<void> {
|
||||
await this.ready
|
||||
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
/** List all materialized sessions' metadata (every row is a materialized session). */
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
await this.ready
|
||||
@@ -234,7 +220,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
/**
|
||||
* Insert-or-replace a session's metadata row. The only caller is the first
|
||||
* materializing `appendBatch`, so writing the row IS the materialization (its
|
||||
* existence is the signal `has`/`list` read).
|
||||
* existence is the signal `list` reads).
|
||||
*/
|
||||
private writeRow(meta: SessionHeader): void {
|
||||
this.db.prepare(`
|
||||
|
||||
@@ -21,7 +21,7 @@ export const SCHEMA_VERSION = 2
|
||||
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
|
||||
* The row's EXISTENCE is the materialization signal: it is written only by the
|
||||
* first `append` (lazy materialization), so a created-but-never-appended
|
||||
* session has no row and is absent from `has`/`list`, mirroring the JSONL
|
||||
* session has no row and is absent from `list`, mirroring the JSONL
|
||||
* backend's "no file until first append".
|
||||
*/
|
||||
export interface SessionRow {
|
||||
|
||||
@@ -214,17 +214,15 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } } },
|
||||
])
|
||||
expect(await b1.ctx.sessionPersistence.has(m.id)).toBe(true) // materialized
|
||||
await b1.dispose()
|
||||
|
||||
// A fresh backend loads it: the interrupted (only) turn's real events are
|
||||
// preserved and closed with a synthetic turn/end {interrupted} — NOT
|
||||
// truncated. The session was materialized, so has()/list() report it present.
|
||||
// truncated. The session was materialized, so list() reports it present.
|
||||
const b2 = await backend(path)
|
||||
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
||||
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
|
||||
expect(loaded.events.at(-1)!.type === 'turn/end' && loaded.events.at(-1)!.data).toMatchObject({ reason: { kind: 'interrupted' } })
|
||||
expect(await b2.ctx.sessionPersistence.has(m.id)).toBe(true)
|
||||
expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
|
||||
await b2.dispose()
|
||||
})
|
||||
|
||||
@@ -11,8 +11,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
|
||||
| `append(id, events): Promise<void>` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
|
||||
| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. |
|
||||
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. |
|
||||
| `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. |
|
||||
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. |
|
||||
|
||||
## Invariants every backend must honor
|
||||
|
||||
@@ -25,7 +24,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
|
||||
The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows).
|
||||
|
||||
`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its six public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice).
|
||||
`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice).
|
||||
|
||||
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
|
||||
|
||||
@@ -36,7 +35,7 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
|
||||
| `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. |
|
||||
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
|
||||
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
|
||||
| `deleteStored(id)` / `list()` | Remove a stored artifact / list all stored metadata. |
|
||||
| `list()` | List all stored metadata. |
|
||||
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
|
||||
|
||||
The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* {@link PersistenceBackend} hook object.
|
||||
*
|
||||
* The abstract {@link SessionPersistence} service's public API is independent of
|
||||
* this: a backend IS a `SessionPersistence` (its six public methods delegate to
|
||||
* this: a backend IS a `SessionPersistence` (its four public methods delegate to
|
||||
* a coordinator it composes), so a third-party backend MAY implement the service
|
||||
* directly without using the coordinator at all.
|
||||
*
|
||||
@@ -95,9 +95,6 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
*/
|
||||
commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>
|
||||
|
||||
/** Remove the stored artifact for `id` (the coordinator clears in-memory state). */
|
||||
deleteStored(id: SessionId): Promise<void>
|
||||
|
||||
/** List all stored (materialized) sessions' metadata. */
|
||||
list(): Promise<SessionHeader[]>
|
||||
|
||||
@@ -119,13 +116,12 @@ interface SessionState {
|
||||
* SQLite row exists). `create()` registers state LAZILY — cursor 0,
|
||||
* materialized false, nothing on disk — so an empty session leaves no
|
||||
* artifact and the FIRST `appendBatch` writes the header + its events in ONE
|
||||
* transaction (the "a row exists ⇔ it has events" invariant `has`/`list`
|
||||
* rely on; a separate up-front materialize could crash leaving a row with
|
||||
* transaction (the "a row exists ⇔ it has events" invariant `list`
|
||||
* relies on; a separate up-front materialize could crash leaving a row with
|
||||
* zero events). The flag is the only signal that distinguishes a session
|
||||
* registered-but-never-written from one durably present, which two callers
|
||||
* need: `has()` (lazy-but-unwritten is not yet durable) and the reclaim path
|
||||
* (an abandoned id with no artifact AND no buffered events is free to reuse;
|
||||
* a materialized one is a real collision).
|
||||
* registered-but-never-written from one durably present, which the reclaim
|
||||
* path needs (an abandoned id with no artifact AND no buffered events is free
|
||||
* to reuse; a materialized one is a real collision).
|
||||
*/
|
||||
materialized: boolean
|
||||
/**
|
||||
@@ -150,7 +146,7 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
|
||||
/**
|
||||
* Owns the backend-agnostic session write-path orchestration. A backend
|
||||
* constructs one (`new PersistenceCoordinator(ctx, this)`), implements
|
||||
* {@link PersistenceBackend}, and delegates its six public service methods to
|
||||
* {@link PersistenceBackend}, and delegates its four public service methods to
|
||||
* the matching coordinator methods.
|
||||
*
|
||||
* All per-id operations are serialized (a per-id promise chain) so concurrent
|
||||
@@ -294,31 +290,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
// through the coordinator would only forward to that same hook, so the
|
||||
// coordinator stays out of the listing path entirely.
|
||||
|
||||
/** Whether a session is durably present (materialized). */
|
||||
async has(id: SessionId): Promise<boolean> {
|
||||
const state = this.states.get(id)
|
||||
if (state?.materialized) return true
|
||||
// A TRACKED lazy session has a known cwd: probe that exact bucket via
|
||||
// loadLive(id, cwd) — including the no-cwd bucket when its cwd is undefined.
|
||||
// An UNTRACKED id has a genuinely UNKNOWN cwd, so it must scan ANY scope via
|
||||
// loadStored — loadLive(id, undefined) would (correctly) look ONLY in the
|
||||
// no-cwd bucket and miss a materialized session that lives in a real cwd.
|
||||
const probe = state !== undefined
|
||||
? await this.backend.loadLive(id, state.meta.cwd)
|
||||
: await this.backend.loadStored(id)
|
||||
return probe !== undefined
|
||||
}
|
||||
|
||||
/** Remove a session and all its persisted artifacts. */
|
||||
delete(id: SessionId): Promise<void> {
|
||||
return this.serialize(id, () => this.deleteCore(id))
|
||||
}
|
||||
|
||||
private async deleteCore(id: SessionId): Promise<void> {
|
||||
await this.backend.deleteStored(id)
|
||||
this.states.delete(id)
|
||||
}
|
||||
|
||||
// --- per-id serialization + adoption helpers ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,7 +103,7 @@ export abstract class SessionPersistence extends Service {
|
||||
/**
|
||||
* Register a new session's metadata. A backend MAY defer the physical write
|
||||
* until the first {@link append} (lazy materialization), in which case a
|
||||
* created-but-never-appended session is absent from {@link has}/{@link list}
|
||||
* created-but-never-appended session is absent from {@link list}
|
||||
* — abandoned sessions leave nothing behind.
|
||||
*/
|
||||
abstract create(meta: SessionHeader): Promise<void>
|
||||
@@ -143,12 +143,6 @@ export abstract class SessionPersistence extends Service {
|
||||
|
||||
/** Lightweight listing from metadata, without a full-log parse. */
|
||||
abstract list(): Promise<SessionHeader[]>
|
||||
|
||||
/** Whether a session is durably present (materialized). */
|
||||
abstract has(id: SessionId): Promise<boolean>
|
||||
|
||||
/** Remove a session and all its persisted artifacts. */
|
||||
abstract delete(id: SessionId): Promise<void>
|
||||
}
|
||||
|
||||
export default SessionPersistence
|
||||
|
||||
@@ -142,24 +142,22 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
}
|
||||
})
|
||||
|
||||
it('has()/list() exclude a created-but-never-appended (zero-event) session', async () => {
|
||||
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
await persistence.create(meta('empty'))
|
||||
expect(await persistence.has(SessionId('empty'))).toBe(false)
|
||||
expect((await persistence.list()).map(m => m.id)).not.toContain(SessionId('empty'))
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('has()/list() include a session once it has events', async () => {
|
||||
it('list() includes a session once it has events', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s2')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog())
|
||||
expect(await persistence.has(m.id)).toBe(true)
|
||||
expect((await persistence.list()).map(x => x.id)).toContain(m.id)
|
||||
} finally {
|
||||
await dispose()
|
||||
@@ -227,19 +225,5 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('delete removes a session', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s6')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog())
|
||||
expect(await persistence.has(m.id)).toBe(true)
|
||||
await persistence.delete(m.id)
|
||||
expect(await persistence.has(m.id)).toBe(false)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -625,7 +625,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const m = meta('empty-batch', WORK)
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, [])
|
||||
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
|
||||
expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -643,17 +643,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('delete of a non-existent session is a no-op', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
await expect(ctx.sessionPersistence.delete(SessionId('ghost'))).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('create rejects a duplicate id (in memory and on a persisted log)', async () => {
|
||||
const fix = await makeFixture()
|
||||
const first = await freshCtx(fix)
|
||||
|
||||
@@ -61,14 +61,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
|
||||
return this.coordinator.load(id)
|
||||
}
|
||||
|
||||
has(id: SessionId): Promise<boolean> {
|
||||
return this.coordinator.has(id)
|
||||
}
|
||||
|
||||
delete(id: SessionId): Promise<void> {
|
||||
return this.coordinator.delete(id)
|
||||
}
|
||||
|
||||
/** White-box accessor: await a specific session's onCreated init. */
|
||||
get inits(): Map<Session, Promise<void>> {
|
||||
return this.coordinator.inits
|
||||
@@ -114,10 +106,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
|
||||
if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[])
|
||||
}
|
||||
|
||||
async deleteStored(id: SessionId): Promise<void> {
|
||||
this.store.delete(id)
|
||||
}
|
||||
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
return [...this.store.values()].map(e => structuredClone(e.meta))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user