mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix: review follow-ups for the session archive set
- WorkspaceRegistry.archiveSession no longer wraps persistence-listing failures as WorkspaceUnknownSessionError: only a definite miss (live lookup, header index, then a fresh list) maps to session-not-found; storage faults propagate as internal errors, with a negative test. - The archived-current sweep moves from the unary path into the projection: any install path (local echo, another tab's frame, a reconnect baseline) clears a selection that landed in the archive set. - An archive set installed while workspace.list is in flight supersedes the stale baseline's set instead of being rolled back by it. - The workspace-management e2e anchors the archived row by its session actions button and asserts the single-stray fixture assumption loudly. - Drop the stale touchSession rows from the workspace READMEs (the method was removed with its Agent Note).
This commit is contained in:
@@ -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-31-session-archive-global-set.md
|
||||
2026-07-31-session-archive-global-set.md: 0fe354ef0ed50ee60a69b0633522323124c80da7
|
||||
2026-07-31-session-archive-global-set.zh.md: 2b13f0eddc9aa62600a95ac5f412c51f3a38af7b
|
||||
2026-07-31-session-archive-global-set.md: b318be9985b197171a046a5abeab3c93b879c710
|
||||
2026-07-31-session-archive-global-set.zh.md: 8e3157f4c55c54c3fdd3132034b28567b9b25013
|
||||
|
||||
@@ -15,7 +15,7 @@ The session row menu in the sidebar workspace browser carried a purely visual "D
|
||||
- Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant.
|
||||
- Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set.
|
||||
- RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code.
|
||||
- Client runtime: `WorkspaceListState.archivedSessionIds` (a `ReadonlySet`, reference replaced only on membership change); the list baseline, the unary echo, and the changed frame each install the complete set. `WorkspacesService.archiveSession` calls `sessions.clear()` when the archived session is the current selection, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero).
|
||||
- Client runtime: `WorkspaceListState.archivedSessionIds` (a `ReadonlySet`, reference replaced only on membership change); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline.
|
||||
- UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -15,7 +15,7 @@ Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直
|
||||
- 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。
|
||||
- Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。
|
||||
- RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。
|
||||
- client runtime:`WorkspaceListState.archivedSessionIds`(`ReadonlySet`,成员不变不换引用);list 基线、unary 回声、changed 帧三路都整体替换安装。`WorkspacesService.archiveSession` 在归档对象恰为当前 selection 时调 `sessions.clear()` 回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)。
|
||||
- client runtime:`WorkspaceListState.archivedSessionIds`(`ReadonlySet`,成员不变不换引用);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。
|
||||
- UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
@@ -429,7 +429,15 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
}
|
||||
return await ungroupedRow.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
|
||||
// Anchor on session rows (the rows carrying a session actions button),
|
||||
// not a positional index, and assert the single-stray assumption loudly
|
||||
// so a fixture gaining a second stray fails here instead of archiving
|
||||
// the wrong row. CSS attribute match, not getByRole: the button is
|
||||
// display:none until its row hovers, and role queries skip hidden nodes.
|
||||
const sessionRows = ungroupedSection.locator('[role="treeitem"]')
|
||||
.filter({ has: page.locator('button[aria-label^="Session actions for "]') })
|
||||
await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1)
|
||||
const sessionRow = sessionRows.first()
|
||||
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
|
||||
// Row menu: hover reveals the actions button; Archive session commits
|
||||
// without a confirmation dialog (non-destructive: log + accounting stay).
|
||||
|
||||
@@ -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/client/runtime/README.md
|
||||
README.md: 4c9122d87bfb0ea2d66478de03c69975b0577ab7
|
||||
README.zh.md: 1ee4731964ea75de29da47137933852581fc45b3
|
||||
README.md: 52d2caf51d2fdaf48a06e6d56292fe087568eba5
|
||||
README.zh.md: d7e807cd7dffdd81a106488884031fccbb8a6d65
|
||||
|
||||
@@ -10,7 +10,7 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
|
||||
|
||||
`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `ReadonlySet` replaced only when membership changes). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire and, when the archived session is the current selection, clears it into the New Session view state; grouping surfaces hide members everywhere while the session rows stay in the list store.
|
||||
`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `ReadonlySet` replaced only when membership changes). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
|
||||
|
||||
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
|
||||
|
||||
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个 `ReadonlySet`,仅在成员变化时才替换)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;当被归档的会话正是当前 selection 时,将其清空为 New Session 视图状态。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
|
||||
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个 `ReadonlySet`,仅在成员变化时才替换)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
|
||||
|
||||
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ export class WorkspaceManager {
|
||||
private error: RpcError | null = null
|
||||
private inflight: Promise<void> | null = null
|
||||
private refreshFrames: WorkspaceDelta[] | null = null
|
||||
/**
|
||||
* True once a frame or unary echo installed the archive set while a list
|
||||
* request was in flight: that install is newer than the pending baseline,
|
||||
* so the baseline's (older) set must not roll it back — the archive
|
||||
* mirror of replaying refreshFrames over the item baseline.
|
||||
*/
|
||||
private archivedSupersedesRefresh = false
|
||||
/**
|
||||
* 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
|
||||
@@ -82,7 +89,7 @@ export class WorkspaceManager {
|
||||
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
|
||||
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
|
||||
this.installViews(items)
|
||||
this.installArchived(result.value.archivedSessionIds)
|
||||
if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
} else {
|
||||
@@ -96,6 +103,7 @@ export class WorkspaceManager {
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
this.refreshFrames = null
|
||||
this.archivedSupersedesRefresh = false
|
||||
this.inflight = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -224,6 +232,7 @@ export class WorkspaceManager {
|
||||
|
||||
/** Replace the archive set when membership actually changed (set identity backs Object.is short-circuits). */
|
||||
private installArchived(archivedSessionIds: readonly SessionId[]): void {
|
||||
if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true
|
||||
if (archivedSessionIds.length === this.archivedSessionIds.size
|
||||
&& archivedSessionIds.every(id => this.archivedSessionIds.has(id))) return
|
||||
this.archivedSessionIds = new Set(archivedSessionIds)
|
||||
|
||||
@@ -260,15 +260,14 @@ export class WorkspacesService implements IWorkspaces {
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a session into the registry-global set. When the archived
|
||||
* session is the current one, the selection is cleared into the New
|
||||
* Session view state — a hidden row must not stay open behind the list.
|
||||
* Archive a session into the registry-global set. Clearing an archived
|
||||
* current selection is the projection sweep's job (one rule for the local
|
||||
* echo and a remote tab's frame alike).
|
||||
* @param sessionId - session to archive.
|
||||
*/
|
||||
async archiveSession(sessionId: SessionId): Promise<void> {
|
||||
const result = await this.manager.archiveSession(sessionId)
|
||||
if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (this.sessions.list.getSnapshot().current === sessionId) this.sessions.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,6 +312,14 @@ export class WorkspacesService implements IWorkspaces {
|
||||
const workspace = this.manager.getSnapshot()
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
|
||||
// An archived current selection clears into the New Session view state —
|
||||
// a hidden row must not stay open behind the list. Sweeping here covers
|
||||
// every install path with one rule: the local unary echo, another tab's
|
||||
// changed frame, and a reconnect baseline restoring a persisted
|
||||
// selection that was archived while this client was away.
|
||||
if (sessions.current !== undefined && workspace.archivedSessionIds.has(sessions.current)) {
|
||||
this.sessions.clear()
|
||||
}
|
||||
this.list.set({
|
||||
items: workspace.items,
|
||||
archivedSessionIds: workspace.archivedSessionIds,
|
||||
|
||||
@@ -337,6 +337,38 @@ describe('WorkspacesService', () => {
|
||||
await workspaces.refresh()
|
||||
expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open'])
|
||||
})
|
||||
|
||||
it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
|
||||
}) as never)
|
||||
await sessions.refresh()
|
||||
sessions.open(sid('s-open'))
|
||||
|
||||
// A stale baseline is in flight (older, empty set) when another tab's
|
||||
// archive frame lands: the frame clears the current selection and its
|
||||
// set survives the baseline's later resolution.
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
|
||||
api.onWorkspaceList = () => gate.promise
|
||||
const hydration = workspaces.refresh()
|
||||
workspaces.handleHostEnvelope({
|
||||
rpcId: 'frame' as never,
|
||||
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
|
||||
} as never)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(sessions.list.getSnapshot().current).toBeUndefined()
|
||||
gate.resolve(ok({ items: [], archivedSessionIds: [] }))
|
||||
await hydration
|
||||
expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open'])
|
||||
// The next (fresh) baseline is authoritative again.
|
||||
api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
|
||||
await workspaces.refresh()
|
||||
expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('startInitialSelection', () => {
|
||||
|
||||
@@ -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: 2d69074d42c28ba5e340cef19c8007bcba94e755
|
||||
README.zh.md: f95246a0c970aede1944868489d122d7f309d8e7
|
||||
README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089
|
||||
README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4
|
||||
|
||||
@@ -12,7 +12,6 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
|
||||
- `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.
|
||||
- `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set.
|
||||
- `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.
|
||||
- `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record.
|
||||
|
||||
@@ -12,7 +12,6 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.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 顺序绝不改变。
|
||||
- `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。
|
||||
- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。
|
||||
- `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。
|
||||
|
||||
@@ -49,14 +49,16 @@ export class WorkspaceNameConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** An archiveSession request named a session neither live nor in session persistence. */
|
||||
/**
|
||||
* An archiveSession request named a session neither live nor in session
|
||||
* persistence — a definite miss only; storage faults propagate as themselves.
|
||||
*/
|
||||
export class WorkspaceUnknownSessionError extends Error {
|
||||
/**
|
||||
* @param sessionId - The unknown session id.
|
||||
* @param options - Standard error options (the header-read failure as `cause`).
|
||||
*/
|
||||
constructor(readonly sessionId: SessionId, options?: ErrorOptions) {
|
||||
super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`, options)
|
||||
constructor(readonly sessionId: SessionId) {
|
||||
super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`)
|
||||
this.name = 'WorkspaceUnknownSessionError'
|
||||
}
|
||||
}
|
||||
@@ -215,16 +217,27 @@ export class WorkspaceRegistry extends Service {
|
||||
// The chain slot serializes against every other registry write, so this
|
||||
// check-then-write pair cannot interleave with another archive.
|
||||
if (this.requireState().archivedSessionIds.includes(sessionId)) return
|
||||
try {
|
||||
await this.readSessionHeader(sessionId)
|
||||
} catch (error) {
|
||||
throw new WorkspaceUnknownSessionError(sessionId, { cause: error })
|
||||
if (!(await this.sessionKnown(sessionId))) {
|
||||
throw new WorkspaceUnknownSessionError(sessionId)
|
||||
}
|
||||
const state = this.requireState()
|
||||
await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session is live, header-indexed, or present in a fresh
|
||||
* persistence listing. Only a definite miss returns false — a failing
|
||||
* `sessionPersistence.list()` propagates so storage faults never
|
||||
* masquerade as an unknown session.
|
||||
*/
|
||||
private async sessionKnown(id: SessionId): Promise<boolean> {
|
||||
if (this.ctx.get('sessions')?.get(id) !== undefined) return true
|
||||
if (this.headers.has(id)) return true
|
||||
await this.indexHeaders(await this.ctx.sessionPersistence.list())
|
||||
return this.headers.has(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
|
||||
@@ -866,6 +866,16 @@ describe('registry-global session archive', () => {
|
||||
expect(storedState(result.pool).archivedSessionIds).toEqual(['stray', 'live-only'])
|
||||
})
|
||||
|
||||
it('propagates a persistence-listing failure instead of reporting an unknown session', async () => {
|
||||
const result = await harness({ sessions: [] })
|
||||
result.list.mockRejectedValueOnce(new Error('persistence backend down'))
|
||||
// The storage fault is the error — never WorkspaceUnknownSessionError,
|
||||
// which the API layer would misreport as session-not-found.
|
||||
await expect(result.registry.archiveSession(SessionId('unlisted')))
|
||||
.rejects.toThrow(/persistence backend down/)
|
||||
expect(storedState(result.pool).archivedSessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('restores the archive set across restarts and defaults it for pre-field media', async () => {
|
||||
const dir = await makeDir('archive-restart')
|
||||
const pool = new MemoryMediaPool()
|
||||
|
||||
Reference in New Issue
Block a user