Parcourir la source

修复归档和删除 Bot 时自动清理电脑共享关系

Samien il y a 1 mois
Parent
commit
0673c72
2 fichiers modifiés avec 93 ajouts et 2 suppressions
  1. 21 2
      src/server/workspaceStore.ts
  2. 72 0
      tests/server/workspaceRoutes.test.ts

+ 21 - 2
src/server/workspaceStore.ts

@@ -8,6 +8,7 @@ import { z } from 'zod'
 import { HttpError } from './errors.js'
 import { isVisibleMessageFile } from '../shared/messageFiles.js'
 import type { StoredWorkspaceFile } from './workspaceAssets.js'
+import type { SharedComputer } from './sharedComputers.js'
 import { notificationPlainText } from './notificationText.js'
 import {readHostTools} from './hostToolSettings.js'
 import {supportsHostEnvironment,deriveComputer} from '../shared/workspace.js'
@@ -535,6 +536,7 @@ export class WorkspaceStore {
       const next = { ...agent, ...patch, revision: agent.revision + 1, updatedAt: Date.now() }
       if (patch.archived !== undefined && patch.archived !== agent.archived)
         next.teamAuthorizationVersion = (agent.teamAuthorizationVersion ?? 0) + 1
+      if (patch.archived === true) this.detachAgentComputer(owner, next)
       this.put(owner, 'agent', id, next)
       if(sourceChanged)for(const binding of this.list<{id:string}>(owner,'binding').filter(binding=>binding.id.endsWith(`:${id}`))){this.put(owner,'binding-reset',binding.id,{id:binding.id});this.remove(owner,'binding',binding.id)}
       this.event(owner, 'agent.changed', next)
@@ -588,7 +590,6 @@ export class WorkspaceStore {
       if (preview.groups.length && !confirmationToken)
         throw new HttpError(409, '此 Bot 仍是群聊成员,请确认退出群聊后再归档或删除', 'agent_in_group')
       if (agent?.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
-      if (agent?.computerEnvironmentId) throw new HttpError(409, '请先解除电脑共享,再归档或删除 Bot', 'shared_computer_active')
       if (agent && this.list<{ agentId: string; status: string }>(owner, 'turn').some(turn => turn.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(turn.status)))
         throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
       for (const conversationId of [id, ...preview.groups.map(group => group.id)]) this.requireConversationIdle(owner, conversationId)
@@ -605,17 +606,35 @@ export class WorkspaceStore {
       }
     })
   }
