* feat(workspace): stop a Session's running work before archiving it Archiving was a pure visibility write, so a Session archived mid-turn kept running out of sight. The Workspace registry now owns archive admission through two Host events: `workspace/session-activity` reports what still runs and refuses the plain archive as `workspace/session-active`; `workspace/session-stop`, requested with `stopActivity`, stops that work the way the user's own stop actions do before the archive write. The API Session Controller reports and stops turns, subagent descendants, and owned jobs and rejects any model step for an archived Session; the Schedule plugin reports and deletes active reminders. The sidebar turns the refusal into a stop-and-archive confirmation listing the Host's answer. * test(workspace): pin the widened archive signature and prove child liveness by heartbeat The Client inspect catalog spec pins archiveSession's new options parameter, and the archive e2e reads the Bash child's liveness from a heartbeat file instead of a pid: the Linux sandbox runs the tool in its own PID namespace, so the pid it reports is not comparable from the test. * test(workspace): cover the archive-admission providers and dialog to the per-file gate Drop the provider's unused disposer and the dialog's unreachable confirm guard, read reminders without a catch that no composition can reach, and add the cases the coverage lane found missing: a stop without a jobs registry, a looping lineage, a descendant whose projections reject, the dialog's Escape guard and non-Error reason, and a family merged by another provider. * fix(workspace): write the archive before stopping, gate subagent lineage, and delete reminders under the schedule barriers Review round on #4765. The registry now writes the archive set before it dispatches `workspace/session-stop`, so the durable set already gates every wake the stops induce; the controller's `agent/pre-step` gate also blocks subagent descendants of an archived Session and contains each cancel or kill on its own. The Schedule plugin's stop listener runs as a management delete in the agent's transaction queue between the same two persistence barriers the `schedule_delete` tool uses. The confirm dialog selects singular or plural English lines and keys list rows by index. Docs: archive-admission section with type-equiv blocks on the workspace subsystem page, write-first order and residual windows in the READMEs and the Agent Note, and the missing zh reminders sentence. * test(web): scope the question composer's New Session wait to the Sessions tree The wide sidebar's New Session button carries the same visible label as a fresh session row, so the unscoped `getByText` either settled on the button before the row existed or hit a strict-mode violation once the row rendered first, which is how the snapshots lane failed on ece68c2ab5. Waiting inside the Sessions tree names the row alone. * fix(schedule): answer archive admission from the owned runtime's fold, not the projection registry The `sessionProjections.snapshot` API is the Client projection view and is not a Host read path (review on #4765). `ScheduleRuntime.activeRecords()` folds the live log the way every drive does, and the archive-admission listeners ask the owned runtime for that fold: a session without a live agent or without an owned runtime has nothing armed to fire, so it reports nothing and has nothing to stop. Covers the runtime-less agent, a fold that turns unreadable at the persistence barrier, and the stopping/faulted runtime answering nothing. * refactor(subagent): report and stop subagent descendants from the Subagent runtime The `subagent` family of archive admission moves out of the API Session Controller into `dsh-subagent`, which records the lineage at delegation and owns the descriptor (review on #4765): `SubagentRuntime` answers `workspace/session-activity` with the running descendants, labelled from each child's descriptor through a live Session observation, and `workspace/session-stop` by cancelling each of them with the parent cause. The controller keeps turns, owned jobs, and the lineage step gate, and composes its provider through `ctx.plugin(SessionActivityProvider)` with declared injections instead of a bare installer call. `dsh-workspace` becomes an optional type-only peer of `dsh-subagent`. * refactor(jobs): answer archive admission for owned jobs from the job registry seam The `job` family moves out of the API Session Controller into `dsh-jobs` (review on #4765): the seam's constructor installs the `workspace/session-activity` and `workspace/session-stop` listeners for every implementation through the abstract `list` and `kill` alone, so the controller no longer knows the jobs vocabulary and keeps only the turn family and the archived-lineage step gate. `dsh-workspace` becomes an optional type-only peer of `dsh-jobs`. * refactor(workspace): leave SessionActivityKindMap empty and let each provider merge its family The workspace package no longer names any activity family (review on #4765): `SessionActivityKindMap` is empty, and each provider merges its own key from a module both its faces import — `turn` in the API Session Controller's types, `job` in the job registry seam's view, `subagent` in the Subagent runtime's control types, `schedule` in the Schedule plugin's types. The confirm dialog imports those modules for its cases and keeps the generic line for any other key; the registry and controller suites merge test-owned families instead of borrowing the providers'. * refactor(agent): report and stop the running turn from the Agent registry The `turn` family moves out of the API Session Controller into `dsh-agent` (review on #4765): the registry's constructor installs the `workspace/session-activity` and `workspace/session-stop` listeners for every Agent it publishes, answering with its own lookup and cancelling the turn with the user cause, and merges the `turn` key from its client-safe types. The controller keeps only the archived-lineage step gate, renamed `ArchivedSessionGate`. `dsh-workspace` becomes an optional type-only peer of `dsh-agent`; the workspace package depends on none of the providers, so the edge stays acyclic. Since #3127 a foreground Bash command is a registered job from its start, so the archive dialog lists it beside the turn; the e2e and its ARIA golden expect that row, and the capture keeps the running job by design.
503 lines
23 KiB
TypeScript
503 lines
23 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'
|
|
|
|
// The controller relays whatever families the providers report; this suite merges its own.
|
|
declare module '@deepseek-ai/dsh-workspace/types' {
|
|
interface SessionActivityKindMap {
|
|
probe: true
|
|
'probe-items': true
|
|
}
|
|
}
|
|
|
|
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(options: { systemDocuments?: boolean } = {}) {
|
|
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, options.systemDocuments === true ? {} : { documentsDirectory: root })
|
|
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)
|
|
|
|
const pinFailure = new Error('pin storage failed')
|
|
vi.spyOn(ctx.workspaceRegistry, 'pinSession').mockRejectedValueOnce(pinFailure)
|
|
await expect(controller.pinSession({ sessionId: SessionId('session') }))
|
|
.rejects.toBe(pinFailure)
|
|
|
|
const unpinFailure = new Error('unpin storage failed')
|
|
vi.spyOn(ctx.workspaceRegistry, 'unpinSession').mockRejectedValueOnce(unpinFailure)
|
|
await expect(controller.unpinSession({ sessionId: SessionId('session') }))
|
|
.rejects.toBe(unpinFailure)
|
|
})
|
|
|
|
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' })
|
|
|
|
// A session reported active by the registry's activity waterfall is a
|
|
// stable business failure carrying what still runs, and nothing is written.
|
|
const activity = [{ kind: 'probe' as const }, { kind: 'probe-items' as const, items: [{ id: 'item-1', label: 'build' }] }]
|
|
const stopReporting = ctx.on('workspace/session-activity', async ({ sessionId }, next) =>
|
|
sessionId === session.id ? [...activity, ...(await next())] : next())
|
|
await expect(controller.archiveSession({ sessionId: session.id })).rejects.toMatchObject({
|
|
code: 'workspace/session-active',
|
|
details: { sessionId: session.id, activity },
|
|
})
|
|
expect([...ctx.workspaceRegistry.archivedSessionIds]).toEqual([])
|
|
// Asking to stop the work archives the still-active Session and reaches
|
|
// the stop providers first.
|
|
const stops: string[] = []
|
|
const stopListening = ctx.on('workspace/session-stop', ({ sessionId }) => { stops.push(String(sessionId)) })
|
|
await expect(controller.archiveSession({ sessionId: session.id, stopActivity: true }))
|
|
.resolves.toEqual({ archivedSessionIds: [session.id] })
|
|
expect(stops).toEqual([String(session.id)])
|
|
stopListening()
|
|
await expect(controller.unarchiveSession({ sessionId: session.id }))
|
|
.resolves.toEqual({ archivedSessionIds: [] })
|
|
stopReporting()
|
|
|
|
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: [] })
|
|
})
|
|
|
|
it('pins only known unarchived Sessions and unpins idempotently', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const created = await controller.create({ path: stageDir(root, 'pins') })
|
|
const session = ctx.sessions.create(SessionId('pin-me'), {
|
|
meta: { cwd: created.workspace.path },
|
|
})
|
|
|
|
await expect(controller.pinSession({ sessionId: session.id }))
|
|
.resolves.toEqual({ pinnedSessionIds: [session.id] })
|
|
await expect(controller.pinSession({ sessionId: SessionId('unknown') }))
|
|
.rejects.toMatchObject({ code: 'session/not-found' })
|
|
|
|
// Pinning an archived Session is a caller error, not a missing session.
|
|
const archived = ctx.sessions.create(SessionId('stored'), {
|
|
meta: { cwd: created.workspace.path },
|
|
})
|
|
await controller.archiveSession({ sessionId: archived.id })
|
|
await expect(controller.pinSession({ sessionId: archived.id }))
|
|
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
|
|
await expect(controller.unpinSession({ sessionId: session.id }))
|
|
.resolves.toEqual({ pinnedSessionIds: [] })
|
|
// Unpin is idempotent: an id that is not pinned is not an error.
|
|
await expect(controller.unpinSession({ sessionId: session.id }))
|
|
.resolves.toEqual({ pinnedSessionIds: [] })
|
|
})
|
|
})
|
|
|
|
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: [],
|
|
pinnedSessionIds: [],
|
|
},
|
|
})
|
|
}).toThrow('references missing Workspace "missing"')
|
|
})
|
|
|
|
it('starts a fresh feed with existing pins and follows their removal', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const session = ctx.sessions.create(SessionId('already-pinned'), { meta: { cwd: root } })
|
|
await controller.pinSession({ sessionId: session.id })
|
|
const feed = new WorkspaceFeed(ctx)
|
|
const abort = new AbortController()
|
|
const iterator = feed.follow(abort.signal)[Symbol.asyncIterator]()
|
|
try {
|
|
await expect(nextFrame(iterator)).resolves.toMatchObject({
|
|
type: 'baseline', value: { pinnedSessionIds: [session.id] },
|
|
})
|
|
await controller.unpinSession({ sessionId: session.id })
|
|
await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [] })
|
|
} finally {
|
|
abort.abort()
|
|
await iterator.return?.()
|
|
}
|
|
})
|
|
|
|
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: [], pinnedSessionIds: [] },
|
|
})
|
|
|
|
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.pinSession({ sessionId: session.id })
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'pinned', pinnedSessionIds: [session.id],
|
|
})
|
|
// Unpin rides the same complete-set increment: no new frame type.
|
|
await controller.unpinSession({ sessionId: session.id })
|
|
await expect(nextFrame(iterator)).resolves.toEqual({
|
|
type: 'pinned', pinnedSessionIds: [],
|
|
})
|
|
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 })
|
|
})
|
|
})
|
|
|
|
describe('first-use Remote', () => {
|
|
it('reuses an initialized Workspace without looking up system Documents', async () => {
|
|
const { controller, ctx, root } = await harness({ systemDocuments: true })
|
|
const workspace = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: root, title: 'Existing default' }))
|
|
const signal = AbortSignal.abort()
|
|
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal))
|
|
.resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: 'Existing default' } })
|
|
})
|
|
|
|
it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])(
|
|
'rejects invalid directory name %j before invoking the registry', async (directoryName) => {
|
|
const { controller, ctx } = await harness()
|
|
const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined)
|
|
await expect(controller.initializeDefault({ directoryName, title: 'Default workspace' }, new AbortController().signal))
|
|
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
expect(initialize).not.toHaveBeenCalled()
|
|
},
|
|
)
|
|
|
|
it('rejects a blank initial title before invoking the registry', async () => {
|
|
const { controller, ctx } = await harness()
|
|
const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined)
|
|
await expect(controller.initializeDefault({ directoryName: 'default-workspace', title: ' ' }, new AbortController().signal))
|
|
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
|
expect(initialize).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the requested display title independently of the directory name', async () => {
|
|
const { controller, root } = await harness()
|
|
const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal)
|
|
expect(result?.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' })
|
|
})
|
|
|
|
it('returns a durable Workspace without allocating a Session', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
const signal = new AbortController().signal
|
|
const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal)
|
|
expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace'))
|
|
expect(existsSync(result!.workspace.path)).toBe(true)
|
|
expect(ctx.sessions.list()).toEqual([])
|
|
expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result)
|
|
})
|
|
|
|
it('skips ineligible first use and propagates preparation failures', async () => {
|
|
const { controller, ctx, root } = await harness()
|
|
await ctx.workspaceRegistry.create(root)
|
|
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal))
|
|
.resolves.toBeUndefined()
|
|
vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied'))
|
|
await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal))
|
|
.rejects.toThrow('permission denied')
|
|
})
|
|
})
|