From d3286e27662eff8f69c8c2bb3662d3b63b6aefc5 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Wed, 25 Feb 2026 17:34:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=A9=BA=E9=97=B4=E5=AD=98=E6=B4=BB=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=8F=82=E6=95=B0=E5=92=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=95=B0=E6=8D=AE=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/routes/workspace/keep-file-live.ts | 114 +++++++++++++ agent/routes/workspace/keep.ts | 205 ++++------------------- package.json | 3 +- src/workspace/keep-file-live.ts | 136 +++++++++++++++ test/keep-file-live.ts | 19 +++ test/keep-test.ts | 3 +- 6 files changed, 307 insertions(+), 173 deletions(-) create mode 100644 agent/routes/workspace/keep-file-live.ts create mode 100644 src/workspace/keep-file-live.ts create mode 100644 test/keep-file-live.ts diff --git a/agent/routes/workspace/keep-file-live.ts b/agent/routes/workspace/keep-file-live.ts new file mode 100644 index 0000000..439a47d --- /dev/null +++ b/agent/routes/workspace/keep-file-live.ts @@ -0,0 +1,114 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { execSync } from 'node:child_process'; + +export type KeepAliveData = { + wsUrl: string; + cookie: string; + repo: string; + pipelineId: string; + createdTime: number; + filePath: string; + pm2Name: string; +} + +type KeepAliveCache = { + data: KeepAliveData[]; +} + +const keepAliveFilePath = path.join(os.homedir(), '.cnb/keepAliveCache.json'); + +export const runLive = (filePath: string, pm2Name: string) => { + // 使用 npx 运行命令 + const cmdArgs = `cnb live -c ${filePath}`; + + // 先停止已存在的同名 pm2 进程 + const stopCmd = `pm2 delete ${pm2Name} 2>/dev/null || true`; + console.log('停止已存在的进程:', stopCmd); + try { + execSync(stopCmd, { stdio: 'inherit' }); + } catch (error) { + console.log('停止进程失败或进程不存在:', error); + } + + // 使用pm2启动 + const pm2Cmd = `pm2 start ev --name ${pm2Name} --no-autorestart -- ${cmdArgs}`; + console.log('执行命令:', pm2Cmd); + try { + const result = execSync(pm2Cmd, { stdio: 'pipe', encoding: 'utf8' }); + console.log(result); + } catch (error) { + console.error("状态码:", error.status); + console.error("错误详情:", error.stderr.toString()); // 这里会显示 ev 命令报的具体错误 + } +} + +export const stopLive = (pm2Name: string): boolean => { + const stopCmd = `pm2 delete ${pm2Name} 2>/dev/null || true`; + console.log('停止进程:', stopCmd); + try { + execSync(stopCmd, { stdio: 'inherit' }); + console.log(`已停止 ${pm2Name} 的保持存活任务`); + return true; + } catch (error) { + console.error('停止进程失败:', error); + } + return false; +} + +export function getKeepAliveCache(): KeepAliveCache { + try { + if (fs.existsSync(keepAliveFilePath)) { + const data = fs.readFileSync(keepAliveFilePath, 'utf-8'); + const cache = JSON.parse(data) as KeepAliveCache; + return cache; + } else { + return { data: [] }; + } + } catch (error) { + console.error('读取保持存活缓存文件失败:', error); + return { data: [] }; + } +} + +export function addKeepAliveData(data: KeepAliveData): KeepAliveCache { + const cache = getKeepAliveCache(); + cache.data.push(data); + runLive(data.filePath, data.pm2Name); + try { + if (!fs.existsSync(path.dirname(keepAliveFilePath))) { + fs.mkdirSync(path.dirname(keepAliveFilePath), { recursive: true }); + } + fs.writeFileSync(keepAliveFilePath, JSON.stringify(cache, null, 2), 'utf-8'); + return cache; + } catch (error) { + console.error('写入保持存活缓存文件失败:', error); + return { data: [] }; + } +} + +export function removeKeepAliveData(repo: string, pipelineId: string): KeepAliveCache { + const cache = getKeepAliveCache(); + cache.data = cache.data.filter(item => item.repo !== repo || item.pipelineId !== pipelineId); + try { + fs.writeFileSync(keepAliveFilePath, JSON.stringify(cache, null, 2), 'utf-8'); + return cache; + } catch (error) { + console.error('写入保持存活缓存文件失败:', error); + return { data: [] }; + } +} + +export const createLiveData = (data: { wsUrl: string, cookie: string, repo: string, pipelineId: string }): KeepAliveData => { + const { wsUrl, cookie, repo, pipelineId } = data; + const createdTime = Date.now(); + const pm2Name = `${repo}__${pipelineId}`.replace(/\//g, '__'); + const filePath = path.join(os.homedir(), '.cnb', `${pm2Name}.json`); + const _newData = { wss: wsUrl, wsUrl, cookie, repo, pipelineId, createdTime, filePath, pm2Name }; + if (!fs.existsSync(path.dirname(filePath))) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(_newData, null, 2), 'utf-8'); + return _newData; +} \ No newline at end of file diff --git a/agent/routes/workspace/keep.ts b/agent/routes/workspace/keep.ts index cafa14f..009334e 100644 --- a/agent/routes/workspace/keep.ts +++ b/agent/routes/workspace/keep.ts @@ -1,214 +1,77 @@ -import { createSkill, tool } from '@kevisual/router'; +import { tool } from '@kevisual/router'; import { app, cnb } from '../../app.ts'; -import { nanoid } from 'nanoid'; -import dayjs from 'dayjs'; -import { createKeepAlive } from '../../../src/keep.ts'; - -type AliveInfo = { - startTime: number; - updatedTime?: number; - KeepAlive: ReturnType; - id: string;// 6位唯一标识符 -} - -const keepAliveMap = new Map(); +import { addKeepAliveData, KeepAliveData, removeKeepAliveData, createLiveData } from '../../../src/workspace/keep-file-live.ts'; // 保持工作空间存活技能 app.route({ path: 'cnb', key: 'keep-workspace-alive', - description: '保持工作空间存活技能,参数wsUrl:工作空间访问URL,cookie:访问工作空间所需的cookie', + description: '保持工作空间存活技能,参数repo:代码仓库路径,例如 user/repo,pipelineId:流水线ID,例如 cnb-708-1ji9sog7o-001', middleware: ['admin-auth'], metadata: { tags: [], ...({ args: { - wsUrl: tool.schema.string().describe('工作空间的访问URL'), - cookie: tool.schema.string().describe('访问工作空间所需的cookie') + repo: tool.schema.string().describe('代码仓库路径,例如 user/repo'), + pipelineId: tool.schema.string().describe('流水线ID,例如 cnb-708-1ji9sog7o-001'), } }) } }).define(async (ctx) => { - const wsUrl = ctx.query?.wsUrl as string; - const cookie = ctx.query?.cookie as string; - if (!wsUrl) { - ctx.throw(400, '缺少工作空间访问URL参数'); - } - if (!cookie) { - ctx.throw(400, '缺少访问工作空间所需的cookie参数'); - } + const repo = ctx.query?.repo as string; + const pipelineId = ctx.query?.pipelineId as string; - // 检测是否已在运行(通过 wsUrl 遍历检查) - const existing = Array.from(keepAliveMap.values()).find(info => (info as AliveInfo).id && (info as any).KeepAlive?.wsUrl === wsUrl); - if (existing) { - ctx.body = { message: `工作空间 ${wsUrl} 的保持存活任务已在运行中`, id: (existing as AliveInfo).id }; - return; + if (!repo || !pipelineId) { + ctx.throw(400, '缺少参数 repo 或 pipelineId'); + } + const validCookie = await cnb.user.checkCookieValid() + if (validCookie.code !== 200) { + ctx.throw(401, 'CNB_COOKIE 环境变量无效或已过期,请重新登录获取新的cookie'); + } + const res = await cnb.workspace.getWorkspaceCookie(repo, pipelineId); + let wsUrl = `wss://${pipelineId}.cnb.space:443?skipWebSocketFrames=false`; + let cookie = ''; + if (res.code === 200) { + cookie = res.data.value; + console.log(`启动保持工作空间 ${wsUrl} 存活的任务`); + } else { + ctx.throw(500, `获取工作空间访问cookie失败: ${res.message}`); } console.log(`启动保持工作空间 ${wsUrl} 存活的任务`); - const keep = createKeepAlive({ - wsUrl, - cookie, - onConnect: () => { - console.log(`工作空间 ${wsUrl} 保持存活任务已连接`); - }, - onMessage: (data) => { - // 可选:处理收到的消息 - // console.log(`工作空间 ${wsUrl} 收到消息: ${data}`); - // 通过 wsUrl 找到对应的 id 并更新时间 - for (const info of keepAliveMap.values()) { - if ((info as any).KeepAlive?.wsUrl === wsUrl) { - info.updatedTime = Date.now(); - break; - } - } - }, - debug: true, - onExit: (code) => { - console.log(`工作空间 ${wsUrl} 保持存活任务已退出,退出码: ${code}`); - // 通过 wsUrl 找到对应的 id 并删除 - for (const [id, info] of keepAliveMap.entries()) { - if ((info as any).KeepAlive?.wsUrl === wsUrl) { - keepAliveMap.delete(id); - break; - } - } - } - }); - const id = nanoid(6).toLowerCase(); - keepAliveMap.set(id, { startTime: Date.now(), updatedTime: Date.now(), KeepAlive: keep, id }); + const config: KeepAliveData = createLiveData({ wsUrl, cookie, repo, pipelineId }); + addKeepAliveData(config); - ctx.body = { content: `已启动保持工作空间 ${wsUrl} 存活的任务`, id }; -}).addTo(app); - -// 获取保持工作空间存活任务列表技能 -app.route({ - path: 'cnb', - key: 'list-keep-alive-tasks', - description: '获取保持工作空间存活任务列表技能', - middleware: ['admin-auth'], - metadata: { - tags: [], - } -}).define(async (ctx) => { - const list = Array.from(keepAliveMap.entries()).map(([id, info]) => { - const now = Date.now(); - const duration = Math.floor((now - info.startTime) / 60000); // 分钟 - return { - id, - wsUrl: (info as any).KeepAlive?.wsUrl, - startTime: info.startTime, - startTimeStr: dayjs(info.startTime).format('YYYY-MM-DD HH:mm'), - updatedTime: info.updatedTime, - updatedTimeStr: dayjs(info.updatedTime).format('YYYY-MM-DD HH:mm'), - duration, - } - }); - ctx.body = { list }; + ctx.body = { content: `已启动保持工作空间 ${wsUrl} 存活的任务`, data: config }; }).addTo(app); // 停止保持工作空间存活技能 app.route({ path: 'cnb', key: 'stop-keep-workspace-alive', - description: '停止保持工作空间存活技能, 参数wsUrl:工作空间访问URL或者id', + description: '停止保持工作空间存活技能, 参数repo:代码仓库路径,例如 user/repo,pipelineId:流水线ID,例如 cnb-708-1ji9sog7o-001', middleware: ['admin-auth'], metadata: { tags: [], ...({ args: { - wsUrl: tool.schema.string().optional().describe('工作空间的访问URL'), - id: tool.schema.string().optional().describe('保持存活任务的唯一标识符'), + repo: tool.schema.string().describe('代码仓库路径,例如 user/repo'), + pipelineId: tool.schema.string().describe('流水线ID,例如 cnb-708-1ji9sog7o-001'), } }) } }).define(async (ctx) => { - const wsUrl = ctx.query?.wsUrl as string; - const id = ctx.query?.id as string; - if (!wsUrl && !id) { - ctx.throw(400, '缺少工作空间访问URL参数或唯一标识符'); - } + const repo = ctx.query?.repo as string; + const pipelineId = ctx.query?.pipelineId as string; - let targetId: string | undefined; - let wsUrlFound: string | undefined; - - if (id) { - const info = keepAliveMap.get(id); - if (info) { - targetId = id; - wsUrlFound = (info as any).KeepAlive?.wsUrl; - } - } else if (wsUrl) { - for (const [key, info] of keepAliveMap.entries()) { - if ((info as any).KeepAlive?.wsUrl === wsUrl) { - targetId = key; - wsUrlFound = wsUrl; - break; - } - } - } - - if (targetId) { - const keepAlive = keepAliveMap.get(targetId); - const endTime = Date.now(); - const duration = endTime - keepAlive!.startTime; - keepAlive?.KeepAlive?.disconnect(); - keepAliveMap.delete(targetId); - ctx.body = { content: `已停止保持工作空间 ${wsUrlFound} 存活的任务,持续时间: ${duration}ms`, id: targetId }; - } else { - ctx.body = { content: `没有找到对应的工作空间保持存活任务` }; + if (!repo || !pipelineId) { + ctx.throw(400, '缺少参数 repo 或 pipelineId'); } + removeKeepAliveData(repo, pipelineId); + ctx.body = { content: `已停止保持工作空间 ${repo}/${pipelineId} 存活的任务` }; }).addTo(app); -app.route({ - path: 'cnb', - key: 'reset-keep-workspace-alive', - description: '对存活的工作空间,startTime进行重置', - middleware: ['admin-auth'], - metadata: { - tags: [], - } -}).define(async (ctx) => { - const now = Date.now(); - for (const info of keepAliveMap.values()) { - info.startTime = now; - } - ctx.body = { content: `已重置所有存活工作空间的开始时间` }; -}).addTo(app); -app.route({ - path: 'cnb', - key: 'clear-keep-workspace-alive', - description: '对存活的工作空间,超过5小时的进行清理', - middleware: ['admin-auth'], - metadata: { - tags: [], - } -}).define(async (ctx) => { - const res = clearKeepAlive(); - ctx.body = { - content: `已清理所有存活工作空间中超过5小时的任务` + (res.length ? `,清理项:${res.map(i => i.wsUrl).join(', ')}` : ''), - list: res - }; -}).addTo(app); -const clearKeepAlive = () => { - const now = Date.now(); - let clearedArr: { id: string; wsUrl: string }[] = []; - for (const [id, info] of keepAliveMap.entries()) { - if (now - info.startTime > FIVE_HOURS) { - console.log(`工作空间 ${(info as any).KeepAlive?.wsUrl} 超过5小时,自动停止`); - info.KeepAlive?.disconnect?.(); - keepAliveMap.delete(id); - clearedArr.push({ id, wsUrl: (info as any).KeepAlive?.wsUrl }); - } - } - return clearedArr; -} -// 每5小时自动清理超时的keepAlive任务 -const FIVE_HOURS = 5 * 60 * 60 * 1000; -setInterval(() => { - clearKeepAlive(); -}, FIVE_HOURS); \ No newline at end of file diff --git a/package.json b/package.json index 13ddb20..32df798 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kevisual/cnb", - "version": "0.0.29", + "version": "0.0.30", "description": "", "main": "index.js", "scripts": { @@ -52,6 +52,7 @@ "./opencode": "./dist/opencode.js", "./keep": "./dist/keep.js", "./keep.ts": "./src/keep.ts", + "./keep-file-live.ts": "./src/workspace/keep-file-live.ts", "./routes": "./dist/routes.js", "./src/*": "./src/*", "./agent/*": "./agent/*" diff --git a/src/workspace/keep-file-live.ts b/src/workspace/keep-file-live.ts new file mode 100644 index 0000000..95bf286 --- /dev/null +++ b/src/workspace/keep-file-live.ts @@ -0,0 +1,136 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { execSync } from 'node:child_process'; + +export type KeepAliveData = { + wsUrl: string; + cookie: string; + repo: string; + pipelineId: string; + createdTime: number; + filePath: string; + pm2Name: string; +} + +type KeepAliveCache = { + data: KeepAliveData[]; +} + +const keepAliveFilePath = path.join(os.homedir(), '.cnb/keepAliveCache.json'); + +export const runLive = (filePath: string, pm2Name: string) => { + // 使用 npx 运行命令 + const cmdArgs = `cnb live -c ${filePath}`; + + // 先停止已存在的同名 pm2 进程 + const stopCmd = `pm2 delete ${pm2Name} 2>/dev/null || true`; + console.log('停止已存在的进程:', stopCmd); + try { + execSync(stopCmd, { stdio: 'inherit' }); + } catch (error) { + console.log('停止进程失败或进程不存在:', error); + } + + // 使用pm2启动 + const pm2Cmd = `pm2 start ev --name ${pm2Name} --no-autorestart -- ${cmdArgs}`; + console.log('执行命令:', pm2Cmd); + try { + const result = execSync(pm2Cmd, { stdio: 'pipe', encoding: 'utf8' }); + console.log(result); + } catch (error) { + console.error("状态码:", error.status); + console.error("错误详情:", error.stderr.toString()); // 这里会显示 ev 命令报的具体错误 + } +} + +export const stopLive = (pm2Name: string): boolean => { + const stopCmd = `pm2 delete ${pm2Name} 2>/dev/null || true`; + console.log('停止进程:', stopCmd); + try { + execSync(stopCmd, { stdio: 'inherit' }); + console.log(`已停止 ${pm2Name} 的保持存活任务`); + return true; + } catch (error) { + console.error('停止进程失败:', error); + } + return false; +} + +export function getKeepAliveCache(): KeepAliveCache { + try { + if (fs.existsSync(keepAliveFilePath)) { + const data = fs.readFileSync(keepAliveFilePath, 'utf-8'); + const cache = JSON.parse(data) as KeepAliveCache; + return cache; + } else { + return { data: [] }; + } + } catch (error) { + console.error('读取保持存活缓存文件失败:', error); + return { data: [] }; + } +} + +export function addKeepAliveData(data: KeepAliveData): KeepAliveCache { + const cache = getKeepAliveCache(); + cache.data.push(data); + runLive(data.filePath, data.pm2Name); + try { + if (!fs.existsSync(path.dirname(keepAliveFilePath))) { + fs.mkdirSync(path.dirname(keepAliveFilePath), { recursive: true }); + } + fs.writeFileSync(keepAliveFilePath, JSON.stringify(cache, null, 2), 'utf-8'); + return cache; + } catch (error) { + console.error('写入保持存活缓存文件失败:', error); + return { data: [] }; + } +} + +export function removeKeepAliveData(repo: string, pipelineId: string): KeepAliveCache { + const cache = getKeepAliveCache(); + const item = cache.data.find(item => item.repo === repo && item.pipelineId === pipelineId); + if (item) { + stopLive(item.pm2Name); + } + cache.data = cache.data.filter(item => item.repo !== repo || item.pipelineId !== pipelineId); + try { + fs.writeFileSync(keepAliveFilePath, JSON.stringify(cache, null, 2), 'utf-8'); + return cache; + } catch (error) { + console.error('写入保持存活缓存文件失败:', error); + return { data: [] }; + } +} + +export const createLiveData = (data: { wsUrl: string, cookie: string, repo: string, pipelineId: string }): KeepAliveData => { + const { wsUrl, cookie, repo, pipelineId } = data; + const createdTime = Date.now(); + const pm2Name = `${repo}__${pipelineId}`.replace(/\//g, '__'); + const filePath = path.join(os.homedir(), '.cnb', `${pm2Name}.json`); + const _newData = { wss: wsUrl, wsUrl, cookie, repo, pipelineId, createdTime, filePath, pm2Name }; + if (!fs.existsSync(path.dirname(filePath))) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(_newData, null, 2), 'utf-8'); + return _newData; +} + +export class KeepAliveManager { + static getCache() { + return getKeepAliveCache(); + } + + static add(data: KeepAliveData) { + return addKeepAliveData(data); + } + + static createLiveData(data: { wsUrl: string, cookie: string, repo: string, pipelineId: string }): KeepAliveData { + return createLiveData(data); + } + + static remove(repo: string, pipelineId: string) { + return removeKeepAliveData(repo, pipelineId); + } +} \ No newline at end of file diff --git a/test/keep-file-live.ts b/test/keep-file-live.ts new file mode 100644 index 0000000..280af1a --- /dev/null +++ b/test/keep-file-live.ts @@ -0,0 +1,19 @@ +import { addKeepAliveData, createLiveData, getKeepAliveCache } from '../agent/routes/workspace/keep-file-live'; + +const repo = 'kevisual/dev-env'; +const pipelineId = 'cnb-708-1ji9sog7o-001'; + +const testData = createLiveData({ + wsUrl: "wss://cnb-708-1ji9sog7o-001.cnb.space:443?skipWebSocketFrames=false", + cookie: "orange:workspace:cookie-session:cnb-708-1ji9sog7o-001=3dc03d84-5617-4e44-a6b9-38ce4398aea5", + repo: repo, + pipelineId: pipelineId +}); + +addKeepAliveData(testData); + +// 运行后可以在 ~/.cnb/kevisual_dev-env_cnb-708-1ji9sog7o-001.json 中看到保持存活的数据 +// 同时可以通过 pm2 list 命令看到对应的保持存活的进程 + +// 注意:如果要测试停止保持存活,可以调用 stopLive(testData.pm2Name) 来停止对应的进程 +// 例如:stopLive('kevisual_dev-env_cnb-708-1ji9sog7o-001'); \ No newline at end of file diff --git a/test/keep-test.ts b/test/keep-test.ts index d050c2e..069ae74 100644 --- a/test/keep-test.ts +++ b/test/keep-test.ts @@ -12,4 +12,5 @@ createKeepAlive({ wsUrl: config.wss, cookie: config.cookie, debug: true, -}); \ No newline at end of file +}); +