feat(workspace): add registry-global archivedSessionIds set

The workspace domain global singleton gains archivedSessionIds (schema
default [], version unchanged): a display-layer archive set layered over
workspace accounting. archiveSession() rides the registry operation
chain, validates the session against live/persisted headers, and is
idempotent; archived sessions keep their sessionIds slot so a future
unarchive restores position.
This commit is contained in:
imccyu
2026-07-31 02:39:26 +08:00
committed by imccyu
parent 6acdb6631d
commit b00cd0cd8c
6 changed files with 145 additions and 14 deletions

View File

@@ -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: bee3e4fcb5dded273f30942ee2e42ee93b839e62
README.zh.md: 9a052f796fb7cc8756999bdc9b2ce905805730ab
README.md: 2d69074d42c28ba5e340cef19c8007bcba94e755
README.zh.md: f95246a0c970aede1944868489d122d7f309d8e7

View File

@@ -13,6 +13,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
- `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.

View File

@@ -13,6 +13,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
- `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'`;目录缺失绝不会改动记录。

View File

@@ -49,6 +49,18 @@ export class WorkspaceNameConflictError extends Error {
}
}
/** An archiveSession request named a session neither live nor in session persistence. */
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)
this.name = 'WorkspaceUnknownSessionError'
}
}
declare module 'cordis' {
interface Context {
@@ -181,6 +193,38 @@ export class WorkspaceRegistry extends Service {
return this.enqueueOperation(() => this.deleteKnown(id))
}
/**
* The registry-global archive set: sessions hidden from every grouping
* surface. Archiving never touches workspace accounting — an archived
* session keeps its `sessionIds` slot so unarchiving restores its position.
* @returns the archived session ids in archive order.
*/
get archivedSessionIds(): readonly SessionId[] {
return this.requireState().archivedSessionIds
}
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
* An already archived id resolves without writing.
* @param sessionId - The session to archive.
* @returns resolution after durability.
*/
archiveSession(sessionId: SessionId): Promise<void> {
return this.enqueueOperation(async () => {
// 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 })
}
const state = this.requireState()
await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] })
})
}
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -245,7 +289,11 @@ export class WorkspaceRegistry extends Service {
}
try {
await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] })
await this.setState({
initialized: true,
workspaceIds: [id, ...state.workspaceIds],
archivedSessionIds: state.archivedSessionIds,
})
} catch (error) {
this.entities.delete(id)
try {
@@ -276,6 +324,7 @@ export class WorkspaceRegistry extends Service {
const nextState = {
initialized: true,
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
archivedSessionIds: state.archivedSessionIds,
}
await this.setState({
...nextState,
@@ -329,7 +378,11 @@ export class WorkspaceRegistry extends Service {
)
}
await this.requireTable().delete(pending.workspaceId)
await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
await this.setState({
initialized: state.initialized,
workspaceIds: state.workspaceIds,
archivedSessionIds: state.archivedSessionIds,
})
}
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
@@ -411,9 +464,9 @@ export class WorkspaceRegistry extends Service {
.map(([id]) => id)
if (!sameIds(state.workspaceIds, workspaceIds)) {
await this.setState({ initialized: false, workspaceIds })
await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds })
}
await this.setState({ initialized: true, workspaceIds })
await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds })
}
private validateStoredState(state: WorkspaceDomainState): void {

View File

@@ -42,11 +42,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [
/**
* Durable registry state. `initialized` distinguishes a valid empty registry
* from one that still needs the header-only history bootstrap;
* `workspaceIds` is the authoritative display order.
* `workspaceIds` is the authoritative display order. `archivedSessionIds` is
* the registry-global archive set layered over workspace accounting: an
* archived session keeps its `sessionIds` slot (unarchiving must restore the
* position), so the set never participates in the one-owner accounting
* invariant. Defaulted so records written before the field parse unchanged.
*/
export const workspaceDomainState = z.object({
initialized: z.boolean(),
workspaceIds: z.array(workspaceId),
archivedSessionIds: z.array(z.string().transform(SessionId)).default([]),
pendingMutation: workspacePendingMutation.optional(),
})
@@ -64,7 +69,7 @@ export const workspaceDomainSpec = defineDomain({
version: 2,
global: {
schema: workspaceDomainState,
initial: { initialized: false, workspaceIds: [] },
initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] },
},
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
})

View File

