- 保留规则改为声明式(retention.ts 每个任务的 rules),定时任务、立即清理、脚本 dry-run 共用一份条件; 删得多(≥ 1000 行)的表删完 ANALYZE - 引擎 run 单独保留期 engineRun(缺省 14 天):只删终态、过期、没被保留中业务任务引用、父 run 已结束的; step_attempts 级联,运行元数据 / 事实 / 信号跟随 run(原来按时间直接删,长时间运行的 run 会丢元数据) - 业务任务被 agent_run / decision 等引用时不删(外键 set null,删了会断开引用) - 新任务 storage.retention.pipeline(每天 04:40):已投递 outbox 事件(只删无幂等键或一次性幂等键)、 识别尝试(每条资源留最近 3 次)、已识别且资源仍绑定作品的待识别条目(忽略 / 归档不删)、 站点快照(每站最近一次成功的列表快照保留) - settings.storage.retentionDays 追加 engineRun / outboxEvent / resolutionAttempt / identityResolved / siteSnapshot(旧库按缺省补),设置页「系统 → 数据保留」显示 - scripts/db-retention.ts:缺省 dry-run 输出每张表将删行数,--apply 执行,--only 选目标 - 测试:各表按规则删、被引用 / 未结束的不删、dry-run 与删除一致、分批;EXPLAIN 确认改写后的查询走索引 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
@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/<area>/ 目录,以及 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/<area>/index.ts 导出,目录内自由拆 routes.ts / service.ts / handlers/*.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/。
- 登记 manifest:在
packages/contracts/src/features.ts的FEATURES里加一份(名称、说明、怎么用、订阅的事件、能力、页面入口、插槽、钩子)。它会进智能体系统提示「能力地图」末尾的功能清单;test/modules.test.ts核对模块与清单一一对应。 - 建模块:
modules/<id>/index.ts导出ServerModule,name与 manifest id 相同,写manifest: featureManifest("<id>"),加进modules/index.ts。 - 订阅广播事件(事后通知,异步、至少一次):
outboxRoutes里写"<事件>": (ev) => 任务 | 任务数组 | null。生命周期事件(LIFECYCLE_EVENTS)可以多个模块同时订阅;payload 用parseEventPayload取,按 ID 回查业务表;订阅任务要幂等(同一事件可能投递多次)。outboxRoutes: (ctx) => ({ "organization.finished": (ev) => { const p = parseEventPayload("organization.finished", ev.payload); return p?.outcome === "succeeded" ? { task: "myFollowUp", input: { recordId: p.recordId } } : null; }, }), - 注册链式钩子(同步改写 / 拦截,挂点见
packages/contracts/src/hooks.ts):钩子可能在业务事务里执行:只做内存里的判断,不发外部请求、不写库;缺省 2 秒超时,抛错 / 超时只跳过自己。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)); }, - 自己发事件:业务节点在事务里
ctx.outbox.emit(trx, "<事件>", payload, { idempotencyKey: lifecycleKey("<事件>", id) });新事件先在contracts/events.ts加类型、payload schema,并登记进LIFECYCLE_EVENTS。 - 前端入口:在
apps/web/app/features/<id>.tsx用插槽注册(作品页操作区 / 面板区、整理记录行操作、侧栏、设置分区),在features/index.ts调一次;默认直接显示。 - 智能体能力(可选):照常在 contracts 定义能力、在
registerCapabilities挂处理器,并登记 Skill 与能力地图(见仓库根 CLAUDE.md)。