feat(web): delete workspace registrations
This commit is contained in:
@@ -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: 0d5ebabfbbb2922a369adb3a5d67ea4aafbe700f
|
||||
README.zh.md: b82e8e6138f3e97c3c047cf1812cee8e558ea29b
|
||||
# pnpm run verify-translation-pairing --write packages/workspace/README.md
|
||||
README.md: ba92e95d3cde0a95eaaeae5a9b4384c3b8c9c4b8
|
||||
README.zh.md: 8c8146bba5fa6d81c0ce5d2ed29add77b4083270
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` |
|
||||
|
||||
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives.
|
||||
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deleting a Workspace removes only this registry record and account: directories, user files, and session logs remain, and the Sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)).
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
|
||||
Workspace 系列拥有持久 workspace 概念:用户工作所在的目录,包含标题以及属于它的有序会话列表。设计记录:[领域 KV 存储 Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `workspace/` | 位于存储领域形式之上的 `WorkspaceRegistry` 服务:按 realpath 唯一的路径、会话所有权计数、实体缓存 | `ctx.workspace` |
|
||||
|
||||
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。本阶段有意不提供删除(workspace 与会话级联);该功能将与会话侧原语一起交付。
|
||||
所有权真相存在 workspace 记录的 `sessionIds`(有序)中,绝不从会话 cwd 派生;`attachSession` 会验证会话头的 cwd 解析到 workspace 路径,因此一个会话在结构上最多属于一个 workspace。删除 Workspace 只会移除该注册表记录及账本:目录、用户文件和会话日志都会保留,相关会话则进入 Ungrouped(参见[决策记录](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md))。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 破坏会在下次刷新或重启后被观测。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user