# @umt/server 服务进程(Deno):Hono HTTP API(前缀 `/api/v1`,OpenAPI 见 `/api/openapi.json`、`/api/docs`)、SSE 事件流与 worker 入口。`--role api|worker|worker:agent|all` 决定本进程承担哪部分,默认 `all` 单进程运行(`worker` 只认领 io 组任务,`worker:agent` 只认领 Agent 组;另有内部角色 `--role fsworker`,由 FsProxy 以子进程方式拉起执行单个文件操作,不要手动启动)。业务规则不写在这里,只做编排、鉴权、审计与协议适配。 ## 模块骨架(W2) ``` src/ main.ts 进程入口:按 --role 装配 context.ts bootstrap():ServerContext、任务注册表(模块 + 空壳兜底)、outbox 路由合并、连接引擎 http/app.ts createApp():系统路由 + 逐个挂 SERVER_MODULES 的 registerRoutes worker/index.ts startWorker():引擎 worker + OutboxRelay + 各模块 start() hooks/ 链式钩子注册表与调用入口(ADR-006) modules/ types.ts ServerModule / ServerContext / OutboxWriter / SecretStore / SettingsReader(共享,不随子任务改) index.ts SERVER_MODULES 清单 auth-settings/ W2-A(provided.ts 提供 secrets / settings 实现) sources-identity/ W2-B acquisition-organize/ W2-C file-manager/ W5-A:/files/*(浏览、mkdir/rename、move/copy/organize 两阶段、回收站、identify/probe/scrape、download), 见 docs/runbook/files.md agent/ W2-D:capability-registry.ts(authorize / audit / 幂等键 / 操作账本)、会话 API、runAgent、resolveDecision (见 docs/runbook/agent.md;agent 模块可以 import 其它模块的 service 导出,反向不行) ``` **分工规则:各 W2 子任务只改自己的 `modules//` 目录,以及 `packages/contracts`、`packages/db` 中本域的文件**(新迁移按编号顺延,开工前在群里认领编号)。`modules/types.ts`、`modules/index.ts`、`context.ts`、`http/app.ts`、`worker/index.ts`、`main.ts` 是共享装配,确需改动时单独提交并通知其他子任务;`http/auth.ts`、`http/sse.ts` 归 W2-A。跨模块只通过 DB 表 + `outbox_event`(事件类型在 contracts/events.ts)+ contracts 类型耦合,不互相 import 对方模块。 一个模块长这样(`modules//index.ts` 导出,目录内自由拆 `routes.ts` / `service.ts` / `handlers/*.ts`): ```ts import { createRoute } from "@hono/zod-openapi"; import { capabilitySpec, EventType } from "@umt/contracts"; import { defineTask } from "@umt/tasks"; import { requireScope } from "../../http/auth.ts"; import type { ServerModule } from "../types.ts"; export const sourcesIdentityModule: ServerModule = { name: "sources-identity", // 路径相对 /api/v1,已过 API Key 鉴权;每个路由自己声明 scope registerRoutes(app, ctx) { app.use("/sites/*", requireScope("read")); app.openapi(createRoute({ method: "get", path: "/sites", /* … */ }), async (c) => c.json(/* … */)); }, // 同名任务只能由一个模块注册;未注册的 §3.1-8 任务由 @umt/tasks 空壳兜底 registerTasks(registry, ctx) { registry.register(defineTask({ name: "resolveObservation", version: 1, /* … */ handler: async (t) => { await ctx.db.transaction().execute(async (trx) => { // …写业务表,同事务发事件(payload 按 contracts 校验) await ctx.outbox.emit(trx, EventType.releaseResolved, payload, { idempotencyKey: `release.resolved:${id}` }); }); } })); }, // 消费别的模块发出的事件:type → 任务;同一 type 只能一个模块认领 outboxRoutes: () => ({ "release.resolved": (ev) => ({ task: "evaluateAcquisition", input: { sourceReleaseId: (ev.payload as { sourceReleaseId: string }).sourceReleaseId } }) }), // 能力:规格一律取 contracts,处理器调本模块服务 registerCapabilities(registry, ctx) { registry.register(capabilitySpec("release.search"), async (input, c) => ({ items: [], coverage: [] })); }, // worker 角色的后台循环(qB 轮询、Feed 调度…);引擎与中继已启动 async start(ctx) {}, async stop() {}, }; ``` `ServerContext` 字段:`db`(Kysely)、`config`、`logger`(pino,自动脱敏)、`events`(SSE 总线 `EventBusPort`)、`engine`(TaskEngine:enqueue/signal/cancel/get/list;register* 阶段不要调用)、`outbox`(`emit(trx, type, payload, {idempotencyKey})` + worker 的 `relay`)、`secrets`(SecretStore,W2-A 实现前调用会抛错)、`settings`(读 settings 表并合并缺省)、`capabilities`(CapabilityRegistry)。domain ↔ contracts 的取值转换一律用 `@umt/mapping`。 角色:`all` 单进程时连接引擎会顺带跑引擎迁移;`api` / `worker` 分进程部署时由 `--role migrate` 预先迁移(db + openworkflow),两个进程都以 `runMigrations:false` 连接。 ## 如何写一个功能模块(ADR-006) 外围功能(补字幕这类)按下面的约定接进来,不做插件系统、不做开关。样板是 `modules/subtitles/`。 1. **登记 manifest**:在 `packages/contracts/src/features.ts` 的 `FEATURES` 里加一份(名称、说明、怎么用、订阅的事件、能力、页面入口、插槽、钩子)。它会进智能体系统提示「能力地图」末尾的功能清单;`test/modules.test.ts` 核对模块与清单一一对应。 2. **建模块**:`modules//index.ts` 导出 `ServerModule`,`name` 与 manifest id 相同,写 `manifest: featureManifest("")`,加进 `modules/index.ts`。 3. **订阅广播事件**(事后通知,异步、至少一次):`outboxRoutes` 里写 `"<事件>": (ev) => 任务 | 任务数组 | null`。生命周期事件(`LIFECYCLE_EVENTS`)可以多个模块同时订阅;payload 用 `parseEventPayload` 取,按 ID 回查业务表;订阅任务要幂等(同一事件可能投递多次)。 ```ts outboxRoutes: (ctx) => ({ "organization.finished": (ev) => { const p = parseEventPayload("organization.finished", ev.payload); return p?.outcome === "succeeded" ? { task: "myFollowUp", input: { recordId: p.recordId } } : null; }, }), ``` 4. **注册链式钩子**(同步改写 / 拦截,挂点见 `packages/contracts/src/hooks.ts`): ```ts registerHooks(hooks) { hooks.rewrite("organize.rename", (name, c) => (c.mediaType === "movie" ? name.replace(/ - 1080p/, "") : undefined)); hooks.intercept("organize.intercept", (c) => (c.files.some((f) => f.dst.includes("/Sample/")) ? "目标在 Sample 目录" : null)); }, ``` 钩子可能在业务事务里执行:只做内存里的判断,不发外部请求、不写库;缺省 2 秒超时,抛错 / 超时只跳过自己。 5. **自己发事件**:业务节点在事务里 `ctx.outbox.emit(trx, "<事件>", payload, { idempotencyKey: lifecycleKey("<事件>", id) })`;新事件先在 `contracts/events.ts` 加类型、payload schema,并登记进 `LIFECYCLE_EVENTS`。 6. **前端入口**:在 `apps/web/app/features/.tsx` 用插槽注册(作品页操作区 / 面板区、整理记录行操作、侧栏、设置分区),在 `features/index.ts` 调一次;默认直接显示。 7. **智能体能力**(可选):照常在 contracts 定义能力、在 `registerCapabilities` 挂处理器,并登记 Skill 与能力地图(见仓库根 CLAUDE.md)。