fix(workspace): make deletion recoverable
This commit is contained in:
+2
-2
@@ -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 .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
|
||||
2026-07-27-workspace-registration-deletion.md: cae01d529bc6fd97da6fb61839bd5ec8e21557e2
|
||||
2026-07-27-workspace-registration-deletion.zh.md: 76377ebc5e93101e1e3efce1d29c3c654df032c2
|
||||
2026-07-27-workspace-registration-deletion.md: 58ae5c4bef2cf1cb0a0158eda5eb37daf2e9703d
|
||||
2026-07-27-workspace-registration-deletion.zh.md: 7a79a1ccc53a0d4fd7e5ab453239ade955313c6e
|
||||
@@ -22,6 +22,8 @@ Registry operations serialize create and delete. Deletion first writes the Works
|
||||
|
||||
The Host stream keeps its committed-id set through the preceding global-order write and removes the id only on the table deletion. Create rollback therefore emits no false removal, while every connected tab receives exactly the id needed to delete its projection.
|
||||
|
||||
Create and delete write a durable `pendingMutation` before their record/order pair can diverge. Startup completes only the named create or delete and clears the marker; it never infers crash provenance from an orphan row alone. Unmarked order/table divergence therefore retains the registry's fail-loud corruption behavior. A deletion whose table write committed but marker cleanup failed still reports success—the requested state and removal frame are already committed—and the next startup clears that marker idempotently.
|
||||
|
||||
## Client convergence
|
||||
|
||||
`WorkspaceManager` treats both `host/workspace-changed` and `host/workspace-removed` as ordered deltas replayed over an in-flight `workspace.list` response. A successful unary delete removes the row immediately instead of waiting for its own stream echo. Removal is idempotent, and a process-local tombstone rejects late changed frames or stale baseline rows for the never-reused Workspace id. A reconnect still refreshes from `workspace.list`; Session state is never pruned by a Workspace delta.
|
||||
@@ -40,14 +42,16 @@ The menu, Modal, and buttons retain their existing structure and design tokens.
|
||||
|
||||
**Delete the table row and repair order later.** Rejected because a crash or write failure would leave an initialized registry whose order and table disagree. The registry updates both under one serialized operation and restores the prior order on table failure.
|
||||
|
||||
**Delete every unreferenced row at startup.** Rejected because the same shape can come from unexplained order corruption; silently discarding it could lose Workspace metadata and Session accounting. Recovery requires the explicit pending marker written by the owning mutation.
|
||||
|
||||
**Refetch both lists after success.** Rejected because the committed removal frame plus immediate unary echo is sufficient, preserves the current Session object, and avoids turning a local mutation into two list requests. Reconnect baselines remain the repair path.
|
||||
|
||||
## Verification
|
||||
|
||||
Workspace package tests pin successful metadata-only deletion, unknown-id idempotence, table-failure rollback, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close.
|
||||
Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, pending-state duplicate suppression, success, failure, Cancel, Escape, and Close.
|
||||
|
||||
The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload.
|
||||
|
||||
## Consequences
|
||||
|
||||
Deleting a Workspace is intentionally reversible by registering the same directory again, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns.
|
||||
Deleting a Workspace is intentionally reversible by registering the same directory again with a fresh id, although its prior manual Session order is gone; re-registration does not automatically re-adopt existing Sessions after bootstrap. The operation gives up a one-click cleanup of Session histories or source directories in exchange for a deletion boundary that matches what the record actually owns.
|
||||
@@ -22,6 +22,8 @@ Workspace 注册已有代码目录,使 GUI 能够为目录命名,并对其
|
||||
|
||||
Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合,只在删除表行时移除该 id。因此,创建回滚不会发出错误的移除帧,而每个已连接标签页都能收到从自身投影中删除该记录所需的准确 id。
|
||||
|
||||
Create 与 delete 会在记录/顺序对可能分叉之前写入持久 `pendingMutation`。启动时只补全其中明确命名的 create 或 delete,并清除该标记;系统绝不会仅凭孤立表行的形状推断崩溃来源。因此,没有标记的顺序/表分叉仍会保持注册表原有的损坏直接失败语义。如果删除的表写入已经提交、但标记清理失败,操作仍会报告成功——请求状态和移除帧都已经提交——下一次启动会以幂等方式清除该标记。
|
||||
|
||||
## 客户端收敛
|
||||
|
||||
`WorkspaceManager` 将 `host/workspace-changed` 与 `host/workspace-removed` 都视为有序增量,并在进行中的 `workspace.list` 响应之上回放。成功的一元删除会立即移除行,无需等待本次操作自己的流回显。移除操作具有幂等性;由于 Workspace id 永不复用,进程本地删除标记会拒绝延迟到达的 changed 帧或陈旧基线行。重连仍从 `workspace.list` 刷新;Workspace 增量绝不会剪除会话状态。
|
||||
@@ -40,14 +42,16 @@ Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合
|
||||
|
||||
**先删除表行,之后再修复顺序。** 不予采纳,因为崩溃或写入失败会使已初始化注册表的顺序与表不一致。注册表会在同一串行操作内更新二者,并在表操作失败时恢复此前顺序。
|
||||
|
||||
**启动时删除所有未引用表行。** 不予采纳,因为来源不明的顺序损坏也会呈现相同形状;静默丢弃可能损失 Workspace 元数据和 Session 账本。恢复必须依赖拥有该变更的操作预先写入的明确待处理标记。
|
||||
|
||||
**成功后重新拉取两个列表。** 不予采纳,因为已提交的移除帧与即时一元回显已足够,既能保留当前会话对象,也避免将局部变更扩大为两次列表请求。重连基线仍是修复路径。
|
||||
|
||||
## Verification
|
||||
|
||||
Workspace 包测试固定了仅删除元数据的成功路径、未知 id 的幂等行为、表操作失败回滚,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。
|
||||
Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、待处理状态下抑制重复提交、成功、失败、Cancel、Escape 与 Close。
|
||||
|
||||
组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。
|
||||
|
||||
## Consequences
|
||||
|
||||
删除 Workspace 后仍可重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。
|
||||
删除 Workspace 后仍可使用新 id 重新注册同一目录,因此该操作有意设计为可逆;但此前的手动会话顺序会丢失,重新注册后,系统也不会在 bootstrap 结束后自动重新收编现有会话。该操作放弃一键清理会话历史或源码目录,以换取与记录实际所有权一致的删除边界。
|
||||
@@ -169,6 +169,34 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
await stat(logLocation.path)
|
||||
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
|
||||
|
||||
// Re-registering the exact deleted path immediately, without a reload, is
|
||||
// a supported reversible flow. It creates a fresh Workspace id without
|
||||
// re-adopting the retained Session.
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
|
||||
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
|
||||
const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
|
||||
await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
|
||||
await reuseFolder.getByRole('button', { name: 'Use folder' }).click()
|
||||
await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0)
|
||||
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
|
||||
expect(reregistered?.id).toBeDefined()
|
||||
expect(reregistered?.id).not.toBe(workspace.id)
|
||||
expect(reregistered?.sessionIds).toEqual([])
|
||||
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
|
||||
await stat(logLocation.path)
|
||||
|
||||
// Restore the deleted-registry state so reload still verifies deletion
|
||||
// persistence independently of the successful re-registration above.
|
||||
if (reregistered === undefined) throw new Error('same-path re-registration did not materialize')
|
||||
await scaffold.ctx.workspace.delete(reregistered.id)
|
||||
await expect.poll(
|
||||
() => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -183,6 +211,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
|
||||
await stat(logLocation.path)
|
||||
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ export class WorkspaceManager {
|
||||
private error: RpcError | null = null
|
||||
private inflight: Promise<void> | null = null
|
||||
private refreshFrames: WorkspaceDelta[] | null = null
|
||||
/**
|
||||
* Ids this process has seen removed, kept for the connection's lifetime so
|
||||
* a late changed frame or a stale baseline row cannot resurrect a deleted
|
||||
* row. Correctness rests on Host ids never being reused (the registry mints
|
||||
* a fresh `randomUUID` per record, including when the same directory is
|
||||
* registered again) — a path-derived id scheme would turn these entries
|
||||
* into permanent blindfolds and must clear them instead.
|
||||
*/
|
||||
private readonly removedIds = new Set<WorkspaceId>()
|
||||
private snapshotCache: WorkspaceListSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
@@ -266,7 +274,7 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
|
||||
: items.map((item, position) => position === index ? workspace : item)
|
||||
}
|
||||
|
||||
|
||||
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
|
||||
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
|
||||
return delta.type === 'upsert'
|
||||
? upsertWorkspace(items, delta.workspace)
|
||||
|
||||
@@ -75,6 +75,10 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
|
||||
items={WORKSPACE_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
// Unknown ids leave before the dispatch: a future menu row must
|
||||
// not inherit the destructive branch as an else fallback.
|
||||
/* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
|
||||
if (id !== 'rename' && id !== 'delete') return
|
||||
if (id === 'rename') actions.rename()
|
||||
else actions.delete()
|
||||
}}
|
||||
|
||||
@@ -269,6 +269,12 @@ describe('Host Workspace increments', () => {
|
||||
ok: false,
|
||||
error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
|
||||
})
|
||||
|
||||
const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
|
||||
expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
|
||||
expect(reregistered.path).toBe(workspace.path)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
@@ -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: 52d03b33b3482dcb6a2f5feddbc15ac9fefee0a8
|
||||
README.zh.md: f899abdc3dd2a551179cd710c6dda84f804a8e80
|
||||
README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62
|
||||
README.zh.md: 7960a2d13df4f881687fd88cdb07e237b3abb7c8
|
||||
@@ -18,6 +18,8 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
|
||||
|
||||
`storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped.
|
||||
|
||||
Create and delete persist an explicit pending-mutation marker before their record and order can diverge. Startup completes only the marked mutation, then clears the marker; an unmarked order/table mismatch remains unexplained corruption and fails loud. Deleting and re-registering the same path creates a fresh Workspace id and does not automatically re-adopt the retained Sessions.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Workspace records and session accounts
|
||||
|
||||
@@ -18,6 +18,8 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
|
||||
|
||||
`storageDomain` 和 `sessionPersistence` 是启动必需依赖。对等服务不可用时,插件保持待处理,且不能提交空的已初始化标记。首次成功启动时,注册表调用 `SessionPersistence.list()`,仅使用头部 `id`、`cwd` 和 `createdAt` 对有效历史目录分组并持久化初始顺序;它绝不读取事件正文。已初始化标记最后写入,因此重启后可安全复用部分启动写入。后续仅有 cwd 的会话仍属于 Ungrouped。
|
||||
|
||||
Create 与 delete 会在记录和顺序可能分叉之前,先持久化明确的待处理变更标记。启动时只补全被该标记证明的变更,随后清除标记;没有标记的顺序/表不一致仍属于来源不明的损坏,并会直接失败。删除后重新注册同一路径会生成新的 Workspace id,且不会自动重新接纳保留下来的 Session。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Workspace 记录与会话记账
|
||||
|
||||
@@ -109,6 +109,7 @@ export class WorkspaceRegistry extends Service {
|
||||
this.global = domain.global
|
||||
this.state = domain.global.get()
|
||||
|
||||
await this.recoverPendingMutation()
|
||||
this.validateStoredState(this.state)
|
||||
if (!this.state.initialized) {
|
||||
const headers = await this.ctx.sessionPersistence.list()
|
||||
@@ -218,10 +219,28 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
const entity = new WorkspaceEntity(this.host, id, record)
|
||||
this.entities.set(id, entity)
|
||||
const pendingState: WorkspaceDomainState = {
|
||||
...state,
|
||||
pendingMutation: { operation: 'create', workspaceId: id },
|
||||
}
|
||||
try {
|
||||
await this.setState(pendingState)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await table.put(id, record)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' record write and pending-marker rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -232,10 +251,17 @@ export class WorkspaceRegistry extends Service {
|
||||
try {
|
||||
await table.delete(id)
|
||||
} catch (rollbackError) {
|
||||
this.entities.set(id, entity)
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' was stored but its registry order and rollback both failed`,
|
||||
`workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' order write and pending-marker rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
@@ -251,7 +277,10 @@ export class WorkspaceRegistry extends Service {
|
||||
initialized: true,
|
||||
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
|
||||
}
|
||||
await this.setState(nextState)
|
||||
await this.setState({
|
||||
...nextState,
|
||||
pendingMutation: { operation: 'delete', workspaceId: id },
|
||||
})
|
||||
this.entities.delete(id)
|
||||
try {
|
||||
await this.requireTable().delete(id)
|
||||
@@ -260,6 +289,10 @@ export class WorkspaceRegistry extends Service {
|
||||
try {
|
||||
await this.setState(state)
|
||||
} catch (rollbackError) {
|
||||
// The durable marker still says to finish deletion, so the cache must
|
||||
// agree with that recoverable direction rather than republish a row
|
||||
// absent from the persisted order.
|
||||
this.entities.delete(id)
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' record deletion and registry-order rollback both failed`,
|
||||
@@ -267,9 +300,38 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
await this.setState(nextState)
|
||||
} catch (error) {
|
||||
// The deletion committed at the table write and was already published
|
||||
// to Host streams. Keep the durable marker for startup recovery rather
|
||||
// than reporting failure after the requested state became true.
|
||||
this.ctx.logger.warn(
|
||||
`workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the one mutation explicitly named by durable state. Unexplained
|
||||
* order/table divergence still reaches {@link validateStoredState} and
|
||||
* fails loud; this path never infers provenance from shape alone.
|
||||
*/
|
||||
private async recoverPendingMutation(): Promise<void> {
|
||||
const state = this.requireState()
|
||||
const pending = state.pendingMutation
|
||||
if (pending === undefined) return
|
||||
if (state.workspaceIds.includes(pending.workspaceId)) {
|
||||
throw new Error(
|
||||
`workspace domain is inconsistent: pending ${pending.operation} workspace `
|
||||
+ `'${pending.workspaceId}' is still present in registry order`,
|
||||
)
|
||||
}
|
||||
await this.requireTable().delete(pending.workspaceId)
|
||||
await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
|
||||
}
|
||||
|
||||
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
|
||||
const table = this.requireTable()
|
||||
const state = this.requireState()
|
||||
@@ -493,7 +555,12 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
|
||||
private enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operationTail.then(operation)
|
||||
const result = this.operationTail.then(async () => {
|
||||
// A committed delete may leave only its marker cleanup pending. Retry
|
||||
// recovery before another create/delete can overwrite that provenance.
|
||||
await this.recoverPendingMutation()
|
||||
return await operation()
|
||||
})
|
||||
this.operationTail = result.then(() => {}, () => {})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ export const workspaceRecord = z.object({
|
||||
/** One stored workspace record, inferred from {@link workspaceRecord}. */
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
/**
|
||||
* Recoverable two-write mutation marker. The marker is persisted before the
|
||||
* record/order pair can diverge, so startup can distinguish an interrupted
|
||||
* registry operation from unexplained medium corruption.
|
||||
*/
|
||||
const workspacePendingMutation = z.discriminatedUnion('operation', [
|
||||
z.object({ operation: z.literal('create'), workspaceId }),
|
||||
z.object({ operation: z.literal('delete'), workspaceId }),
|
||||
])
|
||||
|
||||
/**
|
||||
* Durable registry state. `initialized` distinguishes a valid empty registry
|
||||
* from one that still needs the header-only history bootstrap;
|
||||
@@ -37,6 +47,7 @@ export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
export const workspaceDomainState = z.object({
|
||||
initialized: z.boolean(),
|
||||
workspaceIds: z.array(workspaceId),
|
||||
pendingMutation: workspacePendingMutation.optional(),
|
||||
})
|
||||
|
||||
/** Durable registry state inferred from {@link workspaceDomainState}. */
|
||||
|
||||
@@ -89,7 +89,7 @@ async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = n
|
||||
/** Backend wrapper that injects one selected bootstrap write failure. */
|
||||
function selectiveFailureBackend(
|
||||
pool: MemoryMediaPool,
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number },
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number | readonly number[] },
|
||||
): StorageBackend {
|
||||
const inner = new MemoryStorageBackend(pool)
|
||||
let puts = 0
|
||||
@@ -113,7 +113,8 @@ function selectiveFailureBackend(
|
||||
},
|
||||
setGlobal: async (value) => {
|
||||
globals += 1
|
||||
if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure')
|
||||
const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt]
|
||||
if (failAt.includes(globals)) throw new Error('selected bootstrap marker failure')
|
||||
await unit.setGlobal(value)
|
||||
},
|
||||
close: () => unit.close(),
|
||||
@@ -394,19 +395,34 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
|
||||
it('rolls back the provisional cache when the record write fails', async () => {
|
||||
const dir = await makeDir('write-failure')
|
||||
const result = await harness()
|
||||
result.pool.failNextWrites = 1
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/injected/)
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap put failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(await result.registry.create(dir)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not publish a Workspace when its pending marker cannot be written', async () => {
|
||||
const dir = await makeDir('pending-marker-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')?.size ?? 0).toBe(0)
|
||||
})
|
||||
|
||||
it('rolls back a record when registry-order persistence fails', async () => {
|
||||
const dir = await makeDir('order-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
@@ -418,12 +434,38 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3, deleteAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a record write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('record-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1, globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an order write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('order-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: [3, 4] }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
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)] })
|
||||
@@ -440,6 +482,11 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
expect(result.load).not.toHaveBeenCalled()
|
||||
expect(result.inspect).not.toHaveBeenCalled()
|
||||
|
||||
const reregistered = await result.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(reregistered.path).toBe(dir)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls registry order and cache back when record deletion fails', async () => {
|
||||
@@ -458,11 +505,58 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir })
|
||||
})
|
||||
|
||||
it('commits deletion and leaves a recoverable marker when marker cleanup fails', async () => {
|
||||
const dir = await makeDir('delete-marker-cleanup')
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 5 }),
|
||||
})
|
||||
const workspace = await first.registry.create(dir)
|
||||
|
||||
await expect(first.registry.delete(workspace.id)).resolves.toBe(true)
|
||||
expect(first.registry.list()).toEqual([])
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
const reregistered = await first.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [reregistered.id],
|
||||
})
|
||||
await first.fiber.dispose()
|
||||
|
||||
const restarted = await harness({ pool })
|
||||
expect(restarted.registry.list().map(item => item.id)).toEqual([reregistered.id])
|
||||
})
|
||||
|
||||
it('keeps the failed deletion unpublished when record and order rollback both fail', async () => {
|
||||
const dir = await makeDir('delete-double-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { deleteAt: 1, globalAt: 5 }),
|
||||
})
|
||||
const workspace = await result.registry.create(dir)
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(result.registry.get(workspace.id)).toBeUndefined()
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects table access before the registry has started', async () => {
|
||||
const dir = await makeDir('unstarted')
|
||||
const registry = new WorkspaceRegistry(new Context())
|
||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
||||
expect(() => registry.list()).toThrow(/not started/)
|
||||
const internals = registry as unknown as { requireTable(): unknown }
|
||||
expect(() => internals.requireTable()).toThrow(/not started/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -650,6 +744,49 @@ describe('header-validated membership projection', () => {
|
||||
internals.entities.delete(workspace.id)
|
||||
expect(() => result.registry.list()).toThrow(/references missing workspace/)
|
||||
})
|
||||
|
||||
it('recovers only an explicitly marked interrupted create or delete', async () => {
|
||||
const createDir = await makeDir('pending-create')
|
||||
const deleteDir = await makeDir('pending-delete')
|
||||
const createId = WorkspaceId('00000000-0000-4000-8000-000000000004')
|
||||
const deleteId = WorkspaceId('00000000-0000-4000-8000-000000000005')
|
||||
|
||||
const interruptedCreate = storedPool(
|
||||
[[createId, record(createDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'create', workspaceId: createId },
|
||||
},
|
||||
)
|
||||
const createRecovery = await harness({ pool: interruptedCreate })
|
||||
expect(createRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
|
||||
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const interruptedDelete = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
const deleteRecovery = await harness({ pool: interruptedDelete })
|
||||
expect(deleteRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
|
||||
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const corruptPending = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [deleteId],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
await expect(harness({ pool: corruptPending })).rejects.toThrow(/still present in registry order/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace mutation and status', () => {
|
||||
|
||||
Reference in New Issue
Block a user