api-proxy-workspace.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { Context } from 'cordis'
  6. import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
  7. import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
  8. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  9. import type { Session } from '@deepseek-ai/dsh-session'
  10. import Storage from '@deepseek-ai/dsh-storage'
  11. import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
  12. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  13. import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
  14. import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
  15. import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  16. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  17. import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
  18. import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
  19. let nextRpc = 1
  20. function request<P>(payload: P): RpcRequest<P> {
  21. return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
  22. }
  23. function expectOk<T>(response: RpcResponse<T>): T {
  24. expect(response.result.ok).toBe(true)
  25. if (!response.result.ok) throw new Error('unreachable')
  26. return response.result.value
  27. }
  28. async function nextHostFrame(
  29. stream: AsyncIterator<RpcRequest<HostFrame>>,
  30. ): Promise<RpcRequest<HostFrame>> {
  31. const next = await stream.next()
  32. if (next.done === true) throw new Error('Host stream ended before the expected increment')
  33. return next.value
  34. }
  35. function stubAgent(session: Session): Agent {
  36. return {
  37. id: session.id,
  38. options: {},
  39. session,
  40. status: 'idle',
  41. acceptsNextStep: false,
  42. ctx: new Context(),
  43. followup: () => {},
  44. steer: () => {},
  45. inject: () => {},
  46. send: () => {},
  47. cancel() {},
  48. whenIdle: () => Promise.resolve(),
  49. }
  50. }
  51. /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
  52. async function harness(
  53. workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
  54. extras: {
  55. pickDirectory?: (signal: AbortSignal) => Promise<string | null>
  56. openPath?: (path: string, signal: AbortSignal) => Promise<void>
  57. } = {},
  58. ) {
  59. const ctx = new Context()
  60. await ctx.plugin(SessionStore)
  61. await ctx.plugin(AgentRegistry)
  62. await ctx.plugin(UserInteractionService)
  63. await ctx.plugin(Storage)
  64. ctx.storage.backend.register('memory', new MemoryStorageBackend())
  65. const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
  66. ctx.storage.mount('domain', storageDomain)
  67. ctx.provide('storageDomain', storageDomain)
  68. ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
  69. await ctx.plugin(WorkspaceRegistry)
  70. const factory: AgentFactory = {
  71. async createAgent(_ownerCtx, options) {
  72. const session = ctx.sessions.create(
  73. options.sessionId,
  74. options.meta === undefined ? {} : { meta: options.meta },
  75. )
  76. const agent = stubAgent(session)
  77. const unregister = ctx.agents.register(agent)
  78. return {
  79. agent,
  80. dispose: () => {
  81. unregister()
  82. return Promise.resolve()
  83. },
  84. }
  85. },
  86. async resume() {
  87. throw new Error('test harness has no persisted sessions')
  88. },
  89. }
  90. ctx.agents.setFactory(factory)
  91. const api = createApiProxy(ctx, {
  92. provider: 'test',
  93. model: 'test-model',
  94. cwd: workspaceRoot,
  95. workspaceRoot,
  96. ...extras.pickDirectory === undefined ? {} : { pickDirectory: extras.pickDirectory },
  97. ...extras.openPath === undefined ? {} : { openPath: extras.openPath },
  98. })
  99. return { api, ctx, storageDomain, workspaceRoot }
  100. }
  101. describe('host.pickDirectory', () => {
  102. it('returns a selected path or explicit cancellation from the injected native boundary', async () => {
  103. const selected = await harness(undefined, { pickDirectory: async () => '/tmp/project' })
  104. expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  105. .toEqual({ ok: true, value: { path: '/tmp/project' } })
  106. const cancelled = await harness(undefined, { pickDirectory: async () => null })
  107. expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  108. .toEqual({ ok: true, value: { path: null } })
  109. })
  110. it('propagates abort into the native boundary as a cancelled RPC error', async () => {
  111. const { api } = await harness(undefined, {
  112. pickDirectory: signal => new Promise((_resolve, reject) => {
  113. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  114. }),
  115. })
  116. const abort = new AbortController()
  117. const pending = api.host.pickDirectory(request({}), abort.signal)
  118. abort.abort()
  119. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  120. })
  121. })
  122. describe('host.openPath', () => {
  123. it('opens through the injected native boundary', async () => {
  124. const opened: string[] = []
  125. const { api } = await harness(undefined, {
  126. openPath: async (path) => { opened.push(path) },
  127. })
  128. expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
  129. .toEqual({ ok: true, value: { opened: true } })
  130. expect(opened).toEqual(['/tmp/a.txt'])
  131. })
  132. it('propagates abort into the native boundary as a cancelled RPC error', async () => {
  133. const { api } = await harness(undefined, {
  134. openPath: (_path, signal) => new Promise((_resolve, reject) => {
  135. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  136. }),
  137. })
  138. const abort = new AbortController()
  139. const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
  140. abort.abort()
  141. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  142. })
  143. })
  144. describe('workspace.create', () => {
  145. it('serializes concurrent names and rejects the duplicate', async () => {
  146. const { api, workspaceRoot } = await harness()
  147. const responses = await Promise.all([
  148. api.workspace.create(request({ name: 'alpha' })),
  149. api.workspace.create(request({ name: 'alpha' })),
  150. ])
  151. const created = responses.find(response => response.result.ok)
  152. const duplicate = responses.find(response => !response.result.ok)
  153. expect(created).toBeDefined()
  154. expect(expectOk(created!)).toMatchObject({
  155. created: true,
  156. workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
  157. })
  158. expect(duplicate?.result).toMatchObject({
  159. ok: false,
  160. error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
  161. })
  162. expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
  163. })
  164. it('adopts only existing directories and rejects unsafe names', async () => {
  165. const { api, workspaceRoot } = await harness()
  166. const existing = join(workspaceRoot, 'existing')
  167. mkdirSync(existing)
  168. const first = expectOk(await api.workspace.create(request({ path: existing })))
  169. const repeated = expectOk(await api.workspace.create(request({ path: existing })))
  170. expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
  171. expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
  172. expectOk(await api.workspace.rename(request({
  173. workspaceId: first.workspace.workspaceId,
  174. title: 'renamed-existing',
  175. })))
  176. const reopened = expectOk(await api.workspace.create(request({ path: existing })))
  177. expect(reopened.workspace.title).toBe('renamed-existing')
  178. const missing = join(workspaceRoot, 'missing')
  179. const missingResult = await api.workspace.create(request({ path: missing }))
  180. expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
  181. expect(existsSync(missing)).toBe(false)
  182. for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
  183. const invalid = await api.workspace.create(request({ name }))
  184. expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
  185. }
  186. })
  187. it('rejects different paths that derive the same Workspace title', async () => {
  188. const { api, workspaceRoot } = await harness()
  189. const first = join(workspaceRoot, 'one', 'project')
  190. const second = join(workspaceRoot, 'two', 'project')
  191. mkdirSync(first, { recursive: true })
  192. mkdirSync(second, { recursive: true })
  193. expectOk(await api.workspace.create(request({ path: first })))
  194. const conflict = await api.workspace.create(request({ path: second }))
  195. expect(conflict.result).toMatchObject({
  196. ok: false,
  197. error: { code: 'workspace-name-conflict', details: { name: 'project' } },
  198. })
  199. })
  200. })
  201. describe('session creation and Workspace membership', () => {
  202. it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
  203. const { api, ctx } = await harness()
  204. const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  205. const sessionId = SessionId('session-workspace-preallocated')
  206. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  207. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  208. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  209. expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
  210. const ungrouped = SessionId('session-cwd-only')
  211. expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
  212. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  213. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
  214. const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
  215. expect(conflict.result).toMatchObject({
  216. ok: false,
  217. error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
  218. })
  219. const missing = await api.sessions.create(request({
  220. workspaceId: 'missing-workspace' as WorkspaceId,
  221. sessionId: SessionId('session-missing-workspace'),
  222. }))
  223. expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
  224. })
  225. it('retains a published session when attachment fails and repairs it on retry', async () => {
  226. const { api, ctx } = await harness()
  227. const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  228. const workspace = ctx.workspace.list()[0]
  229. if (workspace === undefined) throw new Error('workspace missing from registry')
  230. vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
  231. const sessionId = SessionId('session-attach-retry')
  232. const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
  233. expect(failed.result).toMatchObject({
  234. ok: false,
  235. error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
  236. })
  237. expect(ctx.agents.get(sessionId)).toBeDefined()
  238. expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
  239. expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
  240. })
  241. })
  242. describe('Host Workspace increments', () => {
  243. it('streams committed Workspace and Session increments after empty baselines', async () => {
  244. const { api } = await harness()
  245. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  246. expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
  247. const abort = new AbortController()
  248. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  249. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  250. const workspaceIncrement = nextHostFrame(stream)
  251. const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
  252. expect(await workspaceIncrement).toMatchObject({
  253. payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
  254. })
  255. const sessionId = SessionId('session-streamed-workspace')
  256. const pending = nextHostFrame(stream)
  257. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  258. const increments: HostFrame[] = []
  259. increments.push((await pending).payload)
  260. while (increments.length < 2) {
  261. const next = await stream.next()
  262. if (next.done === true) throw new Error('Host stream ended before both increments')
  263. increments.push(next.value.payload)
  264. }
  265. expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
  266. // A just-created session has no events: the frame constantly carries blank:true.
  267. type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
  268. })
  269. const workspaceChanged = increments.find(
  270. (increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
  271. increment.type === 'host/workspace-changed',
  272. )
  273. expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
  274. abort.abort()
  275. })
  276. it('does not publish a Workspace whose registry-order commit fails', async () => {
  277. const { api, storageDomain } = await harness()
  278. const domain = storageDomain.get('workspace')
  279. if (domain === undefined) throw new Error('workspace domain is not open')
  280. vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
  281. const abort = new AbortController()
  282. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  283. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  284. const next = stream.next()
  285. const failed = await api.workspace.create(request({ name: 'ghost' }))
  286. expect(failed.result.ok).toBe(false)
  287. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  288. abort.abort()
  289. expect(await next).toMatchObject({ done: true })
  290. })
  291. it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
  292. const { api, ctx } = await harness()
  293. const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
  294. const sessionId = SessionId('session-kept-after-workspace-delete')
  295. expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
  296. const abort = new AbortController()
  297. const stream: AsyncIterator<RpcRequest<HostFrame>> =
  298. api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
  299. const removed = nextHostFrame(stream)
  300. expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
  301. expect(await removed).toMatchObject({
  302. payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
  303. })
  304. expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
  305. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  306. expect(ctx.agents.get(sessionId)).toBeDefined()
  307. expect(existsSync(workspace.path)).toBe(true)
  308. const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
  309. expect(missing.result).toMatchObject({
  310. ok: false,
  311. error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
  312. })
  313. const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
  314. expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
  315. expect(reregistered.path).toBe(workspace.path)
  316. expect(reregistered.sessionIds).toEqual([])
  317. expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
  318. abort.abort()
  319. })
  320. })