Allow same-basename Workspace paths

This commit is contained in:
Tianyi Cui
2026-07-31 18:35:55 +08:00
parent bcb474d13e
commit 678ad97f9a
43 changed files with 203 additions and 135 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089
README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4
README.md: bfa044de50fc71dca95637487df50dc551d40e1b
README.zh.md: 1711e4d90c4cc62cf6e18a6868cd362a2c2e9458

View File

@@ -8,7 +8,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
## Shape
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title.
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title.
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity.
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.

View File

@@ -8,7 +8,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
## 结构
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace且不改变其标题不同路径不能创建重复标题。
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace且不改变其标题不同路径可以共用显示标题。
- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。
- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。
- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。

View File

@@ -38,17 +38,6 @@ export function WorkspaceId(id: string): WorkspaceId {
return id as WorkspaceId
}
/** A create request would give two Workspaces the same display name. */
export class WorkspaceNameConflictError extends Error {
/**
* @param workspaceName - Conflicting display name.
*/
constructor(readonly workspaceName: string) {
super(`workspace name '${workspaceName}' is already in use`)
this.name = 'WorkspaceNameConflictError'
}
}
/**
* An archiveSession request named a session neither live nor in session
* persistence — a definite miss only; storage faults propagate as themselves.
@@ -145,7 +134,7 @@ export class WorkspaceRegistry extends Service {
* original error and a non-directory rejects. Repeated calls for the same
* canonical path return the existing entity without changing its title.
* A newly created workspace is prepended to the durable registry order.
* A different canonical path cannot create a duplicate display title.
* Different canonical paths may share a display title.
* @param path - Existing directory to own, in any path spelling.
* @param title - Display title used only when a new record is created.
* @returns the existing or newly durable workspace.
@@ -259,10 +248,6 @@ export class WorkspaceRegistry extends Service {
}
const workspaceName = title ?? basename(canonical)
if ([...this.entities.values()].some(entity => entity.title === workspaceName)) {
throw new WorkspaceNameConflictError(workspaceName)
}
const table = this.requireTable()
const state = this.requireState()
const id = WorkspaceId(randomUUID())

View File

@@ -10,7 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts'
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts'
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
const DOMAIN_VERSION = 2
@@ -377,17 +377,15 @@ describe('WorkspaceRegistry create and lookup', () => {
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
})
it('rejects a duplicate display name on a different canonical path', async () => {
it('allows a duplicate display name on a different canonical path', async () => {
const firstDir = await makeDir('named-first')
const secondDir = await makeDir('named-second')
const { registry } = await harness()
await registry.create(firstDir, 'Shared')
await expect(registry.create(secondDir, 'Shared')).rejects.toEqual(
expect.objectContaining<Partial<WorkspaceNameConflictError>>({
workspaceName: 'Shared',
}),
)
expect(registry.list()).toHaveLength(1)
const first = await registry.create(firstDir, 'Shared')
const second = await registry.create(secondDir, 'Shared')
expect(first.title).toBe('Shared')
expect(second.title).toBe('Shared')
expect(registry.list()).toEqual([second, first])
})
it('rejects nonexistent and non-directory paths without changing order', async () => {