From 4cc17d93c5a59c770e0d012f038f0b5096dbe43d Mon Sep 17 00:00:00 2001 From: Ning Wei <55093136@qq.com> Date: Wed, 5 Aug 2026 18:05:00 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=85=B3=E9=94=AE=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E6=9D=A5=E6=BA=90=E7=BB=9F=E4=B8=80=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docker/README.md | 5 +- docker/docs/environment-variables.md | 6 +- docker/sample/env.json | 1 - .../capabilities/apps/service.ts | 307 ++++------------ .../capabilities/catalog/snapshot.ts | 31 +- .../business-system-manager.test.ts | 174 ++++----- .../capabilities-registry.test.ts | 25 +- .../server/src/api/routes/index.js | 2 +- .../server/src/app.js | 2 +- .../server/src/db/index.js | 335 ++++++------------ .../src/services/catalog-sync-service.js | 59 +-- .../services/runtime-env-config-service.js | 115 ++++-- .../server/src/services/system-service.js | 45 +-- .../server/test/catalog-sync-service.test.js | 29 +- .../server/test/db-reset-migration.test.js | 108 +++++- .../server/test/external-service.test.js | 5 +- .../test/runtime-env-config-service.test.js | 135 ++++++- .../test/runtime-settings-route.test.js | 2 +- .../test/system-service-alignment.test.js | 64 +++- .../src/pages/SettingsPage.jsx | 10 +- .../test/integration/full.js | 11 + test/case/stress/stress-flow.js | 1 - test/prepare/stress/rebuild-stress-env.js | 36 +- 24 files changed, 698 insertions(+), 812 deletions(-) diff --git a/README.md b/README.md index 190da84..dad1692 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ http://127.0.0.1:3030 ## 步骤4:接入业务系统 -首先,在管理控制台 `系统设置` 页面中,将业务系统和对话应用所在的服务器 IP 地址(对话应用和业务系统需要部署在同一台服务器中)设置到 `对话应用/业务应用服务器`。因为修改后该配置后,系统会同步调整 IP 白名单,需重启容器后生效。 +首先,在管理控制台 `系统设置` 页面中,将业务系统和对话应用所在的服务器 IP 地址或域名(对话应用和业务系统需要部署在同一台服务器中)设置到 `对话应用/业务应用服务器`。服务器地址保存到系统数据库后会立即用于业务调用;IP 地址会同步到 IP 白名单,白名单变更需重启容器后生效;域名按外网地址处理,不加入白名单。 然后,在“业务系统”菜单中添加接入的业务系统。活字格系统通常需要填写: diff --git a/docker/README.md b/docker/README.md index 360c0b5..240f51c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -47,8 +47,7 @@ docker start daedalus-agent ```json { "DA_MANAGEMENT_CONSOLE_PORT": 3030, - "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 16, - "DA_HUOZIGE_SERVER_IP": "" + "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 16 } ``` @@ -120,7 +119,7 @@ daedalus-agent-mqtt-channel-chat-{yyyyMMdd}.log 顶层脚本守护 MQTT、MinIO、OpenClaw supervisor 和 apps supervisor。apps supervisor 内部以 `daedalus-svc` 守护 management web 和 `daedalus-agent-apps-invoke-cli serve`;OpenClaw supervisor 内部守护 OpenClaw gateway。 -对话应用/业务应用所在的活字格服务器地址通过管理控制台“系统设置 -> 基础设置”统一配置。修改后管理控制台会同步维护 `DA_INTERNAL_NETWORK_ALLOWLIST`:移除旧地址并加入新地址;白名单变更需重启容器后生效。OpenClaw/agent 侧仍只执行 CLI client,并连接固定内部 socket `/var/daedalus-agent/run/app-invoke.sock`;真实业务系统访问由 `daedalus-svc` 运行的 invoke service 完成。 +对话应用/业务应用所在的活字格服务器地址通过管理控制台“系统设置 -> 基础设置”统一配置,并保存到 management web 的 SQLite 数据库;修改后会立即用于业务调用。IP 地址会同步维护到 `DA_INTERNAL_NETWORK_ALLOWLIST`,白名单变更需重启容器后生效;域名按外网地址处理,不加入白名单。旧版本 `env.json` 中的 `DA_HUOZIGE_SERVER_IP` 会在升级后的首次启动时迁入数据库并从文件中删除。OpenClaw/agent 侧仍只执行 CLI client,并连接固定内部 socket `/var/daedalus-agent/run/app-invoke.sock`;真实业务系统访问由 `daedalus-svc` 运行的 invoke service 完成。 ## 验收与压测 diff --git a/docker/docs/environment-variables.md b/docker/docs/environment-variables.md index 2c33195..70a85d1 100644 --- a/docker/docs/environment-variables.md +++ b/docker/docs/environment-variables.md @@ -8,8 +8,7 @@ DAEDALUS_AGENT Docker 支持通过 `/var/daedalus-agent/env.json` 配置少量 | --- | --- | --- | | `DA_MANAGEMENT_CONSOLE_PORT` | `3030` | management web 监听端口。建议宿主机映射整个 `3030-3050` 区间。 | | `DA_OPENCLAW_GATEWAY_MAX_CONCURRENT` | `16` | OpenClaw gateway 执行 lane 并发数,启动时写入 `openclaw.json` 的 `agents.defaults.maxConcurrent`。 | -| `DA_HUOZIGE_SERVER_IP` | 空字符串 | 对话应用/业务应用所在的活字格服务器 IP 地址或主机名。通过管理控制台修改时会同步维护 `DA_INTERNAL_NETWORK_ALLOWLIST`。 | -| `DA_INTERNAL_NETWORK_ALLOWLIST` | 空字符串 | 允许 `daedalus-agent` 访问的局域网地址或 CIDR,支持逗号、空格、分号或 JSON 数组字符串。通过基础设置修改活字格服务器地址时会自动移除旧地址并加入新地址。 | +| `DA_INTERNAL_NETWORK_ALLOWLIST` | 空字符串 | 允许 `daedalus-agent` 访问的局域网地址或 CIDR,支持逗号、空格、分号或 JSON 数组字符串。通过基础设置配置活字格服务器 IP 时会自动同步;域名按外网地址处理,不加入白名单。 | | `DA_MANAGEMENT_WEBSITE_TIMEOUT` | `180` | management web 访问内部管理动作的超时时间,单位秒。 | | `DA_SYSTEM_STATUS_INITIAL_DELAY_MS` | `60000` | 管理控制台启动后首次采集系统状态的延迟,单位毫秒;覆盖 MQTT、S3、内核、插件加载和调用服务等状态。 | | `DA_SYSTEM_STATUS_REFRESH_INTERVAL_MS` | `60000` | 首次采集完成后系统状态缓存的刷新间隔,单位毫秒。 | @@ -21,7 +20,6 @@ DAEDALUS_AGENT Docker 支持通过 `/var/daedalus-agent/env.json` 配置少量 { "DA_MANAGEMENT_CONSOLE_PORT": 3030, "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 16, - "DA_HUOZIGE_SERVER_IP": "", "DA_INTERNAL_NETWORK_ALLOWLIST": "", "DA_SYSTEM_STATUS_INITIAL_DELAY_MS": 60000, "DA_SYSTEM_STATUS_REFRESH_INTERVAL_MS": 60000, @@ -29,6 +27,8 @@ DAEDALUS_AGENT Docker 支持通过 `/var/daedalus-agent/env.json` 配置少量 } ``` +对话应用/业务应用所在的活字格服务器 IP 地址或域名保存在 management web 的 SQLite 数据库中,应通过管理控制台“系统设置 -> 基础设置”修改。IP 地址会同步到内部网络白名单;域名按外网地址处理,不加入白名单。旧版本 `env.json` 中的 `DA_HUOZIGE_SERVER_IP` 仅作为升级迁移入口:首次启动时会迁入数据库并从文件中删除,不应继续作为新部署配置项使用。 + ## 容器启动调试变量 这些变量通过 `docker run -e` 或编排系统注入,用于运维调试,不建议放入 `/var/daedalus-agent/env.json`。 diff --git a/docker/sample/env.json b/docker/sample/env.json index 2472b6a..a003ff0 100644 --- a/docker/sample/env.json +++ b/docker/sample/env.json @@ -1,6 +1,5 @@ { "DA_MANAGEMENT_CONSOLE_PORT": 3030, "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 100, - "DA_HUOZIGE_SERVER_IP": "", "DA_INTERNAL_NETWORK_ALLOWLIST": "127.0.0.1,::1" } diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts index 937bc77..6f8be12 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts @@ -1,4 +1,5 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { rmSync } from "node:fs"; +import { stat } from "node:fs/promises"; import path from "node:path"; import type { @@ -14,51 +15,20 @@ import type { import { toChinaISOString } from "../../china-time.js"; import { OntologyManager } from "../ontology/service.js"; -interface SystemsState { - version: 1; - items: BusinessSystemRecord[]; -} - -interface SystemLogsState { - version: 1; - items: BusinessSystemLogRecord[]; -} - -const DEFAULT_SYSTEMS_STATE: SystemsState = { - version: 1, - items: [] -}; - -const DEFAULT_SYSTEM_LOGS_STATE: SystemLogsState = { - version: 1, - items: [] -}; - const SUPPORTED_SYSTEM_PROVIDERS = new Set(["hzg", "phx"]); +const OBSOLETE_GENERATED_STATE_FILES = ["business-systems.json", "system-logs.json"]; -function slugifySystemId(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -function validateSystemId(systemId: string): void { - if (!/^[A-Za-z0-9_-]+$/.test(systemId)) { - throw new Error(`Invalid system id: ${systemId}`); +function removeObsoleteGeneratedStateFiles(stateFile: string): void { + const stateRoot = path.dirname(stateFile); + for (const fileName of OBSOLETE_GENERATED_STATE_FILES) { + rmSync(path.join(stateRoot, fileName), { force: true }); } } -function normalizeSystemStatus(status?: string): "active" | "disabled" { - return status === "disabled" ? "disabled" : "active"; -} - function normalizeRequiredText(value: string | undefined, errorMessage: string): string { if (!value?.trim()) { throw new Error(errorMessage); } - return value.trim(); } @@ -72,61 +42,63 @@ function normalizeSystemProvider(value: string | undefined, action: string): Bus if (!SUPPORTED_SYSTEM_PROVIDERS.has(normalized as BusinessSystemProvider)) { throw new Error(`${action} supports provider hzg or phx only`); } - return normalized as BusinessSystemProvider; } -function normalizeOptionalSystemProvider(value: string | undefined, fallback: BusinessSystemProvider): BusinessSystemProvider { - return value === undefined ? fallback : normalizeSystemProvider(value, "business system update"); +function normalizeSystemId(value: string | undefined, fallback?: string): string { + const systemId = normalizeOptionalText(value) || normalizeOptionalText(fallback); + if (!systemId || !/^[A-Za-z0-9_-]+$/.test(systemId)) { + throw new Error(`Invalid system id: ${systemId || ""}`); + } + return systemId; } -function normalizeUrl(value: string | undefined, errorMessage: string): string { - const normalized = normalizeRequiredText(value, errorMessage); - try { - return new URL(normalized).toString(); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${errorMessage}: ${message}`); - } +function operationRecord(params: { + systemId: string; + name?: string; + provider?: string; + ontologyName?: string; + ontologyPackage?: string; + description?: string; + status?: "active" | "disabled"; + bucket?: string; + objectKey?: string; + lastImportedAt?: string; + operationAt?: string; +}): BusinessSystemRecord { + const now = params.operationAt || toChinaISOString(); + return { + id: params.systemId, + name: normalizeOptionalText(params.name) || params.systemId, + provider: normalizeSystemProvider(params.provider || "hzg", "apps operation"), + ontologyName: normalizeOptionalText(params.ontologyName) || params.systemId, + ontologyPackage: normalizeOptionalText(params.ontologyPackage) || normalizeOptionalText(params.objectKey), + description: normalizeOptionalText(params.description), + status: params.status === "disabled" ? "disabled" : "active", + bucket: normalizeOptionalText(params.bucket), + objectKey: normalizeOptionalText(params.objectKey), + createdAt: now, + updatedAt: now, + lastImportedAt: params.lastImportedAt + }; } export class BusinessSystemManager { constructor( - private readonly env: EnvironmentConfig, + env: EnvironmentConfig, private readonly ontologyManager: OntologyManager - ) {} + ) { + removeObsoleteGeneratedStateFiles(env.stateFile); + } async listSystems(): Promise<{ items: BusinessSystemRecord[] }> { - const state = await this.readSystemsState(); - return { - items: [...state.items].sort((left, right) => left.id.localeCompare(right.id)) - }; + return { items: [] }; } async createSystem(params: BusinessSystemCreateParams): Promise { const name = normalizeRequiredText(params.name, "apps.create requires name"); + const systemId = normalizeSystemId(params.systemId, name); const provider = normalizeSystemProvider(params.provider, "apps.create"); - const applicationEndpoint = normalizeUrl( - params.applicationEndpoint, - "apps.create requires applicationEndpoint" - ); - const sessionServiceEndpoint = normalizeUrl( - params.sessionServiceEndpoint, - "apps.create requires sessionServiceEndpoint" - ); - - const systemId = params.systemId?.trim() || slugifySystemId(name); - if (!systemId) { - throw new Error("apps.create requires systemId or name that can be normalized into an id"); - } - validateSystemId(systemId); - - const state = await this.readSystemsState(); - if (state.items.some((item) => item.id === systemId)) { - throw new Error(`Business system already exists: ${systemId}`); - } - - const now = toChinaISOString(); const ontologyName = normalizeOptionalText(params.ontologyName) || systemId; const bucket = normalizeOptionalText(params.bucket); const objectKey = normalizeOptionalText(params.objectKey); @@ -134,7 +106,7 @@ export class BusinessSystemManager { throw new Error("apps.create requires bucket and objectKey together"); } - let lastImportedAt: string | undefined; + let operationAt: string | undefined; if (bucket && objectKey) { const ontology = await this.ontologyManager.createOntology({ name: ontologyName, @@ -143,137 +115,60 @@ export class BusinessSystemManager { replace: params.replaceOntology ?? true }); await this.assertBusinessOntologyIndex(ontology.path); - lastImportedAt = now; + operationAt = toChinaISOString(); } - const record: BusinessSystemRecord = { - id: systemId, + return operationRecord({ + ...params, + systemId, name, provider, ontologyName, - ontologyPackage: normalizeOptionalText(params.ontologyPackage) || objectKey, - description: normalizeOptionalText(params.description), - applicationEndpoint, - sessionServiceEndpoint, - status: normalizeSystemStatus(params.status), bucket, objectKey, - createdAt: now, - updatedAt: now, - lastImportedAt - }; - - state.items.push(record); - await this.writeSystemsState(state); - return record; + lastImportedAt: operationAt, + operationAt + }); } async updateSystem(params: BusinessSystemUpdateParams): Promise { - validateSystemId(params.systemId); - const state = await this.readSystemsState(); - const index = state.items.findIndex((item) => item.id === params.systemId); - if (index < 0) { - throw new Error(`Business system not found: ${params.systemId}`); - } - - const current = state.items[index]!; - const next: BusinessSystemRecord = { - ...current, - name: normalizeOptionalText(params.name) || current.name, - provider: normalizeOptionalSystemProvider(params.provider, current.provider), - ontologyName: normalizeOptionalText(params.ontologyName) || current.ontologyName, - ontologyPackage: normalizeOptionalText(params.ontologyPackage) || current.ontologyPackage, - description: normalizeOptionalText(params.description) || current.description, - applicationEndpoint: params.applicationEndpoint === undefined - ? current.applicationEndpoint - : normalizeUrl(params.applicationEndpoint, "business system update requires applicationEndpoint"), - sessionServiceEndpoint: params.sessionServiceEndpoint === undefined - ? current.sessionServiceEndpoint - : normalizeUrl(params.sessionServiceEndpoint, "business system update requires sessionServiceEndpoint"), - status: normalizeSystemStatus(params.status ?? current.status), - bucket: normalizeOptionalText(params.bucket) || current.bucket, - objectKey: normalizeOptionalText(params.objectKey) || current.objectKey, - updatedAt: toChinaISOString() - }; - - if ((params.bucket !== undefined || params.objectKey !== undefined) && !(normalizeOptionalText(params.bucket) && normalizeOptionalText(params.objectKey))) { - throw new Error("business system update requires bucket and objectKey together"); - } - - if ((params.bucket?.trim() && params.objectKey?.trim()) || params.replaceOntology) { - if (!(next.bucket && next.objectKey)) { - throw new Error("business system update requires bucket and objectKey when replacing ontology"); - } - const ontology = await this.ontologyManager.createOntology({ - name: next.ontologyName, - bucket: next.bucket, - objectKey: next.objectKey, - replace: params.replaceOntology ?? true - }); - await this.assertBusinessOntologyIndex(ontology.path); - next.lastImportedAt = next.updatedAt; - } - - state.items[index] = next; - await this.writeSystemsState(state); - return next; + return await this.createSystem({ + ...params, + name: normalizeOptionalText(params.name) || params.systemId, + provider: params.provider || "hzg" + }); } async enableSystem(params: BusinessSystemMutationParams): Promise { - return await this.updateSystem({ - systemId: params.systemId, - status: "active" - }); + const systemId = normalizeSystemId(params.systemId); + return operationRecord({ systemId, status: "active" }); } async disableSystem(params: BusinessSystemMutationParams): Promise { - return await this.updateSystem({ - systemId: params.systemId, - status: "disabled" - }); + const systemId = normalizeSystemId(params.systemId); + return operationRecord({ systemId, status: "disabled" }); } async deleteSystem(params: BusinessSystemMutationParams): Promise<{ systemId: string; ontologyName: string }> { - validateSystemId(params.systemId); - const state = await this.readSystemsState(); - const current = state.items.find((item) => item.id === params.systemId); - if (!current) { - throw new Error(`Business system not found: ${params.systemId}`); - } - - await this.ontologyManager.deleteOntology({ name: current.ontologyName }); - await this.writeSystemsState({ - version: 1, - items: state.items.filter((item) => item.id !== params.systemId) - }); - + const systemId = normalizeSystemId(params.systemId); + await this.ontologyManager.deleteOntology({ name: systemId }); return { - systemId: current.id, - ontologyName: current.ontologyName + systemId, + ontologyName: systemId }; } async recordLog(params: BusinessSystemLogRecordParams): Promise { - validateSystemId(params.systemId); - const systems = await this.readSystemsState(); - const system = systems.items.find((item) => item.id === params.systemId); - if (!system) { - throw new Error(`Business system not found: ${params.systemId}`); - } + const systemId = normalizeSystemId(params.systemId); const provider = normalizeSystemProvider(params.provider, "apps.log.record"); - if (provider !== system.provider) { - throw new Error(`apps.log.record provider mismatch: expected ${system.provider}, received ${provider}`); - } const applicationName = normalizeRequiredText(params.applicationName, "apps.log.record requires applicationName"); const commandName = normalizeRequiredText(params.commandName, "apps.log.record requires commandName"); if (!Number.isFinite(params.responseTimeMs)) { throw new Error("apps.log.record requires responseTimeMs"); } - - const logs = await this.readLogsState(); - const record: BusinessSystemLogRecord = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - systemId: system.id, + return { + id: `${systemId}-${Date.now()}`, + systemId, agentId: normalizeOptionalText(params.agentId), conversationId: normalizeOptionalText(params.conversationId), provider, @@ -286,22 +181,6 @@ export class BusinessSystemManager { error: params.error, createdAt: params.createdAt ?? toChinaISOString() }; - - logs.items.push(record); - await this.writeLogsState(logs); - return record; - } - - private getStateRoot(): string { - return path.dirname(this.env.stateFile); - } - - private getSystemsStatePath(): string { - return path.join(this.getStateRoot(), "business-systems.json"); - } - - private getLogsStatePath(): string { - return path.join(this.getStateRoot(), "system-logs.json"); } private async assertBusinessOntologyIndex(ontologyPath: string): Promise { @@ -318,46 +197,4 @@ export class BusinessSystemManager { throw error; } } - - private async readSystemsState(): Promise { - try { - const content = await readFile(this.getSystemsStatePath(), "utf8"); - const parsed = JSON.parse(content) as Partial; - return { - version: 1, - items: Array.isArray(parsed.items) ? parsed.items : [] - }; - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return DEFAULT_SYSTEMS_STATE; - } - throw error; - } - } - - private async writeSystemsState(state: SystemsState): Promise { - await mkdir(this.getStateRoot(), { recursive: true }); - await writeFile(this.getSystemsStatePath(), `${JSON.stringify(state, null, 2)}\n`, "utf8"); - } - - private async readLogsState(): Promise { - try { - const content = await readFile(this.getLogsStatePath(), "utf8"); - const parsed = JSON.parse(content) as Partial; - return { - version: 1, - items: Array.isArray(parsed.items) ? parsed.items : [] - }; - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return DEFAULT_SYSTEM_LOGS_STATE; - } - throw error; - } - } - - private async writeLogsState(state: SystemLogsState): Promise { - await mkdir(this.getStateRoot(), { recursive: true }); - await writeFile(this.getLogsStatePath(), `${JSON.stringify(state, null, 2)}\n`, "utf8"); - } } diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/catalog/snapshot.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/catalog/snapshot.ts index 7274bda..40e90c6 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/catalog/snapshot.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/catalog/snapshot.ts @@ -111,33 +111,6 @@ function mapSkills(payload: unknown) { })); } -function endpointFrom(value: unknown) { - const raw = firstText(value); - if (!raw) { - return null; - } - - try { - const url = new URL(raw); - return { - scheme: url.protocol.replace(/:$/, ""), - host: url.hostname, - port: url.port ? Number(url.port) : (url.protocol === "https:" ? 443 : 80) - }; - } catch { - return null; - } -} - -function mapSystems(payload: unknown) { - return listItems(payload).map((system) => ({ - id: firstText(system.id), - provider: firstText(system.provider), - status: firstText(system.status, "active"), - endpoint: endpointFrom(system.applicationEndpoint) - })); -} - function mapOntologies(payload: unknown) { return listItems(payload).map((ontology) => ({ id: firstText(ontology.name), @@ -151,14 +124,12 @@ export const catalogSnapshot: CapabilityHandler = async (_request, context) => { templates, agents, skills, - systems, ontologies ] = await Promise.all([ context.agentManager.listLlmProviders(), context.templateManager.listTemplates(), context.agentManager.listAgents(), context.agentManager.listGlobalSkills(), - context.businessSystemManager.listSystems(), context.ontologyManager.listOntologies() ]); @@ -167,7 +138,7 @@ export const catalogSnapshot: CapabilityHandler = async (_request, context) => { agents: mapAgents(agents), templates: mapTemplates(templates), skills: mapSkills(skills), - systems: mapSystems(systems), + systems: [], ontologies: mapOntologies(ontologies) }; diff --git a/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts index 279ee7b..48a0917 100644 --- a/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -42,13 +42,44 @@ function createEnv(overrides: Partial = {}): EnvironmentConfi }; } -test("BusinessSystemManager creates systems with validated provider, endpoints, and ontology index", async () => { +async function assertFileDoesNotExist(filePath: string): Promise { + await assert.rejects(access(filePath), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); +} + +test("BusinessSystemManager removes obsolete generated JSON state files", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-legacy-state-")); + const stateRoot = path.join(root, "state"); + const managedAgentsPath = path.join(stateRoot, "managed-agents.json"); + await mkdir(stateRoot, { recursive: true }); + await Promise.all([ + writeFile(path.join(stateRoot, "business-systems.json"), "{}\n", "utf8"), + writeFile(path.join(stateRoot, "system-logs.json"), "{}\n", "utf8"), + writeFile(managedAgentsPath, "{}\n", "utf8") + ]); + + new BusinessSystemManager( + createEnv({ stateFile: managedAgentsPath }), + { + async createOntology() { + return { path: "" }; + }, + async deleteOntology() { + return { path: "" }; + } + } as never + ); + + await assertFileDoesNotExist(path.join(stateRoot, "business-systems.json")); + await assertFileDoesNotExist(path.join(stateRoot, "system-logs.json")); + await access(managedAgentsPath); +}); + +test("BusinessSystemManager imports ontology without persisting systems or endpoints", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-")); + const stateRoot = path.join(root, "state"); let imported: { name: string; bucket: string; objectKey: string; replace?: boolean } | undefined; const manager = new BusinessSystemManager( - createEnv({ - stateFile: path.join(root, "state", "managed-agents.json") - }), + createEnv({ stateFile: path.join(stateRoot, "managed-agents.json") }), { async createOntology(params) { imported = params; @@ -67,8 +98,8 @@ test("BusinessSystemManager creates systems with validated provider, endpoints, systemId: "crm", name: "CRM", provider: "HZG", - applicationEndpoint: "http://172.12.2.23:8081/wms", - sessionServiceEndpoint: "http://172.12.2.23:8080/chat", + applicationEndpoint: "http://legacy.example.com:8081/wms", + sessionServiceEndpoint: "http://legacy.example.com:8080/chat", bucket: "ontology-artifacts", objectKey: "crm/crm.zip" }); @@ -80,25 +111,16 @@ test("BusinessSystemManager creates systems with validated provider, endpoints, replace: true }); assert.equal(created.provider, "hzg"); - assert.equal(created.applicationEndpoint, "http://172.12.2.23:8081/wms"); - assert.equal(created.sessionServiceEndpoint, "http://172.12.2.23:8080/chat"); - - const listed = await manager.listSystems(); - assert.equal(listed.items.length, 1); - assert.equal(listed.items[0]?.id, "crm"); + assert.equal(created.applicationEndpoint, undefined); + assert.equal(created.sessionServiceEndpoint, undefined); + assert.deepEqual((await manager.listSystems()).items, []); + await assertFileDoesNotExist(path.join(stateRoot, "business-systems.json")); }); test("BusinessSystemManager rejects imported ontologies without top-level index.md", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-bad-ontology-")); - await mkdir(path.join(root, "state"), { recursive: true }); - await writeFile(path.join(root, "state", "business-systems.json"), `${JSON.stringify({ - version: 1, - items: [] - }, null, 2)}\n`, "utf8"); const manager = new BusinessSystemManager( - createEnv({ - stateFile: path.join(root, "state", "managed-agents.json") - }), + createEnv({ stateFile: path.join(root, "state", "managed-agents.json") }), { async createOntology(params) { const ontologyPath = path.join(root, "ontology", params.name); @@ -112,42 +134,20 @@ test("BusinessSystemManager rejects imported ontologies without top-level index. } as never ); - await assert.rejects(async () => { - await manager.createSystem({ - systemId: "crm", - name: "CRM", - provider: "hzg", - applicationEndpoint: "http://172.12.2.23:8081/wms", - sessionServiceEndpoint: "http://172.12.2.23:8080/chat", - bucket: "ontology-artifacts", - objectKey: "crm/crm.zip" - }); - }, /top-level index\.md/); + await assert.rejects(manager.createSystem({ + systemId: "crm", + name: "CRM", + provider: "hzg", + bucket: "ontology-artifacts", + objectKey: "crm/crm.zip" + }), /top-level index\.md/); }); -test("BusinessSystemManager records log errors and keeps provider aligned with the system", async () => { +test("BusinessSystemManager returns compatible log results without persisting log JSON", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-logs-")); const stateRoot = path.join(root, "state"); - await mkdir(stateRoot, { recursive: true }); - await writeFile(path.join(stateRoot, "business-systems.json"), `${JSON.stringify({ - version: 1, - items: [{ - id: "crm", - name: "CRM", - provider: "hzg", - ontologyName: "crm", - applicationEndpoint: "http://172.12.2.23:8081/wms", - sessionServiceEndpoint: "http://172.12.2.23:8080/chat", - status: "active", - createdAt: "2026-05-21T00:00:00.000Z", - updatedAt: "2026-05-21T00:00:00.000Z" - }] - }, null, 2)}\n`, "utf8"); - const manager = new BusinessSystemManager( - createEnv({ - stateFile: path.join(stateRoot, "managed-agents.json") - }), + createEnv({ stateFile: path.join(stateRoot, "managed-agents.json") }), { async createOntology() { return { path: "" }; @@ -165,63 +165,28 @@ test("BusinessSystemManager records log errors and keeps provider aligned with t commandName: "searchCustomer", responseTimeMs: 128, success: false, - error: { - code: "upstream_error" - } - }); - - assert.deepEqual(recorded.error, { - code: "upstream_error" + error: { code: "upstream_error" } }); - await assert.rejects(async () => { - await manager.recordLog({ - systemId: "crm", - provider: "phx", - applicationName: "crm", - commandName: "searchCustomer", - responseTimeMs: 100, - success: true - }); - }, /provider mismatch/); - - const persisted = JSON.parse(await readFile(path.join(stateRoot, "system-logs.json"), "utf8")) as { - items: Array<{ error?: unknown }>; - }; - assert.deepEqual(persisted.items[0]?.error, { - code: "upstream_error" - }); + assert.deepEqual(recorded.error, { code: "upstream_error" }); + await assert.rejects(manager.recordLog({ + systemId: "crm", + provider: "unsupported" as "hzg", + applicationName: "crm", + commandName: "searchCustomer", + responseTimeMs: 100, + success: true + }), /supports provider hzg or phx only/); + await assertFileDoesNotExist(path.join(stateRoot, "system-logs.json")); }); -test("BusinessSystemManager updates, toggles, and deletes according to the capability contract", async () => { +test("BusinessSystemManager keeps mutation actions as stateless compatibility adapters", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-lifecycle-")); const stateRoot = path.join(root, "state"); let deletedOntologyName: string | undefined; let imported: { name: string; bucket: string; objectKey: string; replace?: boolean } | undefined; - - await mkdir(stateRoot, { recursive: true }); - await writeFile(path.join(stateRoot, "business-systems.json"), `${JSON.stringify({ - version: 1, - items: [{ - id: "crm", - name: "CRM", - provider: "hzg", - ontologyName: "crm", - ontologyPackage: "crm/crm.zip", - description: "old", - applicationEndpoint: "http://172.12.2.23:8081/wms", - sessionServiceEndpoint: "http://172.12.2.23:8080/chat", - status: "active", - bucket: "old-bucket", - objectKey: "old/object.zip", - createdAt: "2026-05-21T00:00:00.000Z", - updatedAt: "2026-05-21T00:00:00.000Z" - }] - }, null, 2)}\n`, "utf8"); const manager = new BusinessSystemManager( - createEnv({ - stateFile: path.join(stateRoot, "managed-agents.json") - }), + createEnv({ stateFile: path.join(stateRoot, "managed-agents.json") }), { async createOntology(params) { imported = params; @@ -255,15 +220,10 @@ test("BusinessSystemManager updates, toggles, and deletes according to the capab replace: true }); assert.equal(updated.description, "updated"); - assert.equal(updated.bucket, "new-bucket"); - assert.equal(updated.objectKey, "crm/crm.zip"); assert.equal(updated.lastImportedAt, updated.updatedAt); assert.equal(disabled.status, "disabled"); assert.equal(enabled.status, "active"); - assert.deepEqual(deleted, { - systemId: "crm", - ontologyName: "crm" - }); + assert.deepEqual(deleted, { systemId: "crm", ontologyName: "crm" }); assert.equal(deletedOntologyName, "crm"); - assert.deepEqual((await manager.listSystems()).items, []); + await assertFileDoesNotExist(path.join(stateRoot, "business-systems.json")); }); diff --git a/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts b/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts index 792a3c3..3a3bd0e 100644 --- a/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts @@ -374,7 +374,6 @@ test("capability handlers delegate to the expected service methods", async () => "agentManager.listAgents", "agentManager.listGlobalSkills", "agentManager.listLlmProviders", - "businessSystemManager.listSystems", "ontologyManager.listOntologies", "templateManager.listTemplates" ].sort(), item.action); @@ -506,16 +505,9 @@ test("catalog.snapshot emits compact catalog payloads", async () => { isBuiltIn: true }] }) as never; - context.businessSystemManager.listSystems = async () => ({ - items: [{ - id: "crm", - name: "CRM", - provider: "phx", - status: "active", - applicationEndpoint: "https://crm.example.com:8443", - builtIn: true - }] - }) as never; + context.businessSystemManager.listSystems = async () => { + throw new Error("catalog.snapshot must not read kernel business-system state"); + }; context.ontologyManager.listOntologies = async () => ({ items: [{ name: "crm", @@ -565,16 +557,7 @@ test("catalog.snapshot emits compact catalog payloads", async () => { id: "planner", builtIn: true }); - assert.deepEqual(result.catalogs?.systems?.items?.[0], { - id: "crm", - provider: "phx", - status: "active", - endpoint: { - scheme: "https", - host: "crm.example.com", - port: 8443 - } - }); + assert.deepEqual(result.catalogs?.systems?.items, []); assert.deepEqual(result.catalogs?.ontologies?.items?.[0], { id: "crm", builtIn: true diff --git a/packages/daedalus-agent-management-web/server/src/api/routes/index.js b/packages/daedalus-agent-management-web/server/src/api/routes/index.js index cbda22d..938dd43 100644 --- a/packages/daedalus-agent-management-web/server/src/api/routes/index.js +++ b/packages/daedalus-agent-management-web/server/src/api/routes/index.js @@ -928,7 +928,7 @@ export function createRoutes(services) { services, req, "保存 IP 白名单", - "更新 env.json 中的 DA_INTERNAL_NETWORK_ALLOWLIST / DA_HUOZIGE_SERVER_IP" + "更新数据库中的活字格服务器地址及 env.json 中的 DA_INTERNAL_NETWORK_ALLOWLIST" ); res.json(result); })); diff --git a/packages/daedalus-agent-management-web/server/src/app.js b/packages/daedalus-agent-management-web/server/src/app.js index 1cdddc8..833bdba 100644 --- a/packages/daedalus-agent-management-web/server/src/app.js +++ b/packages/daedalus-agent-management-web/server/src/app.js @@ -35,7 +35,7 @@ export function createServerApp() { const updateNotifier = new UpdateNotifier({ authService }); const hzgProviderClient = new HzgProviderClient(); const auditLogService = new AuditLogService({ db }); - const runtimeEnvConfigService = new RuntimeEnvConfigService({ env }); + const runtimeEnvConfigService = new RuntimeEnvConfigService({ db, env }); const runtimeComponentHealthService = new RuntimeComponentHealthService({ env }); const containerService = new ContainerService({ env }); const catalogSyncService = new CatalogSyncService({ db, managementClient, auditLogService, env }); diff --git a/packages/daedalus-agent-management-web/server/src/db/index.js b/packages/daedalus-agent-management-web/server/src/db/index.js index 25fd610..9081cfa 100644 --- a/packages/daedalus-agent-management-web/server/src/db/index.js +++ b/packages/daedalus-agent-management-web/server/src/db/index.js @@ -8,7 +8,7 @@ import { loadEnv } from "../config/env.js"; import { hashPassword } from "../utils/security.js"; const env = loadEnv(); -const SCHEMA_VERSION = 11; +const SCHEMA_VERSION = 12; const DEFAULT_ACCESS_TOKEN = "sk-1234567890qwertyuiop"; const DEFAULT_PORTAL_NAME = "智能体管理与运营平台"; const DEFAULT_BRAND_SUBTITLE = "Your company's name here"; @@ -494,6 +494,91 @@ function migrateV10ToV11() { ); } +function createRuntimeSettingsTable() { + return ` + CREATE TABLE IF NOT EXISTS runtime_settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `; +} + +function readLegacyHuozigeServerHost() { + try { + if (!fs.existsSync(env.userEnvJsonPath)) { + return ""; + } + const payload = JSON.parse(fs.readFileSync(env.userEnvJsonPath, "utf8").replace(/^\uFEFF/, "")); + return String(payload?.DA_HUOZIGE_SERVER_IP || "").trim(); + } catch { + return ""; + } +} + +function migrateV11ToV12() { + db.exec(createRuntimeSettingsTable()); + + const legacyHost = readLegacyHuozigeServerHost(); + const storedHost = hasTableColumn("business_systems", "host") + ? String(db.prepare(` + SELECT host + FROM business_systems + WHERE trim(host) <> '' + ORDER BY updated_at DESC, id DESC + LIMIT 1 + `).get()?.host || "").trim() + : ""; + const serverHost = legacyHost || storedHost; + if (serverHost) { + const now = toChinaISOString(); + db.prepare(` + INSERT INTO runtime_settings ( + setting_key, setting_value, created_at, updated_at + ) VALUES ('huozige_server_host', ?, ?, ?) + ON CONFLICT(setting_key) DO NOTHING + `).run(serverHost, now, now); + } + + if (!hasTableColumn("business_systems", "host")) { + return; + } + + db.exec(` + CREATE TABLE business_systems_v12 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL CHECK (provider IN ('hzg', 'phx')), + application_name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL, + ontology_artifact_id INTEGER, + scheme TEXT NOT NULL CHECK (scheme IN ('http', 'https')), + port INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), + last_connectivity_test_status TEXT NOT NULL DEFAULT 'unknown', + last_connectivity_test_result_json TEXT NOT NULL DEFAULT '{}', + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (ontology_artifact_id) REFERENCES artifacts(id) ON DELETE SET NULL + ); + + INSERT INTO business_systems_v12 ( + id, provider, application_name, description, ontology_artifact_id, + scheme, port, status, last_connectivity_test_status, + last_connectivity_test_result_json, is_builtin, created_at, updated_at + ) + SELECT + id, provider, application_name, description, ontology_artifact_id, + scheme, port, status, last_connectivity_test_status, + last_connectivity_test_result_json, is_builtin, created_at, updated_at + FROM business_systems; + + DROP TABLE business_systems; + ALTER TABLE business_systems_v12 RENAME TO business_systems; + `); +} + function addAgentCreatorColumn() { if (!hasTable("agents")) { return; @@ -539,6 +624,8 @@ function createSchema() { updated_at TEXT NOT NULL ); + ${createRuntimeSettingsTable()} + ${createComponentVersionsTable()} ${createStateTable("usage_refresh_state")} @@ -688,7 +775,6 @@ function createSchema() { description TEXT NOT NULL, ontology_artifact_id INTEGER, scheme TEXT NOT NULL CHECK (scheme IN ('http', 'https')), - host TEXT NOT NULL, port INTEGER NOT NULL, status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), last_connectivity_test_status TEXT NOT NULL DEFAULT 'unknown', @@ -856,225 +942,25 @@ function ensureCoreRows() { function migrate() { const version = currentSchemaVersion(); - - if (version === 1 && SCHEMA_VERSION >= 2) { - db.exec("BEGIN"); - try { - migrateV1ToV2(); - if (SCHEMA_VERSION >= 3) { - migrateV2ToV3(); - } - if (SCHEMA_VERSION >= 4) { - migrateV3ToV4(); - } - if (SCHEMA_VERSION >= 5) { - migrateV4ToV5(); - } - if (SCHEMA_VERSION >= 6) { - migrateV5ToV6(); - } - if (SCHEMA_VERSION >= 7) { - migrateV6ToV7(); - } - if (SCHEMA_VERSION >= 8) { - migrateV7ToV8(); - } - if (SCHEMA_VERSION >= 9) { - migrateV8ToV9(); - } - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 2 && SCHEMA_VERSION >= 3) { - db.exec("BEGIN"); - try { - migrateV2ToV3(); - if (SCHEMA_VERSION >= 4) { - migrateV3ToV4(); - } - if (SCHEMA_VERSION >= 5) { - migrateV4ToV5(); - } - if (SCHEMA_VERSION >= 6) { - migrateV5ToV6(); - } - if (SCHEMA_VERSION >= 7) { - migrateV6ToV7(); - } - if (SCHEMA_VERSION >= 8) { - migrateV7ToV8(); - } - if (SCHEMA_VERSION >= 9) { - migrateV8ToV9(); - } - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 3 && SCHEMA_VERSION >= 4) { - db.exec("BEGIN"); - try { - migrateV3ToV4(); - if (SCHEMA_VERSION >= 5) { - migrateV4ToV5(); - } - if (SCHEMA_VERSION >= 6) { - migrateV5ToV6(); - } - if (SCHEMA_VERSION >= 7) { - migrateV6ToV7(); - } - if (SCHEMA_VERSION >= 8) { - migrateV7ToV8(); - } - if (SCHEMA_VERSION >= 9) { - migrateV8ToV9(); - } - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 4 && SCHEMA_VERSION >= 5) { - db.exec("BEGIN"); - try { - migrateV4ToV5(); - if (SCHEMA_VERSION >= 6) { - migrateV5ToV6(); - } - if (SCHEMA_VERSION >= 7) { - migrateV6ToV7(); - } - if (SCHEMA_VERSION >= 8) { - migrateV7ToV8(); - } - if (SCHEMA_VERSION >= 9) { - migrateV8ToV9(); - } - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 5 && SCHEMA_VERSION >= 6) { - db.exec("BEGIN"); - try { - migrateV5ToV6(); - if (SCHEMA_VERSION >= 7) { - migrateV6ToV7(); - } - if (SCHEMA_VERSION >= 8) { - migrateV7ToV8(); - } - if (SCHEMA_VERSION >= 9) { - migrateV8ToV9(); - } - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 6 && SCHEMA_VERSION >= 7) { - db.exec("BEGIN"); - try { - migrateV6ToV7(); - if (SCHEMA_VERSION >= 8) { - migrateV7ToV8(); - } - if (SCHEMA_VERSION >= 9) { - migrateV8ToV9(); - } - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 7 && SCHEMA_VERSION >= 8) { - db.exec("BEGIN"); - try { - migrateV7ToV8(); - if (SCHEMA_VERSION >= 9) { - migrateV8ToV9(); - } - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 8 && SCHEMA_VERSION >= 9) { - db.exec("BEGIN"); - try { - migrateV8ToV9(); - if (SCHEMA_VERSION >= 10) { - migrateV9ToV10(); - } - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); - } - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - } else if (version === 9 && SCHEMA_VERSION >= 10) { + const migrations = [ + migrateV1ToV2, + migrateV2ToV3, + migrateV3ToV4, + migrateV4ToV5, + migrateV5ToV6, + migrateV6ToV7, + migrateV7ToV8, + migrateV8ToV9, + migrateV9ToV10, + migrateV10ToV11, + migrateV11ToV12 + ]; + + if (version >= 1 && version < SCHEMA_VERSION) { db.exec("BEGIN"); try { - migrateV9ToV10(); - if (SCHEMA_VERSION >= 11) { - migrateV10ToV11(); + for (let current = version; current < SCHEMA_VERSION; current += 1) { + migrations[current - 1](); } setSchemaVersion(SCHEMA_VERSION); db.exec("COMMIT"); @@ -1082,16 +968,6 @@ function migrate() { db.exec("ROLLBACK"); throw error; } - } else if (version === 10 && SCHEMA_VERSION >= 11) { - db.exec("BEGIN"); - try { - migrateV10ToV11(); - setSchemaVersion(SCHEMA_VERSION); - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } } else if (version !== SCHEMA_VERSION) { db.exec("PRAGMA foreign_keys = OFF;"); db.exec("BEGIN"); @@ -1115,6 +991,7 @@ function migrate() { if (!hasTableColumn("system_invocation_logs", "request_size_bytes")) { migrateV8ToV9(); } + migrateV11ToV12(); ensureCoreRows(); } diff --git a/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js b/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js index 42f3738..76df81e 100644 --- a/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js @@ -244,17 +244,6 @@ function builtInOntologyNames(items) { .filter(Boolean)); } -function normalizeSystemEndpoint(item, existing = null) { - const endpoint = item?.endpoint && typeof item.endpoint === "object" ? item.endpoint : {}; - return { - scheme: firstText(endpoint.scheme, existing?.scheme, "http"), - host: firstText(endpoint.host, existing?.host, item?.host, "localhost"), - port: Number.isInteger(Number(endpoint.port)) - ? Number(endpoint.port) - : (Number.isInteger(Number(existing?.port)) ? Number(existing.port) : 80) - }; -} - export class CatalogSyncService { constructor({ db, managementClient, auditLogService = null, env = null }) { this.db = db; @@ -391,8 +380,8 @@ export class CatalogSyncService { const templateItems = readCatalogItems(remote, "templates"); const agentItems = readCatalogItems(remote, "agents"); const skillItems = readCatalogItems(remote, "skills"); - const systemItems = readCatalogItems(remote, "systems"); const ontologyItems = readCatalogItems(remote, "ontologies"); + const systemCount = Number(this.db.prepare("SELECT COUNT(*) AS count FROM business_systems").get()?.count || 0); const summaries = { llm_sync_state: { models: countLargeLanguageModels(llmItems) @@ -407,7 +396,7 @@ export class CatalogSyncService { skills: skillItems.length }, system_sync_state: { - systems: systemItems.length + systems: systemCount } }; @@ -416,7 +405,7 @@ export class CatalogSyncService { this.syncTemplates(templateItems, now); this.syncAgents(agentItems, new Map(), now); this.syncSkills(skillItems, now); - this.syncSystems(systemItems, now, builtInOntologyNames(ontologyItems)); + this.syncSystemBuiltInFlags(builtInOntologyNames(ontologyItems), now); const finishedAt = toChinaISOString(); for (const item of CATALOG_SYNC_STATES) { @@ -913,46 +902,16 @@ export class CatalogSyncService { } } - syncSystems(items, now = toChinaISOString(), builtInOntologies = new Set()) { - const existingRows = this.db.prepare("SELECT * FROM business_systems").all(); - const existingMap = new Map(existingRows.map((row) => [row.application_name, row])); - const seen = new Set(); + syncSystemBuiltInFlags(builtInOntologies = new Set(), now = toChinaISOString()) { const update = this.db.prepare(` UPDATE business_systems - SET provider = ?, scheme = ?, host = ?, port = ?, status = ?, is_builtin = ?, updated_at = ? + SET is_builtin = ?, updated_at = ? WHERE id = ? `); - const remove = this.db.prepare("DELETE FROM business_systems WHERE id = ?"); - - for (const item of items) { - const applicationName = firstText(item?.id, item?.name).toLowerCase(); - if (!applicationName) { - throw new Error("catalogs.systems.items[].id is required"); - } - - seen.add(applicationName); - const existing = existingMap.get(applicationName); - if (!existing) { - continue; - } - - const endpoint = normalizeSystemEndpoint(item, existing); - const isBuiltin = builtInOntologies.has(applicationName) ? 1 : 0; - update.run( - firstText(item?.provider, existing.provider), - endpoint.scheme, - endpoint.host, - endpoint.port, - firstText(item?.status, existing.status) === "disabled" ? "disabled" : "active", - isBuiltin, - now, - existing.id - ); - } - - for (const row of existingRows) { - if (!seen.has(row.application_name)) { - remove.run(row.id); + for (const row of this.db.prepare("SELECT id, application_name, is_builtin FROM business_systems").all()) { + const isBuiltin = builtInOntologies.has(row.application_name) ? 1 : 0; + if (Number(row.is_builtin) !== isBuiltin) { + update.run(isBuiltin, now, row.id); } } } diff --git a/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js b/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js index c3a0292..0885fb0 100644 --- a/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js @@ -1,10 +1,13 @@ import fs from "node:fs"; +import { isIP } from "node:net"; import path from "node:path"; import { badRequest } from "../utils/errors.js"; +import { toChinaISOString } from "../utils/china-time.js"; export const AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY = "DA_INTERNAL_NETWORK_ALLOWLIST"; export const HUOZIGE_SERVER_IP_KEY = "DA_HUOZIGE_SERVER_IP"; +const HUOZIGE_SERVER_HOST_SETTING_KEY = "huozige_server_host"; function firstText(...values) { for (const value of values) { @@ -27,7 +30,7 @@ function normalizeAllowlistValue(value) { return String(value).trim(); } -function normalizeServerIpValue(value) { +function normalizeServerHostValue(value) { const normalized = String(value || "").trim(); if ( normalized.includes("/") @@ -80,13 +83,17 @@ function uniqueAllowlistItems(items) { return result; } -function updateAllowlistServerIp(allowlistValue, previousIp, nextIp) { - const previous = normalizeServerIpValue(previousIp); - const next = normalizeServerIpValue(nextIp); +function updateAllowlistServerHost(allowlistValue, previousHost, nextHost) { + const previous = normalizeServerHostValue(previousHost); + const next = normalizeServerHostValue(nextHost); + const excludedItems = new Set([previous]); + if (next && !isIP(next)) { + excludedItems.add(next); + } const retainedItems = parseAllowlistItems(allowlistValue) - .filter((item) => item !== previous); + .filter((item) => !excludedItems.has(item)); - if (next) { + if (isIP(next)) { retainedItems.push(next); } @@ -130,16 +137,74 @@ function writeJsonObject(filePath, payload) { } export class RuntimeEnvConfigService { - constructor({ env, envPath, credentialsPath } = {}) { + constructor({ db, env, envPath, credentialsPath } = {}) { + this.db = db; this.envPath = envPath || env?.userEnvJsonPath || path.resolve("env.json"); this.credentialsPath = credentialsPath || env?.credentialsJsonPath || path.resolve("credentials.json"); + this.migrateLegacyHuozigeServerHost(); + } + + getHuozigeServerHost() { + return normalizeServerHostValue(this.db?.prepare(` + SELECT setting_value + FROM runtime_settings + WHERE setting_key = ? + `).get(HUOZIGE_SERVER_HOST_SETTING_KEY)?.setting_value); + } + + setHuozigeServerHost(value) { + if (!this.db) { + throw new Error("Runtime settings database is not configured"); + } + const normalized = normalizeServerHostValue(value); + const now = toChinaISOString(); + this.db.prepare(` + INSERT INTO runtime_settings ( + setting_key, setting_value, created_at, updated_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(setting_key) DO UPDATE SET + setting_value = excluded.setting_value, + updated_at = excluded.updated_at + `).run(HUOZIGE_SERVER_HOST_SETTING_KEY, normalized, now, now); + return normalized; + } + + migrateLegacyHuozigeServerHost() { + if (!this.db || !fs.existsSync(this.envPath)) { + return; + } + const current = readJsonObject(this.envPath); + const hasLegacyHost = Object.prototype.hasOwnProperty.call(current, HUOZIGE_SERVER_IP_KEY); + const legacyHost = hasLegacyHost + ? normalizeServerHostValue(current[HUOZIGE_SERVER_IP_KEY]) + : ""; + if (hasLegacyHost && !this.getHuozigeServerHost() && legacyHost) { + this.setHuozigeServerHost(legacyHost); + } + + const configuredHost = this.getHuozigeServerHost(); + const previousAllowlist = normalizeAllowlistValue(current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]); + const domainHosts = new Set([legacyHost, configuredHost].filter((host) => host && !isIP(host))); + const nextAllowlist = uniqueAllowlistItems( + parseAllowlistItems(previousAllowlist).filter((item) => !domainHosts.has(item)) + ).join(","); + if (nextAllowlist !== previousAllowlist) { + current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = nextAllowlist; + } + + if (hasLegacyHost) { + delete current[HUOZIGE_SERVER_IP_KEY]; + } + if (hasLegacyHost || nextAllowlist !== previousAllowlist) { + writeJsonObject(this.envPath, current); + } } getNetworkAllowlistConfig() { const payload = readJsonObject(this.envPath, "env.json"); return { agent_internal_network_allowlist: normalizeAllowlistValue(payload[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]), - huozige_server_ip: normalizeServerIpValue(payload[HUOZIGE_SERVER_IP_KEY]) + huozige_server_ip: this.getHuozigeServerHost() }; } @@ -155,8 +220,9 @@ export class RuntimeEnvConfigService { updateNetworkAllowlistConfig(payload) { const current = readJsonObject(this.envPath); - const previousServerIp = normalizeServerIpValue(current[HUOZIGE_SERVER_IP_KEY]); - const hasServerIpPayload = Object.prototype.hasOwnProperty.call(payload || {}, "huozige_server_ip"); + const previousAllowlist = normalizeAllowlistValue(current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]); + const previousServerHost = this.getHuozigeServerHost(); + const hasServerHostPayload = Object.prototype.hasOwnProperty.call(payload || {}, "huozige_server_ip"); if (Object.prototype.hasOwnProperty.call(payload || {}, "agent_internal_network_allowlist")) { current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = normalizeAllowlistValue( @@ -164,26 +230,31 @@ export class RuntimeEnvConfigService { ); } - if (hasServerIpPayload) { - const nextServerIp = normalizeServerIpValue(payload?.huozige_server_ip); - current[HUOZIGE_SERVER_IP_KEY] = nextServerIp; - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = updateAllowlistServerIp( + if (hasServerHostPayload) { + const nextServerHost = normalizeServerHostValue(payload?.huozige_server_ip); + this.setHuozigeServerHost(nextServerHost); + current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = updateAllowlistServerHost( current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY], - previousServerIp, - nextServerIp + previousServerHost, + nextServerHost ); } else { - const currentServerIp = normalizeServerIpValue(current[HUOZIGE_SERVER_IP_KEY]); - if (currentServerIp) { - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = updateAllowlistServerIp( + const currentServerHost = this.getHuozigeServerHost(); + if (currentServerHost) { + current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = updateAllowlistServerHost( current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY], - "", - currentServerIp + currentServerHost, + currentServerHost ); } } + delete current[HUOZIGE_SERVER_IP_KEY]; + writeJsonObject(this.envPath, current); - return this.getNetworkAllowlistConfig(); + return { + ...this.getNetworkAllowlistConfig(), + network_allowlist_changed: normalizeAllowlistValue(current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]) !== previousAllowlist + }; } } diff --git a/packages/daedalus-agent-management-web/server/src/services/system-service.js b/packages/daedalus-agent-management-web/server/src/services/system-service.js index da78f59..6cae4c9 100644 --- a/packages/daedalus-agent-management-web/server/src/services/system-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/system-service.js @@ -80,7 +80,7 @@ function decorateSystemRow(row, configuredHost = "") { return null; } - const host = normalizeText(configuredHost) || row.host; + const host = normalizeText(configuredHost); const baseUrl = buildBaseUrl(row.scheme, host, row.port, row.application_name); return { ...row, @@ -204,10 +204,8 @@ export class SystemService { getConfiguredHuozigeServerIp() { return normalizeText( - this.runtimeEnvConfigService - ?.getNetworkAllowlistConfig - ?.() - ?.huozige_server_ip + this.runtimeEnvConfigService?.getHuozigeServerHost?.() + || this.runtimeEnvConfigService?.getNetworkAllowlistConfig?.()?.huozige_server_ip ); } @@ -220,7 +218,7 @@ export class SystemService { } resolveSystemHost(row) { - const host = this.getConfiguredHuozigeServerIp() || normalizeText(row?.host); + const host = this.getConfiguredHuozigeServerIp(); if (!host) { throw badRequest("请先在基础设置中配置对话应用/业务应用服务器"); } @@ -311,13 +309,13 @@ export class SystemService { normalizeCreatePayload(payload) { assertHostNotProvided(payload); + this.requireConfiguredHuozigeServerIp(); const applicationName = normalizeApplicationName(payload.application_name); return { provider: FIXED_BUSINESS_SYSTEM_PROVIDER, application_name: applicationName, description: normalizeText(payload.description), scheme: normalizeScheme(payload.scheme), - host: this.requireConfiguredHuozigeServerIp(), port: normalizePort(payload.port), status: normalizeText(payload.status) === "disabled" ? "disabled" : "active" }; @@ -325,6 +323,7 @@ export class SystemService { normalizeUpdatePayload(payload, current) { assertHostNotProvided(payload); + this.requireConfiguredHuozigeServerIp(); const applicationName = hasOwn(payload, "application_name") ? normalizeApplicationName(payload.application_name) : current.application_name; @@ -333,7 +332,6 @@ export class SystemService { application_name: applicationName, description: hasOwn(payload, "description") ? normalizeText(payload.description) : current.description, scheme: hasOwn(payload, "scheme") ? normalizeScheme(payload.scheme) : current.scheme, - host: this.resolveSystemHost(current), port: hasOwn(payload, "port") ? normalizePort(payload.port) : current.port, status: hasOwn(payload, "status") ? (normalizeText(payload.status) === "disabled" ? "disabled" : "active") @@ -397,15 +395,12 @@ export class SystemService { normalized.application_name ]); - const baseUrl = this.buildSystemBaseUrl(normalized); await this.managementClient.call("apps.create", { systemId: normalized.application_name, name: normalized.application_name, provider: normalized.provider, ontologyName: normalized.application_name, description: normalized.description, - applicationEndpoint: baseUrl, - sessionServiceEndpoint: baseUrl, status: normalized.status, bucket, objectKey, @@ -416,16 +411,15 @@ export class SystemService { const result = this.db.prepare(` INSERT INTO business_systems ( provider, application_name, description, ontology_artifact_id, - scheme, host, port, status, last_connectivity_test_status, last_connectivity_test_result_json, + scheme, port, status, last_connectivity_test_status, last_connectivity_test_result_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( normalized.provider, normalized.application_name, normalized.description, artifactId, normalized.scheme, - normalized.host, normalized.port, normalized.status, "unknown", @@ -491,7 +485,6 @@ export class SystemService { const existingArtifact = artifact || this.getArtifactStorageInfo(artifactId); - const baseUrl = this.buildSystemBaseUrl(normalized); const previousSystemId = current.application_name; const { deletedApplicationNames } = await this.deleteRemoteOntologiesIfPresent([ previousSystemId, @@ -504,8 +497,6 @@ export class SystemService { provider: normalized.provider, ontologyName: normalized.application_name, description: normalized.description, - applicationEndpoint: baseUrl, - sessionServiceEndpoint: baseUrl, status: normalized.status, bucket: existingArtifact?.bucket, objectKey: existingArtifact?.object_key || existingArtifact?.objectKey, @@ -515,7 +506,7 @@ export class SystemService { this.db.prepare(` UPDATE business_systems SET provider = ?, application_name = ?, description = ?, ontology_artifact_id = ?, - scheme = ?, host = ?, port = ?, status = ?, updated_at = ? + scheme = ?, port = ?, status = ?, updated_at = ? WHERE id = ? `).run( normalized.provider, @@ -523,7 +514,6 @@ export class SystemService { normalized.description, artifactId, normalized.scheme, - normalized.host, normalized.port, normalized.status, toChinaISOString(), @@ -544,9 +534,6 @@ export class SystemService { } const normalizedStatus = normalizeText(status) === "disabled" ? "disabled" : "active"; - await this.managementClient.call(normalizedStatus === "active" ? "apps.enable" : "apps.disable", { - systemId: current.application_name - }); this.db.prepare(` UPDATE business_systems SET status = ?, updated_at = ? @@ -735,20 +722,6 @@ export class SystemService { createdAt ); - try { - await this.managementClient.call("apps.log.record", { - systemId: log.application_name, - agentId: log.agent_slug, - conversationId: log.session_id, - provider: FIXED_BUSINESS_SYSTEM_PROVIDER, - applicationName: log.application_name, - commandName: log.command_name, - inputParams: requestSummary.preview, - responseTimeMs: log.response_time_ms, - success: log.success, - responseContent: responseSummary.preview - }); - } catch {} } listLogs({ applicationName = "", agentSlug = "" } = {}) { diff --git a/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js b/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js index 83ef56e..779583c 100644 --- a/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js @@ -286,7 +286,6 @@ function createDb() { description TEXT NOT NULL, ontology_artifact_id INTEGER, scheme TEXT NOT NULL, - host TEXT NOT NULL, port INTEGER NOT NULL, status TEXT NOT NULL, last_connectivity_test_status TEXT NOT NULL, @@ -380,16 +379,15 @@ it("syncs startup catalog snapshot with one management action runtime call", asy db.prepare(` INSERT INTO business_systems ( provider, application_name, description, ontology_artifact_id, - scheme, host, port, status, last_connectivity_test_status, last_connectivity_test_result_json, + scheme, port, status, last_connectivity_test_status, last_connectivity_test_result_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( "hzg", "crm", "keep-local", null, "http", - "crm.example.com", 8080, "active", "unknown", @@ -400,16 +398,15 @@ it("syncs startup catalog snapshot with one management action runtime call", asy db.prepare(` INSERT INTO business_systems ( provider, application_name, description, ontology_artifact_id, - scheme, host, port, status, last_connectivity_test_status, last_connectivity_test_result_json, + scheme, port, status, last_connectivity_test_status, last_connectivity_test_result_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( "hzg", "legacy", "remove-local", null, "http", - "legacy.example.com", 8080, "active", "unknown", @@ -509,7 +506,7 @@ it("syncs startup catalog snapshot with one management action runtime call", asy templates: 1, agents: 1, skills: 1, - systems: 1 + systems: 2 }); expect(calls).toEqual(["catalog.snapshot"]); @@ -545,21 +542,21 @@ it("syncs startup catalog snapshot with one management action runtime call", asy jasmine.objectContaining({ slug: "global-skill", description: "" }) ]); expect(db.prepare("SELECT application_name, is_builtin FROM business_systems ORDER BY application_name").all()).toEqual([ - jasmine.objectContaining({ application_name: "crm", is_builtin: 1 }) + jasmine.objectContaining({ application_name: "crm", is_builtin: 1 }), + jasmine.objectContaining({ application_name: "legacy", is_builtin: 0 }) ]); - expect(db.prepare("SELECT provider, scheme, host, port, status FROM business_systems WHERE application_name = ?").get("crm")).toEqual({ - provider: "phx", - scheme: "https", - host: "crm-kernel.example.com", - port: 8443, - status: "disabled" + expect(db.prepare("SELECT provider, scheme, port, status FROM business_systems WHERE application_name = ?").get("crm")).toEqual({ + provider: "hzg", + scheme: "http", + port: 8080, + status: "active" }); expect(service.getLlmSyncStatus().summary).toEqual({ models: 2 }); expect(service.getTemplateSyncStatus().summary).toEqual({ templates: 1 }); expect(service.getAgentSyncStatus().summary).toEqual({ agents: 1 }); expect(service.getSkillSyncStatus().summary).toEqual({ skills: 1 }); - expect(service.getSystemSyncStatus().summary).toEqual({ systems: 1 }); + expect(service.getSystemSyncStatus().summary).toEqual({ systems: 2 }); expect(service.getUsageRefreshStatus().status).toBe("idle"); expect(service.getUsageRefreshStatus().summary).toEqual({}); }); diff --git a/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js b/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js index 664ad7b..efed15f 100644 --- a/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js +++ b/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js @@ -38,6 +38,14 @@ function restoreDataDir(previousDataDir) { } } +function restoreEnvironmentValue(name, previousValue) { + if (previousValue === undefined) { + delete process.env[name]; + } else { + process.env[name] = previousValue; + } +} + it("resets drifted legacy schemas to the latest database structure", async () => { const previousDataDir = process.env.DA_WEB_DATA_DIR; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-web-db-reset-test-")); @@ -162,7 +170,7 @@ it("resets drifted legacy schemas to the latest database structure", async () => const settingsColumns = db.prepare("PRAGMA table_info(settings)").all().map((row) => row.name); - expect(schemaVersion?.value).toBe("11"); + expect(schemaVersion?.value).toBe("12"); expect(settingsColumns).toContain("logo_mime_type"); expect(settingsColumns).toContain("logo_data"); expect(agentUsersTable?.name).toBe("agent_users"); @@ -190,6 +198,7 @@ it("resets drifted legacy schemas to the latest database structure", async () => expect(skillColumns).not.toContain("distribution_scope"); expect(skillColumns).toContain("is_builtin"); expect(systemColumns).toContain("scheme"); + expect(systemColumns).not.toContain("host"); expect(systemColumns).not.toContain("schema"); expect(systemColumns).toContain("last_connectivity_test_status"); expect(systemColumns).toContain("last_connectivity_test_result_json"); @@ -334,7 +343,7 @@ it("migrates v1 agents to template relationships without dropping data", async ( ORDER BY slug `).all(); - expect(schemaVersion?.value).toBe("11"); + expect(schemaVersion?.value).toBe("12"); expect(agentColumns).toContain("template_name"); expect(agentColumns).toContain("llm_model_ref"); expect(agentColumns).toContain("fallback_llm_model_ref"); @@ -456,7 +465,7 @@ it("migrates v3 templates to default llm columns and removes template descriptio const templateColumns = db.prepare("PRAGMA table_info(agent_templates)").all().map((row) => row.name); const template = db.prepare("SELECT template_name, default_llm_model_ref FROM agent_templates WHERE template_name = ?").get("default"); - expect(schemaVersion?.value).toBe("11"); + expect(schemaVersion?.value).toBe("12"); expect(templateColumns).toContain("default_llm_provider_id"); expect(templateColumns).toContain("default_llm_model_id"); expect(templateColumns).toContain("default_llm_model_ref"); @@ -540,7 +549,7 @@ it("migrates v4 settings through uploaded logo columns without dropping data", a const schemaVersion = db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get(); const settings = db.prepare("SELECT portal_name, brand_subtitle, theme_color, logo_mime_type, logo_data FROM settings WHERE id = 1").get(); - expect(schemaVersion?.value).toBe("11"); + expect(schemaVersion?.value).toBe("12"); expect(settings).toEqual({ portal_name: "daedalus-agent", brand_subtitle: "智能体管理与运营平台", @@ -681,7 +690,7 @@ it("migrates v5 templates by dropping template description without dropping data WHERE template_name = ? `).get("default"); - expect(schemaVersion?.value).toBe("11"); + expect(schemaVersion?.value).toBe("12"); expect(templateColumns).not.toContain("description"); expect(template).toEqual({ id: 1, @@ -789,7 +798,7 @@ it("migrates v10 users and portal settings to the daedalus-agent identity", asyn `).get(); const user = db.prepare("SELECT id, role, username, display_name, password_hash FROM users WHERE id = 1").get(); - expect(schemaVersion?.value).toBe("11"); + expect(schemaVersion?.value).toBe("12"); expect(settings).toEqual({ portal_name: "daedalus-agent", brand_subtitle: "智能体管理与运营平台", @@ -807,3 +816,90 @@ it("migrates v10 users and portal settings to the daedalus-agent identity", asyn restoreDataDir(previousDataDir); }); + +it("migrates the configured huozige host from v11 into runtime settings", async () => { + const previousDataDir = process.env.DA_WEB_DATA_DIR; + const previousUserEnvJson = process.env.DA_USER_ENV_JSON; + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-web-db-v11-v12-test-")); + const envPath = path.join(dataDir, "env.json"); + const dbFile = path.join(dataDir, "management-console.db"); + + try { + process.env.DA_WEB_DATA_DIR = dataDir; + process.env.DA_USER_ENV_JSON = envPath; + + const seedModuleUrl = new URL(`../src/db/index.js?case=${Date.now()}-v11-seed`, import.meta.url); + const { db: seedDb } = await import(seedModuleUrl.href); + seedDb.close(); + + const setupDb = new DatabaseSync(dbFile); + setupDb.exec(` + DROP TABLE runtime_settings; + ALTER TABLE business_systems ADD COLUMN host TEXT NOT NULL DEFAULT ''; + UPDATE schema_meta SET value = '11' WHERE key = 'schema_version'; + `); + setupDb.prepare(` + INSERT INTO business_systems ( + provider, application_name, description, ontology_artifact_id, + scheme, port, status, last_connectivity_test_status, + last_connectivity_test_result_json, is_builtin, created_at, updated_at, host + ) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "hzg", + "crm", + "CRM", + "http", + 8080, + "active", + "unknown", + "{}", + 0, + "2026-08-01T00:00:00.000Z", + "2026-08-01T00:00:00.000Z", + "stale.example.com" + ); + setupDb.close(); + fs.writeFileSync(envPath, JSON.stringify({ + DA_HUOZIGE_SERVER_IP: "configured.example.com", + DA_INTERNAL_NETWORK_ALLOWLIST: "configured.example.com" + }, null, 2)); + + const moduleUrl = new URL(`../src/db/index.js?case=${Date.now()}-v11-v12`, import.meta.url); + const { db } = await import(moduleUrl.href); + const schemaVersion = db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get(); + const systemColumns = db.prepare("PRAGMA table_info(business_systems)").all().map((row) => row.name); + const runtimeHost = db.prepare(` + SELECT setting_value + FROM runtime_settings + WHERE setting_key = 'huozige_server_host' + `).get(); + const system = db.prepare(` + SELECT application_name, scheme, port, status + FROM business_systems + WHERE application_name = 'crm' + `).get(); + + expect(schemaVersion?.value).toBe("12"); + expect(systemColumns).not.toContain("host"); + expect(runtimeHost?.setting_value).toBe("configured.example.com"); + expect(system).toEqual({ + application_name: "crm", + scheme: "http", + port: 8080, + status: "active" + }); + db.close(); + + const restartModuleUrl = new URL(`../src/db/index.js?case=${Date.now()}-v12-restart`, import.meta.url); + const { db: restartedDb } = await import(restartModuleUrl.href); + expect(restartedDb.prepare(` + SELECT setting_value + FROM runtime_settings + WHERE setting_key = 'huozige_server_host' + `).get()?.setting_value).toBe("configured.example.com"); + restartedDb.close(); + } finally { + restoreDataDir(previousDataDir); + restoreEnvironmentValue("DA_USER_ENV_JSON", previousUserEnvJson); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/external-service.test.js b/packages/daedalus-agent-management-web/server/test/external-service.test.js index 8d097b3..f7f5ad1 100644 --- a/packages/daedalus-agent-management-web/server/test/external-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/external-service.test.js @@ -51,7 +51,6 @@ function createDb() { description TEXT NOT NULL DEFAULT '', ontology_artifact_id INTEGER, scheme TEXT NOT NULL, - host TEXT NOT NULL, port INTEGER NOT NULL, status TEXT NOT NULL, last_connectivity_test_status TEXT NOT NULL DEFAULT 'unknown', @@ -71,8 +70,8 @@ function createDb() { ); INSERT INTO agent_permissions (agent_id, agent_user_id) VALUES (1, 1); INSERT INTO business_systems ( - id, provider, application_name, scheme, host, port, status - ) VALUES (1, 'hzg', 'crm-app', 'https', 'example.com', 443, 'active'); + id, provider, application_name, scheme, port, status + ) VALUES (1, 'hzg', 'crm-app', 'https', 443, 'active'); `); return db; diff --git a/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js b/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js index fffe097..9726be3 100644 --- a/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { ContainerService } from "../src/services/container-service.js"; import { RuntimeEnvConfigService } from "../src/services/runtime-env-config-service.js"; @@ -9,6 +10,19 @@ function createTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-env-config-")); } +function createRuntimeSettingsDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE runtime_settings ( + setting_key TEXT PRIMARY KEY, + setting_value TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + return db; +} + it("reads the agent internal network allowlist from env.json", () => { const tempDir = createTempDir(); const envPath = path.join(tempDir, "env.json"); @@ -18,7 +32,7 @@ it("reads the agent internal network allowlist from env.json", () => { }, null, 2)); try { - const service = new RuntimeEnvConfigService({ envPath }); + const service = new RuntimeEnvConfigService({ db: createRuntimeSettingsDb(), envPath }); expect(service.getNetworkAllowlistConfig()).toEqual({ agent_internal_network_allowlist: "192.168.1.10,10.20.0.0/16", @@ -39,7 +53,7 @@ it("updates the agent internal network allowlist without dropping other env.json }, null, 2)); try { - const service = new RuntimeEnvConfigService({ envPath }); + const service = new RuntimeEnvConfigService({ db: createRuntimeSettingsDb(), envPath }); const result = service.updateNetworkAllowlistConfig({ agent_internal_network_allowlist: " 192.168.1.10 ; 10.20.0.0/16 " }); @@ -47,7 +61,8 @@ it("updates the agent internal network allowlist without dropping other env.json expect(result).toEqual({ agent_internal_network_allowlist: "192.168.1.10 ; 10.20.0.0/16", - huozige_server_ip: "" + huozige_server_ip: "", + network_allowlist_changed: true }); expect(saved).toEqual({ DA_MANAGEMENT_CONSOLE_PORT: 3030, @@ -59,7 +74,7 @@ it("updates the agent internal network allowlist without dropping other env.json } }); -it("updates the huozige server ip and replaces it in the allowlist", () => { +it("migrates the huozige server ip to SQLite and removes the legacy env.json value", () => { const tempDir = createTempDir(); const envPath = path.join(tempDir, "env.json"); fs.writeFileSync(envPath, JSON.stringify({ @@ -69,21 +84,127 @@ it("updates the huozige server ip and replaces it in the allowlist", () => { }, null, 2)); try { - const service = new RuntimeEnvConfigService({ envPath }); + const db = createRuntimeSettingsDb(); + const service = new RuntimeEnvConfigService({ db, envPath }); + expect(service.getNetworkAllowlistConfig().huozige_server_ip).toBe("192.168.1.10"); + const result = service.updateNetworkAllowlistConfig({ huozige_server_ip: "192.168.1.11" }); const saved = JSON.parse(fs.readFileSync(envPath, "utf8")); + const persisted = db.prepare(` + SELECT setting_value + FROM runtime_settings + WHERE setting_key = 'huozige_server_host' + `).get(); expect(result).toEqual({ agent_internal_network_allowlist: "10.0.0.2,10.20.0.0/16,192.168.1.11", - huozige_server_ip: "192.168.1.11" + huozige_server_ip: "192.168.1.11", + network_allowlist_changed: true }); expect(saved).toEqual({ DA_MANAGEMENT_CONSOLE_PORT: 3030, - DA_HUOZIGE_SERVER_IP: "192.168.1.11", DA_INTERNAL_NETWORK_ALLOWLIST: "10.0.0.2,10.20.0.0/16,192.168.1.11" }); + expect(persisted.setting_value).toBe("192.168.1.11"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +it("does not add a huozige domain to the internal network allowlist", () => { + const tempDir = createTempDir(); + const envPath = path.join(tempDir, "env.json"); + fs.writeFileSync(envPath, JSON.stringify({ + DA_INTERNAL_NETWORK_ALLOWLIST: "10.20.0.0/16" + }, null, 2)); + + try { + const db = createRuntimeSettingsDb(); + const service = new RuntimeEnvConfigService({ db, envPath }); + const domainResult = service.updateNetworkAllowlistConfig({ + huozige_server_ip: "apps.example.com" + }); + + expect(domainResult).toEqual({ + agent_internal_network_allowlist: "10.20.0.0/16", + huozige_server_ip: "apps.example.com", + network_allowlist_changed: false + }); + expect(JSON.parse(fs.readFileSync(envPath, "utf8"))).toEqual({ + DA_INTERNAL_NETWORK_ALLOWLIST: "10.20.0.0/16" + }); + + const nextDomainResult = service.updateNetworkAllowlistConfig({ + huozige_server_ip: "service.example.com" + }); + expect(nextDomainResult).toEqual({ + agent_internal_network_allowlist: "10.20.0.0/16", + huozige_server_ip: "service.example.com", + network_allowlist_changed: false + }); + + const ipResult = service.updateNetworkAllowlistConfig({ + huozige_server_ip: "192.168.1.20" + }); + expect(ipResult).toEqual({ + agent_internal_network_allowlist: "10.20.0.0/16,192.168.1.20", + huozige_server_ip: "192.168.1.20", + network_allowlist_changed: true + }); + + const domainAfterIpResult = service.updateNetworkAllowlistConfig({ + huozige_server_ip: "apps.example.com" + }); + expect(domainAfterIpResult).toEqual({ + agent_internal_network_allowlist: "10.20.0.0/16", + huozige_server_ip: "apps.example.com", + network_allowlist_changed: true + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +it("removes a migrated huozige domain from the internal network allowlist", () => { + const tempDir = createTempDir(); + const envPath = path.join(tempDir, "env.json"); + fs.writeFileSync(envPath, JSON.stringify({ + DA_HUOZIGE_SERVER_IP: "apps.example.com", + DA_INTERNAL_NETWORK_ALLOWLIST: "10.20.0.0/16,apps.example.com" + }, null, 2)); + + try { + const service = new RuntimeEnvConfigService({ db: createRuntimeSettingsDb(), envPath }); + + expect(service.getHuozigeServerHost()).toBe("apps.example.com"); + expect(JSON.parse(fs.readFileSync(envPath, "utf8"))).toEqual({ + DA_INTERNAL_NETWORK_ALLOWLIST: "10.20.0.0/16" + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +it("removes the selected domain when it already exists in the allowlist", () => { + const tempDir = createTempDir(); + const envPath = path.join(tempDir, "env.json"); + fs.writeFileSync(envPath, JSON.stringify({ + DA_INTERNAL_NETWORK_ALLOWLIST: "10.20.0.0/16,apps.example.com" + }, null, 2)); + + try { + const service = new RuntimeEnvConfigService({ db: createRuntimeSettingsDb(), envPath }); + const result = service.updateNetworkAllowlistConfig({ + huozige_server_ip: "apps.example.com" + }); + + expect(result).toEqual({ + agent_internal_network_allowlist: "10.20.0.0/16", + huozige_server_ip: "apps.example.com", + network_allowlist_changed: true + }); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } diff --git a/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js b/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js index df53c01..f1222c8 100644 --- a/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js +++ b/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js @@ -176,7 +176,7 @@ it("updates the env allowlist config and writes an audit log", async () => { userId: 1, username: "daedalus-agent", action: "保存 IP 白名单", - detail: "更新 env.json 中的 DA_INTERNAL_NETWORK_ALLOWLIST / DA_HUOZIGE_SERVER_IP" + detail: "更新数据库中的活字格服务器地址及 env.json 中的 DA_INTERNAL_NETWORK_ALLOWLIST" }]); } finally { await close(server); diff --git a/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js b/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js index afba27c..2b2f836 100644 --- a/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js +++ b/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js @@ -40,6 +40,62 @@ function createDb({ currentSystem = null, createdSystem = null } = {}) { }; } +it("requires the global huozige server host when normalizing systems", () => { + const service = new SystemService({ + db: createDb(), + objectStorage: {}, + managementClient: {}, + runtimeEnvConfigService: { + getHuozigeServerHost() { + return ""; + } + } + }); + + expect(() => service.normalizeCreatePayload({ + application_name: "demo-app", + scheme: "https", + port: 443, + status: "active" + })).toThrowError(/请先在基础设置中配置/); +}); + +it("uses a changed global host without updating the business system row", () => { + const system = { + id: 1, + provider: "hzg", + application_name: "demo-app", + scheme: "https", + port: 443, + status: "active" + }; + let configuredHost = "old.example.com"; + const service = new SystemService({ + db: { + prepare() { + return { + get() { + return system; + } + }; + } + }, + objectStorage: {}, + managementClient: {}, + runtimeEnvConfigService: { + getHuozigeServerHost() { + return configuredHost; + } + } + }); + + expect(service.getInvocationBaseUrlByApplicationName("demo-app").baseUrl) + .toBe("https://old.example.com:443/demo-app"); + configuredHost = "new.example.com"; + expect(service.getInvocationBaseUrlByApplicationName("demo-app").baseUrl) + .toBe("https://new.example.com:443/demo-app"); +}); + it("maps website fields to management action runtime apps.create params", async () => { const createdSystem = { id: 1, @@ -48,7 +104,6 @@ it("maps website fields to management action runtime apps.create params", async description: "desc", ontology_artifact_id: 9, scheme: "https", - host: "example.com", port: 443, status: "active", last_connectivity_test_status: "unknown", @@ -140,8 +195,6 @@ it("maps website fields to management action runtime apps.create params", async provider: "hzg", ontologyName: "demo-app", description: "desc", - applicationEndpoint: "https://example.com:443/demo-app", - sessionServiceEndpoint: "https://example.com:443/demo-app", status: "active", bucket: "admin-in", objectKey: "ontology/demo.zip", @@ -157,7 +210,6 @@ it("recreates remote system with the configured huozige server host", async () = description: "old desc", ontology_artifact_id: 7, scheme: "https", - host: "old.example.com", port: 443, status: "disabled" }; @@ -165,7 +217,6 @@ it("recreates remote system with the configured huozige server host", async () = ...currentSystem, description: "new desc", scheme: "http", - host: "configured.example.com", port: 8443, status: "active" }; @@ -217,8 +268,6 @@ it("recreates remote system with the configured huozige server host", async () = provider: "hzg", ontologyName: "demo-app", description: "new desc", - applicationEndpoint: "http://configured.example.com:8443/demo-app", - sessionServiceEndpoint: "http://configured.example.com:8443/demo-app", status: "active", bucket: "admin-in", objectKey: "ontology/existing.zip", @@ -234,7 +283,6 @@ it("rejects deleting built-in ontology systems", async () => { description: "desc", ontology_artifact_id: 7, scheme: "https", - host: "example.com", port: 443, status: "active", is_builtin: 1 diff --git a/packages/daedalus-agent-management-web/src/pages/SettingsPage.jsx b/packages/daedalus-agent-management-web/src/pages/SettingsPage.jsx index 6f1bbf5..c1f3f83 100644 --- a/packages/daedalus-agent-management-web/src/pages/SettingsPage.jsx +++ b/packages/daedalus-agent-management-web/src/pages/SettingsPage.jsx @@ -693,9 +693,11 @@ export function SettingsPage({ agent_internal_network_allowlist: savedRuntimeConfig.agent_internal_network_allowlist || "" }); notify?.success("对话应用/业务应用服务器已保存"); - setContainerRestartConfirmTitle("对话应用/业务应用服务器已保存"); - setContainerRestartConfirmContent("活字格服务器地址已保存到 env.json,并已同步调整 IP 白名单:旧地址已移除,新地址已加入。重启容器后生效。点击确定将立即重启整个容器;取消后可以继续使用当前页面。"); - setContainerRestartConfirmOpen(true); + if (savedRuntimeConfig.network_allowlist_changed) { + setContainerRestartConfirmTitle("对话应用/业务应用服务器已保存"); + setContainerRestartConfirmContent("活字格服务器地址已保存到系统数据库,并已同步调整 IP 白名单。新地址会立即用于业务调用;IP 白名单需重启容器后生效。点击确定将立即重启整个容器;取消后可以继续使用当前页面。"); + setContainerRestartConfirmOpen(true); + } } } catch (error) { notify?.error(`基础设置保存失败:${getErrorMessage(error, "保存失败")}`); @@ -981,7 +983,7 @@ export function SettingsPage({ { diff --git a/packages/daedalus-agent-management-web/test/integration/full.js b/packages/daedalus-agent-management-web/test/integration/full.js index 10ed1c3..d111150 100644 --- a/packages/daedalus-agent-management-web/test/integration/full.js +++ b/packages/daedalus-agent-management-web/test/integration/full.js @@ -370,6 +370,17 @@ async function main() { addAction(report, "settings.huozige-server.update", hzgServerUpdate.ok, hzgServerUpdate.status, hzgServerUpdate.body); assertOk(hzgServerUpdate, "settings.huozige-server.update"); + const internalSystemAfterHostUpdate = await session.get(`/api/internal/app-invoke/base-url?applicationName=${applicationName}`); + addAction( + report, + "internal.app-invoke.base-url-after-host-update", + internalSystemAfterHostUpdate.ok + && internalSystemAfterHostUpdate.body?.baseUrl === `https://service2.example.com:443/${applicationName}`, + internalSystemAfterHostUpdate.status, + internalSystemAfterHostUpdate.body + ); + assertOk(internalSystemAfterHostUpdate, "internal.app-invoke.base-url-after-host-update"); + const systemUpdate = await session.multipart("PUT", `/api/systems/${systemRow.id}`, { description: "integration system updated", status: "disabled" diff --git a/test/case/stress/stress-flow.js b/test/case/stress/stress-flow.js index 91e3c38..e406747 100644 --- a/test/case/stress/stress-flow.js +++ b/test/case/stress/stress-flow.js @@ -766,7 +766,6 @@ async function createSystems(baseUrl, token, names, artifactRoot, stressConfig) application_name: systemName, description: `Generated stress system ${systemName}`, scheme: "http", - host: "127.0.0.1", port: 18080 + (index % 1000), status: "active" }, { diff --git a/test/prepare/stress/rebuild-stress-env.js b/test/prepare/stress/rebuild-stress-env.js index b7055da..aa56d74 100644 --- a/test/prepare/stress/rebuild-stress-env.js +++ b/test/prepare/stress/rebuild-stress-env.js @@ -735,9 +735,9 @@ async function createManagementDb(env, names, assignments) { const insertSystem = db.prepare(` INSERT INTO business_systems ( provider, application_name, description, ontology_artifact_id, - scheme, host, port, status, last_connectivity_test_status, + scheme, port, status, last_connectivity_test_status, last_connectivity_test_result_json, is_builtin, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', '{}', 0, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, 'active', 'unknown', '{}', 0, ?, ?) `); const insertAgent = db.prepare(` INSERT INTO agents ( @@ -755,6 +755,14 @@ async function createManagementDb(env, names, assignments) { db.exec("BEGIN"); try { + db.prepare(` + INSERT INTO runtime_settings ( + setting_key, setting_value, created_at, updated_at + ) VALUES ('huozige_server_host', '127.0.0.1', ?, ?) + ON CONFLICT(setting_key) DO UPDATE SET + setting_value = excluded.setting_value, + updated_at = excluded.updated_at + `).run(now, now); db.prepare(` INSERT INTO llm_providers ( provider_id, base_url, api_key_mask, api_key_fingerprint, api, @@ -834,7 +842,6 @@ async function createManagementDb(env, names, assignments) { `Generated stress test system ${systemName}.`, result.lastInsertRowid, "http", - "127.0.0.1", 18080, now, now @@ -947,29 +954,6 @@ function writeStressFiles(env, names, assignments, mqttPassword) { agents: managedAgents }); - const systems = names.systemNames.map((systemName, index) => ({ - id: systemName, - name: systemName, - provider: index % 2 === 0 ? "hzg" : "phx", - ontologyName: systemName, - ontologyPackage: `${systemName}.zip`, - description: `Generated stress test system ${systemName}.`, - applicationEndpoint: `http://127.0.0.1:18080/${systemName}`, - sessionServiceEndpoint: `http://127.0.0.1:18080/${systemName}`, - status: "active", - createdAt: toChinaISOString(), - updatedAt: toChinaISOString(), - lastImportedAt: toChinaISOString() - })); - writeJson(path.join(managementStateRoot, "business-systems.json"), { - version: 1, - items: systems - }); - writeJson(path.join(managementStateRoot, "system-logs.json"), { - version: 1, - items: [] - }); - const openClawConfig = buildOpenClawConfig({ names, model: env.model, -- Gitee From df0cad5b6a7ac47ed1076eb86ea9cf3120c14dd2 Mon Sep 17 00:00:00 2001 From: Ning Wei <55093136@qq.com> Date: Wed, 5 Aug 2026 18:25:20 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E7=A6=81=E7=94=A8=E5=BF=83=E8=B7=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/assets/config.json | 1 + docker/assets/scripts/kernel-startup.sh | 1 + docker/assets/scripts/startup.sh | 1 + test/case/upgrade/openclaw-upgrade-contract.test.js | 6 ++++-- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docker/assets/config.json b/docker/assets/config.json index 3a6c0d3..c074978 100644 --- a/docker/assets/config.json +++ b/docker/assets/config.json @@ -4,6 +4,7 @@ "workspace": "/var/daedalus-agent/.openclaw/workspaces/default", "skipBootstrap": true, "heartbeat": { + "every": "0m", "isolatedSession": true, "lightContext": true } diff --git a/docker/assets/scripts/kernel-startup.sh b/docker/assets/scripts/kernel-startup.sh index ca88041..ed6e6ce 100644 --- a/docker/assets/scripts/kernel-startup.sh +++ b/docker/assets/scripts/kernel-startup.sh @@ -2207,6 +2207,7 @@ patch_openclaw_config() { .agents.defaults.workspace = $workspace | .agents.defaults.maxConcurrent = $gatewayMaxConcurrent | .agents.defaults.skipBootstrap = true + | .agents.defaults.heartbeat.every = "0m" | .agents.defaults.heartbeat.isolatedSession = true | .agents.defaults.heartbeat.lightContext = true | .gateway.mode = "local" diff --git a/docker/assets/scripts/startup.sh b/docker/assets/scripts/startup.sh index 237ea9a..256a814 100644 --- a/docker/assets/scripts/startup.sh +++ b/docker/assets/scripts/startup.sh @@ -757,6 +757,7 @@ config.agents.defaults = config.agents.defaults || {}; config.agents.defaults.workspace = path.posix.join(productRoot, ".openclaw/workspaces/default"); config.agents.defaults.heartbeat = { ...(config.agents.defaults.heartbeat || {}), + every: "0m", isolatedSession: true, lightContext: true }; diff --git a/test/case/upgrade/openclaw-upgrade-contract.test.js b/test/case/upgrade/openclaw-upgrade-contract.test.js index dc644d0..ee259da 100644 --- a/test/case/upgrade/openclaw-upgrade-contract.test.js +++ b/test/case/upgrade/openclaw-upgrade-contract.test.js @@ -20,7 +20,7 @@ test("Docker image pins the supported OpenClaw and Node versions", async () => { assert.match(dockerfile, /^FROM npm-builder-base AS web-installer$/m); }); -test("default workspace skips idle heartbeat model calls", async () => { +test("Docker runtime disables idle heartbeat model calls", async () => { const dockerfile = await readRepoFile("docker/Dockerfile"); const config = JSON.parse(await readRepoFile("docker/assets/config.json")); const startup = await readRepoFile("docker/assets/scripts/startup.sh"); @@ -39,10 +39,12 @@ test("default workspace skips idle heartbeat model calls", async () => { ); assert.match(dockerfile, /for doc in AGENTS\.md USER\.md SOUL\.md HEARTBEAT\.md;/); assert.deepEqual(config.agents.defaults.heartbeat, { + every: "0m", isolatedSession: true, lightContext: true }); - assert.match(startup, /isolatedSession: true,\n lightContext: true/); + assert.match(startup, /every: "0m",\n isolatedSession: true,\n lightContext: true/); + assert.match(kernelStartup, /\.agents\.defaults\.heartbeat\.every = "0m"/); assert.match(kernelStartup, /\.agents\.defaults\.heartbeat\.isolatedSession = true/); assert.match(kernelStartup, /\.agents\.defaults\.heartbeat\.lightContext = true/); }); -- Gitee From f5af039518240be13b9edda1772441533b0f0034 Mon Sep 17 00:00:00 2001 From: Ning Wei <55093136@qq.com> Date: Thu, 6 Aug 2026 09:58:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=B8=BA=E5=8D=95=E4=B8=80=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/BUILD.md | 2 +- docker/README.md | 14 +- docker/assets/scripts/kernel-startup.sh | 4 +- docker/assets/scripts/startup.sh | 44 +- docker/docs/environment-variables.md | 9 +- docker/docs/startup-steps.md | 2 +- docker/sample/env.json | 3 +- .../capabilities/agent-template/service.ts | 74 +++- .../capabilities/agent/lifecycle-service.ts | 100 ++++- .../capabilities/agent/service.ts | 136 ++++-- .../capabilities/agent/state-store.ts | 7 +- .../capabilities/agent/update.ts | 6 + .../capabilities/agent/workspace-service.ts | 40 +- .../management-src/capabilities/context.ts | 2 + .../capabilities/ontology/service.ts | 79 +++- .../management-src/capabilities/registry.ts | 2 + .../management-src/tools/atomic-file.ts | 51 +++ .../server/management-src/types.ts | 12 + .../management-test/atomic-file.test.ts | 23 ++ .../capabilities-registry.test.ts | 8 + .../management-test/ontology-manager.test.ts | 60 ++- .../management-test/openclaw-manager.test.ts | 57 +++ .../management-test/template-manager.test.ts | 28 ++ .../server/src/api/routes/index.js | 2 +- .../server/src/db/index.js | 4 +- .../server/src/infra/s3/object-storage.js | 12 +- .../server/src/services/agent-service.js | 113 ++--- .../services/large-language-model-service.js | 159 +++++-- .../server/src/services/portal-service.js | 310 +++++++++----- .../services/runtime-env-config-service.js | 121 ++++-- .../server/src/services/system-service.js | 388 +++++++++++------- .../server/test/agent-service-create.test.js | 49 ++- .../test/portal-service-alignment.test.js | 37 ++ .../test/runtime-env-config-service.test.js | 54 ++- .../test/runtime-settings-route.test.js | 2 +- .../test/system-service-alignment.test.js | 213 ++++++++-- .../src/brand.js | 4 +- test/README.md | 5 +- test/case/integration/playwright-flow.js | 1 - ...-reuse-concurrency-matrix-contract.test.js | 77 ++++ test/common/mock-openai-completions-server.js | 2 +- test/common/mock-openai-server.js | 2 +- test/common/run-container-flow.core.sh | 14 +- .../config.integration.sample.json | 2 +- test/integration/run-flow.windows.ps1 | 28 +- test/package.json | 4 +- test/prepare/stress/rebuild-stress-env.js | 3 +- test/stress/config.stress.sample.json | 2 +- .../integration/fast/config.integration.json | 2 +- .../integration/full/config.integration.json | 2 +- .../integration/run-integration-suite.js | 10 +- test/suite/stress/100a100c/config.stress.json | 2 +- test/suite/stress/100a20c/config.stress.json | 2 +- test/suite/stress/100a50c/config.stress.json | 2 +- test/suite/stress/20a10c/config.stress.json | 2 +- test/suite/stress/50a20c/config.stress.json | 2 +- .../run-100a-reuse-concurrency-matrix.js | 137 ++++++- .../stress/run-existing-mock-lane-matrix.js | 2 +- test/suite/stress/run-mock-lane-matrix.js | 2 +- test/suite/stress/run-mock-stress-suite.js | 10 +- 60 files changed, 1992 insertions(+), 554 deletions(-) create mode 100644 packages/daedalus-agent-management-web/server/management-src/capabilities/agent/update.ts create mode 100644 packages/daedalus-agent-management-web/server/management-src/tools/atomic-file.ts create mode 100644 packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts create mode 100644 test/case/stress/100a-reuse-concurrency-matrix-contract.test.js diff --git a/docker/BUILD.md b/docker/BUILD.md index 4fca08e..23c9783 100644 --- a/docker/BUILD.md +++ b/docker/BUILD.md @@ -92,7 +92,7 @@ Docker 容器测试放在根目录 `test`,目录按职责拆分为 `common/` ```powershell npm --prefix test run test:integration:fast npm --prefix test run test:integration:full -npm --prefix test run test:stress:20a10c +npm --prefix test run test:stress npm --prefix test run test:stress:50a20c npm --prefix test run test:stress:100a50c ``` diff --git a/docker/README.md b/docker/README.md index 240f51c..1e19f7b 100644 --- a/docker/README.md +++ b/docker/README.md @@ -47,7 +47,7 @@ docker start daedalus-agent ```json { "DA_MANAGEMENT_CONSOLE_PORT": 3030, - "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 16 + "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 128 } ``` @@ -55,13 +55,7 @@ docker start daedalus-agent 管理控制台的系统状态采用缓存刷新机制:启动后默认延迟 60 秒首次采集,之后每 60 秒刷新一次,覆盖 MQTT、S3、内核、插件加载和调用服务等状态;可通过 `DA_SYSTEM_STATUS_INITIAL_DELAY_MS`、`DA_SYSTEM_STATUS_REFRESH_INTERVAL_MS`、`DA_SYSTEM_STATUS_TIMEOUT_MS` 调整。 -如需允许 OpenClaw/agent 访问指定局域网地址,可追加: - -```json -{ - "DA_INTERNAL_NETWORK_ALLOWLIST": "192.168.1.10,10.20.0.0/16" -} -``` +OpenClaw/agent 的局域网白名单通过管理控制台“系统设置 -> 基础设置”维护并保存在 SQLite;修改后重启容器生效。紧急运维时也可通过容器环境变量 `DA_INTERNAL_NETWORK_ALLOWLIST` 临时覆盖数据库值。 ## 运行数据 @@ -119,7 +113,7 @@ daedalus-agent-mqtt-channel-chat-{yyyyMMdd}.log 顶层脚本守护 MQTT、MinIO、OpenClaw supervisor 和 apps supervisor。apps supervisor 内部以 `daedalus-svc` 守护 management web 和 `daedalus-agent-apps-invoke-cli serve`;OpenClaw supervisor 内部守护 OpenClaw gateway。 -对话应用/业务应用所在的活字格服务器地址通过管理控制台“系统设置 -> 基础设置”统一配置,并保存到 management web 的 SQLite 数据库;修改后会立即用于业务调用。IP 地址会同步维护到 `DA_INTERNAL_NETWORK_ALLOWLIST`,白名单变更需重启容器后生效;域名按外网地址处理,不加入白名单。旧版本 `env.json` 中的 `DA_HUOZIGE_SERVER_IP` 会在升级后的首次启动时迁入数据库并从文件中删除。OpenClaw/agent 侧仍只执行 CLI client,并连接固定内部 socket `/var/daedalus-agent/run/app-invoke.sock`;真实业务系统访问由 `daedalus-svc` 运行的 invoke service 完成。 +对话应用/业务应用所在的活字格服务器地址和内部网络白名单都由管理控制台保存到 SQLite,并在一次事务内更新;修改后的地址会立即用于业务调用,白名单在重启容器后生效。IP 地址会自动加入白名单,域名按外网地址处理,不加入白名单。旧版本 `env.json` 中的 `DA_HUOZIGE_SERVER_IP` 和 `DA_INTERNAL_NETWORK_ALLOWLIST` 仅作为一次性迁移入口,迁移后会从文件中删除。OpenClaw/agent 侧仍只执行 CLI client,并连接固定内部 socket `/var/daedalus-agent/run/app-invoke.sock`;真实业务系统访问由 `daedalus-svc` 运行的 invoke service 完成。 ## 验收与压测 @@ -128,7 +122,7 @@ daedalus-agent-mqtt-channel-chat-{yyyyMMdd}.log ```powershell npm --prefix test run test:integration:fast npm --prefix test run test:integration:full -npm --prefix test run test:stress:20a10c +npm --prefix test run test:stress npm --prefix test run test:stress:50a20c npm --prefix test run test:stress:100a50c ``` diff --git a/docker/assets/scripts/kernel-startup.sh b/docker/assets/scripts/kernel-startup.sh index ed6e6ce..b69f6f8 100644 --- a/docker/assets/scripts/kernel-startup.sh +++ b/docker/assets/scripts/kernel-startup.sh @@ -1963,14 +1963,14 @@ ensure_runtime_env_defaults() { temp_env="${RUNTIME_ENV_JSON}.tmp.$$" jq ' (if has("DA_INTERNAL_NETWORK_ALLOWLIST") then . else . + { "DA_INTERNAL_NETWORK_ALLOWLIST": "" } end) - | (if has("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT") then . else . + { "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": "16" } end) + | (if has("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT") then . else . + { "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": "128" } end) ' "$RUNTIME_ENV_JSON" > "$temp_env" mv "$temp_env" "$RUNTIME_ENV_JSON" ensure_service_file_mode "$RUNTIME_ENV_JSON" 0640 } normalize_openclaw_gateway_max_concurrent() { - local value="${DA_OPENCLAW_GATEWAY_MAX_CONCURRENT:-16}" + local value="${DA_OPENCLAW_GATEWAY_MAX_CONCURRENT:-128}" if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then startup_step_error "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT must be a positive integer" diff --git a/docker/assets/scripts/startup.sh b/docker/assets/scripts/startup.sh index 256a814..0ca4abf 100644 --- a/docker/assets/scripts/startup.sh +++ b/docker/assets/scripts/startup.sh @@ -300,14 +300,13 @@ reject_unsupported_user_env() { ensure_user_env_defaults() { local temp_env - if jq -e 'has("DA_INTERNAL_NETWORK_ALLOWLIST") and has("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT")' "${USER_ENV_JSON}" >/dev/null; then + if jq -e 'has("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT")' "${USER_ENV_JSON}" >/dev/null; then return fi temp_env="${USER_ENV_JSON}.tmp.$$" jq ' - (if has("DA_INTERNAL_NETWORK_ALLOWLIST") then . else . + { "DA_INTERNAL_NETWORK_ALLOWLIST": "" } end) - | (if has("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT") then . else . + { "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 16 } end) + (if has("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT") then . else . + { "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 128 } end) ' "${USER_ENV_JSON}" > "${temp_env}" mv "${temp_env}" "${USER_ENV_JSON}" ensure_service_file "${USER_ENV_JSON}" 0640 @@ -668,11 +667,11 @@ ensure_default_startup_hook() { } generate_runtime_files() { - node - "${USER_ENV_JSON}" "${USER_CONFIG_JSON}" "${CREDENTIALS_JSON}" "${GENERATED_DIR}" "${DA_ROOT}" <<'NODE' + node - "${USER_ENV_JSON}" "${USER_CONFIG_JSON}" "${CREDENTIALS_JSON}" "${GENERATED_DIR}" "${DA_ROOT}" "${DA_WEB_DATA_DIR}/management-console.db" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); -const [userEnvPath, userConfigPath, credentialsPath, generatedDir, productRoot] = process.argv.slice(2); +const [userEnvPath, userConfigPath, credentialsPath, generatedDir, productRoot, databasePath] = process.argv.slice(2); function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); } @@ -681,6 +680,35 @@ const userEnv = readJson(userEnvPath); const credentials = readJson(credentialsPath); const config = readJson(userConfigPath); +function readRuntimeSetting(name) { + if (!fs.existsSync(databasePath)) { + return undefined; + } + let database; + try { + const { DatabaseSync } = require("node:sqlite"); + database = new DatabaseSync(databasePath, { readOnly: true }); + const table = database.prepare(` + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = 'runtime_settings' + `).get(); + if (!table) { + return undefined; + } + return database.prepare(` + SELECT setting_value + FROM runtime_settings + WHERE setting_key = ? + `).get(name)?.setting_value; + } catch (error) { + process.stderr.write(`[daedalus-agent] failed to read runtime setting ${name}: ${error.message}\n`); + return undefined; + } finally { + database?.close(); + } +} + function value(name, fallback = "") { const current = userEnv[name]; if (current === undefined || current === null || current === "") { @@ -742,8 +770,10 @@ const shared = { DA_ONTOLOGY_DIR: path.posix.join(productRoot, "ontology"), DA_TMP_DIR: path.posix.join(productRoot, ".openclaw/tmp"), DA_WEB_DATA_DIR: path.posix.join(productRoot, "management/data"), - DA_INTERNAL_NETWORK_ALLOWLIST: valueWithDefault("DA_INTERNAL_NETWORK_ALLOWLIST", ""), - DA_OPENCLAW_GATEWAY_MAX_CONCURRENT: positiveIntegerWithDefault("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT", 16), + DA_INTERNAL_NETWORK_ALLOWLIST: process.env.DA_INTERNAL_NETWORK_ALLOWLIST + ?? readRuntimeSetting("internal_network_allowlist") + ?? valueWithDefault("DA_INTERNAL_NETWORK_ALLOWLIST", ""), + DA_OPENCLAW_GATEWAY_MAX_CONCURRENT: positiveIntegerWithDefault("DA_OPENCLAW_GATEWAY_MAX_CONCURRENT", 128), DA_MANAGEMENT_WEBSITE_TIMEOUT: String(value("DA_MANAGEMENT_WEBSITE_TIMEOUT", "180")), DA_SYSTEM_STATUS_INITIAL_DELAY_MS: positiveIntegerWithDefault("DA_SYSTEM_STATUS_INITIAL_DELAY_MS", 60000), DA_SYSTEM_STATUS_REFRESH_INTERVAL_MS: positiveIntegerWithDefault("DA_SYSTEM_STATUS_REFRESH_INTERVAL_MS", 60000), diff --git a/docker/docs/environment-variables.md b/docker/docs/environment-variables.md index 70a85d1..021b03b 100644 --- a/docker/docs/environment-variables.md +++ b/docker/docs/environment-variables.md @@ -7,8 +7,7 @@ DAEDALUS_AGENT Docker 支持通过 `/var/daedalus-agent/env.json` 配置少量 | 变量 | 默认值 | 说明 | | --- | --- | --- | | `DA_MANAGEMENT_CONSOLE_PORT` | `3030` | management web 监听端口。建议宿主机映射整个 `3030-3050` 区间。 | -| `DA_OPENCLAW_GATEWAY_MAX_CONCURRENT` | `16` | OpenClaw gateway 执行 lane 并发数,启动时写入 `openclaw.json` 的 `agents.defaults.maxConcurrent`。 | -| `DA_INTERNAL_NETWORK_ALLOWLIST` | 空字符串 | 允许 `daedalus-agent` 访问的局域网地址或 CIDR,支持逗号、空格、分号或 JSON 数组字符串。通过基础设置配置活字格服务器 IP 时会自动同步;域名按外网地址处理,不加入白名单。 | +| `DA_OPENCLAW_GATEWAY_MAX_CONCURRENT` | `128` | OpenClaw gateway 执行 lane 并发数,启动时写入 `openclaw.json` 的 `agents.defaults.maxConcurrent`。 | | `DA_MANAGEMENT_WEBSITE_TIMEOUT` | `180` | management web 访问内部管理动作的超时时间,单位秒。 | | `DA_SYSTEM_STATUS_INITIAL_DELAY_MS` | `60000` | 管理控制台启动后首次采集系统状态的延迟,单位毫秒;覆盖 MQTT、S3、内核、插件加载和调用服务等状态。 | | `DA_SYSTEM_STATUS_REFRESH_INTERVAL_MS` | `60000` | 首次采集完成后系统状态缓存的刷新间隔,单位毫秒。 | @@ -19,15 +18,14 @@ DAEDALUS_AGENT Docker 支持通过 `/var/daedalus-agent/env.json` 配置少量 ```json { "DA_MANAGEMENT_CONSOLE_PORT": 3030, - "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 16, - "DA_INTERNAL_NETWORK_ALLOWLIST": "", + "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 128, "DA_SYSTEM_STATUS_INITIAL_DELAY_MS": 60000, "DA_SYSTEM_STATUS_REFRESH_INTERVAL_MS": 60000, "DA_SYSTEM_STATUS_TIMEOUT_MS": 5000 } ``` -对话应用/业务应用所在的活字格服务器 IP 地址或域名保存在 management web 的 SQLite 数据库中,应通过管理控制台“系统设置 -> 基础设置”修改。IP 地址会同步到内部网络白名单;域名按外网地址处理,不加入白名单。旧版本 `env.json` 中的 `DA_HUOZIGE_SERVER_IP` 仅作为升级迁移入口:首次启动时会迁入数据库并从文件中删除,不应继续作为新部署配置项使用。 +对话应用/业务应用所在的活字格服务器地址和内部网络白名单保存在 management web 的 SQLite 数据库中,应通过管理控制台“系统设置 -> 基础设置”修改。二者在同一事务内提交;IP 地址会自动加入白名单,域名按外网地址处理,不加入白名单。旧版本 `env.json` 中的 `DA_HUOZIGE_SERVER_IP` 和 `DA_INTERNAL_NETWORK_ALLOWLIST` 仅作为升级迁移入口,迁移后会从文件中删除。 ## 容器启动调试变量 @@ -35,6 +33,7 @@ DAEDALUS_AGENT Docker 支持通过 `/var/daedalus-agent/env.json` 配置少量 | 变量 | 默认值 | 说明 | | --- | --- | --- | +| `DA_INTERNAL_NETWORK_ALLOWLIST` | 数据库配置 | 临时覆盖 SQLite 中的内部网络白名单,支持逗号、空格、分号或 JSON 数组字符串;仅建议应急运维使用。 | | `DA_SERVICE_RESTART_DELAY_SECONDS` | `2` | 顶层 MQTT、MinIO、OpenClaw supervisor、apps supervisor 异常退出后的重启间隔。 | | `DA_APPS_RESTART_DELAY_SECONDS` | `2` | apps supervisor 内部重启 management web 和 invoke service 的间隔。 | | `OPENCLAW_RESTART_DELAY_SECONDS` | `2` | OpenClaw gateway 重启间隔。 | diff --git a/docker/docs/startup-steps.md b/docker/docs/startup-steps.md index e84c10f..842b6f6 100644 --- a/docker/docs/startup-steps.md +++ b/docker/docs/startup-steps.md @@ -9,7 +9,7 @@ | 1/14 | `require_input_files` | 检查 `/var/daedalus-agent/env.json` 和 `config.json`;缺失时播种 sample 文件并退出,存在时校验为 JSON object,并拒绝用户覆盖托管变量。 | | 2/14 | `ensure_directories` | 创建统一运行目录:`.openclaw`、`logs`、`generated`、`management/data`、`ontology`、`workspace-templates`、`minio`、`mqtt/aedes`、`startup-state`、`scripts`、`run`,并规范用户/组/权限。 | | 3/14 | `ensure_credentials` | 生成或复用 MQTT/MinIO 随机密码,写入 `credentials.json`。 | -| 4/14 | `generate_runtime_files` | 合并用户环境、凭据和配置,生成 `generated/runtime-env.json` 与 `generated/runtime-config.json`。 | +| 4/14 | `generate_runtime_files` | 合并容器环境、SQLite 运行设置、用户环境、凭据和配置,生成 `generated/runtime-env.json` 与 `generated/runtime-config.json`。 | | 5/14 | `sync_optional_user_files` | 同步可选 `agent_workspace` 到 `workspace-templates/default`。 | | 6/14 | `ensure_default_startup_hook` | 确保 `/var/daedalus-agent/run_after_startup.sh` 存在;缺失时写入默认脚本。 | | 7/14 | `start_mqtt` | 启动内置 MQTT broker 并等待 1883 可用。 | diff --git a/docker/sample/env.json b/docker/sample/env.json index a003ff0..cd49e33 100644 --- a/docker/sample/env.json +++ b/docker/sample/env.json @@ -1,5 +1,4 @@ { "DA_MANAGEMENT_CONSOLE_PORT": 3030, - "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 100, - "DA_INTERNAL_NETWORK_ALLOWLIST": "127.0.0.1,::1" + "DA_OPENCLAW_GATEWAY_MAX_CONCURRENT": 128 } diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts index 488004c..ddb9880 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts @@ -1,5 +1,6 @@ import type { Stats } from "node:fs"; -import { lstat, mkdir, readdir, rm, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { access, lstat, mkdir, readdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { ManagedStateStore } from "../agent/state-store.js"; @@ -74,26 +75,55 @@ export class TemplateManager { throw new Error("agent.template.create requires objectKey"); } + const templatesRoot = this.getTemplatesRoot(); + await mkdir(templatesRoot, { recursive: true }); const targetDir = this.getTemplateDir(params.templateName); - if (params.replace) { - await rm(targetDir, { recursive: true, force: true }); - } else { + const operationId = `${process.pid}-${randomUUID()}`; + const stagingDir = path.join(templatesRoot, `.${params.templateName}.${operationId}.staging`); + const backupDir = path.join(templatesRoot, `.${params.templateName}.${operationId}.backup`); + let backupCreated = false; + + try { + const zipPayload = await this.getObjectStore().getObject(params.bucket, params.objectKey); + await extractZipToDirectory(zipPayload, stagingDir, "workspace templates root"); + const agentsFile = await stat(path.join(stagingDir, "AGENTS.md")).catch(() => null); + if (!agentsFile?.isFile()) { + throw new Error(`Agent template must contain top-level AGENTS.md: ${params.templateName}`); + } + await this.normalizeTemplateTree(stagingDir); + + const targetExists = await this.pathExists(targetDir); + if (targetExists && !params.replace) { + throw new Error(`Template already exists: ${params.templateName}`); + } + if (targetExists) { + await rename(targetDir, backupDir); + backupCreated = true; + } try { - const info = await stat(targetDir); - if (info.isDirectory()) { - throw new Error(`Template already exists: ${params.templateName}`); - } - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; + await rename(stagingDir, targetDir); + } catch (error) { + if (backupCreated) { + await rename(backupDir, targetDir).catch(() => undefined); + backupCreated = false; } + throw error; + } + if (backupCreated) { + await rm(backupDir, { recursive: true, force: true }); + backupCreated = false; + } + return await this.readTemplateRecord(params.templateName); + } finally { + await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + if (backupCreated && !(await this.pathExists(targetDir))) { + await rename(backupDir, targetDir).catch(() => undefined); + backupCreated = false; + } + if (!backupCreated) { + await rm(backupDir, { recursive: true, force: true }).catch(() => undefined); } } - - const zipPayload = await this.getObjectStore().getObject(params.bucket, params.objectKey); - await extractZipToDirectory(zipPayload, targetDir, "workspace templates root"); - await this.normalizeTemplateTree(targetDir); - return await this.readTemplateRecord(params.templateName); } async deleteTemplate(params: AgentTemplateDeleteParams): Promise<{ templateName: string; path: string }> { @@ -176,6 +206,18 @@ export class TemplateManager { } } + private async pathExists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch (error: unknown) { + if (isMissingPathError(error)) { + return false; + } + throw error; + } + } + private getObjectStore(): S3ObjectStore { if (!this.objectStore) { this.objectStore = createS3ObjectStore(this.env); diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts index d8b1e84..339a211 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { CliRunner } from "../../tools/cli-runner.js"; @@ -7,6 +7,7 @@ import { toChinaISOString } from "../../china-time.js"; import type { AgentNameSetParams, AgentModelSetParams, + AgentUpdateParams, EnvironmentConfig, ManagedAgentRecord } from "../../types.js"; @@ -19,6 +20,7 @@ import { type ResolvedAgentCreateOptions } from "./support.js"; import { ensureSharedMode } from "../shared-permissions.js"; +import { atomicWriteFile } from "../../tools/atomic-file.js"; const USAGE_QUERY_LIMIT = 100000; const GATEWAY_CALL_TIMEOUT_MS = "30000"; @@ -169,6 +171,54 @@ export class AgentLifecycleService { return (await this.findAgentConfigIndex(agentId)) >= 0; } + async deriveManagedAgents(fallbackRecords: ManagedAgentRecord[]): Promise { + const config = await this.readOpenClawConfig(); + const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; + const bindings = Array.isArray(config.bindings) ? config.bindings : []; + const accounts = config.channels?.[MQTT_CHANNEL_ID]?.accounts ?? {}; + const fallbackById = new Map(fallbackRecords.map((record) => [record.id, record])); + const now = toChinaISOString(); + const hasUnsupportedMetadata = agents.some((agent: Record) => ( + Object.prototype.hasOwnProperty.call(agent, "daedalusAgent") + )); + + const derived = agents.flatMap((agent: Record) => { + const binding = bindings.find((item: Record) => ( + item?.type === "route" + && item?.agentId === agent.id + && item?.match?.channel === MQTT_CHANNEL_ID + )); + const accountId = firstText(binding?.match?.accountId, agent.daedalusAgent?.accountId); + const account = accountId ? accounts[accountId] : undefined; + if (!agent.id || !accountId || !account) { + return []; + } + const fallback = fallbackById.get(agent.id); + return [{ + id: agent.id, + accountId, + workspace: firstText(fallback?.workspace, agent.workspace), + inboundTopic: firstText(fallback?.inboundTopic, account.topics?.inbound, agent.daedalusAgent?.inboundTopic), + outboundTopic: firstText(fallback?.outboundTopic, account.topics?.outbound, agent.daedalusAgent?.outboundTopic), + enabled: account.enabled !== false, + templateName: firstText(fallback?.templateName, agent.daedalusAgent?.templateName, "default"), + createdAt: fallback?.createdAt, + updatedAt: fallback?.updatedAt || now + }]; + }); + if (hasUnsupportedMetadata) { + for (const agent of agents) { + delete agent.daedalusAgent; + } + await this.writeOpenClawConfig(config); + } + const derivedIds = new Set(derived.map((record: ManagedAgentRecord) => record.id)); + return [ + ...derived, + ...fallbackRecords.filter((record) => !derivedIds.has(record.id)) + ].sort((left: ManagedAgentRecord, right: ManagedAgentRecord) => left.id.localeCompare(right.id)); + } + async ensureManagedTopologyBase(): Promise { const config = await this.readOpenClawConfig(); config.plugins ??= {}; @@ -455,7 +505,53 @@ export class AgentLifecycleService { const serialized = `${JSON.stringify(config, null, 2)}\n`; await mkdir(path.dirname(targetPath), { recursive: true, mode: SHARED_DIRECTORY_MODE }); - await writeFile(targetPath, serialized, "utf8"); + await atomicWriteFile(targetPath, serialized, {}, SHARED_FILE_MODE); await ensureSharedMode(targetPath, SHARED_FILE_MODE); } + + async snapshotOpenClawConfig(): Promise> { + return await this.readOpenClawConfig(); + } + + async restoreOpenClawConfig(config: Record): Promise { + await this.writeOpenClawConfig(config); + } + + async updateManagedTopology(record: ManagedAgentRecord, params: AgentUpdateParams): Promise { + const config = await this.readOpenClawConfig(); + const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; + const agent = agents.find((entry: { id?: string }) => entry?.id === record.id); + if (!agent) { + throw new Error(`Agent config not found: ${record.id}`); + } + + if (params.name !== undefined) { + const name = firstText(params.name); + if (!name) { + throw new Error("Agent name is required"); + } + agent.name = name; + } + if (params.model !== undefined) { + agent.model = normalizeModelConfig(params.model); + } + if (params.enabled !== undefined) { + this.upsertManagedAccountInConfig( + config, + record.accountId, + record.inboundTopic, + record.outboundTopic, + params.enabled + ); + this.upsertManagedBindingInConfig(config, record.id, record.accountId); + } + + await this.writeOpenClawConfig(config); + return { + agentId: record.id, + name: agent.name, + model: agent.model, + enabled: params.enabled ?? record.enabled + }; + } } diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts index e6b7459..776e0be 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts @@ -7,6 +7,7 @@ import type { AgentGetParams, AgentNameSetParams, AgentModelSetParams, + AgentUpdateParams, AgentMutationParams, AgentWorkspaceExportParams, AgentWorkspaceExportResult, @@ -73,7 +74,7 @@ export class OpenClawManager { } async listAgents(): Promise { - return await this.lifecycleService.listAgents(await this.stateStore.listAgents()); + return await this.lifecycleService.listAgents(await this.reconcileManagedState()); } async getAgent(params: AgentGetParams): Promise { @@ -89,7 +90,7 @@ export class OpenClawManager { async createAgent(params: AgentCreateParams): Promise { const resolved = resolveAgentCreateOptions(this.env, params); - const existing = await this.stateStore.getAgent(resolved.agentId); + const existing = (await this.reconcileManagedState()).find((record) => record.id === resolved.agentId); if (existing) { throw new Error(`Managed agent already exists: ${resolved.agentId}`); } @@ -98,10 +99,6 @@ export class OpenClawManager { throw new Error(`OpenClaw agent already exists: ${resolved.agentId}`); } - await this.lifecycleService.ensureManagedTopologyBase(); - await this.workspaceService.ensureWorkspaceSeeded(resolved.workspace, resolved.templateName, { overwrite: true }); - await this.lifecycleService.createManagedTopology(resolved); - const record: ManagedAgentRecord = { id: resolved.agentId, accountId: resolved.accountId, @@ -114,54 +111,83 @@ export class OpenClawManager { updatedAt: toChinaISOString() }; - await this.stateStore.upsertAgent(record); - await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange( - params.restart, - `agent.create:${resolved.agentId}` - ); - - return record; + const configSnapshot = await this.lifecycleService.snapshotOpenClawConfig(); + try { + await this.lifecycleService.ensureManagedTopologyBase(); + await this.workspaceService.ensureWorkspaceSeeded(resolved.workspace, resolved.templateName, { overwrite: true }); + await this.lifecycleService.createManagedTopology(resolved); + await this.stateStore.upsertAgent(record); + await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange( + params.restart, + `agent.create:${resolved.agentId}` + ); + return record; + } catch (error) { + await this.lifecycleService.restoreOpenClawConfig(configSnapshot).catch(() => undefined); + await this.stateStore.removeAgent(record.id).catch(() => undefined); + throw error; + } } async enableAgent(params: AgentMutationParams): Promise { const record = await this.requireManagedAgent(params.agentId); - await this.lifecycleService.enableManagedTopology(record); - const updated: ManagedAgentRecord = { ...record, enabled: true, updatedAt: toChinaISOString() }; - await this.stateStore.upsertAgent(updated); - await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, `agent.enable:${params.agentId}`); - return updated; + const configSnapshot = await this.lifecycleService.snapshotOpenClawConfig(); + try { + await this.lifecycleService.enableManagedTopology(record); + await this.stateStore.upsertAgent(updated); + await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, `agent.enable:${params.agentId}`); + return updated; + } catch (error) { + await this.lifecycleService.restoreOpenClawConfig(configSnapshot).catch(() => undefined); + await this.stateStore.upsertAgent(record).catch(() => undefined); + throw error; + } } async disableAgent(params: AgentMutationParams): Promise { const record = await this.requireManagedAgent(params.agentId); - await this.lifecycleService.disableManagedTopology(record); - const updated: ManagedAgentRecord = { ...record, enabled: false, updatedAt: toChinaISOString() }; - await this.stateStore.upsertAgent(updated); - await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, `agent.disable:${params.agentId}`); - return updated; + const configSnapshot = await this.lifecycleService.snapshotOpenClawConfig(); + try { + await this.lifecycleService.disableManagedTopology(record); + await this.stateStore.upsertAgent(updated); + await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, `agent.disable:${params.agentId}`); + return updated; + } catch (error) { + await this.lifecycleService.restoreOpenClawConfig(configSnapshot).catch(() => undefined); + await this.stateStore.upsertAgent(record).catch(() => undefined); + throw error; + } } async deleteAgent(params: AgentMutationParams): Promise<{ agentId: string; accountId: string }> { const record = await this.requireManagedAgent(params.agentId); - await this.lifecycleService.deleteManagedTopology(record); - await this.workspaceService.moveWorkspaceToRecycleBin(record.workspace, record.id); - await this.stateStore.removeAgent(record.id); - await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, `agent.delete:${params.agentId}`); - - return { - agentId: record.id, - accountId: record.accountId - }; + const configSnapshot = await this.lifecycleService.snapshotOpenClawConfig(); + let recycledWorkspacePath: string | undefined; + try { + await this.lifecycleService.deleteManagedTopology(record); + recycledWorkspacePath = (await this.workspaceService.moveWorkspaceToRecycleBin(record.workspace, record.id)).path; + await this.stateStore.removeAgent(record.id); + await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, `agent.delete:${params.agentId}`); + return { + agentId: record.id, + accountId: record.accountId + }; + } catch (error) { + await this.lifecycleService.restoreOpenClawConfig(configSnapshot).catch(() => undefined); + await this.workspaceService.restoreWorkspaceFromRecycleBin(recycledWorkspacePath, record.workspace).catch(() => undefined); + await this.stateStore.upsertAgent(record).catch(() => undefined); + throw error; + } } async queryAllUsage(params: { agentIds?: string[] } = {}): Promise { @@ -180,6 +206,41 @@ export class OpenClawManager { return result; } + async updateAgent(params: AgentUpdateParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + const configSnapshot = await this.lifecycleService.snapshotOpenClawConfig(); + const previousDocs = params.docsContent === undefined + ? undefined + : await this.workspaceService.readAgentDocs(record.workspace); + const updatedRecord: ManagedAgentRecord = { + ...record, + enabled: params.enabled ?? record.enabled, + updatedAt: toChinaISOString() + }; + + try { + const result = await this.lifecycleService.updateManagedTopology(record, params); + if (params.docsContent !== undefined) { + await this.workspaceService.updateAgentDocs(record.workspace, params.docsContent); + } + if (params.enabled !== undefined && params.enabled !== record.enabled) { + await this.stateStore.upsertAgent(updatedRecord); + } + await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange( + params.restart, + `agent.update:${params.agentId}` + ); + return result; + } catch (error) { + await this.lifecycleService.restoreOpenClawConfig(configSnapshot).catch(() => undefined); + if (params.docsContent !== undefined) { + await this.workspaceService.restoreAgentDocs(record.workspace, previousDocs).catch(() => undefined); + } + await this.stateStore.upsertAgent(record).catch(() => undefined); + throw error; + } + } + async listLlmProviders(): Promise { return await this.llmModelConfigService.listProviders(); } @@ -280,7 +341,7 @@ export class OpenClawManager { } private async requireManagedAgent(agentId: string): Promise { - const record = await this.stateStore.getAgent(agentId); + const record = (await this.reconcileManagedState()).find((item) => item.id === agentId); if (!record) { throw new Error(`Managed agent not found: ${agentId}`); } @@ -288,6 +349,15 @@ export class OpenClawManager { return record; } + private async reconcileManagedState(): Promise { + const stored = await this.stateStore.listAgents(); + const derived = await this.lifecycleService.deriveManagedAgents(stored); + if (JSON.stringify(derived) !== JSON.stringify(stored)) { + await this.stateStore.write({ version: 1, agents: derived }).catch(() => undefined); + } + return derived; + } + private getObjectStore(): S3ObjectStore { if (!this.objectStore) { this.objectStore = createS3ObjectStore(this.env); diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/state-store.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/state-store.ts index 3817477..5f5efe9 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/state-store.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/state-store.ts @@ -1,7 +1,7 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; +import { readFile } from "node:fs/promises"; import type { ManagedAgentRecord, ManagedState } from "../../types.js"; +import { atomicWriteFile } from "../../tools/atomic-file.js"; const DEFAULT_STATE: ManagedState = { version: 1, @@ -30,8 +30,7 @@ export class ManagedStateStore { } async write(state: ManagedState): Promise { - await mkdir(path.dirname(this.filePath), { recursive: true }); - await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await atomicWriteFile(this.filePath, `${JSON.stringify(state, null, 2)}\n`); } async listAgents(): Promise { diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/update.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/update.ts new file mode 100644 index 0000000..812e735 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/update.ts @@ -0,0 +1,6 @@ +import type { AgentUpdateParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentUpdate: CapabilityHandler = async (request, context) => { + return await context.agentManager.updateAgent(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts index 5ded28e..7eb58dc 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts @@ -1,5 +1,5 @@ import { constants as fsConstants } from "node:fs"; -import { copyFile, lstat, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises"; +import { copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import AdmZip from "adm-zip"; @@ -12,6 +12,7 @@ import { SHARED_FILE_MODE } from "./support.js"; import { ensureSharedMode, ensureSharedPathPermissions } from "../shared-permissions.js"; +import { atomicWriteFile } from "../../tools/atomic-file.js"; const WORKSPACE_TEMPLATE_METADATA_FILES = new Set([ ".daedalus-agent-builtin.fingerprint" @@ -44,11 +45,30 @@ export class AgentWorkspaceService { await mkdir(workspace, { recursive: true, mode: SHARED_DIRECTORY_MODE }); await ensureSharedPathPermissions(workspace, SHARED_DIRECTORY_MODE, workspaceGroupId); const normalized = content.endsWith("\n") ? content : `${content}\n`; - await writeFile(targetPath, normalized, "utf8"); + await atomicWriteFile(targetPath, normalized, {}, SHARED_AGENT_DOCS_FILE_MODE); await ensureSharedPathPermissions(targetPath, SHARED_AGENT_DOCS_FILE_MODE, workspaceGroupId); return { path: targetPath }; } + async readAgentDocs(workspace: string): Promise { + try { + return await readFile(path.join(workspace, "AGENTS.md"), "utf8"); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return undefined; + } + throw error; + } + } + + async restoreAgentDocs(workspace: string, content: string | undefined): Promise { + if (content === undefined) { + await rm(path.join(workspace, "AGENTS.md"), { force: true }); + return; + } + await this.updateAgentDocs(workspace, content); + } + async buildWorkspaceExportArchive(workspace: string): Promise<{ buffer: Buffer; entries: string[]; @@ -100,6 +120,22 @@ export class AgentWorkspaceService { return { path: targetPath }; } + async restoreWorkspaceFromRecycleBin(recyclePath: string | undefined, workspace: string): Promise { + if (!recyclePath) { + return; + } + try { + await stat(recyclePath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return; + } + throw error; + } + await mkdir(path.dirname(workspace), { recursive: true, mode: SHARED_DIRECTORY_MODE }); + await rename(recyclePath, workspace); + } + private async copyDirectoryContents(sourceDir: string, targetDir: string, options: { overwrite?: boolean } = {}): Promise { const workspaceGroupId = await this.resolveWorkspaceGroupId(targetDir); await this.copyDirectoryContentsWithGroup(sourceDir, targetDir, workspaceGroupId, options); diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts index 2894e34..2e387de 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts @@ -5,6 +5,7 @@ import type { AgentNameSetParams, AgentModelSetParams, AgentMutationParams, + AgentUpdateParams, AgentWorkspaceExportParams, AgentWorkspaceExportResult, AgentTemplateCreateParams, @@ -44,6 +45,7 @@ export interface AgentCapacityService { setAgentModel(params: AgentModelSetParams): Promise; setAgentName(params: AgentNameSetParams): Promise; updateAgentDocs(params: AgentDocsUpdateParams): Promise<{ path: string }>; + updateAgent(params: AgentUpdateParams): Promise; exportAgentWorkspace(params: AgentWorkspaceExportParams): Promise; listLlmProviders(): Promise; createLlmProvider(params: LlmProviderCreateParams): Promise; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts index a537918..3f54013 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts @@ -1,4 +1,5 @@ -import { mkdir, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { access, mkdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { createS3ObjectStore, extractZipToDirectory, type S3ObjectStore } from "../../tools/s3-archive.js"; @@ -53,15 +54,52 @@ export class OntologyManager { throw new Error("ontology.create requires objectKey"); } + await mkdir(this.env.ontologyRoot, { recursive: true }); const targetDir = path.join(this.env.ontologyRoot, params.name); - if (params.replace) { - await rm(targetDir, { recursive: true, force: true }); - } + const operationId = `${process.pid}-${randomUUID()}`; + const stagingDir = path.join(this.env.ontologyRoot, `.${params.name}.${operationId}.staging`); + const backupDir = path.join(this.env.ontologyRoot, `.${params.name}.${operationId}.backup`); + let backupCreated = false; - const zipPayload = await this.getObjectStore().getObject(params.bucket, params.objectKey); - await extractZipToDirectory(zipPayload, targetDir, "ontology root"); + try { + const zipPayload = await this.getObjectStore().getObject(params.bucket, params.objectKey); + await extractZipToDirectory(zipPayload, stagingDir, "ontology root"); + await this.assertTopLevelIndex(stagingDir); - return { path: targetDir }; + const targetExists = await this.pathExists(targetDir); + if (targetExists && !params.replace) { + throw new Error(`Ontology already exists: ${params.name}`); + } + if (targetExists) { + await rename(targetDir, backupDir); + backupCreated = true; + } + + try { + await rename(stagingDir, targetDir); + } catch (error) { + if (backupCreated) { + await rename(backupDir, targetDir).catch(() => undefined); + backupCreated = false; + } + throw error; + } + + if (backupCreated) { + await rm(backupDir, { recursive: true, force: true }); + backupCreated = false; + } + return { path: targetDir }; + } finally { + await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + if (backupCreated && !(await this.pathExists(targetDir))) { + await rename(backupDir, targetDir).catch(() => undefined); + backupCreated = false; + } + if (!backupCreated) { + await rm(backupDir, { recursive: true, force: true }).catch(() => undefined); + } + } } async deleteOntology(params: OntologyDeleteParams): Promise<{ path: string }> { @@ -78,4 +116,31 @@ export class OntologyManager { return this.objectStore; } + + private async assertTopLevelIndex(ontologyPath: string): Promise { + const indexPath = path.join(ontologyPath, "index.md"); + try { + const info = await stat(indexPath); + if (!info.isFile()) { + throw new Error(`Business system ontology must contain top-level index.md: ${ontologyPath}`); + } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new Error(`Business system ontology must contain top-level index.md: ${ontologyPath}`); + } + throw error; + } + } + + private async pathExists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return false; + } + throw error; + } + } } diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts index 3f2b879..70f9001 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts @@ -7,6 +7,7 @@ import { agentGet } from "./agent/get.js"; import { agentModelSet } from "./agent/model-set.js"; import { agentNameSet } from "./agent/name-set.js"; import { agentUsageList } from "./agent/usage-list.js"; +import { agentUpdate } from "./agent/update.js"; import { agentWorkspaceExport } from "./agent/workspace-export.js"; import { agentTemplateCreate } from "./agent-template/create.js"; import { agentTemplateDelete } from "./agent-template/delete.js"; @@ -47,6 +48,7 @@ export const capabilityHandlers: Record = { "agent.model.set": agentModelSet, "agent.name.set": agentNameSet, "agent.docs.update": agentDocsUpdate, + "agent.update": agentUpdate, "agent.workspace.export": agentWorkspaceExport, "agent.template.create": agentTemplateCreate, "agent.template.delete": agentTemplateDelete, diff --git a/packages/daedalus-agent-management-web/server/management-src/tools/atomic-file.ts b/packages/daedalus-agent-management-web/server/management-src/tools/atomic-file.ts new file mode 100644 index 0000000..d258fac --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/tools/atomic-file.ts @@ -0,0 +1,51 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + open, + rename, + rm, + type FileHandle +} from "node:fs/promises"; +import path from "node:path"; + +interface AtomicFileOperations { + mkdir: typeof mkdir; + open: typeof open; + rename: typeof rename; + rm: typeof rm; +} + +const DEFAULT_OPERATIONS: AtomicFileOperations = { + mkdir, + open, + rename, + rm +}; + +export async function atomicWriteFile( + targetPath: string, + content: string | Buffer, + operationOverrides: Partial = {}, + mode = 0o640 +): Promise { + const operations = { ...DEFAULT_OPERATIONS, ...operationOverrides }; + const targetDir = path.dirname(targetPath); + const tempPath = path.join(targetDir, `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`); + let handle: FileHandle | undefined; + + await operations.mkdir(targetDir, { recursive: true }); + try { + handle = await operations.open(tempPath, "wx", mode); + await handle.writeFile(content); + await handle.sync(); + await handle.close(); + handle = undefined; + await operations.rename(tempPath, targetPath); + } catch (error) { + if (handle) { + await handle.close().catch(() => undefined); + } + await operations.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} diff --git a/packages/daedalus-agent-management-web/server/management-src/types.ts b/packages/daedalus-agent-management-web/server/management-src/types.ts index 490bd9f..01d34a2 100644 --- a/packages/daedalus-agent-management-web/server/management-src/types.ts +++ b/packages/daedalus-agent-management-web/server/management-src/types.ts @@ -137,6 +137,18 @@ export interface AgentNameSetParams { restart?: boolean; } +export interface AgentUpdateParams { + agentId: string; + name?: string; + model?: string | { + primary: string; + fallbacks?: string[]; + }; + docsContent?: string; + enabled?: boolean; + restart?: boolean; +} + export interface LlmProviderCreateParams { providerId: string; baseUrl: string; diff --git a/packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts b/packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts new file mode 100644 index 0000000..907ac87 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { atomicWriteFile } from "../management-src/tools/atomic-file.js"; + +test("atomicWriteFile preserves the previous file when the final rename fails", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-atomic-file-")); + const targetPath = path.join(root, "state.json"); + await writeFile(targetPath, "{\"version\":1}\n", "utf8"); + + await assert.rejects( + atomicWriteFile(targetPath, "{\"version\":2}\n", { + async rename() { + throw new Error("injected rename failure"); + } + }), + /injected rename failure/ + ); + + assert.equal(await readFile(targetPath, "utf8"), "{\"version\":1}\n"); +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts b/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts index 3a3bd0e..cf41a76 100644 --- a/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts @@ -20,6 +20,7 @@ const expectedActions = [ "agent.model.set", "agent.name.set", "agent.docs.update", + "agent.update", "agent.workspace.export", "agent.template.create", "agent.template.delete", @@ -72,6 +73,7 @@ function createContext(calls: Array<{ method: string; args: unknown[] }>): Capab setAgentModel: respond("agentManager.setAgentModel"), setAgentName: respond("agentManager.setAgentName"), updateAgentDocs: respond("agentManager.updateAgentDocs") as never, + updateAgent: respond("agentManager.updateAgent"), exportAgentWorkspace: respond("agentManager.exportAgentWorkspace") as never, listLlmProviders: respond("agentManager.listLlmProviders"), createLlmProvider: respond("agentManager.createLlmProvider"), @@ -216,6 +218,12 @@ test("capability handlers delegate to the expected service methods", async () => expectedMethod: "agentManager.updateAgentDocs", expectedArgs: [{ agentId: "lowcode", content: "# AGENTS" }] }, + { + action: "agent.update", + params: { agentId: "lowcode", name: "Lowcode Agent Updated", enabled: false }, + expectedMethod: "agentManager.updateAgent", + expectedArgs: [{ agentId: "lowcode", name: "Lowcode Agent Updated", enabled: false }] + }, { action: "agent.workspace.export", params: { agentId: "lowcode", bucket: "admin-out", objectKey: "agent-workspace/lowcode.zip" }, diff --git a/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts index 388f152..0463b13 100644 --- a/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -46,6 +46,7 @@ test("OntologyManager creates, lists, and deletes ontologies", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-ontology-")); const AdmZip = (await import("adm-zip")).default; const zip = new AdmZip(); + zip.addFile("index.md", Buffer.from("# CRM", "utf8")); zip.addFile("schema.json", Buffer.from("{\"name\":\"crm\"}", "utf8")); const manager = new OntologyManager( createEnv({ @@ -90,6 +91,63 @@ test("OntologyManager creates, lists, and deletes ontologies", async () => { ]); }); +test("OntologyManager preserves the current ontology when replacement download fails", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-ontology-download-failure-")); + const ontologyRoot = path.join(root, "ontology"); + const currentRoot = path.join(ontologyRoot, "crm"); + await mkdir(currentRoot, { recursive: true }); + await writeFile(path.join(currentRoot, "index.md"), "# Current\n", "utf8"); + + const manager = new OntologyManager(createEnv({ ontologyRoot }), { + objectStore: { + async getObject(): Promise { + throw new Error("injected download failure"); + } + } + }); + + await assert.rejects( + manager.createOntology({ + name: "crm", + bucket: "ontology-bucket", + objectKey: "crm/crm.zip", + replace: true + }), + /injected download failure/ + ); + assert.equal(await readFile(path.join(currentRoot, "index.md"), "utf8"), "# Current\n"); +}); + +test("OntologyManager rejects an invalid replacement before switching directories", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-ontology-validation-failure-")); + const ontologyRoot = path.join(root, "ontology"); + const currentRoot = path.join(ontologyRoot, "crm"); + await mkdir(currentRoot, { recursive: true }); + await writeFile(path.join(currentRoot, "index.md"), "# Current\n", "utf8"); + const invalidZip = new (await import("adm-zip")).default(); + invalidZip.addFile("schema.json", Buffer.from("{}", "utf8")); + + const manager = new OntologyManager(createEnv({ ontologyRoot }), { + objectStore: { + async getObject(): Promise { + return invalidZip.toBuffer(); + } + } + }); + + await assert.rejects( + manager.createOntology({ + name: "crm", + bucket: "ontology-bucket", + objectKey: "crm/crm.zip", + replace: true + }), + /top-level index\.md/ + ); + assert.equal(await readFile(path.join(currentRoot, "index.md"), "utf8"), "# Current\n"); + await assert.rejects(access(path.join(ontologyRoot, "crm.staging"))); +}); + test("OntologyManager marks built-in ontologies from manifest", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-ontology-built-in-")); await mkdir(path.join(root, "ontology", "crm"), { recursive: true }); diff --git a/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts index 25480e3..86ce9fb 100644 --- a/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts @@ -683,6 +683,63 @@ test("listAgents includes managed agents missing from openclaw config", async () }); }); +test("listAgents migrates unsupported managed metadata out of openclaw agent config", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-metadata-migration-")); + const configPath = path.join(root, ".openclaw", "openclaw.json"); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + await writeOpenClawConfig(configPath, { + agents: { + list: [{ + id: "lowcode", + name: "Lowcode Agent", + workspace: "/var/platform_data/openclaw/workspaces/lowcode", + daedalusAgent: { + accountId: "lowcode", + templateName: "sales-template", + inboundTopic: "legacy/inbound", + outboundTopic: "legacy/outbound" + } + }] + }, + channels: { + "daedalus-agent-mqtt-channel": { + accounts: { + lowcode: { + enabled: true, + topics: { + inbound: "daedalus-agent/agent/lowcode/inbound", + outbound: "daedalus-agent/agent/lowcode/outbound" + } + } + } + } + }, + bindings: [{ + type: "route", + agentId: "lowcode", + match: { + channel: "daedalus-agent-mqtt-channel", + accountId: "lowcode" + } + }] + }); + + const manager = new OpenClawManager( + new MockCliRunner() as never, + new MockCliRunner() as never, + createEnv({ openclawConfigPath: configPath }), + stateStore + ); + + const result = await manager.listAgents() as { items: Array> }; + const config = await readOpenClawConfig(configPath); + const record = await stateStore.getAgent("lowcode"); + + assert.equal(result.items[0]?.templateName, "sales-template"); + assert.equal(record?.templateName, "sales-template"); + assert.equal("daedalusAgent" in config.agents.list[0], false); +}); + test("createAgent issues the expected openclaw commands", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-create-")); const configPath = path.join(root, ".openclaw", "openclaw.json"); diff --git a/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts index 73ad61a..ec1e402 100644 --- a/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts @@ -144,3 +144,31 @@ test("TemplateManager marks built-in templates from manifest", async () => { { templateName: "default", "is-built-in": true } ]); }); + +test("TemplateManager preserves the current template when replacement download fails", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-template-failure-")); + const templatesRoot = path.join(root, "workspace-templates"); + const templateRoot = path.join(templatesRoot, "sales"); + await mkdir(templateRoot, { recursive: true }); + await writeFile(path.join(templateRoot, "AGENTS.md"), "# Current\n", "utf8"); + + const manager = new TemplateManager( + createEnv({ workspaceTemplatesRoot: templatesRoot }), + new ManagedStateStore(path.join(root, "managed-state.json")), + { + objectStore: { + async getObject(): Promise { + throw new Error("injected template download failure"); + } + } + } + ); + + await assert.rejects(manager.createTemplate({ + templateName: "sales", + bucket: "template-bucket", + objectKey: "workspace/sales.zip", + replace: true + }), /injected template download failure/); + assert.equal(await readFile(path.join(templateRoot, "AGENTS.md"), "utf8"), "# Current\n"); +}); diff --git a/packages/daedalus-agent-management-web/server/src/api/routes/index.js b/packages/daedalus-agent-management-web/server/src/api/routes/index.js index 938dd43..3bed25f 100644 --- a/packages/daedalus-agent-management-web/server/src/api/routes/index.js +++ b/packages/daedalus-agent-management-web/server/src/api/routes/index.js @@ -928,7 +928,7 @@ export function createRoutes(services) { services, req, "保存 IP 白名单", - "更新数据库中的活字格服务器地址及 env.json 中的 DA_INTERNAL_NETWORK_ALLOWLIST" + "原子更新数据库中的活字格服务器地址及内部网络白名单" ); res.json(result); })); diff --git a/packages/daedalus-agent-management-web/server/src/db/index.js b/packages/daedalus-agent-management-web/server/src/db/index.js index 9081cfa..58d88a6 100644 --- a/packages/daedalus-agent-management-web/server/src/db/index.js +++ b/packages/daedalus-agent-management-web/server/src/db/index.js @@ -10,8 +10,8 @@ import { hashPassword } from "../utils/security.js"; const env = loadEnv(); const SCHEMA_VERSION = 12; const DEFAULT_ACCESS_TOKEN = "sk-1234567890qwertyuiop"; -const DEFAULT_PORTAL_NAME = "智能体管理与运营平台"; -const DEFAULT_BRAND_SUBTITLE = "Your company's name here"; +const DEFAULT_PORTAL_NAME = "daedalus-agent"; +const DEFAULT_BRAND_SUBTITLE = "智能体管理与运营平台"; const DEFAULT_THEME_COLOR = "#a5522d"; const DEFAULT_ADMIN_USERNAME = "admin"; const DEFAULT_ADMIN_DISPLAY_NAME = "管理员"; diff --git a/packages/daedalus-agent-management-web/server/src/infra/s3/object-storage.js b/packages/daedalus-agent-management-web/server/src/infra/s3/object-storage.js index 76511e6..c0dee32 100644 --- a/packages/daedalus-agent-management-web/server/src/infra/s3/object-storage.js +++ b/packages/daedalus-agent-management-web/server/src/infra/s3/object-storage.js @@ -1,4 +1,4 @@ -import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; import { chinaDateKey } from "../../utils/china-time.js"; @@ -95,4 +95,14 @@ export class ObjectStorage { return await bodyToBuffer(response.Body); } + + async deleteObject(bucket, objectKey) { + if (!bucket || !objectKey) { + return; + } + await this.getClient().send(new DeleteObjectCommand({ + Bucket: bucket, + Key: objectKey + })); + } } diff --git a/packages/daedalus-agent-management-web/server/src/services/agent-service.js b/packages/daedalus-agent-management-web/server/src/services/agent-service.js index b8b4718..6abbf25 100644 --- a/packages/daedalus-agent-management-web/server/src/services/agent-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/agent-service.js @@ -886,62 +886,71 @@ export class AgentService { const nextAgentName = firstText(payload.agent_name, payload.name, current.agent_name, current.slug); const runtimeAction = runtimeActionForStatusChange(current.status, nextStatus); - if (runtimeAction) { - await this.managementClient.call(runtimeAction, { - agentId: current.slug, - restart: payload.restart !== false - }); - } - - if (payload.docs_content !== undefined && payload.docs_content !== current.docs_content) { - await this.managementClient.call("agent.docs.update", { - agentId: current.slug, - content: payload.docs_content - }); + const docsChanged = payload.docs_content !== undefined && payload.docs_content !== current.docs_content; + const nameChanged = nextAgentName !== current.agent_name; + const modelChanged = llmSelection.changed || fallbackLlmSelection.changed; + const remoteParams = { + agentId: current.slug, + restart: payload.restart !== false, + ...(runtimeAction ? { enabled: nextStatus !== "disabled" } : {}), + ...(docsChanged ? { docsContent: payload.docs_content } : {}), + ...(nameChanged ? { name: nextAgentName } : {}), + ...(modelChanged ? { model: modelConfig } : {}) + }; + const remoteChanged = runtimeAction || docsChanged || nameChanged || modelChanged; + let remoteApplied = false; + if (remoteChanged) { + await this.managementClient.call("agent.update", remoteParams); + remoteApplied = true; } - if (nextAgentName !== current.agent_name) { - await this.managementClient.call("agent.name.set", { - agentId: current.slug, - name: nextAgentName, - restart: payload.restart !== false - }); - } + try { + runInTransaction(this.db, () => { + this.db.prepare(` + UPDATE agents + SET agent_name = ?, description = ?, docs_content = ?, status = ?, tags_json = ?, + daily_limit = ?, llm_provider_id = ?, llm_model_id = ?, llm_model_ref = ?, + fallback_llm_provider_id = ?, fallback_llm_model_id = ?, fallback_llm_model_ref = ?, + updated_at = ? + WHERE id = ? + `).run( + nextAgentName, + payload.description ?? current.description, + payload.docs_content ?? current.docs_content, + nextStatus, + jsonStringify(payload.tags ?? jsonParse(current.tags_json)), + Number(payload.daily_limit ?? current.daily_limit), + llmSelection.providerId, + llmSelection.modelId, + llmSelection.modelRef, + fallbackLlmSelection.providerId, + fallbackLlmSelection.modelId, + fallbackLlmSelection.modelRef, + toChinaISOString(), + agentId + ); - if (llmSelection.changed || fallbackLlmSelection.changed) { - await this.managementClient.call("agent.model.set", { - agentId: current.slug, - model: modelConfig, - restart: payload.restart !== false + if (hasOwnValue(payload, "permission_usernames") || hasOwnValue(payload, "users")) { + this.replacePermissions(agentId, resolvePermissionUsernames(payload)); + } }); - } - - this.db.prepare(` - UPDATE agents - SET agent_name = ?, description = ?, docs_content = ?, status = ?, tags_json = ?, - daily_limit = ?, llm_provider_id = ?, llm_model_id = ?, llm_model_ref = ?, - fallback_llm_provider_id = ?, fallback_llm_model_id = ?, fallback_llm_model_ref = ?, - updated_at = ? - WHERE id = ? - `).run( - nextAgentName, - payload.description ?? current.description, - payload.docs_content ?? current.docs_content, - nextStatus, - jsonStringify(payload.tags ?? jsonParse(current.tags_json)), - Number(payload.daily_limit ?? current.daily_limit), - llmSelection.providerId, - llmSelection.modelId, - llmSelection.modelRef, - fallbackLlmSelection.providerId, - fallbackLlmSelection.modelId, - fallbackLlmSelection.modelRef, - toChinaISOString(), - agentId - ); - - if (hasOwnValue(payload, "permission_usernames") || hasOwnValue(payload, "users")) { - this.replacePermissions(agentId, resolvePermissionUsernames(payload)); + } catch (error) { + if (remoteApplied) { + await this.managementClient.call("agent.update", { + agentId: current.slug, + restart: payload.restart !== false, + ...(runtimeAction ? { enabled: current.status !== "disabled" } : {}), + ...(docsChanged ? { docsContent: current.docs_content } : {}), + ...(nameChanged ? { name: current.agent_name } : {}), + ...(modelChanged ? { + model: buildAgentModelConfig( + { modelRef: current.llm_model_ref }, + { modelRef: current.fallback_llm_model_ref } + ) + } : {}) + }).catch(() => undefined); + } + throw error; } return (await this.listAgents()).find((item) => item.id === agentId); diff --git a/packages/daedalus-agent-management-web/server/src/services/large-language-model-service.js b/packages/daedalus-agent-management-web/server/src/services/large-language-model-service.js index c5069a9..17ec8d4 100644 --- a/packages/daedalus-agent-management-web/server/src/services/large-language-model-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/large-language-model-service.js @@ -275,6 +275,53 @@ export class LargeLanguageModelService { return this.getModel(model.provider_id, model.model_id); } + async refreshProviderProjection(providerId) { + const snapshot = await this.managementClient.call("catalog.snapshot", {}); + const providers = snapshot?.catalogs?.llmProviders?.items; + if (!Array.isArray(providers)) { + throw new Error("catalog.snapshot did not return llmProviders"); + } + const provider = providers.find((item) => firstText(item?.id) === providerId); + if (!provider) { + this.db.prepare("DELETE FROM llm_models WHERE provider_id = ?").run(providerId); + this.db.prepare("DELETE FROM llm_providers WHERE provider_id = ?").run(providerId); + return null; + } + + this.upsertProvider({ + providerId, + baseUrl: provider.baseUrl, + apiKeyMask: provider.secret?.mask, + apiKeyFingerprint: provider.secret?.fingerprint, + api: firstText(provider.api, "openaiCompletions") === "openaiCompletions" + ? "openai-completions" + : provider.api + }); + const seenModels = new Set(); + for (const model of Array.isArray(provider.models) ? provider.models : []) { + const modelId = firstText(model?.id); + if (!modelId) { + continue; + } + const modelRef = firstText(model.ref, buildModelRef(providerId, modelId)); + seenModels.add(modelRef); + this.upsertModel({ + providerId, + modelId, + modelRef, + name: firstText(model.name, modelId), + contextTokens: model.limits?.contextTokens ?? null, + maxTokens: model.limits?.maxOutputTokens ?? null + }); + } + for (const local of this.db.prepare("SELECT model_ref FROM llm_models WHERE provider_id = ?").all(providerId)) { + if (!seenModels.has(local.model_ref)) { + this.db.prepare("DELETE FROM llm_models WHERE model_ref = ?").run(local.model_ref); + } + } + return this.getProvider(providerId); + } + async createProvider(payload) { const providerId = normalizeProviderId(payload.provider_id ?? payload.providerId); const baseUrl = normalizeBaseUrl(payload.base_url ?? payload.baseUrl); @@ -285,12 +332,21 @@ export class LargeLanguageModelService { apiKey, restart: false }); - return this.upsertProvider(remote, { - providerId, - baseUrl, - apiKeyMask: maskApiKey(apiKey), - apiKeyFingerprint: fingerprintApiKey(apiKey) - }); + try { + return this.upsertProvider(remote, { + providerId, + baseUrl, + apiKeyMask: maskApiKey(apiKey), + apiKeyFingerprint: fingerprintApiKey(apiKey) + }); + } catch (error) { + try { + return await this.refreshProviderProjection(providerId); + } catch { + await this.managementClient.call("llm.provider.delete", { providerId, restart: false }).catch(() => undefined); + throw error; + } + } } async updateProvider(providerId, payload) { @@ -308,12 +364,20 @@ export class LargeLanguageModelService { } const remote = await this.managementClient.call("llm.provider.update", remotePayload); - return this.upsertProvider(remote, { - providerId: normalizedProviderId, - baseUrl: remotePayload.baseUrl, - apiKeyMask: remotePayload.apiKey ? maskApiKey(remotePayload.apiKey) : undefined, - apiKeyFingerprint: remotePayload.apiKey ? fingerprintApiKey(remotePayload.apiKey) : undefined - }); + try { + return this.upsertProvider(remote, { + providerId: normalizedProviderId, + baseUrl: remotePayload.baseUrl, + apiKeyMask: remotePayload.apiKey ? maskApiKey(remotePayload.apiKey) : undefined, + apiKeyFingerprint: remotePayload.apiKey ? fingerprintApiKey(remotePayload.apiKey) : undefined + }); + } catch (error) { + try { + return await this.refreshProviderProjection(normalizedProviderId); + } catch { + throw error; + } + } } async deleteProvider(providerId) { @@ -330,7 +394,15 @@ export class LargeLanguageModelService { providerId: normalizedProviderId, restart: false }); - this.db.prepare("DELETE FROM llm_providers WHERE provider_id = ?").run(normalizedProviderId); + try { + this.db.prepare("DELETE FROM llm_providers WHERE provider_id = ?").run(normalizedProviderId); + } catch (error) { + try { + await this.refreshProviderProjection(normalizedProviderId); + } catch { + throw error; + } + } return { ok: true, provider_id: normalizedProviderId }; } @@ -414,14 +486,24 @@ export class LargeLanguageModelService { maxTokens, restart: false }); - return this.upsertModel(remote, { - providerId, - modelId, - modelRef: buildModelRef(providerId, modelId), - name: firstText(payload.name, modelId), - contextTokens, - maxTokens - }); + try { + return this.upsertModel(remote, { + providerId, + modelId, + modelRef: buildModelRef(providerId, modelId), + name: firstText(payload.name, modelId), + contextTokens, + maxTokens + }); + } catch (error) { + try { + await this.refreshProviderProjection(providerId); + return this.getModel(providerId, modelId); + } catch { + await this.managementClient.call("llm.model.delete", { providerId, modelId, restart: false }).catch(() => undefined); + throw error; + } + } } async updateModel(providerId, modelId, payload) { @@ -442,14 +524,23 @@ export class LargeLanguageModelService { } const remote = await this.managementClient.call("llm.model.update", remotePayload); - return this.upsertModel(remote, { - providerId: normalizedProviderId, - modelId: normalizedModelId, - modelRef: buildModelRef(normalizedProviderId, normalizedModelId), - name: payload.name, - contextTokens: payload.context_tokens ?? payload.contextTokens, - maxTokens: payload.max_tokens ?? payload.maxTokens - }); + try { + return this.upsertModel(remote, { + providerId: normalizedProviderId, + modelId: normalizedModelId, + modelRef: buildModelRef(normalizedProviderId, normalizedModelId), + name: payload.name, + contextTokens: payload.context_tokens ?? payload.contextTokens, + maxTokens: payload.max_tokens ?? payload.maxTokens + }); + } catch (error) { + try { + await this.refreshProviderProjection(normalizedProviderId); + return this.getModel(normalizedProviderId, normalizedModelId); + } catch { + throw error; + } + } } async deleteModel(providerId, modelId) { @@ -465,7 +556,15 @@ export class LargeLanguageModelService { modelId: normalizedModelId, restart: false }); - this.db.prepare("DELETE FROM llm_models WHERE model_ref = ?").run(model.model_ref); + try { + this.db.prepare("DELETE FROM llm_models WHERE model_ref = ?").run(model.model_ref); + } catch (error) { + try { + await this.refreshProviderProjection(normalizedProviderId); + } catch { + throw error; + } + } return { ok: true, model_ref: model.model_ref }; } } diff --git a/packages/daedalus-agent-management-web/server/src/services/portal-service.js b/packages/daedalus-agent-management-web/server/src/services/portal-service.js index 865ec83..77bfba4 100644 --- a/packages/daedalus-agent-management-web/server/src/services/portal-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/portal-service.js @@ -725,24 +725,66 @@ export class PortalService { async persistArtifact({ kind, file, createdBy }) { const upload = await this.objectStorage.uploadAdminArtifact({ kind, file }); - const result = this.db.prepare(` - INSERT INTO artifacts ( - kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - kind, - upload.bucket, - upload.objectKey, - file.originalname, - file.mimetype || "application/octet-stream", - file.size, - createdBy, - toChinaISOString() - ); - return { - id: result.lastInsertRowid, - ...upload - }; + try { + const result = this.db.prepare(` + INSERT INTO artifacts ( + kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + kind, + upload.bucket, + upload.objectKey, + file.originalname, + file.mimetype || "application/octet-stream", + file.size, + createdBy, + toChinaISOString() + ); + return { + id: result.lastInsertRowid, + ...upload + }; + } catch (error) { + await this.objectStorage.deleteObject?.(upload.bucket, upload.objectKey).catch(() => undefined); + throw error; + } + } + + getArtifactStorageInfo(artifactId) { + if (!artifactId) { + return null; + } + return this.db.prepare(` + SELECT id, bucket, object_key + FROM artifacts + WHERE id = ? + `).get(artifactId) || null; + } + + async discardArtifact(artifact) { + if (!artifact) { + return; + } + if (artifact.id) { + try { + const references = this.db.prepare(` + SELECT + (SELECT COUNT(*) FROM business_systems WHERE ontology_artifact_id = ?) + + (SELECT COUNT(*) FROM agent_templates WHERE artifact_id = ?) + + (SELECT COUNT(*) FROM skills WHERE artifact_id = ?) AS count + `).get(artifact.id, artifact.id, artifact.id); + if (Number(references?.count || 0) > 0) { + return; + } + this.db.prepare("DELETE FROM artifacts WHERE id = ?").run(artifact.id); + } catch { + return; + } + } + const objectKey = artifact.object_key || artifact.objectKey; + if (artifact.bucket && objectKey && this.objectStorage.deleteObject) { + await this.objectStorage.deleteObject(artifact.bucket, objectKey).catch(() => undefined); + } } listTemplates() { @@ -832,39 +874,63 @@ export class PortalService { const defaultSelection = this.resolveTemplateDefaultLlm(defaultLlm); ensureZipContains(file.buffer, "AGENTS.md"); + const existing = this.db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(templateName); + const previousArtifact = this.getArtifactStorageInfo(existing?.artifact_id); const artifact = await this.persistArtifact({ kind: "template", file, createdBy }); - const remoteResult = await this.managementClient.call("agent.template.create", { - templateName, - bucket: artifact.bucket, - objectKey: artifact.objectKey, - replace: true - }); - const now = toChinaISOString(); - this.db.prepare(` - INSERT INTO agent_templates ( - template_name, artifact_id, remote_status, remote_result_json, - default_llm_provider_id, default_llm_model_id, default_llm_model_ref, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(template_name) DO UPDATE SET - artifact_id = excluded.artifact_id, - remote_status = excluded.remote_status, - remote_result_json = excluded.remote_result_json, - default_llm_provider_id = excluded.default_llm_provider_id, - default_llm_model_id = excluded.default_llm_model_id, - default_llm_model_ref = excluded.default_llm_model_ref, - updated_at = excluded.updated_at - `).run( - templateName, - artifact.id, - "ready", - JSON.stringify(remoteResult ?? {}), - defaultSelection.providerId, - defaultSelection.modelId, - defaultSelection.modelRef, - now, - now - ); + let remoteApplied = false; + try { + const remoteResult = await this.managementClient.call("agent.template.create", { + templateName, + bucket: artifact.bucket, + objectKey: artifact.objectKey, + replace: true + }); + remoteApplied = true; + const now = toChinaISOString(); + this.db.prepare(` + INSERT INTO agent_templates ( + template_name, artifact_id, remote_status, remote_result_json, + default_llm_provider_id, default_llm_model_id, default_llm_model_ref, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(template_name) DO UPDATE SET + artifact_id = excluded.artifact_id, + remote_status = excluded.remote_status, + remote_result_json = excluded.remote_result_json, + default_llm_provider_id = excluded.default_llm_provider_id, + default_llm_model_id = excluded.default_llm_model_id, + default_llm_model_ref = excluded.default_llm_model_ref, + updated_at = excluded.updated_at + `).run( + templateName, + artifact.id, + "ready", + JSON.stringify(remoteResult ?? {}), + defaultSelection.providerId, + defaultSelection.modelId, + defaultSelection.modelRef, + now, + now + ); + } catch (error) { + if (remoteApplied) { + if (previousArtifact) { + await this.managementClient.call("agent.template.create", { + templateName, + bucket: previousArtifact.bucket, + objectKey: previousArtifact.object_key, + replace: true + }).catch(() => undefined); + } else { + await this.managementClient.call("agent.template.delete", { templateName }).catch(() => undefined); + } + } + await this.discardArtifact(artifact); + throw error; + } + if (previousArtifact?.id !== artifact.id) { + await this.discardArtifact(previousArtifact); + } return this.listTemplates().find((item) => item.template_name === templateName); } @@ -908,8 +974,22 @@ export class PortalService { throw conflict("该模板仍有数字员工使用,无法删除"); } + const artifact = this.getArtifactStorageInfo(existing.artifact_id); await this.managementClient.call("agent.template.delete", { templateName }); - this.db.prepare("DELETE FROM agent_templates WHERE template_name = ?").run(templateName); + try { + this.db.prepare("DELETE FROM agent_templates WHERE template_name = ?").run(templateName); + } catch (error) { + if (artifact) { + await this.managementClient.call("agent.template.create", { + templateName, + bucket: artifact.bucket, + objectKey: artifact.object_key, + replace: true + }).catch(() => undefined); + } + throw error; + } + await this.discardArtifact(artifact); return { ok: true }; } @@ -947,31 +1027,39 @@ export class PortalService { const normalizedArchive = normalizeSkillArchive(file); const slug = normalizedArchive.metadata.slug; const artifact = await this.persistArtifact({ kind: "skill", file: normalizedArchive.file, createdBy }); - - await this.managementClient.call("skills.global.install.local", { - slug, - bucket: artifact.bucket, - objectKey: artifact.objectKey, - force: false - }); - const remoteStatus = "installed"; - - const now = toChinaISOString(); - const result = this.db.prepare(` - INSERT INTO skills ( - slug, description, artifact_id, remote_status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?) - `).run( - slug, - "", - artifact.id, - remoteStatus, - now, - now - ); - - const skillId = result.lastInsertRowid; - return this.listSkills().find((item) => item.id === skillId); + let remoteApplied = false; + try { + await this.managementClient.call("skills.global.install.local", { + slug, + bucket: artifact.bucket, + objectKey: artifact.objectKey, + force: false + }); + remoteApplied = true; + + const now = toChinaISOString(); + const result = this.db.prepare(` + INSERT INTO skills ( + slug, description, artifact_id, remote_status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + `).run( + slug, + "", + artifact.id, + "installed", + now, + now + ); + + const skillId = result.lastInsertRowid; + return this.listSkills().find((item) => item.id === skillId); + } catch (error) { + if (remoteApplied) { + await this.managementClient.call("skills.global.delete", { slug }).catch(() => undefined); + } + await this.discardArtifact(artifact); + throw error; + } } async updateSkill(skillId, { payload, file, createdBy }) { @@ -984,6 +1072,7 @@ export class PortalService { if (normalizedArchive.metadata.slug !== slug) { throw badRequest(`技能 zip 中的 name 归一化后必须与当前技能 ${slug} 一致`); } + const previousArtifact = this.getArtifactStorageInfo(current.artifact_id); const artifact = await this.persistArtifact({ kind: "skill", file: normalizedArchive.file, createdBy }); const next = { @@ -993,25 +1082,40 @@ export class PortalService { updated_at: toChinaISOString() }; - await this.managementClient.call("skills.global.install.local", { - slug, - bucket: artifact.bucket, - objectKey: artifact.objectKey, - force: true - }); - const remoteStatus = "installed"; - - this.db.prepare(` - UPDATE skills - SET description = ?, artifact_id = ?, remote_status = ?, updated_at = ? - WHERE id = ? - `).run( - "", - next.artifact_id, - remoteStatus, - next.updated_at, - skillId - ); + let remoteApplied = false; + try { + await this.managementClient.call("skills.global.install.local", { + slug, + bucket: artifact.bucket, + objectKey: artifact.objectKey, + force: true + }); + remoteApplied = true; + + this.db.prepare(` + UPDATE skills + SET description = ?, artifact_id = ?, remote_status = ?, updated_at = ? + WHERE id = ? + `).run( + "", + next.artifact_id, + "installed", + next.updated_at, + skillId + ); + } catch (error) { + if (remoteApplied && previousArtifact) { + await this.managementClient.call("skills.global.install.local", { + slug, + bucket: previousArtifact.bucket, + objectKey: previousArtifact.object_key, + force: true + }).catch(() => undefined); + } + await this.discardArtifact(artifact); + throw error; + } + await this.discardArtifact(previousArtifact); return this.listSkills().find((item) => item.id === skillId); } @@ -1035,11 +1139,25 @@ export class PortalService { throw conflict("内置技能不允许删除"); } + const artifact = this.getArtifactStorageInfo(current.artifact_id); if (current.remote_status === "installed") { await this.managementClient.call("skills.global.delete", { slug: current.slug }); } - this.db.prepare("DELETE FROM skills WHERE id = ?").run(skillId); + try { + this.db.prepare("DELETE FROM skills WHERE id = ?").run(skillId); + } catch (error) { + if (current.remote_status === "installed" && artifact) { + await this.managementClient.call("skills.global.install.local", { + slug: current.slug, + bucket: artifact.bucket, + objectKey: artifact.object_key, + force: true + }).catch(() => undefined); + } + throw error; + } + await this.discardArtifact(artifact); return { ok: true }; } diff --git a/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js b/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js index 0885fb0..29b195e 100644 --- a/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/runtime-env-config-service.js @@ -8,6 +8,7 @@ import { toChinaISOString } from "../utils/china-time.js"; export const AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY = "DA_INTERNAL_NETWORK_ALLOWLIST"; export const HUOZIGE_SERVER_IP_KEY = "DA_HUOZIGE_SERVER_IP"; const HUOZIGE_SERVER_HOST_SETTING_KEY = "huozige_server_host"; +const INTERNAL_NETWORK_ALLOWLIST_SETTING_KEY = "internal_network_allowlist"; function firstText(...values) { for (const value of values) { @@ -141,23 +142,18 @@ export class RuntimeEnvConfigService { this.db = db; this.envPath = envPath || env?.userEnvJsonPath || path.resolve("env.json"); this.credentialsPath = credentialsPath || env?.credentialsJsonPath || path.resolve("credentials.json"); - this.migrateLegacyHuozigeServerHost(); + this.migrateLegacyRuntimeSettings(); } - getHuozigeServerHost() { - return normalizeServerHostValue(this.db?.prepare(` + getRuntimeSetting(key) { + return this.db?.prepare(` SELECT setting_value FROM runtime_settings WHERE setting_key = ? - `).get(HUOZIGE_SERVER_HOST_SETTING_KEY)?.setting_value); + `).get(key)?.setting_value; } - setHuozigeServerHost(value) { - if (!this.db) { - throw new Error("Runtime settings database is not configured"); - } - const normalized = normalizeServerHostValue(value); - const now = toChinaISOString(); + upsertRuntimeSetting(key, value, now = toChinaISOString()) { this.db.prepare(` INSERT INTO runtime_settings ( setting_key, setting_value, created_at, updated_at @@ -165,11 +161,27 @@ export class RuntimeEnvConfigService { ON CONFLICT(setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at - `).run(HUOZIGE_SERVER_HOST_SETTING_KEY, normalized, now, now); + `).run(key, value, now, now); + } + + getHuozigeServerHost() { + return normalizeServerHostValue(this.getRuntimeSetting(HUOZIGE_SERVER_HOST_SETTING_KEY)); + } + + getInternalNetworkAllowlist() { + return normalizeAllowlistValue(this.getRuntimeSetting(INTERNAL_NETWORK_ALLOWLIST_SETTING_KEY)); + } + + setHuozigeServerHost(value) { + if (!this.db) { + throw new Error("Runtime settings database is not configured"); + } + const normalized = normalizeServerHostValue(value); + this.upsertRuntimeSetting(HUOZIGE_SERVER_HOST_SETTING_KEY, normalized); return normalized; } - migrateLegacyHuozigeServerHost() { + migrateLegacyRuntimeSettings() { if (!this.db || !fs.existsSync(this.envPath)) { return; } @@ -178,32 +190,48 @@ export class RuntimeEnvConfigService { const legacyHost = hasLegacyHost ? normalizeServerHostValue(current[HUOZIGE_SERVER_IP_KEY]) : ""; - if (hasLegacyHost && !this.getHuozigeServerHost() && legacyHost) { - this.setHuozigeServerHost(legacyHost); - } - - const configuredHost = this.getHuozigeServerHost(); - const previousAllowlist = normalizeAllowlistValue(current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]); + const hasLegacyAllowlist = Object.prototype.hasOwnProperty.call(current, AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY); + const storedHost = this.getRuntimeSetting(HUOZIGE_SERVER_HOST_SETTING_KEY); + const storedAllowlist = this.getRuntimeSetting(INTERNAL_NETWORK_ALLOWLIST_SETTING_KEY); + const configuredHost = normalizeServerHostValue(storedHost === undefined ? legacyHost : storedHost); + const previousAllowlist = normalizeAllowlistValue( + storedAllowlist === undefined ? current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] : storedAllowlist + ); const domainHosts = new Set([legacyHost, configuredHost].filter((host) => host && !isIP(host))); - const nextAllowlist = uniqueAllowlistItems( + let nextAllowlist = uniqueAllowlistItems( parseAllowlistItems(previousAllowlist).filter((item) => !domainHosts.has(item)) ).join(","); - if (nextAllowlist !== previousAllowlist) { - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = nextAllowlist; + nextAllowlist = updateAllowlistServerHost(nextAllowlist, "", configuredHost); + + this.db.exec("BEGIN IMMEDIATE"); + try { + const now = toChinaISOString(); + if (storedHost === undefined && configuredHost) { + this.upsertRuntimeSetting(HUOZIGE_SERVER_HOST_SETTING_KEY, configuredHost, now); + } + if (storedAllowlist === undefined) { + this.upsertRuntimeSetting(INTERNAL_NETWORK_ALLOWLIST_SETTING_KEY, nextAllowlist, now); + } + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; } if (hasLegacyHost) { delete current[HUOZIGE_SERVER_IP_KEY]; } - if (hasLegacyHost || nextAllowlist !== previousAllowlist) { + if (hasLegacyAllowlist) { + delete current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]; + } + if (hasLegacyHost || hasLegacyAllowlist) { writeJsonObject(this.envPath, current); } } getNetworkAllowlistConfig() { - const payload = readJsonObject(this.envPath, "env.json"); return { - agent_internal_network_allowlist: normalizeAllowlistValue(payload[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]), + agent_internal_network_allowlist: this.getInternalNetworkAllowlist(), huozige_server_ip: this.getHuozigeServerHost() }; } @@ -219,42 +247,47 @@ export class RuntimeEnvConfigService { } updateNetworkAllowlistConfig(payload) { - const current = readJsonObject(this.envPath); - const previousAllowlist = normalizeAllowlistValue(current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]); + if (!this.db) { + throw new Error("Runtime settings database is not configured"); + } + const previousAllowlist = this.getInternalNetworkAllowlist(); const previousServerHost = this.getHuozigeServerHost(); const hasServerHostPayload = Object.prototype.hasOwnProperty.call(payload || {}, "huozige_server_ip"); + let nextAllowlist = previousAllowlist; + let nextServerHost = previousServerHost; if (Object.prototype.hasOwnProperty.call(payload || {}, "agent_internal_network_allowlist")) { - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = normalizeAllowlistValue( + nextAllowlist = normalizeAllowlistValue( payload?.agent_internal_network_allowlist ); } if (hasServerHostPayload) { - const nextServerHost = normalizeServerHostValue(payload?.huozige_server_ip); - this.setHuozigeServerHost(nextServerHost); - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = updateAllowlistServerHost( - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY], + nextServerHost = normalizeServerHostValue(payload?.huozige_server_ip); + nextAllowlist = updateAllowlistServerHost( + nextAllowlist, previousServerHost, nextServerHost ); - } else { - const currentServerHost = this.getHuozigeServerHost(); - if (currentServerHost) { - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY] = updateAllowlistServerHost( - current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY], - currentServerHost, - currentServerHost - ); - } + } else if (nextServerHost) { + nextAllowlist = updateAllowlistServerHost(nextAllowlist, nextServerHost, nextServerHost); } - delete current[HUOZIGE_SERVER_IP_KEY]; - - writeJsonObject(this.envPath, current); + this.db.exec("BEGIN IMMEDIATE"); + try { + const now = toChinaISOString(); + if (hasServerHostPayload) { + this.upsertRuntimeSetting(HUOZIGE_SERVER_HOST_SETTING_KEY, nextServerHost, now); + } + this.upsertRuntimeSetting(INTERNAL_NETWORK_ALLOWLIST_SETTING_KEY, nextAllowlist, now); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } return { ...this.getNetworkAllowlistConfig(), - network_allowlist_changed: normalizeAllowlistValue(current[AGENT_INTERNAL_NETWORK_ALLOWLIST_KEY]) !== previousAllowlist + network_allowlist_changed: nextAllowlist !== previousAllowlist }; } } diff --git a/packages/daedalus-agent-management-web/server/src/services/system-service.js b/packages/daedalus-agent-management-web/server/src/services/system-service.js index 6cae4c9..66a46b1 100644 --- a/packages/daedalus-agent-management-web/server/src/services/system-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/system-service.js @@ -345,12 +345,53 @@ export class SystemService { } return this.db.prepare(` - SELECT bucket, object_key + SELECT id, bucket, object_key FROM artifacts WHERE id = ? `).get(artifactId) || null; } + buildRemoteSystemParams(system, artifact) { + const objectKey = artifact?.object_key || artifact?.objectKey; + return { + systemId: system.application_name, + name: system.application_name, + provider: system.provider || FIXED_BUSINESS_SYSTEM_PROVIDER, + ontologyName: system.application_name, + description: system.description || "", + status: system.status || "active", + bucket: artifact?.bucket, + objectKey, + replaceOntology: Boolean(artifact?.bucket && objectKey) + }; + } + + async discardArtifact(artifact) { + if (!artifact) { + return; + } + if (artifact.id) { + try { + const references = this.db.prepare(` + SELECT + (SELECT COUNT(*) FROM business_systems WHERE ontology_artifact_id = ?) + + (SELECT COUNT(*) FROM agent_templates WHERE artifact_id = ?) + + (SELECT COUNT(*) FROM skills WHERE artifact_id = ?) AS count + `).get(artifact.id, artifact.id, artifact.id); + if (Number(references?.count || 0) > 0) { + return; + } + this.db.prepare("DELETE FROM artifacts WHERE id = ?").run(artifact.id); + } catch { + return; + } + } + const objectKey = artifact.object_key || artifact.objectKey; + if (artifact.bucket && objectKey && this.objectStorage.deleteObject) { + await this.objectStorage.deleteObject(artifact.bucket, objectKey).catch(() => undefined); + } + } + async deleteRemoteOntologiesIfPresent(applicationNames, { ignoreDeleteErrors = false } = {}) { const normalizedNames = Array.from(new Set( applicationNames @@ -388,51 +429,45 @@ export class SystemService { const artifact = await this.persistOntologyArtifact({ file, createdBy }); const artifactId = artifact.id; - const bucket = artifact.bucket; - const objectKey = artifact.objectKey; - - const { deletedApplicationNames } = await this.deleteRemoteOntologiesIfPresent([ - normalized.application_name - ]); - - await this.managementClient.call("apps.create", { - systemId: normalized.application_name, - name: normalized.application_name, - provider: normalized.provider, - ontologyName: normalized.application_name, - description: normalized.description, - status: normalized.status, - bucket, - objectKey, - replaceOntology: Boolean(bucket && objectKey) - }); - const now = toChinaISOString(); - const result = this.db.prepare(` - INSERT INTO business_systems ( - provider, application_name, description, ontology_artifact_id, - scheme, port, status, last_connectivity_test_status, last_connectivity_test_result_json, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - normalized.provider, - normalized.application_name, - normalized.description, - artifactId, - normalized.scheme, - normalized.port, - normalized.status, - "unknown", - JSON.stringify({}), - now, - now - ); + let remoteCreated = false; + try { + await this.managementClient.call("apps.create", this.buildRemoteSystemParams(normalized, artifact)); + remoteCreated = true; + + const now = toChinaISOString(); + const result = this.db.prepare(` + INSERT INTO business_systems ( + provider, application_name, description, ontology_artifact_id, + scheme, port, status, last_connectivity_test_status, last_connectivity_test_result_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + normalized.provider, + normalized.application_name, + normalized.description, + artifactId, + normalized.scheme, + normalized.port, + normalized.status, + "unknown", + JSON.stringify({}), + now, + now + ); - return { - system: this.getSystemById(result.lastInsertRowid), - kernelOntologyDeleted: deletedApplicationNames.length > 0, - deletedKernelOntologyNames: deletedApplicationNames - }; + return { + system: this.getSystemById(result.lastInsertRowid), + kernelOntologyDeleted: false, + deletedKernelOntologyNames: [] + }; + } catch (error) { + if (remoteCreated) { + await this.deleteRemoteOntologiesIfPresent([normalized.application_name], { ignoreDeleteErrors: true }); + } + await this.discardArtifact(artifact); + throw error; + } } async persistOntologyArtifact({ file, createdBy }) { @@ -446,25 +481,30 @@ export class SystemService { } const artifact = await this.objectStorage.uploadAdminArtifact({ kind: "ontology", file }); - const result = this.db.prepare(` - INSERT INTO artifacts ( - kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - "ontology", - artifact.bucket, - artifact.objectKey, - file.originalname, - file.mimetype || "application/octet-stream", - file.size, - createdBy, - toChinaISOString() - ); + try { + const result = this.db.prepare(` + INSERT INTO artifacts ( + kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + "ontology", + artifact.bucket, + artifact.objectKey, + file.originalname, + file.mimetype || "application/octet-stream", + file.size, + createdBy, + toChinaISOString() + ); - return { - id: result.lastInsertRowid, - ...artifact - }; + return { + id: result.lastInsertRowid, + ...artifact + }; + } catch (error) { + await this.objectStorage.deleteObject?.(artifact.bucket, artifact.objectKey).catch(() => undefined); + throw error; + } } async updateSystem(id, { payload, file, createdBy }) { @@ -484,41 +524,54 @@ export class SystemService { } const existingArtifact = artifact || this.getArtifactStorageInfo(artifactId); - + const previousArtifact = this.getArtifactStorageInfo(current.ontology_artifact_id); const previousSystemId = current.application_name; - const { deletedApplicationNames } = await this.deleteRemoteOntologiesIfPresent([ - previousSystemId, - normalized.application_name - ], { ignoreDeleteErrors: true }); - - await this.managementClient.call("apps.create", { - systemId: normalized.application_name, - name: normalized.application_name, - provider: normalized.provider, - ontologyName: normalized.application_name, - description: normalized.description, - status: normalized.status, - bucket: existingArtifact?.bucket, - objectKey: existingArtifact?.object_key || existingArtifact?.objectKey, - replaceOntology: Boolean(existingArtifact?.bucket && (existingArtifact?.object_key || existingArtifact?.objectKey)) - }); + let remoteUpdated = false; - this.db.prepare(` - UPDATE business_systems - SET provider = ?, application_name = ?, description = ?, ontology_artifact_id = ?, - scheme = ?, port = ?, status = ?, updated_at = ? - WHERE id = ? - `).run( - normalized.provider, - normalized.application_name, - normalized.description, - artifactId, - normalized.scheme, - normalized.port, - normalized.status, - toChinaISOString(), - id - ); + try { + await this.managementClient.call("apps.create", this.buildRemoteSystemParams(normalized, existingArtifact)); + remoteUpdated = true; + + this.db.prepare(` + UPDATE business_systems + SET provider = ?, application_name = ?, description = ?, ontology_artifact_id = ?, + scheme = ?, port = ?, status = ?, updated_at = ? + WHERE id = ? + `).run( + normalized.provider, + normalized.application_name, + normalized.description, + artifactId, + normalized.scheme, + normalized.port, + normalized.status, + toChinaISOString(), + id + ); + } catch (error) { + if (remoteUpdated) { + await this.managementClient.call( + "apps.create", + this.buildRemoteSystemParams(current, previousArtifact) + ).catch(() => undefined); + if (previousSystemId !== normalized.application_name) { + await this.deleteRemoteOntologiesIfPresent([normalized.application_name], { ignoreDeleteErrors: true }); + } + } + if (artifact) { + await this.discardArtifact(artifact); + } + throw error; + } + + const deletedApplicationNames = []; + if (previousSystemId !== normalized.application_name) { + const deleted = await this.deleteRemoteOntologiesIfPresent([previousSystemId], { ignoreDeleteErrors: true }); + deletedApplicationNames.push(...deleted.deletedApplicationNames); + } + if (artifact && previousArtifact?.id !== artifact.id) { + await this.discardArtifact(previousArtifact); + } return { system: this.getSystemById(id), @@ -553,8 +606,10 @@ export class SystemService { throw conflict("内置 ontology 不允许删除"); } + const artifact = this.getArtifactStorageInfo(current.ontology_artifact_id); await this.managementClient.call("apps.delete", { systemId: current.application_name }); this.db.prepare("DELETE FROM business_systems WHERE id = ?").run(id); + await this.discardArtifact(artifact); return { ok: true }; } @@ -629,7 +684,7 @@ export class SystemService { return filePath; } - async persistInvocationArtifact({ traceId, direction, applicationName, summary, createdAt }) { + async prepareInvocationArtifact({ traceId, direction, applicationName, summary, createdAt }) { let storage = null; let redactionStatus = "metadata_only"; try { @@ -643,50 +698,79 @@ export class SystemService { storage = null; } + return { + traceId, + direction, + summary, + storage, + redactionStatus, + createdAt + }; + } + + insertInvocationArtifact(prepared) { const result = this.db.prepare(` INSERT INTO system_invocation_artifacts ( trace_id, direction, content_type, byte_size, sha256, storage_bucket, storage_object_key, preview_json, redaction_status, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - traceId, - direction, + prepared.traceId, + prepared.direction, "application/json", - summary.byteSize, - summary.sha256, - storage?.bucket || null, - storage?.objectKey || null, - JSON.stringify(summary.preview ?? {}), - redactionStatus, - createdAt + prepared.summary.byteSize, + prepared.summary.sha256, + prepared.storage?.bucket || null, + prepared.storage?.objectKey || null, + JSON.stringify(prepared.summary.preview ?? {}), + prepared.redactionStatus, + prepared.createdAt ); return { id: result.lastInsertRowid, - direction, + direction: prepared.direction, content_type: "application/json", - byte_size: summary.byteSize, - sha256: summary.sha256, - storage_bucket: storage?.bucket || null, - storage_object_key: storage?.objectKey || null, - preview: summary.preview, - redaction_status: redactionStatus + byte_size: prepared.summary.byteSize, + sha256: prepared.summary.sha256, + storage_bucket: prepared.storage?.bucket || null, + storage_object_key: prepared.storage?.objectKey || null, + preview: prepared.summary.preview, + redaction_status: prepared.redactionStatus }; } + async removeInvocationArtifactFile(prepared) { + if (!prepared?.storage?.objectKey) { + return; + } + const filePath = this.resolveInvocationArtifactFilePath(prepared.storage.objectKey); + await fs.rm(filePath, { force: true }).catch(() => undefined); + } + + async persistInvocationArtifact(params) { + const prepared = await this.prepareInvocationArtifact(params); + try { + return this.insertInvocationArtifact(prepared); + } catch (error) { + await this.removeInvocationArtifactFile(prepared); + throw error; + } + } + async recordInvocation(log) { const createdAt = log.created_at || toChinaISOString(); const traceId = log.trace_id; const requestSummary = summarizePayload(log.request_payload ?? {}); const responseSummary = summarizePayload(log.response_payload ?? {}); - const requestArtifact = await this.persistInvocationArtifact({ + const requestPrepared = await this.prepareInvocationArtifact({ traceId, direction: "request", applicationName: log.application_name, summary: requestSummary, createdAt }); - const responseArtifact = await this.persistInvocationArtifact({ + const responsePrepared = await this.prepareInvocationArtifact({ traceId, direction: "response", applicationName: log.application_name, @@ -694,33 +778,55 @@ export class SystemService { createdAt }); - this.db.prepare(` - INSERT INTO system_invocation_logs ( - trace_id, agent_slug, session_id, application_name, command_name, method, - request_size_bytes, response_size_bytes, request_sha256, response_sha256, - request_preview_json, response_preview_json, request_artifact_id, response_artifact_id, - response_time_ms, success, error_message, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - traceId, - log.agent_slug || null, - log.session_id || null, - log.application_name, - log.command_name, - log.method || "", - requestSummary.byteSize, - responseSummary.byteSize, - requestSummary.sha256, - responseSummary.sha256, - JSON.stringify(requestSummary.preview ?? {}), - JSON.stringify(responseSummary.preview ?? {}), - requestArtifact.id, - responseArtifact.id, - log.response_time_ms, - log.success ? 1 : 0, - log.error_message || null, - createdAt - ); + const supportsTransactions = typeof this.db.exec === "function"; + try { + if (supportsTransactions) { + this.db.exec("BEGIN IMMEDIATE"); + } + const requestArtifact = this.insertInvocationArtifact(requestPrepared); + const responseArtifact = this.insertInvocationArtifact(responsePrepared); + this.db.prepare(` + INSERT INTO system_invocation_logs ( + trace_id, agent_slug, session_id, application_name, command_name, method, + request_size_bytes, response_size_bytes, request_sha256, response_sha256, + request_preview_json, response_preview_json, request_artifact_id, response_artifact_id, + response_time_ms, success, error_message, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + traceId, + log.agent_slug || null, + log.session_id || null, + log.application_name, + log.command_name, + log.method || "", + requestSummary.byteSize, + responseSummary.byteSize, + requestSummary.sha256, + responseSummary.sha256, + JSON.stringify(requestSummary.preview ?? {}), + JSON.stringify(responseSummary.preview ?? {}), + requestArtifact.id, + responseArtifact.id, + log.response_time_ms, + log.success ? 1 : 0, + log.error_message || null, + createdAt + ); + if (supportsTransactions) { + this.db.exec("COMMIT"); + } + } catch (error) { + if (supportsTransactions) { + try { + this.db.exec("ROLLBACK"); + } catch {} + } + await Promise.all([ + this.removeInvocationArtifactFile(requestPrepared), + this.removeInvocationArtifactFile(responsePrepared) + ]); + throw error; + } } diff --git a/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js b/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js index 880b270..7c671c5 100644 --- a/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js +++ b/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js @@ -23,7 +23,7 @@ const FALLBACK_LLM_SELECTION = { }; const FALLBACK_MODEL_REF = "backup-openai/qwen-fallback"; -function createDb({ failAgentInsert = false, templates = ["default"] } = {}) { +function createDb({ failAgentInsert = false, failAgentUpdate = false, templates = ["default"] } = {}) { const insertedRows = []; const permissions = []; const llmModels = new Map([ @@ -221,6 +221,9 @@ function createDb({ failAgentInsert = false, templates = ["default"] } = {}) { if (sql.includes("UPDATE agents")) { return { run: (...params) => { + if (failAgentUpdate) { + throw new Error("injected agent update failure"); + } if (!insertedRows[0]) { insertedRows.push({ id: 1, @@ -959,7 +962,7 @@ it("updates remote and local llm model during agent update", async () => { expect(updated.llm_model_ref).toBe(ALT_MODEL_REF); expect(actionCalls).toEqual([{ - action: "agent.model.set", + action: "agent.update", params: { agentId: "agent-a", model: { @@ -970,6 +973,42 @@ it("updates remote and local llm model during agent update", async () => { }]); }); +it("restores the remote agent when the local update fails", async () => { + const actionCalls = []; + const service = new AgentService({ + db: createDb({ failAgentUpdate: true }), + managementClient: { + async call(action, params) { + actionCalls.push({ action, params }); + return { ok: true }; + } + }, + objectStorage: {} + }); + + await expectAsync(service.updateAgent(1, { + agent_name: "Agent A Updated" + })).toBeRejectedWithError(/injected agent update failure/); + expect(actionCalls).toEqual([ + { + action: "agent.update", + params: { + agentId: "agent-a", + name: "Agent A Updated", + restart: true + } + }, + { + action: "agent.update", + params: { + agentId: "agent-a", + name: "Agent A", + restart: true + } + } + ]); +}); + it("updates fallback llm model without changing the primary model", async () => { const actionCalls = []; const service = new AgentService({ @@ -990,7 +1029,7 @@ it("updates fallback llm model without changing the primary model", async () => expect(updated.llm_model_ref).toBe(DEFAULT_MODEL_REF); expect(updated.fallback_llm_model_ref).toBe(FALLBACK_MODEL_REF); expect(actionCalls).toEqual([{ - action: "agent.model.set", + action: "agent.update", params: { agentId: "agent-a", model: { @@ -1047,7 +1086,7 @@ it("clears fallback llm model during agent update", async () => { expect(updated.fallback_llm_model_id).toBeNull(); expect(updated.fallback_llm_model_ref).toBeNull(); expect(actionCalls).toEqual([{ - action: "agent.model.set", + action: "agent.update", params: { agentId: "agent-a", model: { @@ -1112,7 +1151,7 @@ it("keeps agent-user bindings when update payload omits assignment fields", asyn { id: 11, username: "zhangsan" } ]); expect(actionCalls).toEqual([{ - action: "agent.name.set", + action: "agent.update", params: { agentId: "agent-a", name: "Agent A Updated", diff --git a/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js b/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js index 4d84530..a84b29e 100644 --- a/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js +++ b/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js @@ -753,6 +753,43 @@ it("rejects updating a global skill with a zip for a different slug before persi expect(actionCalls.length).toBe(0); }); +it("deletes an uploaded object when the artifact row cannot be persisted", async () => { + const deleted = []; + const service = new PortalService({ + db: { + prepare() { + return { + run() { + throw new Error("injected artifact insert failure"); + } + }; + } + }, + objectStorage: { + async uploadAdminArtifact() { + return { bucket: "admin-in", objectKey: "skill/new.zip" }; + }, + async deleteObject(bucket, objectKey) { + deleted.push({ bucket, objectKey }); + } + }, + managementClient: {}, + authService: {} + }); + + await expectAsync(service.persistArtifact({ + kind: "skill", + file: { + buffer: Buffer.from("zip"), + originalname: "new.zip", + mimetype: "application/zip", + size: 3 + }, + createdBy: 1 + })).toBeRejectedWithError(/injected artifact insert failure/); + expect(deleted).toEqual([{ bucket: "admin-in", objectKey: "skill/new.zip" }]); +}); + it("deletes installed global skill through skills.global.delete using skill slug only", async () => { const actionCalls = []; const db = { diff --git a/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js b/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js index 9726be3..ea0c9fe 100644 --- a/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js @@ -23,7 +23,7 @@ function createRuntimeSettingsDb() { return db; } -it("reads the agent internal network allowlist from env.json", () => { +it("migrates the agent internal network allowlist from env.json to SQLite", () => { const tempDir = createTempDir(); const envPath = path.join(tempDir, "env.json"); fs.writeFileSync(envPath, JSON.stringify({ @@ -38,17 +38,20 @@ it("reads the agent internal network allowlist from env.json", () => { agent_internal_network_allowlist: "192.168.1.10,10.20.0.0/16", huozige_server_ip: "" }); + expect(JSON.parse(fs.readFileSync(envPath, "utf8"))).toEqual({ + DA_MANAGEMENT_CONSOLE_PORT: 3030 + }); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }); -it("updates the agent internal network allowlist without dropping other env.json values", () => { +it("updates the agent internal network allowlist in SQLite without rewriting env.json", () => { const tempDir = createTempDir(); const envPath = path.join(tempDir, "env.json"); fs.writeFileSync(envPath, JSON.stringify({ DA_MANAGEMENT_CONSOLE_PORT: 3030, - DA_OPENCLAW_GATEWAY_MAX_CONCURRENT: 16, + DA_OPENCLAW_GATEWAY_MAX_CONCURRENT: 128, DA_INTERNAL_NETWORK_ALLOWLIST: "" }, null, 2)); @@ -66,8 +69,7 @@ it("updates the agent internal network allowlist without dropping other env.json }); expect(saved).toEqual({ DA_MANAGEMENT_CONSOLE_PORT: 3030, - DA_OPENCLAW_GATEWAY_MAX_CONCURRENT: 16, - DA_INTERNAL_NETWORK_ALLOWLIST: "192.168.1.10 ; 10.20.0.0/16" + DA_OPENCLAW_GATEWAY_MAX_CONCURRENT: 128 }); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -104,8 +106,7 @@ it("migrates the huozige server ip to SQLite and removes the legacy env.json val network_allowlist_changed: true }); expect(saved).toEqual({ - DA_MANAGEMENT_CONSOLE_PORT: 3030, - DA_INTERNAL_NETWORK_ALLOWLIST: "10.0.0.2,10.20.0.0/16,192.168.1.11" + DA_MANAGEMENT_CONSOLE_PORT: 3030 }); expect(persisted.setting_value).toBe("192.168.1.11"); } finally { @@ -133,7 +134,10 @@ it("does not add a huozige domain to the internal network allowlist", () => { network_allowlist_changed: false }); expect(JSON.parse(fs.readFileSync(envPath, "utf8"))).toEqual({ - DA_INTERNAL_NETWORK_ALLOWLIST: "10.20.0.0/16" + }); + expect(service.getNetworkAllowlistConfig()).toEqual({ + agent_internal_network_allowlist: "10.20.0.0/16", + huozige_server_ip: "apps.example.com" }); const nextDomainResult = service.updateNetworkAllowlistConfig({ @@ -180,7 +184,39 @@ it("removes a migrated huozige domain from the internal network allowlist", () = expect(service.getHuozigeServerHost()).toBe("apps.example.com"); expect(JSON.parse(fs.readFileSync(envPath, "utf8"))).toEqual({ - DA_INTERNAL_NETWORK_ALLOWLIST: "10.20.0.0/16" + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +it("rolls back the server host when the allowlist update fails", () => { + const tempDir = createTempDir(); + const envPath = path.join(tempDir, "env.json"); + fs.writeFileSync(envPath, "{}\n"); + + try { + const db = createRuntimeSettingsDb(); + const service = new RuntimeEnvConfigService({ db, envPath }); + service.updateNetworkAllowlistConfig({ + huozige_server_ip: "192.168.1.10", + agent_internal_network_allowlist: "10.20.0.0/16" + }); + db.exec(` + CREATE TRIGGER fail_allowlist_update + BEFORE UPDATE ON runtime_settings + WHEN NEW.setting_key = 'internal_network_allowlist' + BEGIN + SELECT RAISE(ABORT, 'injected allowlist failure'); + END; + `); + + expect(() => service.updateNetworkAllowlistConfig({ + huozige_server_ip: "192.168.1.11" + })).toThrowError(/injected allowlist failure/); + expect(service.getNetworkAllowlistConfig()).toEqual({ + agent_internal_network_allowlist: "10.20.0.0/16,192.168.1.10", + huozige_server_ip: "192.168.1.10" }); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js b/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js index f1222c8..4c2cdd5 100644 --- a/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js +++ b/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js @@ -176,7 +176,7 @@ it("updates the env allowlist config and writes an audit log", async () => { userId: 1, username: "daedalus-agent", action: "保存 IP 白名单", - detail: "更新数据库中的活字格服务器地址及 env.json 中的 DA_INTERNAL_NETWORK_ALLOWLIST" + detail: "原子更新数据库中的活字格服务器地址及内部网络白名单" }]); } finally { await close(server); diff --git a/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js b/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js index 2b2f836..dae7b87 100644 --- a/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js +++ b/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-web-system-test-")); process.env.DA_WEB_DATA_DIR = dataDir; @@ -179,27 +180,23 @@ it("maps website fields to management action runtime apps.create params", async expect(result.system.application_name).toBe("demo-app"); expect(result.system.base_url).toBe("https://example.com:443/demo-app"); - expect(result.kernelOntologyDeleted).toBe(true); - expect(result.deletedKernelOntologyNames).toEqual(["demo-app"]); - expect(actionCalls.length).toBe(2); + expect(result.kernelOntologyDeleted).toBe(false); + expect(result.deletedKernelOntologyNames).toEqual([]); + expect(actionCalls.length).toBe(1); expect(actionCalls[0]).toEqual({ - action: "apps.delete", + action: "apps.create", params: { - systemId: "demo-app" + systemId: "demo-app", + name: "demo-app", + provider: "hzg", + ontologyName: "demo-app", + description: "desc", + status: "active", + bucket: "admin-in", + objectKey: "ontology/demo.zip", + replaceOntology: true } }); - expect(actionCalls[1].action).toBe("apps.create"); - expect(actionCalls[1].params).toEqual({ - systemId: "demo-app", - name: "demo-app", - provider: "hzg", - ontologyName: "demo-app", - description: "desc", - status: "active", - bucket: "admin-in", - objectKey: "ontology/demo.zip", - replaceOntology: true - }); }); it("recreates remote system with the configured huozige server host", async () => { @@ -252,27 +249,23 @@ it("recreates remote system with the configured huozige server host", async () = }); expect(result.system.application_name).toBe("demo-app"); - expect(result.kernelOntologyDeleted).toBe(true); - expect(result.deletedKernelOntologyNames).toEqual(["demo-app"]); - expect(actionCalls.length).toBe(2); + expect(result.kernelOntologyDeleted).toBe(false); + expect(result.deletedKernelOntologyNames).toEqual([]); + expect(actionCalls.length).toBe(1); expect(actionCalls[0]).toEqual({ - action: "apps.delete", + action: "apps.create", params: { - systemId: "demo-app" + systemId: "demo-app", + name: "demo-app", + provider: "hzg", + ontologyName: "demo-app", + description: "new desc", + status: "active", + bucket: "admin-in", + objectKey: "ontology/existing.zip", + replaceOntology: true } }); - expect(actionCalls[1].action).toBe("apps.create"); - expect(actionCalls[1].params).toEqual({ - systemId: "demo-app", - name: "demo-app", - provider: "hzg", - ontologyName: "demo-app", - description: "new desc", - status: "active", - bucket: "admin-in", - objectKey: "ontology/existing.zip", - replaceOntology: true - }); }); it("rejects deleting built-in ontology systems", async () => { @@ -310,6 +303,87 @@ it("rejects deleting built-in ontology systems", async () => { expect(actionCalls).toEqual([]); }); +it("restores the previous remote ontology when the local system update fails", async () => { + const currentSystem = { + id: 1, + provider: "hzg", + application_name: "demo-app", + description: "old desc", + ontology_artifact_id: 7, + scheme: "https", + port: 443, + status: "active", + is_builtin: 0 + }; + const db = createDb({ currentSystem }); + const originalPrepare = db.prepare.bind(db); + db.prepare = (sql) => { + const statement = originalPrepare(sql); + if (sql.includes("UPDATE business_systems")) { + statement.run = () => { + throw new Error("injected system update failure"); + }; + } + return statement; + }; + const actionCalls = []; + const service = new SystemService({ + db, + objectStorage: {}, + managementClient: { + async call(action, params) { + actionCalls.push({ action, params }); + return {}; + } + }, + runtimeEnvConfigService: { + getHuozigeServerHost: () => "configured.example.com" + } + }); + + await expectAsync(service.updateSystem(1, { + payload: { description: "new desc" }, + file: null, + createdBy: 1 + })).toBeRejectedWithError(/injected system update failure/); + expect(actionCalls.map((item) => item.action)).toEqual(["apps.create", "apps.create"]); + expect(actionCalls[0].params.description).toBe("new desc"); + expect(actionCalls[1].params.description).toBe("old desc"); +}); + +it("deletes an uploaded ontology object when artifact persistence fails", async () => { + const deleted = []; + const service = new SystemService({ + db: { + prepare() { + return { + run() { + throw new Error("injected artifact insert failure"); + } + }; + } + }, + objectStorage: { + async uploadAdminArtifact() { + return { bucket: "admin-in", objectKey: "ontology/new.zip" }; + }, + async deleteObject(bucket, objectKey) { + deleted.push({ bucket, objectKey }); + } + }, + managementClient: {} + }); + const zip = new (await import("adm-zip")).default(); + zip.addFile("index.md", Buffer.from("# index", "utf8")); + const buffer = zip.toBuffer(); + + await expectAsync(service.persistOntologyArtifact({ + file: { buffer, originalname: "new.zip", mimetype: "application/zip", size: buffer.length }, + createdBy: 1 + })).toBeRejectedWithError(/injected artifact insert failure/); + expect(deleted).toEqual([{ bucket: "admin-in", objectKey: "ontology/new.zip" }]); +}); + it("stores invocation payload files under DAEDALUS_AGENT data logs invoke date folders", async () => { const invokeDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-data-invoke-test-")); try { @@ -381,3 +455,74 @@ it("stores invocation payload files under DAEDALUS_AGENT data logs invoke date f fs.rmSync(invokeDataDir, { recursive: true, force: true }); } }); + +it("rolls back invocation rows and payload files when the log insert fails", async () => { + const invokeDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-invoke-rollback-")); + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE system_invocation_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + direction TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + storage_bucket TEXT, + storage_object_key TEXT, + preview_json TEXT NOT NULL, + redaction_status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE system_invocation_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + agent_slug TEXT, + session_id TEXT, + application_name TEXT NOT NULL, + command_name TEXT NOT NULL, + method TEXT NOT NULL, + request_size_bytes INTEGER NOT NULL, + response_size_bytes INTEGER NOT NULL, + request_sha256 TEXT NOT NULL, + response_sha256 TEXT NOT NULL, + request_preview_json TEXT NOT NULL, + response_preview_json TEXT NOT NULL, + request_artifact_id INTEGER, + response_artifact_id INTEGER, + response_time_ms INTEGER NOT NULL, + success INTEGER NOT NULL, + error_message TEXT, + created_at TEXT NOT NULL + ); + CREATE TRIGGER fail_invocation_log + BEFORE INSERT ON system_invocation_logs + BEGIN + SELECT RAISE(ABORT, 'injected invocation log failure'); + END; + `); + try { + const service = new SystemService({ + db, + objectStorage: {}, + managementClient: {}, + env: { productDataDir: invokeDataDir } + }); + await expectAsync(service.recordInvocation({ + trace_id: "trace-failure", + application_name: "crm-app", + command_name: "query", + request_payload: { input: "a" }, + response_payload: { output: "b" }, + response_time_ms: 10, + success: true, + created_at: "2026-07-06T05:04:03.002+08:00" + })).toBeRejectedWithError(/injected invocation log failure/); + + expect(db.prepare("SELECT COUNT(*) AS count FROM system_invocation_artifacts").get().count).toBe(0); + const artifactDir = path.join(invokeDataDir, "logs", "invoke", "20260706"); + expect(fs.readdirSync(artifactDir)).toEqual([]); + } finally { + db.close(); + fs.rmSync(invokeDataDir, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/src/brand.js b/packages/daedalus-agent-management-web/src/brand.js index 2a4e711..c3c41f6 100644 --- a/packages/daedalus-agent-management-web/src/brand.js +++ b/packages/daedalus-agent-management-web/src/brand.js @@ -1,5 +1,5 @@ -export const BRAND_NAME = "智能体管理与运营平台"; -export const BRAND_SUBTITLE = "Your company's name here"; +export const BRAND_NAME = "daedalus-agent"; +export const BRAND_SUBTITLE = "智能体管理与运营平台"; export const BRAND_SLOGAN = "说出你的困境,我来解决它"; export const BRAND_THEME_COLOR = "#a5522d"; diff --git a/test/README.md b/test/README.md index 05eacde..a97cfc1 100644 --- a/test/README.md +++ b/test/README.md @@ -66,14 +66,15 @@ When a containerized agent calls a mock server on the Windows host, configure th The stress suite runner starts `common/mock-openai-server.js`, prepares the local seed/report directory, syncs seed data into a Docker volume, starts the DAEDALUS_AGENT container, and then runs `case/stress/stress-flow.js`. ```powershell -npm --prefix test run test:stress:20a10c +npm --prefix test run test:stress npm --prefix test run test:stress:50a20c npm --prefix test run test:stress:100a50c ``` The suite names mean: -- `20a10c`: 20 agents, 10 concurrent requests/agents. +- `test:stress`: reuse 100 agents and run four mock-LLM load rounds at 5, 10, 30, and 100 concurrent requests/agents. +- `test:stress:20a10c`: quick single-round check with 20 agents and 10 concurrent requests/agents. - `50a20c`: 50 agents, 20 concurrent requests/agents. - `100a50c`: 100 agents, 50 concurrent requests/agents. diff --git a/test/case/integration/playwright-flow.js b/test/case/integration/playwright-flow.js index 724ca26..027c4c2 100644 --- a/test/case/integration/playwright-flow.js +++ b/test/case/integration/playwright-flow.js @@ -1763,7 +1763,6 @@ async function agentPageWriteSenderIsolationMarker(page, baseUrl, marker, timeou ].join("\n"), timeoutMs, { - expectedText, maxReplyLength: 1200 } ); diff --git a/test/case/stress/100a-reuse-concurrency-matrix-contract.test.js b/test/case/stress/100a-reuse-concurrency-matrix-contract.test.js new file mode 100644 index 0000000..11c46ea --- /dev/null +++ b/test/case/stress/100a-reuse-concurrency-matrix-contract.test.js @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + DEFAULT_CONCURRENCY_MATRIX, + DEFAULT_DURATION_MS, + DEFAULT_MAX_CONCURRENT, + DEFAULT_WARMUP_MS, + buildConfig, + readMatrix +} from "../../suite/stress/run-100a-reuse-concurrency-matrix.js"; + +const testDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const packageJson = JSON.parse(fs.readFileSync(path.join(testDir, "package.json"), "utf8")); + +test("default stress command runs the 100-agent concurrency matrix", () => { + assert.equal( + packageJson.scripts["test:stress"], + "node ./suite/stress/run-100a-reuse-concurrency-matrix.js" + ); + assert.equal(packageJson.scripts["test:stress:100a-matrix"], packageJson.scripts["test:stress"]); +}); + +test("100-agent matrix defaults to 5, 10, 30, and 100 concurrent requests", () => { + assert.deepEqual([...DEFAULT_CONCURRENCY_MATRIX], [5, 10, 30, 100]); + assert.deepEqual(readMatrix("", DEFAULT_CONCURRENCY_MATRIX), DEFAULT_CONCURRENCY_MATRIX); + + for (const concurrency of DEFAULT_CONCURRENCY_MATRIX) { + const config = buildConfig(concurrency); + assert.equal(config.stress.counts.agents, 100); + assert.equal(config.stress.counts.concurrentRequests, concurrency); + assert.equal(config.stress.counts.concurrentAgents, concurrency); + } +}); + +test("all matrix rounds reuse the same resources and deterministic agent selection", () => { + const configs = DEFAULT_CONCURRENCY_MATRIX.map((concurrency) => buildConfig(concurrency)); + assert.deepEqual(new Set(configs.map((config) => config.stress.prefix)), new Set(["mock-100a-reuse"])); + assert.deepEqual(new Set(configs.map((config) => config.stress.randomSeed)), new Set(["mock-100a-reuse"])); + assert.ok(configs.every((config) => config.stress.skipResourceCreation)); + assert.ok(configs.every((config) => config.stress.skipSeededStartup)); + assert.deepEqual(readMatrix("5,10,30,100", []), [5, 10, 30, 100]); + assert.deepEqual(readMatrix("5,10,10,30,100,200", []), [5, 10, 30, 100]); +}); + +test("matrix runner always uses the local mock OpenAI server", () => { + for (const concurrency of DEFAULT_CONCURRENCY_MATRIX) { + const config = buildConfig(concurrency); + assert.equal(config.llm.key, "sk-mock-local"); + assert.equal(config.llm.model, "mock-openai"); + assert.equal(config.llm.modelName, "mock-openai"); + assert.match(config.llm.url, /^http:\/\/host\.docker\.internal:\d+\/v1$/); + assert.equal(config.mockLlm.capacity, 128); + assert.equal(config.stress.gatewayMaxConcurrent, 128); + assert.equal(config.stress.load.warmupMs, 30000); + assert.equal(config.stress.load.durationMs, 900000); + } + + assert.equal(DEFAULT_MAX_CONCURRENT, 128); + assert.equal(DEFAULT_WARMUP_MS, 30000); + assert.equal(DEFAULT_DURATION_MS, 900000); +}); + +test("stress environment persists the configured gateway concurrency", () => { + const prepareSource = fs.readFileSync( + path.join(testDir, "prepare", "stress", "rebuild-stress-env.js"), + "utf8" + ); + + assert.match( + prepareSource, + /DA_OPENCLAW_GATEWAY_MAX_CONCURRENT:\s*env\.gatewayMaxConcurrent/ + ); +}); diff --git a/test/common/mock-openai-completions-server.js b/test/common/mock-openai-completions-server.js index 5c81edb..3c43395 100644 --- a/test/common/mock-openai-completions-server.js +++ b/test/common/mock-openai-completions-server.js @@ -7,7 +7,7 @@ function readPositiveInteger(value, fallback) { } const DEFAULT_DELAY_MS = 5000; -const DEFAULT_CAPACITY = 1000; +const DEFAULT_CAPACITY = 128; const host = process.env.MOCK_OPENAI_HOST || "0.0.0.0"; const port = readPositiveInteger(process.env.MOCK_OPENAI_PORT, 18080); const delayMs = readPositiveInteger(process.env.MOCK_OPENAI_DELAY_MS, DEFAULT_DELAY_MS); diff --git a/test/common/mock-openai-server.js b/test/common/mock-openai-server.js index 7aa5327..0d00827 100644 --- a/test/common/mock-openai-server.js +++ b/test/common/mock-openai-server.js @@ -7,7 +7,7 @@ function readPositiveInteger(value, fallback) { } const DEFAULT_DELAY_MS = 5000; -const DEFAULT_CAPACITY = 1000; +const DEFAULT_CAPACITY = 128; const host = process.env.MOCK_OPENAI_HOST || "0.0.0.0"; const port = readPositiveInteger(process.env.MOCK_OPENAI_PORT, 18080); const delayMs = readPositiveInteger(process.env.MOCK_OPENAI_DELAY_MS, DEFAULT_DELAY_MS); diff --git a/test/common/run-container-flow.core.sh b/test/common/run-container-flow.core.sh index e067b03..efed127 100644 --- a/test/common/run-container-flow.core.sh +++ b/test/common/run-container-flow.core.sh @@ -192,6 +192,7 @@ const output = { SOURCE_RUNTIME_ENV_PATH: text(config.sourceRuntimeEnvPath, "/var/daedalus-agent/generated/runtime-env.json"), SOURCE_CONFIG_PATH: text(config.sourceConfigPath, "/opt/daedalus-agent-assets/config.json"), HUOZIGE_SERVER_IP: text(process.env.DA_HUOZIGE_SERVER_IP, text(config.huozigeServerIp, text(config.businessSystemHost))), + AGENT_NETWORK_ALLOWLIST: text(process.env.DA_INTERNAL_NETWORK_ALLOWLIST, text(process.env.MOCK_AGENT_NETWORK_ALLOWLIST, text(config.agentNetworkAllowlist))), CLAWHUB_FETCH_TIMEOUT_MS: positiveInteger(config.clawhubFetchTimeoutMs, 180000), STARTUP_WAIT_SECONDS: positiveInteger(config.startupWaitSeconds, 300) }; @@ -478,9 +479,9 @@ prepare_runtime() { docker cp "${SOURCE_CONTAINER}:${SOURCE_RUNTIME_ENV_PATH}" "${MOUNT_DIR}/env.json" docker cp "${SOURCE_CONTAINER}:${SOURCE_CONFIG_PATH}" "${MOUNT_DIR}/config.json" - node - "${MOUNT_DIR}/env.json" "${MOUNT_DIR}/credentials.json" "${HUOZIGE_SERVER_IP}" <<'NODE' + node - "${MOUNT_DIR}/env.json" "${MOUNT_DIR}/credentials.json" "${HUOZIGE_SERVER_IP}" "${AGENT_NETWORK_ALLOWLIST}" <<'NODE' const fs = require("node:fs"); -const [envTarget, credentialsTarget, huozigeServerIp] = process.argv.slice(2); +const [envTarget, credentialsTarget, huozigeServerIp, agentNetworkAllowlist] = process.argv.slice(2); function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); @@ -512,7 +513,7 @@ const credentials = { const userEnv = { DA_MANAGEMENT_CONSOLE_PORT: 3030, - DA_INTERNAL_NETWORK_ALLOWLIST: sourceEnv.DA_INTERNAL_NETWORK_ALLOWLIST || "" + DA_INTERNAL_NETWORK_ALLOWLIST: agentNetworkAllowlist || sourceEnv.DA_INTERNAL_NETWORK_ALLOWLIST || "" }; if (huozigeServerIp) { userEnv.DA_HUOZIGE_SERVER_IP = huozigeServerIp; @@ -525,10 +526,10 @@ NODE return 0 fi - node - "${MOUNT_DIR}/env.json" "${REPO_ROOT}/docker/assets/config.json" "${HUOZIGE_SERVER_IP}" <<'NODE' + node - "${MOUNT_DIR}/env.json" "${REPO_ROOT}/docker/assets/config.json" "${HUOZIGE_SERVER_IP}" "${AGENT_NETWORK_ALLOWLIST}" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); -const [envTarget, sampleConfig, huozigeServerIp] = process.argv.slice(2); +const [envTarget, sampleConfig, huozigeServerIp, agentNetworkAllowlist] = process.argv.slice(2); const payload = { DA_MANAGEMENT_CONSOLE_PORT: 3030 @@ -536,6 +537,9 @@ const payload = { if (huozigeServerIp) { payload.DA_HUOZIGE_SERVER_IP = huozigeServerIp; } +if (agentNetworkAllowlist) { + payload.DA_INTERNAL_NETWORK_ALLOWLIST = agentNetworkAllowlist; +} fs.writeFileSync(envTarget, `${JSON.stringify(payload, null, 2)}\n`); fs.copyFileSync(sampleConfig, path.join(path.dirname(envTarget), "config.json")); diff --git a/test/integration/config.integration.sample.json b/test/integration/config.integration.sample.json index f35c604..a03bf11 100644 --- a/test/integration/config.integration.sample.json +++ b/test/integration/config.integration.sample.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18083/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": false, diff --git a/test/integration/run-flow.windows.ps1 b/test/integration/run-flow.windows.ps1 index 9179df2..20a2847 100644 --- a/test/integration/run-flow.windows.ps1 +++ b/test/integration/run-flow.windows.ps1 @@ -582,7 +582,7 @@ function Initialize-MountDir { $script = @' const fs = require("node:fs"); -const [envTarget, credentialsTarget, huozigeServerIp] = process.argv.slice(2); +const [envTarget, credentialsTarget, huozigeServerIp, agentNetworkAllowlist] = process.argv.slice(2); function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); @@ -614,7 +614,7 @@ const credentials = { const userEnv = { DA_MANAGEMENT_CONSOLE_PORT: 3030, - DA_INTERNAL_NETWORK_ALLOWLIST: sourceEnv.DA_INTERNAL_NETWORK_ALLOWLIST || "" + DA_INTERNAL_NETWORK_ALLOWLIST: agentNetworkAllowlist || sourceEnv.DA_INTERNAL_NETWORK_ALLOWLIST || "" }; if (huozigeServerIp) { userEnv.DA_HUOZIGE_SERVER_IP = huozigeServerIp; @@ -625,14 +625,19 @@ fs.writeFileSync(credentialsTarget, `${JSON.stringify(credentials, null, 2)}\n`, fs.chmodSync(credentialsTarget, 0o600); '@ - Invoke-NodeScript $script @((Join-Path $mountDir "env.json"), (Join-Path $mountDir "credentials.json"), $huozigeServerIp) + Invoke-NodeScript $script @( + (Join-Path $mountDir "env.json"), + (Join-Path $mountDir "credentials.json"), + $huozigeServerIp, + $agentNetworkAllowlist + ) return } $script = @' const fs = require("node:fs"); const path = require("node:path"); -const [envTarget, sampleConfig, huozigeServerIp] = process.argv.slice(2); +const [envTarget, sampleConfig, huozigeServerIp, agentNetworkAllowlist] = process.argv.slice(2); const payload = { DA_MANAGEMENT_CONSOLE_PORT: 3030 @@ -640,12 +645,20 @@ const payload = { if (huozigeServerIp) { payload.DA_HUOZIGE_SERVER_IP = huozigeServerIp; } +if (agentNetworkAllowlist) { + payload.DA_INTERNAL_NETWORK_ALLOWLIST = agentNetworkAllowlist; +} fs.writeFileSync(envTarget, `${JSON.stringify(payload, null, 2)}\n`); fs.copyFileSync(sampleConfig, path.join(path.dirname(envTarget), "config.json")); '@ - Invoke-NodeScript $script @((Join-Path $mountDir "env.json"), (Join-Path (Join-Path $repoRoot "docker\assets") "config.json"), $huozigeServerIp) + Invoke-NodeScript $script @( + (Join-Path $mountDir "env.json"), + (Join-Path (Join-Path $repoRoot "docker\assets") "config.json"), + $huozigeServerIp, + $agentNetworkAllowlist + ) } function Get-DockerDaedalusAgentMountArgs { @@ -898,6 +911,11 @@ $huozigeServerIp = Get-TextValue $env:DA_HUOZIGE_SERVER_IP ( Get-TextValue (Get-JsonProperty $config "businessSystemHost") ) ) +$agentNetworkAllowlist = Get-TextValue $env:DA_INTERNAL_NETWORK_ALLOWLIST ( + Get-TextValue $env:MOCK_AGENT_NETWORK_ALLOWLIST ( + Get-TextValue (Get-JsonProperty $config "agentNetworkAllowlist") + ) +) $clawhubFetchTimeoutMs = Get-PositiveInteger (Get-JsonProperty $config "clawhubFetchTimeoutMs") 180000 $startupWaitSeconds = Get-PositiveInteger (Get-JsonProperty $config "startupWaitSeconds") 300 $reservedHttpPortEnd = $webPort + 20 diff --git a/test/package.json b/test/package.json index 736d7fc..4e31da5 100644 --- a/test/package.json +++ b/test/package.json @@ -11,7 +11,9 @@ "test:flow": "node ./suite/integration/run-integration-suite.js full", "test:integration:fast": "node ./suite/integration/run-integration-suite.js fast", "test:integration:full": "node ./suite/integration/run-integration-suite.js full", - "test:stress": "node ./suite/stress/run-mock-stress-suite.js 20a10c", + "test:stress": "node ./suite/stress/run-100a-reuse-concurrency-matrix.js", + "test:stress:contract": "node --test ./case/stress/100a-reuse-concurrency-matrix-contract.test.js", + "test:stress:100a-matrix": "node ./suite/stress/run-100a-reuse-concurrency-matrix.js", "test:stress:100a50c": "node ./suite/stress/run-mock-stress-suite.js 100a50c", "test:stress:50a20c": "node ./suite/stress/run-mock-stress-suite.js 50a20c", "test:stress:20a10c": "node ./suite/stress/run-mock-stress-suite.js 20a10c", diff --git a/test/prepare/stress/rebuild-stress-env.js b/test/prepare/stress/rebuild-stress-env.js index aa56d74..9972bce 100644 --- a/test/prepare/stress/rebuild-stress-env.js +++ b/test/prepare/stress/rebuild-stress-env.js @@ -671,7 +671,8 @@ function writeRuntimeFiles(env) { writeJson(path.join(env.mountDir, "env.json"), { DA_MANAGEMENT_CONSOLE_PORT: 3030, - DA_INTERNAL_NETWORK_ALLOWLIST: env.agentNetworkAllowlist + DA_INTERNAL_NETWORK_ALLOWLIST: env.agentNetworkAllowlist, + DA_OPENCLAW_GATEWAY_MAX_CONCURRENT: env.gatewayMaxConcurrent }, 0o640); writeJson(path.join(env.mountDir, "credentials.json"), credentials, 0o600); writeJson(path.join(env.mountDir, "generated", "runtime-env.json"), runtimeEnv, 0o640); diff --git a/test/stress/config.stress.sample.json b/test/stress/config.stress.sample.json index 586f3d4..42745e5 100644 --- a/test/stress/config.stress.sample.json +++ b/test/stress/config.stress.sample.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18080/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": false, diff --git a/test/suite/integration/fast/config.integration.json b/test/suite/integration/fast/config.integration.json index 56ea226..42b676d 100644 --- a/test/suite/integration/fast/config.integration.json +++ b/test/suite/integration/fast/config.integration.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18083/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": false, diff --git a/test/suite/integration/full/config.integration.json b/test/suite/integration/full/config.integration.json index fc0d892..e19ec1a 100644 --- a/test/suite/integration/full/config.integration.json +++ b/test/suite/integration/full/config.integration.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18084/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": false, diff --git a/test/suite/integration/run-integration-suite.js b/test/suite/integration/run-integration-suite.js index 7f4fff8..104c6b2 100644 --- a/test/suite/integration/run-integration-suite.js +++ b/test/suite/integration/run-integration-suite.js @@ -18,6 +18,7 @@ const runtimeStatePath = path.resolve( process.env.DA_INTEGRATION_RUNTIME_STATE || path.join(runtimeRoot, "run-state.integration.json") ); const mockServerPath = path.join(testDir, "common", "mock-openai-server.js"); +const DEFAULT_DOCKER_DESKTOP_HOST_IP = "192.168.65.254"; function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); @@ -84,7 +85,7 @@ function buildRuntimeConfig() { const port = readNonNegativeInteger(process.env.MOCK_OPENAI_PORT, readNonNegativeInteger(mockLlm.port, 18080)); const delayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_DELAY_MS, readNonNegativeInteger(mockLlm.delayMs, 5000)); const chunkDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, readNonNegativeInteger(mockLlm.chunkDelayMs, 0)); - const capacity = readNonNegativeInteger(process.env.MOCK_OPENAI_CAPACITY, readNonNegativeInteger(mockLlm.capacity, 1000)); + const capacity = readNonNegativeInteger(process.env.MOCK_OPENAI_CAPACITY, readNonNegativeInteger(mockLlm.capacity, 128)); const containerBaseUrl = readText( process.env.DA_MOCK_OPENAI_CONTAINER_BASE_URL, readText(process.env.MOCK_OPENAI_BASE_URL, readText(mockLlm.containerBaseUrl, `http://host.docker.internal:${port}/v1`)) @@ -105,6 +106,13 @@ function buildRuntimeConfig() { capacity, containerBaseUrl }; + config.agentNetworkAllowlist = readText( + process.env.DA_INTERNAL_NETWORK_ALLOWLIST, + readText( + process.env.MOCK_AGENT_NETWORK_ALLOWLIST, + readText(config.agentNetworkAllowlist, DEFAULT_DOCKER_DESKTOP_HOST_IP) + ) + ); writeJson(runtimeConfigPath, config); return { mockLlm: config.mockLlm }; } diff --git a/test/suite/stress/100a100c/config.stress.json b/test/suite/stress/100a100c/config.stress.json index b1d4462..3392334 100644 --- a/test/suite/stress/100a100c/config.stress.json +++ b/test/suite/stress/100a100c/config.stress.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18084/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": true, diff --git a/test/suite/stress/100a20c/config.stress.json b/test/suite/stress/100a20c/config.stress.json index 1074093..21e6332 100644 --- a/test/suite/stress/100a20c/config.stress.json +++ b/test/suite/stress/100a20c/config.stress.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18083/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": true, diff --git a/test/suite/stress/100a50c/config.stress.json b/test/suite/stress/100a50c/config.stress.json index 28d1536..fe2022f 100644 --- a/test/suite/stress/100a50c/config.stress.json +++ b/test/suite/stress/100a50c/config.stress.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18080/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": true, diff --git a/test/suite/stress/20a10c/config.stress.json b/test/suite/stress/20a10c/config.stress.json index fae1976..23b0f23 100644 --- a/test/suite/stress/20a10c/config.stress.json +++ b/test/suite/stress/20a10c/config.stress.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18082/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": true, diff --git a/test/suite/stress/50a20c/config.stress.json b/test/suite/stress/50a20c/config.stress.json index c165f2c..0b73aab 100644 --- a/test/suite/stress/50a20c/config.stress.json +++ b/test/suite/stress/50a20c/config.stress.json @@ -11,7 +11,7 @@ "delayMs": 5000, "chunkDelayMs": 0, "containerBaseUrl": "http://host.docker.internal:18081/v1", - "capacity": 1000 + "capacity": 128 }, "image": { "skipBuild": true, diff --git a/test/suite/stress/run-100a-reuse-concurrency-matrix.js b/test/suite/stress/run-100a-reuse-concurrency-matrix.js index 472ba88..f09cfe0 100644 --- a/test/suite/stress/run-100a-reuse-concurrency-matrix.js +++ b/test/suite/stress/run-100a-reuse-concurrency-matrix.js @@ -13,10 +13,11 @@ const configPath = path.join(runtimeRoot, "config.stress.json"); const runtimeStatePath = path.join(runtimeRoot, "run-state.stress.json"); const imageTag = readText(process.env.DA_STRESS_IMAGE_TAG, defaultImageTag()); +export const DEFAULT_MAX_CONCURRENT = 128; const mockOpenAiPort = readPositiveInteger(process.env.MOCK_OPENAI_PORT, 18085); const mockOpenAiDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_DELAY_MS, 5000); const mockOpenAiChunkDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, 0); -const mockOpenAiCapacity = readPositiveInteger(process.env.MOCK_OPENAI_CAPACITY, 1000); +const mockOpenAiCapacity = readPositiveInteger(process.env.MOCK_OPENAI_CAPACITY, DEFAULT_MAX_CONCURRENT); const modelBaseUrl = readText(process.env.MOCK_OPENAI_BASE_URL, `http://host.docker.internal:${mockOpenAiPort}/v1`); const hostMockBaseUrl = readText(process.env.MOCK_OPENAI_HOST_BASE_URL, `http://127.0.0.1:${mockOpenAiPort}/v1`); const mountDir = path.resolve(readText(process.env.DA_STRESS_MOUNT_DIR, "/tmp/daedalus-agent-stress-100a-reuse")); @@ -30,9 +31,12 @@ const agentNetworkAllowlist = readText( process.env.DA_INTERNAL_NETWORK_ALLOWLIST, readText(process.env.MOCK_AGENT_NETWORK_ALLOWLIST, "192.168.65.254,127.0.0.1,::1") ); -const matrixValues = readMatrix(process.env.DA_STRESS_CONCURRENCY_MATRIX, [20, 50, 100]); -const warmupMs = readPositiveInteger(process.env.DA_STRESS_WARMUP_MS, 60000); -const durationMs = readPositiveInteger(process.env.DA_STRESS_DURATION_MS, 600000); +export const DEFAULT_CONCURRENCY_MATRIX = Object.freeze([5, 10, 30, 100]); +const matrixValues = readMatrix(process.env.DA_STRESS_CONCURRENCY_MATRIX, DEFAULT_CONCURRENCY_MATRIX); +export const DEFAULT_WARMUP_MS = 30000; +export const DEFAULT_DURATION_MS = 15 * 60 * 1000; +const warmupMs = readPositiveInteger(process.env.DA_STRESS_WARMUP_MS, DEFAULT_WARMUP_MS); +const durationMs = readPositiveInteger(process.env.DA_STRESS_DURATION_MS, DEFAULT_DURATION_MS); const progressEveryMs = readPositiveInteger(process.env.DA_STRESS_PROGRESS_EVERY_MS, 30000); const resourceLimits = { cpus: readText(process.env.DA_STRESS_CPUS, "2"), @@ -58,12 +62,12 @@ function readNonNegativeInteger(value, fallback) { return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback; } -function readMatrix(value, fallback) { - const items = String(value || "") +export function readMatrix(value, fallback) { + const items = [...new Set(String(value || "") .split(",") .map((item) => Number(item.trim())) - .filter((item) => Number.isFinite(item) && item > 0) - .map((item) => Math.round(item)); + .filter((item) => Number.isFinite(item) && item > 0 && item <= 100) + .map((item) => Math.round(item)))]; return items.length > 0 ? items : fallback; } @@ -142,6 +146,95 @@ async function waitForMockServer(port, timeoutMs = 15000) { throw new Error(`mock LLM did not become ready at ${url}: ${lastError}`); } +async function readContainerLogsSince(sinceIso) { + const result = await run("docker", ["logs", "--since", sinceIso, containerName], { capture: true }); + return `${result.stdout}\n${result.stderr}`; +} + +async function waitForOpenClawGatewayReady(sinceIso, timeoutMs = 240000) { + const deadline = Date.now() + timeoutMs; + let lastLog = ""; + while (Date.now() < deadline) { + lastLog = await readContainerLogsSince(sinceIso); + if (/\[gateway\] ready|http server listening/.test(lastLog)) { + return; + } + await sleep(2000); + } + throw new Error(`OpenClaw gateway did not become ready after hot maxConcurrent update. Recent logs:\n${lastLog.slice(-4000)}`); +} + +async function hotUpdateMaxConcurrent(maxConcurrent) { + const patchScript = [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const maxConcurrent = Number(process.argv[1]);', + 'function writeJsonAtomic(filePath, value) {', + ' const mode = fs.statSync(filePath).mode & 0o777;', + ' const tempPath = `${filePath}.${process.pid}.tmp`;', + ' fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\\n`, { mode });', + ' fs.renameSync(tempPath, filePath);', + ' fs.chmodSync(filePath, mode);', + '}', + 'for (const filePath of [', + ' "/var/daedalus-agent/.openclaw/openclaw.json",', + ' "/var/daedalus-agent/config.json",', + ' "/var/daedalus-agent/generated/runtime-config.json"', + ']) {', + ' if (!fs.existsSync(filePath)) continue;', + ' const config = JSON.parse(fs.readFileSync(filePath, "utf8"));', + ' config.agents = config.agents && typeof config.agents === "object" ? config.agents : {};', + ' config.agents.defaults = config.agents.defaults && typeof config.agents.defaults === "object" ? config.agents.defaults : {};', + ' config.agents.defaults.maxConcurrent = maxConcurrent;', + ' writeJsonAtomic(filePath, config);', + '}', + 'for (const filePath of [', + ' "/var/daedalus-agent/env.json",', + ' "/var/daedalus-agent/generated/runtime-env.json"', + ']) {', + ' if (!fs.existsSync(filePath)) continue;', + ' const env = JSON.parse(fs.readFileSync(filePath, "utf8"));', + ' env.DA_OPENCLAW_GATEWAY_MAX_CONCURRENT = maxConcurrent;', + ' writeJsonAtomic(filePath, env);', + '}' + ].join("\n"); + await runRequired("docker", [ + "exec", + containerName, + "node", + "-e", + patchScript, + String(maxConcurrent) + ]); + + const sinceIso = new Date().toISOString(); + await runRequired("docker", [ + "exec", + containerName, + "sh", + "-c", + "pids=\"$(pgrep -x openclaw || true)\"; if [ -n \"$pids\" ]; then kill $pids; fi" + ]); + await waitForOpenClawGatewayReady(sinceIso); + + const verifyScript = [ + 'const fs = require("node:fs");', + 'const config = JSON.parse(fs.readFileSync("/var/daedalus-agent/.openclaw/openclaw.json", "utf8"));', + 'process.stdout.write(String(config.agents?.defaults?.maxConcurrent ?? ""));' + ].join("\n"); + const verification = await runRequired("docker", [ + "exec", + containerName, + "node", + "-e", + verifyScript + ], { capture: true }); + if (Number(verification.stdout.trim()) !== maxConcurrent) { + throw new Error(`hot maxConcurrent verification failed: ${verification.stdout.trim()}`); + } + return sinceIso; +} + async function fetchMockStats(pathname = "/stats") { const url = new URL(hostMockBaseUrl); url.pathname = pathname; @@ -153,7 +246,7 @@ async function fetchMockStats(pathname = "/stats") { return await response.json(); } -function buildConfig(concurrency) { +export function buildConfig(concurrency) { const prefix = "mock-100a-reuse"; return { llm: { @@ -199,13 +292,13 @@ function buildConfig(concurrency) { stress: { prefix, runId: `${prefix}-${concurrency}c`, - randomSeed: `${prefix}-${concurrency}c`, + randomSeed: prefix, senderId: `daedalus-agent-stress-100a-reuse-${concurrency}c`, startupMode: "docker", skipResourceCreation: true, skipSeededStartup: true, cleanupArtifacts: true, - gatewayMaxConcurrent: 100, + gatewayMaxConcurrent: DEFAULT_MAX_CONCURRENT, management: { username: "admin", password: "admin" @@ -456,7 +549,7 @@ function buildMarkdownReport(summary) { "## 结论", "", summary.runs.every((run) => run.ok) - ? "三组并发压测均通过,稳态阶段未出现失败请求。" + ? `${summary.runs.length} 轮并发压测均通过,稳态阶段未出现失败请求。` : "存在未通过的并发轮次,详见对应原始报告的 error / failures 字段。", "" ].join("\n"); @@ -497,6 +590,18 @@ async function main() { console.log("[100a-reuse] rebuild and start 100a container"); await runRequired("node", [prepareScriptPath, "--start-container"]); } + console.log(`[100a-reuse] hot update gateway maxConcurrent=${DEFAULT_MAX_CONCURRENT}`); + const lastGatewayRestartStartedAt = await hotUpdateMaxConcurrent(DEFAULT_MAX_CONCURRENT); + const runtimeState = fs.existsSync(runtimeStatePath) ? readJson(runtimeStatePath) : {}; + writeJson(runtimeStatePath, { + ...runtimeState, + lastGatewayRestartStartedAt, + gatewayMaxConcurrent: DEFAULT_MAX_CONCURRENT + }); + const initialMockStats = await fetchMockStats(); + if (initialMockStats.capacity !== mockOpenAiCapacity) { + throw new Error(`mock LLM capacity verification failed: ${initialMockStats.capacity}`); + } const containerInspect = await inspectJson(containerName); const imageInspect = await inspectJson(imageTag); @@ -545,6 +650,8 @@ async function main() { hostMockBaseUrl, mockOpenAiDelayMs, mockOpenAiCapacity, + gatewayMaxConcurrent: DEFAULT_MAX_CONCURRENT, + lastGatewayRestartStartedAt, matrixValues, warmupMs, durationMs, @@ -561,4 +668,8 @@ async function main() { } } -await main(); +const isMain = process.argv[1] + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + await main(); +} diff --git a/test/suite/stress/run-existing-mock-lane-matrix.js b/test/suite/stress/run-existing-mock-lane-matrix.js index 5db13de..13dc678 100644 --- a/test/suite/stress/run-existing-mock-lane-matrix.js +++ b/test/suite/stress/run-existing-mock-lane-matrix.js @@ -25,7 +25,7 @@ const baseUrl = `http://127.0.0.1:${webPort}`; const mockOpenAiPort = readNonNegativeInteger(process.env.MOCK_OPENAI_PORT, 18080); const mockOpenAiDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_DELAY_MS, 5000); const mockOpenAiChunkDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, 0); -const mockOpenAiCapacity = readNonNegativeInteger(process.env.MOCK_OPENAI_CAPACITY, 1000); +const mockOpenAiCapacity = readNonNegativeInteger(process.env.MOCK_OPENAI_CAPACITY, 128); const modelBaseUrl = process.env.MOCK_OPENAI_BASE_URL || `http://host.docker.internal:${mockOpenAiPort}/v1`; const agentNetworkAllowlist = process.env.MOCK_AGENT_NETWORK_ALLOWLIST || "192.168.65.254"; const memoryBackend = normalizeMemoryBackend(process.env.MOCK_MEMORY_BACKEND); diff --git a/test/suite/stress/run-mock-lane-matrix.js b/test/suite/stress/run-mock-lane-matrix.js index 43105eb..02a0cb4 100644 --- a/test/suite/stress/run-mock-lane-matrix.js +++ b/test/suite/stress/run-mock-lane-matrix.js @@ -40,7 +40,7 @@ function readMockSettings(config) { const port = readNonNegativeInteger(process.env.MOCK_OPENAI_PORT, readNonNegativeInteger(mockLlm.port, 18081)); const delayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_DELAY_MS, readNonNegativeInteger(mockLlm.delayMs, 5000)); const chunkDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, readNonNegativeInteger(mockLlm.chunkDelayMs, 0)); - const capacity = readNonNegativeInteger(process.env.MOCK_OPENAI_CAPACITY, readNonNegativeInteger(mockLlm.capacity, 1000)); + const capacity = readNonNegativeInteger(process.env.MOCK_OPENAI_CAPACITY, readNonNegativeInteger(mockLlm.capacity, 128)); const containerBaseUrl = readText( process.env.MOCK_OPENAI_BASE_URL, readText(mockLlm.containerBaseUrl, `http://host.docker.internal:${port}/v1`) diff --git a/test/suite/stress/run-mock-stress-suite.js b/test/suite/stress/run-mock-stress-suite.js index 30c1fbc..3b5da71 100644 --- a/test/suite/stress/run-mock-stress-suite.js +++ b/test/suite/stress/run-mock-stress-suite.js @@ -14,6 +14,7 @@ const runtimeStatePath = path.resolve(process.env.DA_STRESS_RUNTIME_STATE || pat const mockServerPath = path.join(testDir, "common", "mock-openai-server.js"); const prepareScriptPath = path.join(testDir, "prepare", "stress", "rebuild-stress-env.js"); const stressCasePath = path.join(testDir, "case", "stress", "stress-flow.js"); +const DEFAULT_DOCKER_DESKTOP_HOST_IP = "192.168.65.254"; function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); @@ -104,7 +105,7 @@ function buildRuntimeConfig() { const port = readPositiveInteger(process.env.MOCK_OPENAI_PORT, readPositiveInteger(mockLlm.port, 18080)); const delayMs = readPositiveInteger(process.env.MOCK_OPENAI_DELAY_MS, readPositiveInteger(mockLlm.delayMs, 5000)); const chunkDelayMs = readPositiveInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, readPositiveInteger(mockLlm.chunkDelayMs, 0)); - const capacity = readPositiveInteger(process.env.MOCK_OPENAI_CAPACITY, readPositiveInteger(mockLlm.capacity, 1000)); + const capacity = readPositiveInteger(process.env.MOCK_OPENAI_CAPACITY, readPositiveInteger(mockLlm.capacity, 128)); const containerBaseUrl = readText( process.env.DA_MOCK_OPENAI_CONTAINER_BASE_URL, readText(mockLlm.containerBaseUrl, `http://host.docker.internal:${port}/v1`) @@ -127,6 +128,13 @@ function buildRuntimeConfig() { capacity, containerBaseUrl }; + config.agentNetworkAllowlist = readText( + process.env.DA_INTERNAL_NETWORK_ALLOWLIST, + readText( + process.env.MOCK_AGENT_NETWORK_ALLOWLIST, + readText(config.agentNetworkAllowlist, DEFAULT_DOCKER_DESKTOP_HOST_IP) + ) + ); config.image = { ...(config.image && typeof config.image === "object" ? config.image : {}), skipBuild: true, -- Gitee From e8bf77911f2a6acc93c4a32dfd0462074decc029 Mon Sep 17 00:00:00 2001 From: Ning Wei <55093136@qq.com> Date: Thu, 6 Aug 2026 15:03:54 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E5=8E=8B=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +++++---- doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md | 60 +++++++++++++++++++ docker/BUILD.md | 2 +- docker/README.md | 2 +- .../server/src/db/index.js | 4 +- .../server/test/db-reset-migration.test.js | 8 +-- .../test/portal-service-alignment.test.js | 8 +-- .../src/brand.js | 4 +- test/README.md | 6 +- .../branding/daedalus-brand-contract.test.js | 4 +- ...-reuse-concurrency-matrix-contract.test.js | 18 ++++++ test/case/stress/mock-delay.test.js | 42 +++++++++++++ test/case/stress/stress-flow.js | 4 +- test/common/mock-delay.js | 30 ++++++++++ test/common/mock-openai-completions-server.js | 37 ++++++++++-- test/common/mock-openai-server.js | 32 ++++++++-- test/package.json | 1 + test/stress/config.stress.sample.json | 2 +- test/suite/stress/100a100c/config.stress.json | 4 +- test/suite/stress/100a20c/config.stress.json | 4 +- test/suite/stress/100a50c/config.stress.json | 4 +- test/suite/stress/20a10c/config.stress.json | 2 +- test/suite/stress/50a20c/config.stress.json | 2 +- .../run-100a-reuse-concurrency-matrix.js | 41 +++++++++++-- 24 files changed, 290 insertions(+), 58 deletions(-) create mode 100644 doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md create mode 100644 test/case/stress/mock-delay.test.js create mode 100644 test/common/mock-delay.js diff --git a/README.md b/README.md index dad1692..2ec47e0 100644 --- a/README.md +++ b/README.md @@ -112,24 +112,25 @@ MQTT/MinIO 密码可在 `系统设置` 页面中 `基础设置` 选项卡获取 ## 性能测试 -- 测试版本:20260701.01 -- 测试环境:2C4G -- LLM:本地模拟,收到请求后等待 5 秒再返回,非阻塞 +- 测试日期:2026-08-06 +- 测试环境:2C4G,100 个数字员工 +- LLM:本地模拟,在 4-6 秒之间随机等待后返回,理论均值和中位数均为 5 秒,容量 128 - 测试规格: - - 数字员工 x 100 - 用户 x 1000,每个数字员工关联 10 个用户 - 数字员工模板 x 100,每个工作区模板 100 个文件 - 技能 x 100 - 系统 x 100 -- 测试计划: - - 预热:1 分钟 - - 稳态:10 分钟 - -| 并发 | 结果 | 稳态成功/请求 | 失败 | 吞吐 req/s | P50 | P95 | P99 | CPU 峰值 | 内存峰值 | -| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 20 | 通过 | 986/986 | 0 | 1.64 | 11.857s | 17.119s | 21.945s | 202.39% | 1.66GiB | -| 50 | 通过 | 1000/1000 | 0 | 1.67 | 28.185s | 40.100s | 58.307s | 199.70% | 2.05GiB | -| 100 | 通过 | 998/998 | 0 | 1.66 | 56.599s | 74.609s | 93.801s | 180.19% | 2.13GiB | +- 测试计划:每档预热 30 秒,稳态压测 15 分钟;并发档位为 5、10、30、100 +- 测试结论:10 并发下,2C4G 的配置可以正常使用;更高并发量不会导致错误,但体验下滑严重。 + +| 并发 | 结果 | 稳态成功/请求 | 失败 | 吞吐 req/s | Avg | P50 | P90 | P99 | CPU 峰值 | 内存峰值 | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 5 | 通过 | 707/707 | 0 | 0.79 | 6.370s | 6.344s | 7.487s | 8.467s | 198.26% | 2.33GiB | +| 10 | 通过 | 1213/1213 | 0 | 1.35 | 7.411s | 7.280s | 8.758s | 10.435s | 203.01% | 2.57GiB | +| 30 | 通过 | 1109/1109 | 0 | 1.23 | 24.347s | 23.691s | 27.510s | 46.764s | 201.26% | 3.23GiB | +| 100 | 通过 | 1110/1110 | 0 | 1.23 | 79.367s | 78.747s | 90.629s | 120.325s | 199.54% | 3.99GiB | + +详细测试报告见 [DAEDALUS_AGENT-100a-2C4G-stress-report.md](./doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md)。 ## 本地开发文件 diff --git a/doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md b/doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md new file mode 100644 index 0000000..d15a837 --- /dev/null +++ b/doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md @@ -0,0 +1,60 @@ +# DAEDALUS_AGENT 容器压力测试报告 + +**100 Agent 并发矩阵正式测试结果** + +| 项目 | 内容 | +| --- | --- | +| 测试日期 | 2026-08-06 | +| 测试环境 | 2 CPU / 4 GiB,100 个数字员工 | +| 容器 | `daedalus-agent-stress-100a-reuse-mock` | +| 镜像 | `daedalus-agent-x64:local` (`sha256:ff3d7e3f058376ad29b8cc67b8660803281e3ea3e753b44552f72d84b2cbd1f4`) | +| 负载策略 | 5 / 10 / 30 / 100 并发;每档预热 30 秒、稳态压测 15 分钟 | +| 模拟模型 | Mock OpenAI,4000-6000ms 均匀随机延迟,理论均值和中位数均为 5000ms,容量 128 | +| Gateway 并发 | `maxConcurrent = 128` | + +> **结论**:四档并发压测全部通过,稳态成功率均为 100%,失败请求为 0,Mock LLM 容量拒绝为 0。 + +## 1. 测试范围与方法 + +本次测试复用同一个 100 Agent 容器,使用固定随机种子选择 Agent,依次执行 5、10、30、100 并发。每档在 30 秒预热后持续施加 900 秒稳态负载,模型端使用容量为 128 的本地 Mock LLM,单次模型等待时间从 4000-6000ms 均匀随机采样。 + +通过条件为压测脚本正常退出、稳态请求无失败且 Mock LLM 未发生容量拒绝。容器资源限制保持为 2 CPU / 4 GiB。 + +## 2. 压测结果 + +| 并发 | 结果 | 稳态成功/请求 | 失败 | 吞吐 req/s | Avg | P50 | P90 | P99 | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 5 | 通过 | 707/707 | 0 | 0.79 | 6.370s | 6.344s | 7.487s | 8.467s | +| 10 | 通过 | 1213/1213 | 0 | 1.35 | 7.411s | 7.280s | 8.758s | 10.435s | +| 30 | 通过 | 1109/1109 | 0 | 1.23 | 24.347s | 23.691s | 27.510s | 46.764s | +| 100 | 通过 | 1110/1110 | 0 | 1.23 | 79.367s | 78.747s | 90.629s | 120.325s | + +响应时间为从压测客户端发起 MQTT 请求到收到完整回复的端到端耗时,包含网关、Agent、模型调用和消息链路开销。 + +## 3. 资源与容量观察 + +| 并发 | CPU 平均 | CPU 峰值 | 内存平均 | 内存峰值 | 内存峰值占限额 | Mock 实际峰值 | 容量拒绝 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 5 | 71.82% | 198.26% | 1.67 GiB | 2.33 GiB | 58.15% | 5 | 0 | +| 10 | 127.09% | 203.01% | 1.92 GiB | 2.57 GiB | 64.18% | 10 | 0 | +| 30 | 134.17% | 201.26% | 2.37 GiB | 3.23 GiB | 80.68% | 30 | 0 | +| 100 | 128.55% | 199.54% | 2.76 GiB | 3.99 GiB | 99.68% | 100 | 0 | + +Docker CPU 百分比按多核累计,约 200% 表示 2 个 CPU 核心接近满载。100 并发时内存峰值为 3.99 GiB,占 4 GiB 限制的 99.68%;本次未发生 OOM,但可用内存余量非常有限。 + +## 4. 运行验证 + +| 验证项 | 结果 | +| --- | --- | +| 压测矩阵 | 5 / 10 / 30 / 100,四档均通过 | +| 时间策略 | 每档预热 30 秒,稳态 900 秒 | +| Mock 延迟 | 所有档位健康检查返回 4000-6000ms,中心值 5000ms,抖动 1000ms | +| Mock 容量 | 容量配置 128;四档实际峰值分别为 5、10、30、100;容量拒绝均为 0 | +| Gateway 配置 | `openclaw.json`、`env.json`、`runtime-env.json` 均为 128 | +| 容器状态 | 复用原容器,Docker `RestartCount = 0` | +| 进程状态 | 压测结束后 Mock LLM 与压测进程均已退出 | +| 结果字段 | 原始报告记录 Avg、P50、P90、P99 | + +## 5. 结论 + +本次 100 Agent 并发矩阵压力测试整体通过。系统在 5、10、30、100 并发下均保持 100% 稳态成功率,随机 Mock LLM 未触发容量限制。100 并发场景下 CPU 接近满载,内存达到 99.68%,后续容量规划应重点增加内存余量并关注 CPU 饱和。 diff --git a/docker/BUILD.md b/docker/BUILD.md index 23c9783..b822535 100644 --- a/docker/BUILD.md +++ b/docker/BUILD.md @@ -97,7 +97,7 @@ npm --prefix test run test:stress:50a20c npm --prefix test run test:stress:100a50c ``` -集成与压测 suite 全部使用 mock LLM。mock LLM 收到 chat completion 请求后默认等待 5 秒再返回;响应时间在各 suite 配置的 `mockLlm.delayMs`、`mockLlm.chunkDelayMs` 中配置,也可以用 `MOCK_OPENAI_DELAY_MS`、`MOCK_OPENAI_CHUNK_DELAY_MS` 覆盖。suite 运行时状态默认写入 `test/.runtime/`。 +集成与压测 suite 全部使用 mock LLM。mock LLM 收到 chat completion 请求后,默认在 4-6 秒之间随机等待再返回,理论均值和中位数均为 5 秒;响应中心值在各 suite 配置的 `mockLlm.delayMs` 中配置,随机半径可用 `MOCK_OPENAI_DELAY_JITTER_MS` 覆盖,`mockLlm.chunkDelayMs` 和 `MOCK_OPENAI_CHUNK_DELAY_MS` 仍用于流式分片间隔。suite 运行时状态默认写入 `test/.runtime/`。 底层调试脚本仍保留独立配置和独立运行状态文件,适合单独排查 case 或准备阶段。 diff --git a/docker/README.md b/docker/README.md index 1e19f7b..b1d1402 100644 --- a/docker/README.md +++ b/docker/README.md @@ -127,7 +127,7 @@ npm --prefix test run test:stress:50a20c npm --prefix test run test:stress:100a50c ``` -集成与压测 suite 全部使用 mock LLM。mock LLM 收到 chat completion 请求后默认等待 5 秒再返回;响应时间可在各 suite 配置的 `mockLlm.delayMs`、`mockLlm.chunkDelayMs` 中配置,也可以用 `MOCK_OPENAI_DELAY_MS`、`MOCK_OPENAI_CHUNK_DELAY_MS` 覆盖。 +集成与压测 suite 全部使用 mock LLM。mock LLM 收到 chat completion 请求后,默认在 4-6 秒之间随机等待再返回,理论均值和中位数均为 5 秒;响应中心值可在各 suite 配置的 `mockLlm.delayMs` 中配置,随机半径可用 `MOCK_OPENAI_DELAY_JITTER_MS` 覆盖,分片间隔可用 `mockLlm.chunkDelayMs` 或 `MOCK_OPENAI_CHUNK_DELAY_MS` 配置。 底层脚本仍保留独立配置和独立运行状态文件。首次运行前可分别复制本地配置文件: diff --git a/packages/daedalus-agent-management-web/server/src/db/index.js b/packages/daedalus-agent-management-web/server/src/db/index.js index 58d88a6..ac2e0a4 100644 --- a/packages/daedalus-agent-management-web/server/src/db/index.js +++ b/packages/daedalus-agent-management-web/server/src/db/index.js @@ -10,8 +10,8 @@ import { hashPassword } from "../utils/security.js"; const env = loadEnv(); const SCHEMA_VERSION = 12; const DEFAULT_ACCESS_TOKEN = "sk-1234567890qwertyuiop"; -const DEFAULT_PORTAL_NAME = "daedalus-agent"; -const DEFAULT_BRAND_SUBTITLE = "智能体管理与运营平台"; +const DEFAULT_PORTAL_NAME = "智能体集群管理控制台"; +const DEFAULT_BRAND_SUBTITLE = "Agents Management Console"; const DEFAULT_THEME_COLOR = "#a5522d"; const DEFAULT_ADMIN_USERNAME = "admin"; const DEFAULT_ADMIN_DISPLAY_NAME = "管理员"; diff --git a/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js b/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js index efed15f..a0c22a7 100644 --- a/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js +++ b/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js @@ -551,8 +551,8 @@ it("migrates v4 settings through uploaded logo columns without dropping data", a expect(schemaVersion?.value).toBe("12"); expect(settings).toEqual({ - portal_name: "daedalus-agent", - brand_subtitle: "智能体管理与运营平台", + portal_name: "智能体集群管理控制台", + brand_subtitle: "Agents Management Console", theme_color: "#a5522d", logo_mime_type: "", logo_data: null @@ -800,8 +800,8 @@ it("migrates v10 users and portal settings to the daedalus-agent identity", asyn expect(schemaVersion?.value).toBe("12"); expect(settings).toEqual({ - portal_name: "daedalus-agent", - brand_subtitle: "智能体管理与运营平台", + portal_name: "智能体集群管理控制台", + brand_subtitle: "Agents Management Console", theme_color: "#a5522d", logo_mime_type: "", logo_data: null diff --git a/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js b/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js index a84b29e..6d657e4 100644 --- a/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js +++ b/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js @@ -399,8 +399,8 @@ it("updates portal settings without touching uploaded logo data", () => { const calls = []; let settings = { id: 1, - portal_name: "daedalus-agent", - brand_subtitle: "智能体管理与运营平台", + portal_name: "智能体集群管理控制台", + brand_subtitle: "Agents Management Console", theme_color: "#a5522d", logo_mime_type: "image/png", has_logo: true, @@ -447,8 +447,8 @@ it("updates and clears uploaded portal logo", () => { const calls = []; let settings = { id: 1, - portal_name: "daedalus-agent", - brand_subtitle: "智能体管理与运营平台", + portal_name: "智能体集群管理控制台", + brand_subtitle: "Agents Management Console", theme_color: "#a5522d", logo_mime_type: "", has_logo: false, diff --git a/packages/daedalus-agent-management-web/src/brand.js b/packages/daedalus-agent-management-web/src/brand.js index c3c41f6..6eaf5fe 100644 --- a/packages/daedalus-agent-management-web/src/brand.js +++ b/packages/daedalus-agent-management-web/src/brand.js @@ -1,5 +1,5 @@ -export const BRAND_NAME = "daedalus-agent"; -export const BRAND_SUBTITLE = "智能体管理与运营平台"; +export const BRAND_NAME = "智能体集群管理控制台"; +export const BRAND_SUBTITLE = "Agents Management Console"; export const BRAND_SLOGAN = "说出你的困境,我来解决它"; export const BRAND_THEME_COLOR = "#a5522d"; diff --git a/test/README.md b/test/README.md index a97cfc1..5fc05a5 100644 --- a/test/README.md +++ b/test/README.md @@ -43,7 +43,7 @@ powershell -ExecutionPolicy Bypass -File .\test\integration\run-flow.windows.ps1 ## Mock LLM -Integration and stress suites use the mock LLM server by default. The mock waits 5 seconds after receiving a request, then returns the response. `chunkDelayMs` stays at `0` unless a test explicitly needs streamed chunk spacing. +Integration and stress suites use the mock LLM server by default. With the default `delayMs=5000`, the mock samples an integer delay uniformly from 4000ms through 6000ms (theoretical mean and median: 5000ms), then returns the response. Set `MOCK_OPENAI_DELAY_JITTER_MS=0` to restore a fixed delay. `chunkDelayMs` stays at `0` unless a test explicitly needs streamed chunk spacing. Use `common/mock-openai-completions-server.js` when testing providers configured as OpenAI Completions. It exposes both `/v1/completions` and `/v1/chat/completions`, extracts the requested stress-test marker, and returns only that marker after the configured delay: @@ -51,6 +51,7 @@ Use `common/mock-openai-completions-server.js` when testing providers configured $env:MOCK_OPENAI_HOST = "0.0.0.0" $env:MOCK_OPENAI_PORT = "18085" $env:MOCK_OPENAI_DELAY_MS = "5000" +$env:MOCK_OPENAI_DELAY_JITTER_MS = "1000" $env:MOCK_OPENAI_CHUNK_DELAY_MS = "0" node .\test\common\mock-openai-completions-server.js ``` @@ -86,7 +87,7 @@ $env:DA_STRESS_RUNTIME_STATE = "E:\CODE\daedalus-agent-oc\test\.runtime\suite\st node .\test\case\stress\stress-flow.js ``` -Mock LLM response time is configured in each suite config: +Mock LLM response time is configured in each suite config. `delayMs` is the center of the random delay range; the default jitter is 1000ms: ```json { @@ -101,6 +102,7 @@ Environment variables can override the config: ```powershell $env:MOCK_OPENAI_DELAY_MS = "5000" +$env:MOCK_OPENAI_DELAY_JITTER_MS = "1000" $env:MOCK_OPENAI_CHUNK_DELAY_MS = "25" $env:DA_STRESS_IMAGE_TAG = "daedalus-agent-root-only-perms:20260630-5" npm --prefix test run test:stress:50a20c diff --git a/test/case/branding/daedalus-brand-contract.test.js b/test/case/branding/daedalus-brand-contract.test.js index 6f4b89b..20dedc0 100644 --- a/test/case/branding/daedalus-brand-contract.test.js +++ b/test/case/branding/daedalus-brand-contract.test.js @@ -75,8 +75,8 @@ test("exposes the daedalus-agent visual identity contract", async () => { const loginPageSource = fs.readFileSync(path.join(webRoot, "src", "pages", "LoginPage.jsx"), "utf8"); const faviconSource = fs.readFileSync(path.join(webRoot, "public", "daedalus-favicon.svg"), "utf8"); - assert.equal(brand.BRAND_NAME, "daedalus-agent"); - assert.equal(brand.BRAND_SUBTITLE, "智能体管理与运营平台"); + assert.equal(brand.BRAND_NAME, "智能体集群管理控制台"); + assert.equal(brand.BRAND_SUBTITLE, "Agents Management Console"); assert.equal(brand.BRAND_SLOGAN, "说出你的困境,我来解决它"); assert.equal(brand.BRAND_THEME_COLOR, "#a5522d"); assert.match(logoSource, /daedalus-mark/); diff --git a/test/case/stress/100a-reuse-concurrency-matrix-contract.test.js b/test/case/stress/100a-reuse-concurrency-matrix-contract.test.js index 11c46ea..3f7a28d 100644 --- a/test/case/stress/100a-reuse-concurrency-matrix-contract.test.js +++ b/test/case/stress/100a-reuse-concurrency-matrix-contract.test.js @@ -53,6 +53,8 @@ test("matrix runner always uses the local mock OpenAI server", () => { assert.equal(config.llm.model, "mock-openai"); assert.equal(config.llm.modelName, "mock-openai"); assert.match(config.llm.url, /^http:\/\/host\.docker\.internal:\d+\/v1$/); + assert.equal(config.mockLlm.delayMs, 5000); + assert.equal(config.mockLlm.delayJitterMs, 1000); assert.equal(config.mockLlm.capacity, 128); assert.equal(config.stress.gatewayMaxConcurrent, 128); assert.equal(config.stress.load.warmupMs, 30000); @@ -75,3 +77,19 @@ test("stress environment persists the configured gateway concurrency", () => { /DA_OPENCLAW_GATEWAY_MAX_CONCURRENT:\s*env\.gatewayMaxConcurrent/ ); }); + +test("stress reports use Avg, P50, P90, and P99 response metrics", () => { + const stressFlowSource = fs.readFileSync( + path.join(testDir, "case", "stress", "stress-flow.js"), + "utf8" + ); + const matrixSource = fs.readFileSync( + path.join(testDir, "suite", "stress", "run-100a-reuse-concurrency-matrix.js"), + "utf8" + ); + + assert.match(stressFlowSource, /p90:\s*percentile\(responseTimes,\s*90\)/); + assert.doesNotMatch(stressFlowSource, /p95/i); + assert.match(matrixSource, /\| Avg \| P50 \| P90 \| P99 \|/); + assert.doesNotMatch(matrixSource, /p95/i); +}); diff --git a/test/case/stress/mock-delay.test.js b/test/case/stress/mock-delay.test.js new file mode 100644 index 0000000..8896282 --- /dev/null +++ b/test/case/stress/mock-delay.test.js @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_DELAY_JITTER_MS, + DEFAULT_DELAY_MS, + resolveDelayRange, + sampleDelayMs +} from "../../common/mock-delay.js"; + +test("default mock delay is symmetric from 4 to 6 seconds", () => { + const range = resolveDelayRange(); + assert.deepEqual(range, { minMs: 4000, maxMs: 6000 }); + assert.equal(DEFAULT_DELAY_MS, 5000); + assert.equal(DEFAULT_DELAY_JITTER_MS, 1000); + assert.equal(sampleDelayMs(range, () => 0), 4000); + assert.equal(sampleDelayMs(range, () => 0.999999), 6000); +}); + +test("uniform integer samples have a 5 second mean and median", () => { + const range = resolveDelayRange(); + const samples = Array.from({ length: range.maxMs - range.minMs + 1 }, (_, index) => { + return sampleDelayMs( + range, + () => (index + 0.5) / (range.maxMs - range.minMs + 1) + ); + }); + const sorted = [...samples].sort((left, right) => left - right); + const mean = samples.reduce((sum, value) => sum + value, 0) / samples.length; + const median = sorted[Math.floor(sorted.length / 2)]; + + assert.equal(Math.min(...samples), 4000); + assert.equal(Math.max(...samples), 6000); + assert.equal(mean, 5000); + assert.equal(median, 5000); +}); + +test("zero jitter preserves the existing fixed-delay mode", () => { + const range = resolveDelayRange(5000, 0); + assert.deepEqual(range, { minMs: 5000, maxMs: 5000 }); + assert.equal(sampleDelayMs(range), 5000); +}); diff --git a/test/case/stress/stress-flow.js b/test/case/stress/stress-flow.js index e406747..f9a222f 100644 --- a/test/case/stress/stress-flow.js +++ b/test/case/stress/stress-flow.js @@ -52,7 +52,7 @@ const DEFAULT_CREATION_CONCURRENCY = { }; const DEFAULT_LOAD_PROFILE = { warmupMs: 30000, - durationMs: 300000, + durationMs: 900000, progressEveryMs: 30000 }; const PROVIDER_ID_PREFIX = "provider"; @@ -1035,7 +1035,7 @@ function summarizeMqttCollector(collector, durationMs) { max: responseTimes.length ? Math.max(...responseTimes) : null, avg: average(responseTimes), p50: percentile(responseTimes, 50), - p95: percentile(responseTimes, 95), + p90: percentile(responseTimes, 90), p99: percentile(responseTimes, 99) }, sample: collector.sample, diff --git a/test/common/mock-delay.js b/test/common/mock-delay.js new file mode 100644 index 0000000..a4629d6 --- /dev/null +++ b/test/common/mock-delay.js @@ -0,0 +1,30 @@ +export const DEFAULT_DELAY_MS = 5000; +export const DEFAULT_DELAY_JITTER_MS = 1000; + +function normalizeNonNegativeInteger(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback; +} + +export function resolveDelayRange(centerMs = DEFAULT_DELAY_MS, jitterMs = DEFAULT_DELAY_JITTER_MS) { + const center = normalizeNonNegativeInteger(centerMs, DEFAULT_DELAY_MS); + const jitter = Math.min( + normalizeNonNegativeInteger(jitterMs, DEFAULT_DELAY_JITTER_MS), + center + ); + return { + minMs: center - jitter, + maxMs: center + jitter + }; +} + +export function sampleDelayMs(range, random = Math.random) { + const minMs = normalizeNonNegativeInteger(range?.minMs, 0); + const maxMs = Math.max(minMs, normalizeNonNegativeInteger(range?.maxMs, minMs)); + const bucketCount = maxMs - minMs + 1; + const randomValue = Number(random()); + const normalizedRandom = Number.isFinite(randomValue) + ? Math.min(Math.max(randomValue, 0), 1 - Number.EPSILON) + : 0.5; + return minMs + Math.floor(normalizedRandom * bucketCount); +} diff --git a/test/common/mock-openai-completions-server.js b/test/common/mock-openai-completions-server.js index 3c43395..ef25f17 100644 --- a/test/common/mock-openai-completions-server.js +++ b/test/common/mock-openai-completions-server.js @@ -1,16 +1,26 @@ import http from "node:http"; import { randomUUID } from "node:crypto"; +import { + DEFAULT_DELAY_JITTER_MS, + DEFAULT_DELAY_MS, + resolveDelayRange, + sampleDelayMs +} from "./mock-delay.js"; function readPositiveInteger(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback; } -const DEFAULT_DELAY_MS = 5000; const DEFAULT_CAPACITY = 128; const host = process.env.MOCK_OPENAI_HOST || "0.0.0.0"; const port = readPositiveInteger(process.env.MOCK_OPENAI_PORT, 18080); const delayMs = readPositiveInteger(process.env.MOCK_OPENAI_DELAY_MS, DEFAULT_DELAY_MS); +const delayJitterMs = readPositiveInteger( + process.env.MOCK_OPENAI_DELAY_JITTER_MS, + DEFAULT_DELAY_JITTER_MS +); +const delayRange = resolveDelayRange(delayMs, delayJitterMs); const chunkDelayMs = readPositiveInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, 0); const capacity = readPositiveInteger(process.env.MOCK_OPENAI_CAPACITY, DEFAULT_CAPACITY); const startedAt = new Date(); @@ -195,8 +205,9 @@ async function sendCompletion(request, response) { }); stats.recentMarkers = stats.recentMarkers.slice(-20); - if (delayMs > 0) { - await sleep(delayMs); + const sampledDelayMs = sampleDelayMs(delayRange); + if (sampledDelayMs > 0) { + await sleep(sampledDelayMs); } if (payload.stream === true) { @@ -249,8 +260,9 @@ async function sendChatCompletion(request, response) { }); stats.recentMarkers = stats.recentMarkers.slice(-20); - if (delayMs > 0) { - await sleep(delayMs); + const sampledDelayMs = sampleDelayMs(delayRange); + if (sampledDelayMs > 0) { + await sleep(sampledDelayMs); } if (payload.stream === true) { @@ -330,6 +342,9 @@ const server = http.createServer(async (request, response) => { ok: true, service: "mock-openai-completions-server", delayMs, + delayMinMs: delayRange.minMs, + delayMaxMs: delayRange.maxMs, + delayJitterMs, chunkDelayMs, capacity }); @@ -356,6 +371,9 @@ const server = http.createServer(async (request, response) => { startedAt: startedAt.toISOString(), uptimeMs: Date.now() - startedAt.getTime(), delayMs, + delayMinMs: delayRange.minMs, + delayMaxMs: delayRange.maxMs, + delayJitterMs, chunkDelayMs, capacity, ...stats @@ -369,6 +387,9 @@ const server = http.createServer(async (request, response) => { service: "mock-openai-completions-server", resetAt: new Date().toISOString(), delayMs, + delayMinMs: delayRange.minMs, + delayMaxMs: delayRange.maxMs, + delayJitterMs, chunkDelayMs, capacity }); @@ -397,5 +418,9 @@ server.keepAliveTimeout = 65000; server.headersTimeout = 66000; server.listen(port, host, () => { - console.log(`[mock-openai-completions] listening on http://${host}:${port}/v1 delayMs=${delayMs} chunkDelayMs=${chunkDelayMs} capacity=${capacity}`); + console.log( + `[mock-openai-completions] listening on http://${host}:${port}/v1 ` + + `delayMs=${delayMs} delayRangeMs=${delayRange.minMs}-${delayRange.maxMs} ` + + `chunkDelayMs=${chunkDelayMs} capacity=${capacity}` + ); }); diff --git a/test/common/mock-openai-server.js b/test/common/mock-openai-server.js index 0d00827..328c427 100644 --- a/test/common/mock-openai-server.js +++ b/test/common/mock-openai-server.js @@ -1,16 +1,26 @@ import http from "node:http"; import { randomUUID } from "node:crypto"; +import { + DEFAULT_DELAY_JITTER_MS, + DEFAULT_DELAY_MS, + resolveDelayRange, + sampleDelayMs +} from "./mock-delay.js"; function readPositiveInteger(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback; } -const DEFAULT_DELAY_MS = 5000; const DEFAULT_CAPACITY = 128; const host = process.env.MOCK_OPENAI_HOST || "0.0.0.0"; const port = readPositiveInteger(process.env.MOCK_OPENAI_PORT, 18080); const delayMs = readPositiveInteger(process.env.MOCK_OPENAI_DELAY_MS, DEFAULT_DELAY_MS); +const delayJitterMs = readPositiveInteger( + process.env.MOCK_OPENAI_DELAY_JITTER_MS, + DEFAULT_DELAY_JITTER_MS +); +const delayRange = resolveDelayRange(delayMs, delayJitterMs); const chunkDelayMs = readPositiveInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, 0); const capacity = readPositiveInteger(process.env.MOCK_OPENAI_CAPACITY, DEFAULT_CAPACITY); const startedAt = new Date(); @@ -146,8 +156,9 @@ async function sendChatCompletion(request, response) { const id = `chatcmpl-mock-${randomUUID()}`; const model = typeof payload.model === "string" ? payload.model : "mock-openai"; try { - if (delayMs > 0) { - await sleep(delayMs); + const sampledDelayMs = sampleDelayMs(delayRange); + if (sampledDelayMs > 0) { + await sleep(sampledDelayMs); } if (payload.stream === true) { @@ -247,6 +258,9 @@ const server = http.createServer(async (request, response) => { ok: true, service: "mock-openai-server", delayMs, + delayMinMs: delayRange.minMs, + delayMaxMs: delayRange.maxMs, + delayJitterMs, chunkDelayMs, capacity }); @@ -259,6 +273,9 @@ const server = http.createServer(async (request, response) => { startedAt: startedAt.toISOString(), uptimeMs: Date.now() - startedAt.getTime(), delayMs, + delayMinMs: delayRange.minMs, + delayMaxMs: delayRange.maxMs, + delayJitterMs, chunkDelayMs, capacity, ...stats @@ -272,6 +289,9 @@ const server = http.createServer(async (request, response) => { service: "mock-openai-server", resetAt: new Date().toISOString(), delayMs, + delayMinMs: delayRange.minMs, + delayMaxMs: delayRange.maxMs, + delayJitterMs, chunkDelayMs, capacity }); @@ -296,5 +316,9 @@ server.keepAliveTimeout = 65000; server.headersTimeout = 66000; server.listen(port, host, () => { - console.log(`[mock-openai] listening on http://${host}:${port}/v1 delayMs=${delayMs} chunkDelayMs=${chunkDelayMs} capacity=${capacity}`); + console.log( + `[mock-openai] listening on http://${host}:${port}/v1 ` + + `delayMs=${delayMs} delayRangeMs=${delayRange.minMs}-${delayRange.maxMs} ` + + `chunkDelayMs=${chunkDelayMs} capacity=${capacity}` + ); }); diff --git a/test/package.json b/test/package.json index 4e31da5..dc19c3f 100644 --- a/test/package.json +++ b/test/package.json @@ -6,6 +6,7 @@ "test:brand": "node --test ./case/branding/daedalus-brand-contract.test.js", "test:upgrade-contract": "node --test ./case/upgrade/openclaw-upgrade-contract.test.js", "test:integration:contract": "node --test ./case/integration/file-output-content.test.js", + "test:mock-delay": "node --test ./case/stress/mock-delay.test.js", "case:integration": "node ./case/integration/playwright-flow.js", "case:stress": "node ./case/stress/stress-flow.js", "test:flow": "node ./suite/integration/run-integration-suite.js full", diff --git a/test/stress/config.stress.sample.json b/test/stress/config.stress.sample.json index 42745e5..82bdeef 100644 --- a/test/stress/config.stress.sample.json +++ b/test/stress/config.stress.sample.json @@ -83,7 +83,7 @@ }, "load": { "warmupMs": 30000, - "durationMs": 300000, + "durationMs": 900000, "progressEveryMs": 30000 } }, diff --git a/test/suite/stress/100a100c/config.stress.json b/test/suite/stress/100a100c/config.stress.json index 3392334..56145e5 100644 --- a/test/suite/stress/100a100c/config.stress.json +++ b/test/suite/stress/100a100c/config.stress.json @@ -72,8 +72,8 @@ "blockIdle": 1500 }, "load": { - "warmupMs": 60000, - "durationMs": 600000, + "warmupMs": 30000, + "durationMs": 900000, "progressEveryMs": 30000 } } diff --git a/test/suite/stress/100a20c/config.stress.json b/test/suite/stress/100a20c/config.stress.json index 21e6332..bb5cdd0 100644 --- a/test/suite/stress/100a20c/config.stress.json +++ b/test/suite/stress/100a20c/config.stress.json @@ -72,8 +72,8 @@ "blockIdle": 1500 }, "load": { - "warmupMs": 60000, - "durationMs": 600000, + "warmupMs": 30000, + "durationMs": 900000, "progressEveryMs": 30000 } } diff --git a/test/suite/stress/100a50c/config.stress.json b/test/suite/stress/100a50c/config.stress.json index fe2022f..e91a295 100644 --- a/test/suite/stress/100a50c/config.stress.json +++ b/test/suite/stress/100a50c/config.stress.json @@ -72,8 +72,8 @@ "blockIdle": 1500 }, "load": { - "warmupMs": 60000, - "durationMs": 600000, + "warmupMs": 30000, + "durationMs": 900000, "progressEveryMs": 30000 } } diff --git a/test/suite/stress/20a10c/config.stress.json b/test/suite/stress/20a10c/config.stress.json index 23b0f23..ae02887 100644 --- a/test/suite/stress/20a10c/config.stress.json +++ b/test/suite/stress/20a10c/config.stress.json @@ -71,7 +71,7 @@ }, "load": { "warmupMs": 30000, - "durationMs": 300000, + "durationMs": 900000, "progressEveryMs": 30000 } } diff --git a/test/suite/stress/50a20c/config.stress.json b/test/suite/stress/50a20c/config.stress.json index 0b73aab..94295b5 100644 --- a/test/suite/stress/50a20c/config.stress.json +++ b/test/suite/stress/50a20c/config.stress.json @@ -71,7 +71,7 @@ }, "load": { "warmupMs": 30000, - "durationMs": 300000, + "durationMs": 900000, "progressEveryMs": 30000 } } diff --git a/test/suite/stress/run-100a-reuse-concurrency-matrix.js b/test/suite/stress/run-100a-reuse-concurrency-matrix.js index f09cfe0..f3fc11b 100644 --- a/test/suite/stress/run-100a-reuse-concurrency-matrix.js +++ b/test/suite/stress/run-100a-reuse-concurrency-matrix.js @@ -2,6 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + DEFAULT_DELAY_JITTER_MS, + resolveDelayRange +} from "../../common/mock-delay.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const testDir = path.resolve(moduleDir, "..", ".."); @@ -16,6 +20,11 @@ const imageTag = readText(process.env.DA_STRESS_IMAGE_TAG, defaultImageTag()); export const DEFAULT_MAX_CONCURRENT = 128; const mockOpenAiPort = readPositiveInteger(process.env.MOCK_OPENAI_PORT, 18085); const mockOpenAiDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_DELAY_MS, 5000); +const mockOpenAiDelayJitterMs = readNonNegativeInteger( + process.env.MOCK_OPENAI_DELAY_JITTER_MS, + DEFAULT_DELAY_JITTER_MS +); +const mockOpenAiDelayRange = resolveDelayRange(mockOpenAiDelayMs, mockOpenAiDelayJitterMs); const mockOpenAiChunkDelayMs = readNonNegativeInteger(process.env.MOCK_OPENAI_CHUNK_DELAY_MS, 0); const mockOpenAiCapacity = readPositiveInteger(process.env.MOCK_OPENAI_CAPACITY, DEFAULT_MAX_CONCURRENT); const modelBaseUrl = readText(process.env.MOCK_OPENAI_BASE_URL, `http://host.docker.internal:${mockOpenAiPort}/v1`); @@ -208,12 +217,27 @@ async function hotUpdateMaxConcurrent(maxConcurrent) { ]); const sinceIso = new Date().toISOString(); + const restartScript = [ + 'const fs = require("node:fs");', + 'const gatewayPids = fs.readdirSync("/proc")', + ' .filter((name) => /^\\d+$/.test(name))', + ' .filter((name) => {', + ' try {', + ' return fs.readFileSync(`/proc/${name}/cmdline`).toString().replace(/\\0/g, " ").trim() === "openclaw-gateway";', + ' } catch {', + ' return false;', + ' }', + ' })', + ' .map(Number);', + 'if (gatewayPids.length === 0) throw new Error("running openclaw-gateway process not found");', + 'for (const pid of gatewayPids) process.kill(pid, "SIGTERM");' + ].join("\n"); await runRequired("docker", [ "exec", containerName, - "sh", - "-c", - "pids=\"$(pgrep -x openclaw || true)\"; if [ -n \"$pids\" ]; then kill $pids; fi" + "node", + "-e", + restartScript ]); await waitForOpenClawGatewayReady(sinceIso); @@ -259,6 +283,7 @@ export function buildConfig(concurrency) { host: "0.0.0.0", port: mockOpenAiPort, delayMs: mockOpenAiDelayMs, + delayJitterMs: mockOpenAiDelayJitterMs, chunkDelayMs: mockOpenAiChunkDelayMs, containerBaseUrl: modelBaseUrl, capacity: mockOpenAiCapacity @@ -515,7 +540,7 @@ function buildMarkdownReport(summary) { formatNumber(run.steadyThroughputRps, 2), formatMs(run.responseTimeMs?.avg), formatMs(run.responseTimeMs?.p50), - formatMs(run.responseTimeMs?.p95), + formatMs(run.responseTimeMs?.p90), formatMs(run.responseTimeMs?.p99), `${formatNumber(run.resources.cpuPercent.max, 2)}%`, formatBytes(run.resources.memoryUsedBytes.max) @@ -533,11 +558,11 @@ function buildMarkdownReport(summary) { `- 资源限制:${summary.resourceLimits.cpus} CPU / ${summary.resourceLimits.memory} memory`, `- 环境规模:100 Agent、100 模板、100 技能、100 业务系统、1000 用户、每 workspace 100 文件`, `- 负载:${matrixValues.join(" / ")} 并发;每组 ${formatMs(warmupMs)} 预热 + ${formatMs(durationMs)} 稳态`, - `- Mock LLM:${modelBaseUrl},固定延迟 ${mockOpenAiDelayMs}ms,容量 ${mockOpenAiCapacity}`, + `- Mock LLM:${modelBaseUrl},均匀随机延迟 ${mockOpenAiDelayRange.minMs}-${mockOpenAiDelayRange.maxMs}ms(理论均值/中位数 ${mockOpenAiDelayMs}ms),容量 ${mockOpenAiCapacity}`, "", "## 结果汇总", "", - "| 并发 | 结论 | 稳态成功/请求 | 失败 | 吞吐 req/s | Avg | P50 | P95 | P99 | CPU 峰值 | 内存峰值 |", + "| 并发 | 结论 | 稳态成功/请求 | 失败 | 吞吐 req/s | Avg | P50 | P90 | P99 | CPU 峰值 | 内存峰值 |", "| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ...rows.map((row) => `| ${row.join(" | ")} |`), "", @@ -576,6 +601,7 @@ async function main() { MOCK_OPENAI_HOST: "0.0.0.0", MOCK_OPENAI_PORT: String(mockOpenAiPort), MOCK_OPENAI_DELAY_MS: String(mockOpenAiDelayMs), + MOCK_OPENAI_DELAY_JITTER_MS: String(mockOpenAiDelayJitterMs), MOCK_OPENAI_CHUNK_DELAY_MS: String(mockOpenAiChunkDelayMs), MOCK_OPENAI_CAPACITY: String(mockOpenAiCapacity) } @@ -649,6 +675,9 @@ async function main() { modelBaseUrl, hostMockBaseUrl, mockOpenAiDelayMs, + mockOpenAiDelayJitterMs, + mockOpenAiDelayMinMs: mockOpenAiDelayRange.minMs, + mockOpenAiDelayMaxMs: mockOpenAiDelayRange.maxMs, mockOpenAiCapacity, gatewayMaxConcurrent: DEFAULT_MAX_CONCURRENT, lastGatewayRestartStartedAt, -- Gitee