feat(web): delete workspace registrations

This commit is contained in:
NI0317
2026-07-27 12:38:11 +08:00
parent 79eb3a9035
commit 187cf6f804
57 changed files with 786 additions and 84 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 0d395ecc58fc5e3362cb5f3c565a0539bb09c4dd
README.zh.md: 017e1e4d3aae9f8708ead3565f8b5b59d9b249ca
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
README.md: 52d03b33b3482dcb6a2f5feddbc15ac9fefee0a8
README.zh.md: f899abdc3dd2a551179cd710c6dda84f804a8e80

View File

@@ -10,6 +10,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
- `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.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.
- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes.
- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup.
@@ -35,5 +36,5 @@ Independent of live requests: the package never touches a request prefix, so it
## Known Limitations and Deferred Work
- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed.
- Session deletion and destructive folder removal are separate, absent capabilities; Workspace registration deletion never substitutes for either ([decision](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
- The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart.

View File

@@ -10,6 +10,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.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` 只移除候选索引条目。
- `ctx.workspace.touchSession(id)`仅将已验证、已记账的会话移到最前。未分组或被过滤的会话为空操作workspace 顺序绝不改变。
- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、从两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。
@@ -35,5 +36,5 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
## 已知限制与延后工作
- 本阶段没有删除入口workspace 删除将与会话删除原语和级联编排一起作为完整语义交付(参见 Agent Note 的未来工作一节);系统有意不公开「删除记录、保留会话」的半成品操作
- 会话删除与破坏性的文件夹移除是彼此独立且尚未提供的功能;删除 Workspace 注册记录绝不能替代二者(参见[决策记录](../../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)
- 头部索引会在启动时刷新,也会在 attach 必须解析未缓存持久 id 时刷新;另一进程执行的删除或 cwd 破坏会在下次刷新或重启后被观测。

View File

@@ -168,6 +168,18 @@ export class WorkspaceRegistry extends Service {
})
}
/**
* Delete one workspace registration while retaining its directory and every
* session log. The durable order is updated before the table deletion; a
* failed table write restores the prior order and keeps the entity
* published. Unknown ids are an idempotent no-op for domain callers.
* @param id - Workspace registration to remove.
* @returns `true` when a record was deleted, `false` when it was unknown.
*/
delete(id: WorkspaceId): Promise<boolean> {
return this.enqueueOperation(() => this.deleteKnown(id))
}
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -231,6 +243,33 @@ export class WorkspaceRegistry extends Service {
return entity
}
private async deleteKnown(id: WorkspaceId): Promise<boolean> {
const entity = this.entities.get(id)
if (entity === undefined) return false
const state = this.requireState()
const nextState = {
initialized: true,
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
}
await this.setState(nextState)
this.entities.delete(id)
try {
await this.requireTable().delete(id)
} catch (error) {
this.entities.set(id, entity)
try {
await this.setState(state)
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' record deletion and registry-order rollback both failed`,
)
}
throw error
}
return true
}
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
const table = this.requireTable()
const state = this.requireState()

View File

@@ -20,8 +20,9 @@ export const inject = ['invariants']
* domain's durable table. Every `domain/changed` for the `workspaces` table
* must name a record the cache already holds an entity for (the registry
* caches before the durable put and mutates only through cached entities).
* A delete is valid only for create rollback, after the provisional cache
* entry has been removed; deleting a published entity proves a bypass.
* A delete is valid only after the registry has removed the entity from its
* cache, whether for create rollback or an explicit registration deletion;
* deleting while the cache still publishes the entity proves a bypass.
*/
const install: InvariantInstaller = Object.assign(
(ctx: Context, fail: (message: string) => never) => {

View File

@@ -49,7 +49,7 @@ describe('workspace cache/table invariant', () => {
.toThrow(/cache still publishes/)
})
it('allows deletion only after a provisional create cache entry was removed for rollback', async () => {
it('allows deletion after the registry removed the cache entry for rollback or explicit deletion', async () => {
const ctx = await setup([])
expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow()
})

View File

@@ -424,6 +424,40 @@ describe('WorkspaceRegistry create and lookup', () => {
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
})
it('deletes only the registration and leaves its directory and session headers untouched', async () => {
const dir = await makeDir('delete-registration')
const result = await harness({ sessions: [header('kept-session', dir)] })
const workspace = await result.registry.create(dir)
await workspace.attachSession(SessionId('kept-session'))
await expect(result.registry.delete(workspace.id)).resolves.toBe(true)
await expect(result.registry.delete(workspace.id)).resolves.toBe(false)
expect(result.registry.get(workspace.id)).toBeUndefined()
expect(result.registry.list()).toEqual([])
expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] })
expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false)
await expect(realpath(dir)).resolves.toBe(dir)
expect(result.list).toHaveBeenCalledTimes(1)
expect(result.load).not.toHaveBeenCalled()
expect(result.inspect).not.toHaveBeenCalled()
})
it('rolls registry order and cache back when record deletion fails', async () => {
const dir = await makeDir('delete-rollback')
const pool = new MemoryMediaPool()
const result = await harness({
pool,
backend: selectiveFailureBackend(pool, { deleteAt: 1 }),
})
const workspace = await result.registry.create(dir)
await expect(result.registry.delete(workspace.id)).rejects.toThrow(/selected rollback delete failure/)
expect(result.registry.get(workspace.id)).toBe(workspace)
expect(result.registry.list()).toEqual([workspace])
expect(storedState(pool).workspaceIds).toEqual([workspace.id])
expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir })
})
it('rejects table access before the registry has started', async () => {
const dir = await makeDir('unstarted')
const registry = new WorkspaceRegistry(new Context())