import { EventEmitter } from 'node:events' import path from 'node:path' import { eq } from 'drizzle-orm' import { z } from 'zod' import type { AppSettings, DeepPartial } from '@agent-server/shared' import { config } from '../config' import type { DB } from '../db' import { settings as settingsTable } from '../db/schema' import { badRequest } from '../lib/errors' import { bundledClaudePath } from '../providers/claude/cli' import { bundledCodexPath } from '../providers/codex/cli' const KEY = 'app' export function defaultSettings(): AppSettings { const shared = config.sharedDir return { paths: { workspaceRoot: path.join(config.dataDir, 'workspaces'), skillsDir: path.join(shared, 'skills'), pythonRuntime: path.join(shared, 'runtime/py'), nodeModules: path.join(shared, 'runtime/node/node_modules'), matplotlibrc: path.join(shared, 'runtime/matplotlibrc'), }, security: { globalDeny: [], defaultFileAccessMode: 'restricted', corsOrigins: [] }, network: { allowedDomains: ['pypi.org', 'files.pythonhosted.org'], blockPrivateFetch: true }, limits: { concurrency: { global: 4, claude: 2, codex: 2 }, interactionTimeoutSec: 600, uploadMaxMB: 50, workspaceMaxMB: 2048, }, retention: { workspaceDays: 30 }, teamLimits: { maxTasksPerJob: 20, maxLeadRunsPerJob: 30, maxCostUsdPerJob: null, maxMinutesPerJob: 120, maxConcurrentRuns: 4 }, providers: { claude: { enabled: true, cliPath: bundledClaudePath() ?? path.join(config.home, '.local/bin/claude'), defaultModel: 'sonnet', models: ['sonnet', 'opus', 'haiku', 'fable'], }, codex: { enabled: false, cliPath: bundledCodexPath() ?? path.join(config.home, '.local/bin/codex'), defaultModel: 'gpt-6.1-sol', models: [], configOverrides: [], }, }, } } const absPath = z.string().min(1).refine(p => path.isAbsolute(p), '必须是绝对路径') const domain = z.string().regex(/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/i, '域名格式不正确') export const teamLimitsSchema = z.object({ maxTasksPerJob: z.number().int().min(1).max(500), maxLeadRunsPerJob: z.number().int().min(1).max(1000), maxCostUsdPerJob: z.number().min(0.01).max(10000).nullable(), maxMinutesPerJob: z.number().int().min(1).max(7 * 24 * 60), maxConcurrentRuns: z.number().int().min(1).max(32), }) const settingsSchema: z.ZodType = z.object({ paths: z.object({ workspaceRoot: absPath, skillsDir: absPath, pythonRuntime: absPath, nodeModules: absPath, matplotlibrc: absPath }), security: z.object({ globalDeny: z.array(z.string().min(1)), defaultFileAccessMode: z.enum(['restricted', 'broad']), corsOrigins: z.array(z.string().regex(/^https?:\/\/[a-z0-9.-]+(:\d+)?$/i, '来源格式应为 https://host[:port],不带路径')), }), network: z.object({ allowedDomains: z.array(domain), blockPrivateFetch: z.boolean() }), limits: z.object({ concurrency: z.object({ global: z.number().int().min(1).max(32), claude: z.number().int().min(0).max(32), codex: z.number().int().min(0).max(32), }), interactionTimeoutSec: z.number().int().min(30).max(86400), uploadMaxMB: z.number().int().min(1).max(1024), workspaceMaxMB: z.number().int().min(10).max(102400), }), retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }), teamLimits: teamLimitsSchema, providers: z.object({ claude: z.object({ enabled: z.boolean(), cliPath: absPath, defaultModel: z.string().min(1), models: z.array(z.string().min(1)) }), codex: z.object({ enabled: z.boolean(), cliPath: absPath, defaultModel: z.string(), models: z.array(z.string().min(1)), configOverrides: z.array(z.string().min(1)), }), }), }) function deepMerge(base: T, patch: unknown): T { if (patch === undefined) return base if (typeof base !== 'object' || base === null || Array.isArray(base) || typeof patch !== 'object' || patch === null || Array.isArray(patch)) { return patch as T } const out: Record = { ...(base as Record) } for (const [k, v] of Object.entries(patch)) out[k] = deepMerge(out[k], v) return out as T } export class SettingsService extends EventEmitter<{ changed: [AppSettings] }> { private cache: AppSettings constructor(private db: DB) { super() const row = db.select().from(settingsTable).where(eq(settingsTable.key, KEY)).get() // 存储的值与默认值合并,以便新版本增加的配置项自动获得默认值 this.cache = deepMerge(defaultSettings(), row?.value) } get(): AppSettings { return this.cache } update(patch: DeepPartial): AppSettings { const merged = deepMerge(this.cache, patch) const parsed = settingsSchema.safeParse(merged) if (!parsed.success) throw badRequest('配置校验失败', z.treeifyError(parsed.error)) this.db.insert(settingsTable) .values({ key: KEY, value: parsed.data }) .onConflictDoUpdate({ target: settingsTable.key, set: { value: parsed.data, updatedAt: new Date().toISOString() } }) .run() this.cache = parsed.data this.emit('changed', this.cache) return this.cache } }