mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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: 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