mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
516 lines
24 KiB
TypeScript
516 lines
24 KiB
TypeScript
import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
|
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
import Storage from '@deepseek-ai/dsh-storage'
|
|
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
|
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
|
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
|
|
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
|
|
import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
|
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
|
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
|
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
|
import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
|
|
|
let nextRpc = 1
|
|
|
|
function request<P>(payload: P): RpcRequest<P> {
|
|
return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
|
|
}
|
|
|
|
function expectOk<T>(response: RpcResponse<T>): T {
|
|
expect(response.result.ok).toBe(true)
|
|
if (!response.result.ok) throw new Error('unreachable')
|
|
return response.result.value
|
|
}
|
|
|
|
async function nextHostFrame(
|
|
stream: AsyncIterator<RpcRequest<HostFrame>>,
|
|
): Promise<RpcRequest<HostFrame>> {
|
|
const next = await stream.next()
|
|
if (next.done === true) throw new Error('Host stream ended before the expected increment')
|
|
return next.value
|
|
}
|
|
|
|
function stubAgent(session: Session): Agent {
|
|
return {
|
|
id: session.id,
|
|
options: {},
|
|
session,
|
|
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
|
status: 'idle',
|
|
ctx: new Context(),
|
|
send: () => {},
|
|
followup: () => {},
|
|
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
|
inject: () => {},
|
|
cancel() {},
|
|
runMaintenance: task => task(new AbortController().signal),
|
|
whenIdle: () => Promise.resolve(),
|
|
}
|
|
}
|
|
|
|
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
|
|
async function harness(
|
|
root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
|
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
|
|
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
|
|
) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(UserInteractionService)
|
|
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 factory: AgentFactory = {
|
|
async createAgent(_ownerCtx, options) {
|
|
const session = ctx.sessions.create(
|
|
options.sessionId,
|
|
options.meta === undefined ? {} : { meta: options.meta },
|
|
)
|
|
const agent = stubAgent(session)
|
|
const unregister = ctx.agents.register(agent)
|
|
return {
|
|
agent,
|
|
dispose: () => {
|
|
unregister()
|
|
return Promise.resolve()
|
|
},
|
|
}
|
|
},
|
|
async resume() {
|
|
throw new Error('test harness has no persisted sessions')
|
|
},
|
|
}
|
|
ctx.agents.setFactory(factory)
|
|
// Structural picker fake: the gateway only reads capability(); a stable
|
|
// object per harness mirrors the seam's stability contract.
|
|
ctx.provide('directoryPicker', { capability: () => picker } as never)
|
|
const api = createApiProxy(ctx, {
|
|
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
|
cwd: root,
|
|
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
|
|
})
|
|
return { api, ctx, storageDomain, root }
|
|
}
|
|
|
|
/** Stage one directory under the harness root for path adoption. */
|
|
function stageDir(root: string, name: string): string {
|
|
const path = join(root, name)
|
|
mkdirSync(path)
|
|
return path
|
|
}
|
|
|
|
describe('host.pickDirectory', () => {
|
|
it('returns a selected path or explicit cancellation from the native capability', async () => {
|
|
const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' })
|
|
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
|
|
.toEqual({ ok: true, value: { path: '/tmp/project' } })
|
|
|
|
const cancelled = await harness(undefined, { kind: 'native', pick: async () => null })
|
|
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
|
|
.toEqual({ ok: true, value: { path: null } })
|
|
})
|
|
|
|
it('propagates abort into the native capability as a cancelled RPC error', async () => {
|
|
const { api } = await harness(undefined, {
|
|
kind: 'native',
|
|
pick: signal => new Promise((_resolve, reject) => {
|
|
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
|
}),
|
|
})
|
|
const abort = new AbortController()
|
|
const pending = api.host.pickDirectory(request({}), abort.signal)
|
|
abort.abort()
|
|
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
|
|
})
|
|
|
|
it('folds a non-abort native-chooser failure into an internal error', async () => {
|
|
const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
|
|
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
|
|
expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
|
})
|
|
|
|
it('refuses the native RPC under a browse composition', async () => {
|
|
const { api } = await harness(undefined, BROWSE_STUB)
|
|
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
|
|
expect(response.result).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
|
|
})
|
|
})
|
|
})
|
|
|
|
/** Canned browse capability: one listing, one created path, typed failures on demand. */
|
|
const BROWSE_STUB: DirectoryPickerCapability = {
|
|
kind: 'browse',
|
|
list: async (path) => {
|
|
if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
|
|
const target = path ?? '/home/user'
|
|
return {
|
|
path: target,
|
|
home: '/home/user',
|
|
crumbs: [{ name: '/', path: '/', hidden: false }],
|
|
entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
|
|
truncated: false,
|
|
}
|
|
},
|
|
createDirectory: async (path, name) => {
|
|
if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
|
|
if (name === 'unwritable') throw new Error('disk detached')
|
|
return `${path}/${name}`
|
|
},
|
|
}
|
|
|
|
describe('host.listDirectory / host.createDirectory', () => {
|
|
it('serves listings and creation through the browse capability, defaulting to home', async () => {
|
|
const { api } = await harness(undefined, BROWSE_STUB)
|
|
const home = await api.host.listDirectory(request({}), new AbortController().signal)
|
|
expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
|
|
const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal)
|
|
expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
|
|
const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
|
|
expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
|
|
})
|
|
|
|
it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => {
|
|
const { api } = await harness(undefined, BROWSE_STUB)
|
|
expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({
|
|
ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
|
|
})
|
|
expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
|
|
ok: false, error: { code: 'directory-exists' },
|
|
})
|
|
expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({
|
|
ok: false, error: { code: 'internal' },
|
|
})
|
|
})
|
|
|
|
it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => {
|
|
const { api } = await harness(undefined, {
|
|
kind: 'browse',
|
|
list: (_path, signal) => new Promise((_resolve, reject) => {
|
|
signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
|
|
}),
|
|
createDirectory: async () => '/never',
|
|
})
|
|
const abort = new AbortController()
|
|
const pending = api.host.listDirectory(request({}), abort.signal)
|
|
abort.abort()
|
|
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
|
|
})
|
|
|
|
it('refuses the browse RPCs under a native composition', async () => {
|
|
const { api } = await harness()
|
|
expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({
|
|
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
|
|
})
|
|
expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
|
|
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('host.openPath', () => {
|
|
it('opens through the injected native boundary', async () => {
|
|
const opened: string[] = []
|
|
const { api } = await harness(undefined, undefined, {
|
|
openPath: async (path) => { opened.push(path) },
|
|
})
|
|
expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
|
|
.toEqual({ ok: true, value: { opened: true } })
|
|
expect(opened).toEqual(['/tmp/a.txt'])
|
|
})
|
|
|
|
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
|
|
const { api } = await harness(undefined, undefined, {
|
|
openPath: (_path, signal) => new Promise((_resolve, reject) => {
|
|
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
|
}),
|
|
})
|
|
const abort = new AbortController()
|
|
const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
|
|
abort.abort()
|
|
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
|
|
})
|
|
})
|
|
|
|
describe('workspace.create', () => {
|
|
it('serializes concurrent creates of one path into a single registration', async () => {
|
|
const { api, root } = await harness()
|
|
const target = stageDir(root, 'alpha')
|
|
const responses = await Promise.all([
|
|
api.workspace.create(request({ path: target })),
|
|
api.workspace.create(request({ path: target })),
|
|
])
|
|
const values = responses.map(response => expectOk(response))
|
|
const created = values.find(value => value.created)
|
|
const resolved = values.find(value => !value.created)
|
|
|
|
expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } })
|
|
expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
|
|
expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1)
|
|
})
|
|
|
|
it('adopts only existing directories', async () => {
|
|
const { api, root } = await harness()
|
|
const existing = stageDir(root, 'existing')
|
|
const first = expectOk(await api.workspace.create(request({ path: existing })))
|
|
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
|
|
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
|
|
expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
|
|
|
|
expectOk(await api.workspace.rename(request({
|
|
workspaceId: first.workspace.workspaceId,
|
|
title: 'renamed-existing',
|
|
})))
|
|
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
|
|
expect(reopened.workspace.title).toBe('renamed-existing')
|
|
|
|
const missing = join(root, 'missing')
|
|
const missingResult = await api.workspace.create(request({ path: missing }))
|
|
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
|
expect(existsSync(missing)).toBe(false)
|
|
})
|
|
|
|
it('adopts different paths that derive the same Workspace title', async () => {
|
|
const { api, root } = await harness()
|
|
const first = join(root, 'one', 'project')
|
|
const second = join(root, 'two', 'project')
|
|
mkdirSync(first, { recursive: true })
|
|
mkdirSync(second, { recursive: true })
|
|
const firstResult = expectOk(await api.workspace.create(request({ path: first })))
|
|
const secondResult = expectOk(await api.workspace.create(request({ path: second })))
|
|
expect(firstResult).toMatchObject({
|
|
created: true,
|
|
workspace: { path: first, title: 'project' },
|
|
})
|
|
expect(secondResult).toMatchObject({
|
|
created: true,
|
|
workspace: { path: second, title: 'project' },
|
|
})
|
|
expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId)
|
|
expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path))
|
|
.toEqual([second, first])
|
|
})
|
|
})
|
|
|
|
describe('session creation and Workspace membership', () => {
|
|
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
|
|
const { api, ctx, root } = await harness()
|
|
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
|
|
const sessionId = SessionId('session-workspace-preallocated')
|
|
|
|
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
|
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
|
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
|
expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
|
|
|
|
const ungrouped = SessionId('session-cwd-only')
|
|
expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
|
|
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
|
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
|
|
|
|
const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
|
|
expect(conflict.result).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
|
|
})
|
|
const missing = await api.sessions.create(request({
|
|
workspaceId: 'missing-workspace' as WorkspaceId,
|
|
sessionId: SessionId('session-missing-workspace'),
|
|
}))
|
|
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
|
|
})
|
|
|
|
it('retains a published session when attachment fails and repairs it on retry', async () => {
|
|
const { api, ctx, root } = await harness()
|
|
const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
|
|
const workspace = ctx.workspace.list()[0]
|
|
if (workspace === undefined) throw new Error('workspace missing from registry')
|
|
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
|
|
const sessionId = SessionId('session-attach-retry')
|
|
|
|
const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
|
|
expect(failed.result).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
|
|
})
|
|
expect(ctx.agents.get(sessionId)).toBeDefined()
|
|
|
|
expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
|
|
expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
|
|
})
|
|
})
|
|
|
|
describe('Host Workspace increments', () => {
|
|
it('projects subagent origin in attached summaries and creation increments', async () => {
|
|
const { api, ctx } = await harness()
|
|
const abort = new AbortController()
|
|
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
|
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
|
const pending = nextHostFrame(stream)
|
|
const childId = SessionId('session-subagent-child')
|
|
|
|
ctx.sessions.create(childId, {
|
|
meta: {
|
|
cwd: '/tmp',
|
|
parentSession: SessionId('session-parent'),
|
|
origin: 'subagent',
|
|
},
|
|
})
|
|
|
|
expect(await pending).toMatchObject({
|
|
payload: {
|
|
type: 'host/session-added',
|
|
sessionId: childId,
|
|
parentSessionId: 'session-parent',
|
|
origin: 'subagent',
|
|
},
|
|
})
|
|
expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
|
|
expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
|
|
)
|
|
abort.abort()
|
|
})
|
|
|
|
it('streams committed Workspace and Session increments after empty baselines', async () => {
|
|
const { api, root } = await harness()
|
|
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
|
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
|
|
|
|
const abort = new AbortController()
|
|
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
|
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
|
const workspaceIncrement = nextHostFrame(stream)
|
|
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
|
|
expect(await workspaceIncrement).toMatchObject({
|
|
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
|
|
})
|
|
|
|
const sessionId = SessionId('session-streamed-workspace')
|
|
const pending = nextHostFrame(stream)
|
|
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
|
const increments: HostFrame[] = []
|
|
increments.push((await pending).payload)
|
|
while (increments.length < 2) {
|
|
const next = await stream.next()
|
|
if (next.done === true) throw new Error('Host stream ended before both increments')
|
|
increments.push(next.value.payload)
|
|
}
|
|
expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
|
|
// A just-created session has no events: the frame constantly carries blank:true.
|
|
type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
|
|
})
|
|
const workspaceChanged = increments.find(
|
|
(increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
|
|
increment.type === 'host/workspace-changed',
|
|
)
|
|
expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
|
|
abort.abort()
|
|
})
|
|
|
|
it('does not publish a Workspace whose registry-order commit fails', async () => {
|
|
const { api, storageDomain, root } = await harness()
|
|
const domain = storageDomain.get('workspace')
|
|
if (domain === undefined) throw new Error('workspace domain is not open')
|
|
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
|
|
const abort = new AbortController()
|
|
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
|
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
|
const next = stream.next()
|
|
|
|
const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') }))
|
|
expect(failed.result.ok).toBe(false)
|
|
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
|
abort.abort()
|
|
expect(await next).toMatchObject({ done: true })
|
|
})
|
|
|
|
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
|
|
const { api, ctx, root } = await harness()
|
|
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace
|
|
const sessionId = SessionId('session-kept-after-workspace-delete')
|
|
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
|
|
|
const abort = new AbortController()
|
|
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
|
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
|
const removed = nextHostFrame(stream)
|
|
expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
|
|
expect(await removed).toMatchObject({
|
|
payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
|
|
})
|
|
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
|
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
|
expect(ctx.agents.get(sessionId)).toBeDefined()
|
|
expect(existsSync(workspace.path)).toBe(true)
|
|
|
|
const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
|
|
expect(missing.result).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
|
|
})
|
|
|
|
const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
|
|
expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
|
|
expect(reregistered.path).toBe(workspace.path)
|
|
expect(reregistered.sessionIds).toEqual([])
|
|
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
|
abort.abort()
|
|
})
|
|
|
|
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
|
|
const { api, root } = await harness()
|
|
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace
|
|
const sessionId = SessionId('session-to-archive')
|
|
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
|
|
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
|
|
|
|
const abort = new AbortController()
|
|
const stream: AsyncIterator<RpcRequest<HostFrame>> =
|
|
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
|
const changed = nextHostFrame(stream)
|
|
expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
|
|
.toEqual([sessionId])
|
|
expect(await changed).toMatchObject({
|
|
payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] },
|
|
})
|
|
|
|
// Accounting and the session itself are untouched; list re-baselines the set.
|
|
const listed = expectOk(await api.workspace.list(request({})))
|
|
expect(listed.archivedSessionIds).toEqual([sessionId])
|
|
expect(listed.items[0]?.sessionIds).toEqual([sessionId])
|
|
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
|
|
|
|
// The idempotent repeat emits no second frame: the next observed frame is
|
|
// the workspace-changed of a later attach, not another archive snapshot.
|
|
const after = nextHostFrame(stream)
|
|
expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
|
|
.toEqual([sessionId])
|
|
const otherSession = SessionId('session-after-archive')
|
|
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession })))
|
|
expect((await after).payload.type).not.toBe('host/archived-sessions-changed')
|
|
|
|
const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') }))
|
|
expect(missing.result).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } },
|
|
})
|
|
abort.abort()
|
|
})
|
|
})
|