Sessions could be archived from the sidebar row menu but never restored: the registry-global archive set was a one-way display filter with no inverse RPC and no surface listing its members. WorkspaceRegistry.unarchiveSession drops one id from archivedSessionIds in one durable write on the same operation chain as archive, with no session-existence probe because a removal cannot introduce an unknown referent. The Workspace controller exposes it as workspace.unarchiveSession returning the complete archive set, which the existing archived follow increment already propagates, so no frame type and no persisted field changes. The new client package dsh-client-ui-settings-unarchive-sessions owns the restore surface: one localized settings.section page listing the archive set joined with loaded session summaries, newest archive first, searchable by title or workspace, with one Unarchive action per row.
353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
import Storage from '@deepseek-ai/dsh-storage'
|
|
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
|
|
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
|
import WorkspaceController from '../src/index.ts'
|
|
import { WorkspaceFeed } from '../src/feed.ts'
|
|
import type { WorkspaceFollowFrame } from '../src/types.ts'
|
|
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
|
|
|
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
interface RemoteErrorDetailsMap {
|
|
'fixture/failure': {}
|
|
}
|
|
}
|
|
|
|
const roots: Context[] = []
|
|
|
|
/** Workspace roots created per test, removed after their context settles. */
|
|
const tempDirs: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
|
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
interface Deferred<T> {
|
|
readonly promise: Promise<T>
|
|
resolve(value: T): void
|
|
}
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((settle) => { resolve = settle })
|
|
return { promise, resolve }
|
|
}
|
|
|
|
async function harness() {
|
|
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-')))
|
|
tempDirs.push(root)
|
|
const ctx = new Context()
|
|
roots.push(ctx)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(Storage)
|
|
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
|
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
|
ctx.storage.mount('domain', storageDomain)
|
|
ctx.provide('storageDomain', storageDomain)
|
|
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
|
|
await ctx.plugin(WorkspaceRegistry)
|
|
const dispose = (): void => {}
|
|
ctx.provide('typert', {
|
|
lookups: { configure: () => dispose },
|
|
contexts: { configureHost: () => dispose },
|
|
} as never)
|
|
const controller = new WorkspaceController(ctx)
|
|
return { controller, ctx, root, storageDomain }
|
|
}
|
|
|
|
function stageDir(root: string, name: string): string {
|
|
const path = join(root, name)
|
|
mkdirSync(path, { recursive: true })
|
|
return path
|
|
}
|
|
|
|
async function nextFrame(
|
|
iterator: AsyncIterator<WorkspaceFollowFrame>,
|
|
): Promise<WorkspaceFollowFrame> {
|
|
const next = await iterator.next()
|
|
if (next.done === true) throw new Error('Workspace stream ended before the expected frame')
|
|
return next.value
|
|
}
|
|
|
|
describe('WorkspaceController commands', () => {
|
|
it('serializes concurrent path adoption and preserves an existing title', async () => {
|
|
const { controller, root } = await harness()
|
|
const path = stageDir(root, 'alpha')
|
|
const results = await Promise.all([
|
|
controller.create({ path }),
|
|
controller.create({ path }),
|
|
])
|
|
const created = results.find(result => result.created)
|
|
const resolved = results.find(result => !result.created)
|
|
expect(created).toMatchObject({ workspace: { path, title: 'alpha' } })
|
|
expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
|
|
|
|
const workspaceId = created?.workspace.workspaceId
|
|
if (workspaceId === undefined) throw new Error('fixture did not create a Workspace')
|
|
await controller.rename({ workspaceId, title: 'renamed' })
|
|
await expect(controller.create({ path })).resolves.toMatchObject({
|
|
created: false,
|
|
workspace: { workspaceId, title: 'renamed' },
|
|
})
|
|
})
|
|
|
|
it('maps invalid paths, blank names, conflicts, and unknown ids to stable failures', async () => {
|
|
const { controller, root } = await harness()
|
|
const first = await controller.create({ path: stageDir(root, 'first') })
|
|
const second = await controller.create({ path: stageDir(root, 'second') })
|
|
|
|
await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({
|
|
code: 'workspace/invalid-path',
|
|
details: { path: join(root, 'missing') },
|
|
})
|
|
expect(existsSync(join(root, 'missing'))).toBe(false)
|
|
await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: ' ' }))
|
|
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' })
|
|
await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' }))
|
|
.rejects.toMatchObject({ code: 'workspace/name-conflict' })
|
|
await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId }))
|
|
.rejects.toMatchObject({ code: 'workspace/not-found' })
|
|
})
|
|
|
|
it('preserves Remote failures and propagates unexpected registry failures', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const remoteFailure = new RemoteError('fixture/failure', 'already mapped', {})
|
|
const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath')
|
|
.mockRejectedValueOnce(remoteFailure)
|
|
.mockRejectedValueOnce('plain failure')
|
|
await expect(controller.create({ path: stageDir(root, 'remote-failure') }))
|
|
.rejects.toBe(remoteFailure)
|
|
const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') })
|
|
await expect(plainFailure).rejects.toMatchObject({ code: 'workspace/invalid-path' })
|
|
await expect(plainFailure).rejects.toThrow('plain failure')
|
|
resolveByPath.mockRestore()
|
|
|
|
const created = await controller.create({ path: stageDir(root, 'created') })
|
|
const workspace = ctx.workspaceRegistry.get(created.workspace.workspaceId)
|
|
if (workspace === undefined) throw new Error('fixture Workspace disappeared')
|
|
|
|
const orderFailure = new Error('order storage failed')
|
|
vi.spyOn(ctx.workspaceRegistry, 'insertBefore').mockRejectedValueOnce(orderFailure)
|
|
await expect(controller.insertBefore({ workspaceId: created.workspace.workspaceId }))
|
|
.rejects.toBe(orderFailure)
|
|
|
|
const moveFailure = new Error('membership storage failed')
|
|
vi.spyOn(workspace, 'insertSessionBefore').mockRejectedValueOnce(moveFailure)
|
|
await expect(controller.insertSessionBefore({
|
|
workspaceId: created.workspace.workspaceId,
|
|
sessionId: SessionId('session'),
|
|
})).rejects.toBe(moveFailure)
|
|
|
|
const archiveFailure = new Error('archive storage failed')
|
|
vi.spyOn(ctx.workspaceRegistry, 'archiveSession').mockRejectedValueOnce(archiveFailure)
|
|
await expect(controller.archiveSession({ sessionId: SessionId('session') }))
|
|
.rejects.toBe(archiveFailure)
|
|
|
|
const unarchiveFailure = new Error('unarchive storage failed')
|
|
vi.spyOn(ctx.workspaceRegistry, 'unarchiveSession').mockRejectedValueOnce(unarchiveFailure)
|
|
await expect(controller.unarchiveSession({ sessionId: SessionId('session') }))
|
|
.rejects.toBe(unarchiveFailure)
|
|
})
|
|
|
|
it('resolves queued Workspace identities when their operation starts', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const target = await controller.create({ path: stageDir(root, 'target') })
|
|
const blockerPath = stageDir(root, 'blocker')
|
|
const gate = deferred<undefined>()
|
|
const originalResolveByPath = ctx.workspaceRegistry.resolveByPath.bind(ctx.workspaceRegistry)
|
|
const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath')
|
|
resolveByPath.mockImplementationOnce(async (path) => {
|
|
await gate.promise
|
|
return originalResolveByPath(path)
|
|
})
|
|
|
|
const blocker = controller.create({ path: blockerPath })
|
|
const deletion = controller.delete({ workspaceId: target.workspace.workspaceId })
|
|
const staleRename = controller.rename({
|
|
workspaceId: target.workspace.workspaceId,
|
|
title: 'must-not-land',
|
|
})
|
|
gate.resolve(undefined)
|
|
await blocker
|
|
await expect(deletion).resolves.toEqual({ deleted: true })
|
|
await expect(staleRename).rejects.toMatchObject({ code: 'workspace/not-found' })
|
|
})
|
|
|
|
it('reorders Workspaces and Sessions and archives only known Sessions', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const first = await controller.create({ path: stageDir(root, 'first') })
|
|
const second = await controller.create({ path: stageDir(root, 'second') })
|
|
await expect(controller.insertBefore({
|
|
workspaceId: first.workspace.workspaceId,
|
|
beforeWorkspaceId: second.workspace.workspaceId,
|
|
})).resolves.toEqual({
|
|
workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId],
|
|
})
|
|
await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId }))
|
|
.rejects.toMatchObject({ code: 'workspace/not-found' })
|
|
|
|
const session = ctx.sessions.create(SessionId('session-one'), {
|
|
meta: { cwd: first.workspace.path },
|
|
})
|
|
const workspace = ctx.workspaceRegistry.get(first.workspace.workspaceId)
|
|
if (workspace === undefined) throw new Error('fixture Workspace disappeared')
|
|
await workspace.attachSession(session.id)
|
|
await expect(controller.insertSessionBefore({
|
|
workspaceId: first.workspace.workspaceId,
|
|
sessionId: session.id,
|
|
})).resolves.toMatchObject({ workspace: { sessionIds: [session.id] } })
|
|
await expect(controller.insertSessionBefore({
|
|
workspaceId: first.workspace.workspaceId,
|
|
sessionId: SessionId('missing-session'),
|
|
})).rejects.toMatchObject({ code: 'workspace/move-invalid' })
|
|
await expect(controller.insertSessionBefore({
|
|
workspaceId: first.workspace.workspaceId,
|
|
sessionId: session.id,
|
|
beforeSessionId: SessionId('missing-anchor'),
|
|
})).rejects.toMatchObject({
|
|
code: 'workspace/move-invalid',
|
|
details: { beforeSessionId: 'missing-anchor' },
|
|
})
|
|
await expect(controller.insertSessionBefore({
|
|
workspaceId: 'missing' as WorkspaceId,
|
|
sessionId: session.id,
|
|
})).rejects.toMatchObject({ code: 'workspace/not-found' })
|
|
|
|
await expect(controller.archiveSession({ sessionId: session.id }))
|
|
.resolves.toEqual({ archivedSessionIds: [session.id] })
|
|
await expect(controller.archiveSession({ sessionId: SessionId('unknown') }))
|
|
.rejects.toMatchObject({ code: 'session/not-found' })
|
|
await expect(controller.unarchiveSession({ sessionId: session.id }))
|
|
.resolves.toEqual({ archivedSessionIds: [] })
|
|
// Unarchive is idempotent: an id that is not archived is not an error.
|
|
await expect(controller.unarchiveSession({ sessionId: session.id }))
|
|
.resolves.toEqual({ archivedSessionIds: [] })
|
|
})
|
|
})
|
|
|
|
describe('WorkspaceController follow', () => {
|
|
it('seeds a new feed from existing rows and rejects an inconsistent registry commit', async () => {
|
|
const { ctx, root } = await harness()
|
|
const existing = await ctx.workspaceRegistry.create(stageDir(root, 'existing'))
|
|
const feed = new WorkspaceFeed(ctx)
|
|
expect(feed.baseline()).toMatchObject({
|
|
items: [{ workspaceId: existing.id }],
|
|
})
|
|
|
|
expect(() => {
|
|
ctx.emit('domain/changed', {
|
|
domain: 'workspace',
|
|
table: '',
|
|
key: '',
|
|
operation: 'put',
|
|
value: {
|
|
initialized: true,
|
|
workspaceIds: ['missing'],
|
|
archivedSessionIds: [],
|
|
},
|
|
})
|
|
}).toThrow('references missing Workspace "missing"')
|
|
})
|
|
|
|
it('starts with a complete baseline and emits committed increments in domain order', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const abort = new AbortController()
|
|
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'baseline',
|
|
value: { items: [], archivedSessionIds: [] },
|
|
})
|
|
|
|
const first = await controller.create({ path: stageDir(root, 'first') })
|
|
await expect(nextFrame(iterator)).resolves.toMatchObject({
|
|
type: 'upsert', workspace: { workspaceId: first.workspace.workspaceId },
|
|
})
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'order', workspaceIds: [first.workspace.workspaceId],
|
|
})
|
|
await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'renamed' })
|
|
await expect(nextFrame(iterator)).resolves.toMatchObject({
|
|
type: 'upsert', workspace: { title: 'renamed' },
|
|
})
|
|
|
|
const second = await controller.create({ path: stageDir(root, 'second') })
|
|
await expect(nextFrame(iterator)).resolves.toMatchObject({
|
|
type: 'upsert', workspace: { workspaceId: second.workspace.workspaceId },
|
|
})
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'order', workspaceIds: [second.workspace.workspaceId, first.workspace.workspaceId],
|
|
})
|
|
await controller.insertBefore({
|
|
workspaceId: first.workspace.workspaceId,
|
|
beforeWorkspaceId: second.workspace.workspaceId,
|
|
})
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'order',
|
|
workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId],
|
|
})
|
|
|
|
const session = ctx.sessions.create(SessionId('archived'), {
|
|
meta: { cwd: first.workspace.path },
|
|
})
|
|
await controller.archiveSession({ sessionId: session.id })
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'archived', archivedSessionIds: [session.id],
|
|
})
|
|
// Unarchive rides the same complete-set increment: no new frame type.
|
|
await controller.unarchiveSession({ sessionId: session.id })
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'archived', archivedSessionIds: [],
|
|
})
|
|
await controller.delete({ workspaceId: second.workspace.workspaceId })
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'order', workspaceIds: [first.workspace.workspaceId],
|
|
})
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'remove', workspaceId: second.workspace.workspaceId,
|
|
})
|
|
|
|
abort.abort()
|
|
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
|
|
it('ignores unrelated domain writes and closes active followers on disposal', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const abort = new AbortController()
|
|
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
|
|
await nextFrame(iterator)
|
|
ctx.emit('domain/changed', {
|
|
domain: 'other', table: 'records', key: 'x', operation: 'put', value: {},
|
|
})
|
|
ctx.emit('domain/changed', {
|
|
domain: 'workspace', table: '', key: '', operation: 'deleted',
|
|
})
|
|
ctx.emit('domain/changed', {
|
|
domain: 'workspace', table: 'other', key: 'x', operation: 'put', value: {},
|
|
})
|
|
ctx.emit('domain/changed', {
|
|
domain: 'workspace', table: 'workspaces', key: 'unknown', operation: 'deleted',
|
|
})
|
|
const pending = iterator.next()
|
|
const created = await controller.create({ path: stageDir(root, 'visible') })
|
|
await expect(pending).resolves.toMatchObject({ value: { type: 'upsert' } })
|
|
await expect(iterator.next()).resolves.toEqual({
|
|
done: false,
|
|
value: { type: 'order', workspaceIds: [created.workspace.workspaceId] },
|
|
})
|
|
|
|
const closing = iterator.next()
|
|
await ctx.fiber.dispose()
|
|
roots.splice(roots.indexOf(ctx), 1)
|
|
await expect(closing).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
})
|