|
|
@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
|
|
|
|
|
|
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
|
|
|
async function harness(
|
|
|
- workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
|
|
+ root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
|
|
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
|
|
|
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
|
|
|
) {
|
|
|
@@ -101,11 +101,17 @@ async function harness(
|
|
|
ctx.provide('directoryPicker', { capability: () => picker } as never)
|
|
|
const api = createApiProxy(ctx, {
|
|
|
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
|
|
- cwd: workspaceRoot,
|
|
|
- workspaceRoot,
|
|
|
+ cwd: root,
|
|
|
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
|
|
|
})
|
|
|
- return { api, ctx, storageDomain, workspaceRoot }
|
|
|
+ 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', () => {
|
|
|
@@ -243,31 +249,25 @@ describe('host.openPath', () => {
|
|
|
})
|
|
|
|
|
|
describe('workspace.create', () => {
|
|
|
- it('serializes concurrent names and rejects the duplicate', async () => {
|
|
|
- const { api, workspaceRoot } = await harness()
|
|
|
+ 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({ name: 'alpha' })),
|
|
|
- api.workspace.create(request({ name: 'alpha' })),
|
|
|
+ api.workspace.create(request({ path: target })),
|
|
|
+ api.workspace.create(request({ path: target })),
|
|
|
])
|
|
|
- const created = responses.find(response => response.result.ok)
|
|
|
- const duplicate = responses.find(response => !response.result.ok)
|
|
|
+ const values = responses.map(response => expectOk(response))
|
|
|
+ const created = values.find(value => value.created)
|
|
|
+ const resolved = values.find(value => !value.created)
|
|
|
|
|
|
- expect(created).toBeDefined()
|
|
|
- expect(expectOk(created!)).toMatchObject({
|
|
|
- created: true,
|
|
|
- workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
|
|
|
- })
|
|
|
- expect(duplicate?.result).toMatchObject({
|
|
|
- ok: false,
|
|
|
- error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
|
|
|
- })
|
|
|
- expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
|
|
|
+ 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 and rejects unsafe names', async () => {
|
|
|
- const { api, workspaceRoot } = await harness()
|
|
|
- const existing = join(workspaceRoot, 'existing')
|
|
|
- mkdirSync(existing)
|
|
|
+ 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' } })
|
|
|
@@ -280,21 +280,16 @@ describe('workspace.create', () => {
|
|
|
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
|
|
|
expect(reopened.workspace.title).toBe('renamed-existing')
|
|
|
|
|
|
- const missing = join(workspaceRoot, 'missing')
|
|
|
+ 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)
|
|
|
-
|
|
|
- for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
|
|
|
- const invalid = await api.workspace.create(request({ name }))
|
|
|
- expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
|
|
|
- }
|
|
|
})
|
|
|
|
|
|
it('adopts different paths that derive the same Workspace title', async () => {
|
|
|
- const { api, workspaceRoot } = await harness()
|
|
|
- const first = join(workspaceRoot, 'one', 'project')
|
|
|
- const second = join(workspaceRoot, 'two', 'project')
|
|
|
+ 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 })))
|
|
|
@@ -315,8 +310,8 @@ describe('workspace.create', () => {
|
|
|
|
|
|
describe('session creation and Workspace membership', () => {
|
|
|
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
|
|
|
- const { api, ctx } = await harness()
|
|
|
- const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
|
|
+ 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 })))
|
|
|
@@ -342,8 +337,8 @@ describe('session creation and Workspace membership', () => {
|
|
|
})
|
|
|
|
|
|
it('retains a published session when attachment fails and repairs it on retry', async () => {
|
|
|
- const { api, ctx } = await harness()
|
|
|
- const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
|
|
|
+ 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'))
|
|
|
@@ -393,7 +388,7 @@ describe('Host Workspace increments', () => {
|
|
|
})
|
|
|
|
|
|
it('streams committed Workspace and Session increments after empty baselines', async () => {
|
|
|
- const { api } = await harness()
|
|
|
+ const { api, root } = await harness()
|
|
|
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
|
|
|
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
|
|
|
|
|
|
@@ -401,7 +396,7 @@ describe('Host Workspace increments', () => {
|
|
|
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({ name: 'project' }))).workspace
|
|
|
+ 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 } },
|
|
|
})
|
|
|
@@ -429,7 +424,7 @@ describe('Host Workspace increments', () => {
|
|
|
})
|
|
|
|
|
|
it('does not publish a Workspace whose registry-order commit fails', async () => {
|
|
|
- const { api, storageDomain } = await harness()
|
|
|
+ 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'))
|
|
|
@@ -438,7 +433,7 @@ describe('Host Workspace increments', () => {
|
|
|
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
|
|
|
const next = stream.next()
|
|
|
|
|
|
- const failed = await api.workspace.create(request({ name: 'ghost' }))
|
|
|
+ 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()
|
|
|
@@ -446,8 +441,8 @@ describe('Host Workspace increments', () => {
|
|
|
})
|
|
|
|
|
|
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
|
|
|
- const { api, ctx } = await harness()
|
|
|
- const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
|
|
|
+ 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 })))
|
|
|
|
|
|
@@ -479,8 +474,8 @@ describe('Host Workspace increments', () => {
|
|
|
})
|
|
|
|
|
|
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
|
|
|
- const { api } = await harness()
|
|
|
- const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
|
|
|
+ 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([])
|