+  private detachAgentComputer(owner: string, agent: Agent): void {
+    const environmentId = agent.computerEnvironmentId
+    if (!environmentId) return
+    if (this.list<{ agentId: string; status: string }>(owner, 'turn').some(work => work.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(work.status)))
+      throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
+    if (this.list<{ owner: string; agentId: string; environmentId?: string; expiresAt: number }>('_system', 'computer-control').some(grant =>
+      grant.owner === owner && grant.expiresAt > Date.now() && (grant.agentId === agent.id || grant.environmentId === environmentId)))
+      throw new HttpError(409, '请先交还电脑控制权,再归档或删除 Bot', 'agent_execution_busy')
+    // Revoke only this membership; the desktop and other members keep their data.
+    const shared = this.get<SharedComputer>(owner, 'shared-computer', environmentId)
+    if (shared) {
+      shared.memberIds = shared.memberIds.filter(id => id !== agent.id)
+      if (!shared.memberIds.length) shared.archived = true
+      this.put(owner, 'shared-computer', shared.id, shared)
+    }
+    delete agent.computerEnvironmentId
+    delete agent.computerEnvironmentName
+  }
   deleteAgent(owner: string, id: string): void {
     this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
       if (!agent.archived) throw new HttpError(409, '请先归档 Bot,再删除', 'agent_not_archived')
       if (agent.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
-      if (agent.computerEnvironmentId) throw new HttpError(409, '请先解除电脑共享,再删除 Bot', 'shared_computer_active')
       const conversations = this.list<Conversation>(owner, 'conversation')
       if (conversations.some(c => c.kind === 'group' && c.memberIds.includes(id)))
         throw new HttpError(409, '此 Bot 仍是群聊成员,请先从群聊移除或删除相关群聊', 'agent_in_group')
       if (this.list<{ agentId: string; status: string }>(owner, 'turn').some(work => work.agentId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(work.status)))
         throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
+      this.detachAgentComputer(owner, agent)
       for (const conversation of conversations.filter(c => c.kind === 'direct' && c.memberIds[0] === id))
         this.deleteConversation(owner, conversation.id, true)
       for(const kind of ['routine','routine-run'])for(const item of this.list<{id:string;agentId:string}>(owner,kind).filter(item=>item.agentId===id))this.remove(owner,kind,item.id)

+ 72 - 0
tests/server/workspaceRoutes.test.ts

@@ -11,6 +11,8 @@ import { loadServerConfig } from '../../src/server/config'
 import { LocalAuthStore, type LocalUser } from '../../src/server/localAuth'
 import { WorkspaceAssets } from '../../src/server/workspaceAssets'
 import { WorkspaceTranscriptStore } from '../../src/client/components/workspace/transcriptStore'
+import { SharedComputers, type SharedComputer } from '../../src/server/sharedComputers'
+import type { WorkspaceAgent } from '../../src/shared/workspace'
 
 let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, upstream: string[], bridgeReady: boolean
 const first: LocalUser = {
@@ -49,6 +51,15 @@ function req(method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string,
     .set('X-CSRF-Token', csrf)
     .set('x-test-user', user)
 }
+function shareComputer(...agents: WorkspaceAgent[]): SharedComputer {
+  const store = runtime.workspace
+  const shared = new SharedComputers(store, runtime.auth, runtime.workspaceRuntime.nodes, runtime.runners)
+  for (const agent of agents) {
+    shared.attachLocalVm('first', agent, 'fixture-runner')
+    store.put('first', 'agent', agent.id, agent)
+  }
+  return store.require('first', 'shared-computer', agents[0]!.computerEnvironmentId!)
+}
 it('serves file-backed project and memory CRUD with revisions and account isolation', async () => {
   const a = runtime.workspace.createAgent('first', { name: '记忆甲', profile: 'default' })
   const b = runtime.workspace.createAgent('first', { name: '记忆乙', profile: 'default' })
@@ -229,10 +240,69 @@ describe('application workspace HTTP contract', () => {
     expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
     expect(store.cursor('first')).toBe(cursor)
   })
+  it.each(['archive', 'delete'] as const)('%s detaches only the selected Bot from its automatic shared computer', async action => {
+    const store = runtime.workspace
+    const bot = store.createAgent('first', { name: '待移除共享成员', profile: 'default' })
+    const other = store.createAgent('first', { name: '继续使用电脑', profile: 'default' })
+    const shared = shareComputer(bot, other)
+    const otherBefore = store.require('first', 'agent', other.id)
+    const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
+    const path = `/api/app/conversations/${direct.id}/lifecycle`
+    const preview = (await req('get', path).expect(200)).body
+    // Another member's running task must not prevent removing this idle Bot.
+    store.put('first', 'turn', 'other-running', { id: 'other-running', agentId: other.id, status: 'running' })
+    await req('post', path).send({ action, confirmationToken: preview.confirmationToken }).expect(200)
+    expect(store.require('first', 'shared-computer', shared.id)).toEqual({ ...shared, memberIds: [other.id] })
+    expect(store.require('first', 'agent', other.id)).toEqual(otherBefore)
+    if (action === 'delete') expect(store.get('first', 'agent', bot.id)).toBeUndefined()
+    else {
+      const archived = store.require<WorkspaceAgent>('first', 'agent', bot.id)
+      expect(archived.archived).toBe(true)
+      expect(archived.computerEnvironmentId).toBeUndefined()
+      expect(archived.computerEnvironmentName).toBeUndefined()
+    }
+    store.remove('first', 'turn', 'other-running')
+  })
+  it('keeps shared membership intact while the Bot is busy or its computer is under human control', async () => {
+    const store = runtime.workspace
+    const bot = store.createAgent('first', { name: '电脑成员', profile: 'default' })
+    const shared = shareComputer(bot)
+    const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)
+    const path = `/api/app/conversations/${direct.id}/lifecycle`
+    const preview = (await req('get', path).expect(200)).body
+    for (const status of ['queued', 'running', 'waiting', 'uncertain', 'cancelling']) {
+      store.put('first', 'turn', 'busy', { id: 'busy', agentId: bot.id, status })
+      expect((await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(409)).body.code).toBe('agent_running')
+    }
+    store.remove('first', 'turn', 'busy')
+    store.put('_system', 'computer-control', 'human', { owner: 'first', agentId: randomUUID(), environmentId: shared.id, expiresAt: Date.now() + 30_000 })
+    expect((await req('post', path).send({ action: 'delete', confirmationToken: preview.confirmationToken }).expect(409)).body.code).toBe('agent_execution_busy')
+    expect(store.require('first', 'shared-computer', shared.id)).toEqual(shared)
+    expect(store.require('first', 'agent', bot.id)).toEqual(bot)
+    store.remove('_system', 'computer-control', 'human')
+    await req('patch', `/api/app/agents/${bot.id}`).send({ archived: true }).expect(200)
+    expect(store.require('first', 'shared-computer', shared.id)).toMatchObject({ archived: true, memberIds: [] })
+    expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBeUndefined()
+  })
+  it.each(['manual', 'compose', 'missing'] as const)('deletes a legacy archived Bot with a %s computer binding', async mode => {
+    const store = runtime.workspace
+    const bot = store.createAgent('first', { name: '旧版归档成员', profile: 'default' })
+    const shared = shareComputer(bot)
+    const direct = store.list<any>('first', 'conversation').find(c => c.memberIds[0] === bot.id)
+    store.put('first', 'agent', bot.id, { ...bot, archived: true })
+    store.put('first', 'conversation', direct.id, { ...direct, archived: true })
+    if (mode === 'missing') store.remove('first', 'shared-computer', shared.id)
+    else store.put('first', 'shared-computer', shared.id, { ...shared, managedLocalVm: undefined, managedCompose: mode === 'compose' })
+    await req('delete', `/api/app/agents/${bot.id}`).expect(200)
+    expect(store.get('first', 'agent', bot.id)).toBeUndefined()
+    expect(store.get('first', 'conversation', direct.id)).toBeUndefined()
+    if (mode !== 'missing') expect(store.require('first', 'shared-computer', shared.id)).toMatchObject({ archived: true, memberIds: [] })
+  })
   it('rejects a stale group confirmation and rolls back membership on a later deletion failure', async () => {
     const store = runtime.workspace
     const lead = store.createAgent('first', { name: '负责人', profile: 'default' })
     const bot = store.createAgent('first', { name: '成员', profile: 'default' })
+    const shared = shareComputer(bot, lead)
     const direct = store.list<any>('first', 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === bot.id)
     const path = `/api/app/conversations/${direct.id}/lifecycle`
     const old = (await req('get', path).expect(200)).body
@@ -249,6 +319,8 @@ describe('application workspace HTTP contract', () => {
     expect(store.require<any>('first', 'conversation', group.id).memberIds).toContain(bot.id)
     expect(store.require<any>('first', 'agent', bot.id).archived).toBe(false)
     expect(store.require<any>('first', 'conversation', direct.id).archived).toBe(false)
+    expect(store.require('first', 'shared-computer', shared.id)).toEqual(shared)
+    expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBe(shared.id)
     expect(store.cursor('first')).toBe(cursor)
   })
   it('deletes an unarchived group while preserving its member Bots', async () => {