import { join, resolve } from "node:path"; import { z } from "zod"; const LogLevel = z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]); export const ConfigSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: z.coerce.number().int().min(1).max(65535).default(4380), /** 监听地址 */ HOST: z.string().min(1).default("0.0.0.0"), DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"), SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"), LOG_LEVEL: LogLevel.default("info"), /** 仅开发模式生效的管理员 Key */ DEV_ADMIN_KEY: z.string().min(8).optional(), /** worker 心跳间隔 */ WORKER_HEARTBEAT_MS: z.coerce.number().int().min(1000).default(30_000), /** io 组 worker(除 runAgent/resolveDecision 外的全部任务)并发槽数 */ WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).default(4), /** agent 组 worker(runAgent/resolveDecision:整段会话占一槽数分钟)并发槽数 */ WORKER_AGENT_CONCURRENCY: z.coerce.number().int().min(1).max(16).default(2), /** 手动整理 dryRun / 按路径整理时递归遍历的文件数上限(超出截断并标记 truncated) */ ORGANIZE_WALK_MAX_FILES: z.coerce.number().int().min(1).max(1_000_000).default(5000), /** web 静态产物目录(单容器模式):设置后同端口托管 SPA,非 /api 路径回退 index.html */ WEB_ROOT: z.string().min(1).optional(), /** * 应用数据目录(容器内 /data,由卷挂载):agent-sessions/、backups/、exports/、tmp/。 * 编译后的二进制与 /app 保持只读,所有运行期写入都落在这里。相对路径按进程 cwd 解析。 */ DATA_DIR: z.string().min(1).default("./data"), /** Agent JSONL 会话目录;缺省 DATA_DIR/agent-sessions(api 与 worker 必须共享同一目录) */ AGENT_SESSION_DIR: z.string().min(1).optional(), // ── 文件操作挂死隔离(adapters fsproxy,对标 MP fsproxy)── /** auto:能起 deno 子进程就把单个文件操作放进执行子进程(可 SIGKILL 回收),否则进程内看门狗;inprocess 强制进程内 */ FS_PROXY_MODE: z.enum(["auto", "subprocess", "inprocess"]).default("auto"), /** 复制/哈希:进度停滞多久判挂死(ms),超时 SIGTERM→SIGKILL 并以 TIMEOUT 失败 */ FS_HANG_TIMEOUT_MS: z.coerce.number().int().min(1000).default(120_000), /** stat/根检查/同卷 move 等快操作的总超时(ms) */ FS_QUICK_TIMEOUT_MS: z.coerce.number().int().min(500).default(30_000), /** 执行子进程上报进度心跳的间隔(ms) */ FS_PROGRESS_INTERVAL_MS: z.coerce.number().int().min(10).default(1000), /** SIGTERM 到 SIGKILL 的宽限(ms) */ FS_KILL_GRACE_MS: z.coerce.number().int().min(100).default(2000), /** 写入前清理目标目录残留 partial 的年龄阈值(ms);0 = 不清 */ FS_PARTIAL_MAX_AGE_MS: z.coerce .number() .int() .min(0) .default(24 * 3600_000), /** 单个文件的 TIMEOUT/IO 类失败最多尝试几次(含首次);超限转待处理,不删记录不重下 */ FS_TRANSFER_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(3), /** 替换入库(replace_with_verify)时旧文件去向:trash = 移到库根 .umt-trash;delete = 删除 */ FS_REPLACE_OLD: z.enum(["trash", "delete"]).default("trash"), /** 入库目标已存在时的缺省策略:conflict = 转待处理;skip = 跳过;replace_with_verify = 授权后写临时→校验→原子替换 */ ORGANIZE_ON_EXISTING: z.enum(["conflict", "skip", "replace_with_verify"]).default("conflict"), /** 随行字幕中此语言加 .default 标记(对照 MP DEFAULT_SUB);不设 = 不加 */ ORGANIZE_DEFAULT_SUBTITLE_LANG: z.enum(["zh-cn", "zh-tw", "en", "ja", "ko"]).optional(), }); export type Config = z.infer; /** 从环境变量加载并校验配置;失败时抛出可读错误(不含变量值) */ export function loadConfig(env: Record = Deno.env.toObject()): Config { const parsed = ConfigSchema.safeParse(env); if (!parsed.success) { const lines = parsed.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`); throw new Error(`配置无效:\n${lines.join("\n")}`); } return parsed.data; } /** DATA_DIR 下的固定子目录 */ export const DATA_SUBDIRS = ["agent-sessions", "backups", "exports", "tmp"] as const; /** 解析数据目录与会话目录为绝对路径 */ export function dataPaths(c: Pick, cwd: string = Deno.cwd()) { const dataDir = resolve(cwd, c.DATA_DIR); return { dataDir, sessionDir: resolve(cwd, c.AGENT_SESSION_DIR ?? join(dataDir, "agent-sessions")), backupsDir: join(dataDir, "backups"), exportsDir: join(dataDir, "exports"), tmpDir: join(dataDir, "tmp"), }; } export const isDev = (c: Pick) => c.NODE_ENV !== "production";