fix(storage,workspace): post-review hardening

Review findings applied across the group:
- storage hub: stale disposers no longer remove a successor registration;
  the package now default-exports the Storage service class per the
  service-package export shape.
- json backend: failed publishes roll back the authoritative memory state
  (a rejected write can no longer resurface via get() or ride the next
  publish); close() drains in-flight writes and blocks in-flight opens;
  double-open rejects as a plain caller error instead of malformed-medium.
- sqlite backend: loadAll builds records on a null prototype (__proto__
  keys round-trip instead of polluting), user_version is stamped only
  after the schema is fully created, and corrupt record JSON rejects as
  malformed-medium instead of a bare SyntaxError.
- domain form: writes persist before mutating authoritative memory or
  emitting; DomainChanged is a put/deleted discriminated union.
- workspace: attach/detach idempotence decided on the write chain (stale
  snapshots no longer short-circuit), create() requires a directory, and
  startup fails loud on duplicate stored paths.

Eleven regression tests pin the fixed behaviors.
This commit is contained in:
imccyu
2026-07-24 21:30:32 +08:00
parent 3f16cb4c3c
commit 80b3b6d917
19 changed files with 470 additions and 136 deletions
+22 -7
View File
@@ -7,6 +7,7 @@
*/
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Context, Service } from 'cordis'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -88,12 +89,22 @@ export class WorkspaceRegistry extends Service {
if (persistence !== undefined) {
this.known = new Set<string>((await persistence.list()).map(header => header.id))
}
// Rebuild entities, rejecting a double account: one session recorded
// under two workspaces means the medium was edited externally (the attach
// check makes it structurally impossible to write), and hiding it would
// silently pick a winner.
// Rebuild entities, rejecting states the write side makes structurally
// impossible (an external medium edit is the only way in, and hiding it
// would silently pick a winner): one session accounted under two
// workspaces, or two records claiming one canonical path (plain string
// equality — stored paths are already canonical, so no realpath here).
const accounted = new Map<string, WorkspaceId>()
const paths = new Map<string, WorkspaceId>()
for (const [id, record] of this.table.entries()) {
const pathHolder = paths.get(record.path)
if (pathHolder !== undefined) {
throw new Error(
`workspace domain is inconsistent: path '${record.path}' is claimed `
+ `by both workspace '${pathHolder}' and workspace '${id}'`,
)
}
paths.set(record.path, id)
for (const sessionId of record.sessionIds) {
const holder = accounted.get(sessionId)
if (holder !== undefined) {
@@ -110,9 +121,10 @@ export class WorkspaceRegistry extends Service {
/**
* Create a workspace over an existing directory. The path is canonicalized
* through `fs.realpath` first — a nonexistent directory rejects with the
* original `ENOENT`, and a canonical path already owned by another
* workspace (including a symlink resolving to it) rejects.
* through `fs.realpath` first — a nonexistent path rejects with the
* original `ENOENT`, a path resolving to anything but a directory rejects,
* and a canonical path already owned by another workspace (including a
* symlink resolving to it) rejects.
* @param path - Directory the workspace points at; canonicalized before storing.
* @param title - Display title; defaults to `basename` of the canonical path.
* @returns the created workspace after durability.
@@ -120,6 +132,9 @@ export class WorkspaceRegistry extends Service {
async create(path: string, title?: string): Promise<Workspace> {
const table = this.requireTable()
const canonical = await realpathNormalize(path)
if (!(await stat(canonical)).isDirectory()) {
throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`)
}
for (const entity of this.entities.values()) {
if (entity.path === canonical) {
throw new Error(`a workspace for '${canonical}' already exists ('${entity.id}')`)