diff --git a/app/service/agents.js b/app/service/agents.js index d749817..99f86ce 100644 --- a/app/service/agents.js +++ b/app/service/agents.js @@ -1010,20 +1010,27 @@ class AgentsService extends Service { }; } - // 从 Git 仓库导入或更新 Agent - async importAgentFromGit(gitUrl, gitBranch = 'master', category = null) { + // 从 Git 仓库导入或更新 Agent,支持重命名迁移 + async importAgentFromGit(gitUrl, gitBranch = 'master', category = null, options = {}) { await this.ensureStorageReady(); + const { currentAgent } = options; const { cleanGitUrl, cloneGitUrl, targetBranch, repoName } = this.normalizeGitSource( gitUrl, gitBranch ); - // 并发同步锁:若当前仓库正在同步中,阻止并发执行以避免 index.lock 冲突 - if (activeSyncRepos.has(repoName)) { - this.ctx.throw(409, `Agent「${repoName}」正在同步中,请稍后再试`); + // 并发同步锁:锁定当前仓库及原 Agent 名称,防止并发冲突 + const lockNames = [repoName]; + if (currentAgent?.name && currentAgent.name !== repoName) { + lockNames.push(currentAgent.name); } - activeSyncRepos.add(repoName); + for (const lockName of lockNames) { + if (activeSyncRepos.has(lockName)) { + this.ctx.throw(409, `Agent「${lockName}」正在同步中,请稍后再试`); + } + } + lockNames.forEach((lockName) => activeSyncRepos.add(lockName)); try { const storageDir = this.getAgentMarketConfig().storageDir; @@ -1204,7 +1211,35 @@ class AgentsService extends Service { // 5. 数据库持久化 const { Agent, AgentSkill } = this.app.model; - const existing = await Agent.findOne({ where: { name: validated.name } }); + const { Op } = this.app.Sequelize; + + let existing = null; + const isRenaming = Boolean(currentAgent && currentAgent.name !== validated.name); + + if (isRenaming) { + // 检查新名称是否已被其他记录占用(包含已删除记录,规避 uk_agents_name 唯一索引冲突) + const conflictAgent = await Agent.findOne({ + where: { + name: validated.name, + id: { [Op.ne]: currentAgent.id }, + }, + }); + if (conflictAgent) { + this.ctx.throw( + 400, + `Agent 重命名失败:新名称「${validated.name}」已被${ + conflictAgent.is_delete === 0 ? '已有 Agent' : '已删除记录' + }占用` + ); + } + existing = currentAgent; + } else { + existing = await Agent.findOne({ where: { name: validated.name } }); + // 若传入当前 Agent 且名称一致,优先复用当前 Agent 记录 + if (!existing && currentAgent && currentAgent.name === validated.name) { + existing = currentAgent; + } + } let agentId = existing ? existing.id : null; @@ -1215,7 +1250,8 @@ class AgentsService extends Service { : existing?.category || validated.category || '工程效率'; const now = new Date(); - const isContentChanged = !existing || existing.content_hash !== contentHash; + const isContentChanged = + !existing || existing.content_hash !== contentHash || isRenaming; const agentPayload = { name: validated.name, @@ -1278,6 +1314,7 @@ class AgentsService extends Service { agentId = created.id; } else { await Agent.update(agentPayload, { where: { id: existing.id }, transaction }); + agentId = existing.id; } // 提取 skills 目录下的 SKILL.md @@ -1316,21 +1353,32 @@ class AgentsService extends Service { } }); - // 清理旧版本的归档目录 - if (existing && existing.content_hash && existing.content_hash !== contentHash) { + // 清理旧资源目录:重命名时清理原 Agent 存储与临时仓库目录,非重命名清理旧 hash 归档 + if (isRenaming) { + const oldStorageDir = path.join(storageDir, currentAgent.name); + const oldRepoDir = path.join(reposDir, currentAgent.name); + try { + this.removeDirectory(oldStorageDir); + this.removeDirectory(oldRepoDir); + } catch (cleanErr) { + this.ctx.logger.warn(`[agents] 重命名清理原目录失败: ${cleanErr.message}`); + } + } else if (existing && existing.content_hash && existing.content_hash !== contentHash) { this.removeDirectory(path.join(storageDir, validated.name, existing.content_hash)); } return { id: agentId, name: validated.name, + oldName: isRenaming ? currentAgent.name : validated.name, + renamed: isRenaming, version: validated.version, updated: !!existing, contentHash, isContentChanged, }; } finally { - activeSyncRepos.delete(repoName); + lockNames.forEach((lockName) => activeSyncRepos.delete(lockName)); } } @@ -1355,10 +1403,13 @@ class AgentsService extends Service { const result = await this.importAgentFromGit( agent.git_url, agent.git_branch || 'master', - agent.category + agent.category, + { currentAgent: agent } ); return { - name: agent.name, + name: result.name, + oldName: agent.name, + renamed: result.renamed, success: true, version: result.version, contentHash: result.contentHash, @@ -1385,10 +1436,13 @@ class AgentsService extends Service { const result = await this.importAgentFromGit( agent.git_url, agent.git_branch, - agent.category + agent.category, + { currentAgent: agent } ); results.push({ - name: agent.name, + name: result.name, + oldName: agent.name, + renamed: result.renamed, success: true, version: result.version, contentHash: result.contentHash, @@ -1421,8 +1475,30 @@ class AgentsService extends Service { if (gitUrl !== undefined) { const rawUrl = String(gitUrl || '').trim(); if (rawUrl) { - const { cleanGitUrl: normalizedUrl, targetBranch: normalizedBranch } = - this.normalizeGitSource(rawUrl, gitBranch || agent.git_branch); + const { + cleanGitUrl: normalizedUrl, + targetBranch: normalizedBranch, + repoName, + } = this.normalizeGitSource(rawUrl, gitBranch || agent.git_branch); + + // 若修改后的仓库名称与当前 Agent 不一致,前置检查是否已被其他记录占用 + if (repoName !== agent.name) { + const conflictAgent = await this.app.model.Agent.findOne({ + where: { + name: repoName, + id: { [this.app.Sequelize.Op.ne]: agent.id }, + }, + }); + if (conflictAgent) { + this.ctx.throw( + 400, + `Git 仓库对应名称「${repoName}」已被${ + conflictAgent.is_delete === 0 ? '已有 Agent' : '已删除记录' + }占用` + ); + } + } + updates.git_url = normalizedUrl; if (gitBranch === undefined) { updates.git_branch = normalizedBranch; @@ -1453,10 +1529,13 @@ class AgentsService extends Service { const syncResult = await this.importAgentFromGit( agent.git_url, agent.git_branch || 'master', - agent.category + agent.category, + { currentAgent: agent } ); return { - name: agent.name, + name: syncResult.name, + oldName: agent.name, + renamed: syncResult.renamed, gitUrl: agent.git_url, gitBranch: agent.git_branch, category: agent.category, diff --git a/app/web/pages/agents/detail/AgentDetailContent.tsx b/app/web/pages/agents/detail/AgentDetailContent.tsx index 1237204..bdb1470 100644 --- a/app/web/pages/agents/detail/AgentDetailContent.tsx +++ b/app/web/pages/agents/detail/AgentDetailContent.tsx @@ -142,6 +142,12 @@ const AgentDetailContent: React.FC = ({ name, history } message.success('配置保存成功'); setSettingVisible(false); + // 若配置保存并发生重命名,重定向至新路由 + const newConfigName = response.data?.name; + if (newConfigName && newConfigName !== detail.name) { + history.push(`/page/agents/${encodeURIComponent(newConfigName)}`); + return; + } const detailRes = await API.getAgentDetail({ name }); if (detailRes.success) { setDetail(detailRes.data as AgentDetail); @@ -163,6 +169,13 @@ const AgentDetailContent: React.FC = ({ name, history } message.error(res.msg || '同步失败'); return; } + // 若远端仓库改名发生 Agent 重命名迁移,提示并自动平滑重定向至新详情页 + const newAgentName = res.data?.name; + if (newAgentName && newAgentName !== detail.name) { + message.success(`Agent 名称已更新为「${newAgentName}」,正在跳转...`); + history.push(`/page/agents/${encodeURIComponent(newAgentName)}`); + return; + } // 根据远端代码是否发生变更给出差异化反馈 if (res.data?.isContentChanged) { message.success('同步成功,已更新至最新代码'); diff --git a/test/agent-git-rename.test.js b/test/agent-git-rename.test.js new file mode 100644 index 0000000..63c8453 --- /dev/null +++ b/test/agent-git-rename.test.js @@ -0,0 +1,185 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const AgentsService = require('../app/service/agents'); + +function createService() { + const service = Object.create(AgentsService.prototype); + service.ctx = { + logger: { + info() {}, + warn() {}, + error() {}, + }, + throw(status, message) { + const error = new Error(message); + error.status = status; + throw error; + }, + }; + service.app = { + config: { + agentMarket: { + storageDir: '/data/doraemon/agent-market', + }, + }, + Sequelize: { + Op: { + ne: Symbol('ne'), + }, + }, + }; + return service; +} + +test('updateAgentGitConfig 修改为已被其他 Agent 占用的仓库名时直接拦截抛错', async () => { + const service = createService(); + const currentAgent = { + id: 1, + name: 'operator-register-agent', + git_url: 'http://gitlab.prod.dtstack.cn/test/operator-register-agent.git', + git_branch: 'master', + category: '工程效率', + update: async () => {}, + }; + + service.app.model = { + Agent: { + async findOne({ where }) { + if (where.name === 'operator-register-agent') { + return currentAgent; + } + if (where.name === 'conflict-agent') { + return { id: 2, name: 'conflict-agent', is_delete: 0 }; + } + return null; + }, + }, + }; + + await assert.rejects( + () => + service.updateAgentGitConfig({ + name: 'operator-register-agent', + gitUrl: 'http://gitlab.prod.dtstack.cn/test/conflict-agent.git', + }), + /已被已有 Agent占用/ + ); +}); + +test('syncGitAgentByName 支持识别平滑重命名并返回新旧名称', async () => { + const service = createService(); + const currentAgent = { + id: 10, + name: 'operator-register-agent', + git_url: 'http://gitlab.prod.dtstack.cn/test/operator-registration-agent.git', + git_branch: 'master', + category: '工程效率', + }; + + service.app.model = { + Agent: { + async findOne({ where }) { + if (where.name === 'operator-register-agent') { + return currentAgent; + } + return null; + }, + }, + }; + + // mock importAgentFromGit + service.importAgentFromGit = async (gitUrl, gitBranch, category, options) => { + assert.equal(options.currentAgent.id, 10); + assert.equal(options.currentAgent.name, 'operator-register-agent'); + return { + id: 10, + name: 'operator-registration-agent', + oldName: 'operator-register-agent', + renamed: true, + version: '1.0.1', + contentHash: 'hash-new', + isContentChanged: true, + }; + }; + + const res = await service.syncGitAgentByName('operator-register-agent'); + assert.equal(res.name, 'operator-registration-agent'); + assert.equal(res.oldName, 'operator-register-agent'); + assert.equal(res.renamed, true); + assert.equal(res.success, true); +}); + +test('importAgentFromGit 遇到名称冲突时阻止重命名', async () => { + const service = createService(); + const currentAgent = { + id: 1, + name: 'old-agent', + }; + + service.ensureStorageReady = async () => {}; + service.normalizeGitSource = () => ({ + cleanGitUrl: 'http://gitlab.prod.dtstack.cn/test/occupied-agent.git', + cloneGitUrl: 'http://gitlab.prod.dtstack.cn/test/occupied-agent.git', + targetBranch: 'master', + repoName: 'occupied-agent', + }); + service.getGitAuthArgs = () => []; + service.runGitCommand = async (args) => { + if (args.includes('rev-parse')) return { stdout: 'hash123\n' }; + return { stdout: '' }; + }; + service.validateCodexManifest = () => ({ + name: 'occupied-agent', + displayName: 'Occupied', + version: '1.0.0', + description: 'desc', + authorName: 'author', + }); + service.validateClaudeManifest = () => ({ + name: 'occupied-agent', + version: '1.0.0', + }); + + const tmpStorage = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-rename-test-')); + service.getAgentMarketConfig = () => ({ storageDir: tmpStorage }); + + const targetDir = path.join(tmpStorage, 'git_repos', 'occupied-agent'); + fs.mkdirSync(path.join(targetDir, '.codex-plugin'), { recursive: true }); + fs.mkdirSync(path.join(targetDir, '.claude-plugin'), { recursive: true }); + fs.writeFileSync(path.join(targetDir, '.codex-plugin', 'plugin.json'), '{}'); + fs.writeFileSync(path.join(targetDir, '.claude-plugin', 'plugin.json'), '{}'); + + service.parseCodexPluginJson = () => ({}); + service.parseClaudePluginJson = () => ({}); + + service.app.model = { + Agent: { + async findOne({ where }) { + if (where.name === 'occupied-agent') { + // 已被 id=2 的记录占用 + return { id: 2, name: 'occupied-agent', is_delete: 0 }; + } + return null; + }, + }, + }; + + try { + await assert.rejects( + () => + service.importAgentFromGit( + 'http://gitlab.prod.dtstack.cn/test/occupied-agent.git', + 'master', + '工程效率', + { currentAgent } + ), + /已被已有 Agent占用/ + ); + } finally { + fs.rmSync(tmpStorage, { recursive: true, force: true }); + } +});