| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- 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)
- })
- 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' })
- })
- })
- 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],
- })
- 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 })
- })
- })
|