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

@@ -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([])
})
})