@@ -135,9 +135,16 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0
}
}
/**
* Media written before archivedSessionIds existed omit the field; keeping the
* fixtures in that shape continuously proves the schema default upgrades them.
*/
type StoredDomainState = Omit<WorkspaceDomainState, 'archivedSessionIds'>
& Partial<Pick<WorkspaceDomainState, 'archivedSessionIds'>>
function storedPool(
entries: Array<[string, WorkspaceRecord]>,
state: WorkspaceDomainState,
state: StoredDomainState,
): MemoryMediaPool {
const pool = new MemoryMediaPool()
pool.versions.set('workspace', DOMAIN_VERSION)
@@ -185,7 +192,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
await fiber.await()
expect(ctx.workspace.list()).toEqual([])
expect(list).toHaveBeenCalledTimes(1)
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] })
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
})
it('bootstraps once from list headers only, in workspace/session createdAt order', async () => {
@@ -218,6 +225,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
expect(storedState(result.pool)).toEqual({
initialized: true,
workspaceIds: result.registry.list().map(workspace => workspace.id),
archivedSessionIds: [],
})
})
@@ -246,7 +254,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
const second = await harness({ pool, sessions: [header('late', late, 100)] })
expect(second.list).not.toHaveBeenCalled()
expect(second.registry.list()).toEqual([])
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] })
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
})
it('reuses partial records after a bootstrap record write fails', async () => {
@@ -476,7 +484,7 @@ describe('WorkspaceRegistry create and lookup', () => {
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(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
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)
@@ -519,6 +527,7 @@ describe('WorkspaceRegistry create and lookup', () => {
expect(storedState(pool)).toEqual({
initialized: true,
workspaceIds: [],
archivedSessionIds: [],
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
})
const reregistered = await first.registry.create(dir)
@@ -526,6 +535,7 @@ describe('WorkspaceRegistry create and lookup', () => {
expect(storedState(pool)).toEqual({
initialized: true,
workspaceIds: [reregistered.id],
archivedSessionIds: [],
})
await first.fiber.dispose()
@@ -762,7 +772,7 @@ describe('header-validated membership projection', () => {
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: [] })
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
const interruptedDelete = storedPool(
[[deleteId, record(deleteDir, [])]],
@@ -775,7 +785,7 @@ describe('header-validated membership projection', () => {
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: [] })
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
const corruptPending = storedPool(
[[deleteId, record(deleteDir, [])]],
@@ -816,3 +826,64 @@ describe('workspace mutation and status', () => {
expect(registry.get(workspace.id)).toBe(workspace)
})
})
describe('registry-global session archive', () => {
it('archives durably in order, idempotently skips repeats, and leaves accounting untouched', async () => {
const dir = await makeDir('archive-home')
const result = await harness({ sessions: [header('kept', dir, 100), header('gone', dir, 200)] })
const workspace = result.registry.list()[0]!
expect(result.registry.archivedSessionIds).toEqual([])
await result.registry.archiveSession(SessionId('gone'))
expect(result.registry.archivedSessionIds).toEqual(['gone'])
// Archiving is a display-set write: the workspace account keeps the id.
expect(workspace.sessionIds).toContain('gone')
expect(storedState(result.pool).archivedSessionIds).toEqual(['gone'])
const changesAfterFirst = result.changes.filter(change => change.table === '').length
await result.registry.archiveSession(SessionId('gone'))
expect(result.registry.archivedSessionIds).toEqual(['gone'])
// The idempotent repeat neither rewrites the medium nor emits a change.
expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst)
await result.registry.archiveSession(SessionId('kept'))
expect(result.registry.archivedSessionIds).toEqual(['gone', 'kept'])
})
it('accepts unaccounted and live sessions but rejects unknown ids without writing', async () => {
const dir = await makeDir('archive-strays')
const live = await makeDir('archive-live')
const result = await harness({
sessions: [header('stray', dir, 100)],
liveSessions: [header('live-only', live, 200)],
})
await result.registry.archiveSession(SessionId('stray'))
await result.registry.archiveSession(SessionId('live-only'))
expect(result.registry.archivedSessionIds).toEqual(['stray', 'live-only'])
await expect(result.registry.archiveSession(SessionId('ghost')))
.rejects.toThrow(/cannot archive session 'ghost'/)
expect(storedState(result.pool).archivedSessionIds).toEqual(['stray', 'live-only'])
})
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()
const first = await harness({ pool, sessions: [header('s1', dir, 100)] })
await first.registry.archiveSession(SessionId('s1'))
await first.fiber.dispose()
const second = await harness({ pool, sessions: [header('s1', dir, 100)] })
expect(second.registry.archivedSessionIds).toEqual(['s1'])
await second.fiber.dispose()
// A medium written before the field existed parses through the schema default.
const legacyId = WorkspaceId('00000000-0000-4000-8000-00000000000a')
const legacy = storedPool(
[[legacyId, record(dir, [])]],
{ initialized: true, workspaceIds: [legacyId] },
)
const upgraded = await harness({ pool: legacy })
expect(upgraded.registry.archivedSessionIds).toEqual([])
})
})