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 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