feat: 生命周期广播事件与链式钩子设施(ADR-006)
- contracts:补齐生命周期广播事件(订阅、下载、整理结束、刮削、字幕、作品关注 / 元数据 / 季集结构、站点可达性) 的类型与 payload schema,LIFECYCLE_EVENTS 清单与 lifecycleKey 幂等键;HOOK_POINTS 链式钩子挂点; FEATURES 功能清单与 SLOT_POSITIONS 前端插槽位置 - outbox 中继支持一条事件路由给多个订阅方(其余订阅方用 <键>#<任务名>);生命周期事件允许多模块订阅, 没有订阅方时也被中继认领并镜像到 SSE - 服务端 HookRegistry:按注册顺序执行、rewrite / intercept 两类、单钩子超时与异常隔离、无注册零开销; ServerModule 增加 manifest 与 registerHooks,bootstrap 装配 ctx.hooks - ADR-006 与 server README「如何写一个功能模块」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
0f656f0f58
commit
1db689d9a7
16 files changed
+1121
-22
No files matched your search
@@ -10,6 +10,7 @@ src/
|
||||
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 清单
|
||||
@@ -64,3 +65,31 @@ export const sourcesIdentityModule: ServerModule = {
|
||||
`ServerContext` 字段:`db`(Kysely<DB>)、`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/<id>/index.ts` 导出 `ServerModule`,`name` 与 manifest id 相同,写 `manifest: featureManifest("<id>")`,加进 `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/<id>.tsx` 用插槽注册(作品页操作区 / 面板区、整理记录行操作、侧栏、设置分区),在 `features/index.ts` 调一次;默认直接显示。
|
||||
7. **智能体能力**(可选):照常在 contracts 定义能力、在 `registerCapabilities` 挂处理器,并登记 Skill 与能力地图(见仓库根 CLAUDE.md)。
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { type EventType, parseEventPayload } from "@umt/contracts";
|
||||
import { type EventType, LIFECYCLE_EVENTS, parseEventPayload } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import {
|
||||
createDefaultRegistry,
|
||||
type LogSink,
|
||||
OpenWorkflowEngine,
|
||||
type OutboxRelay,
|
||||
type OutboxRoute,
|
||||
type OutboxRouteFn,
|
||||
type TaskEngine,
|
||||
TaskRegistry,
|
||||
} from "@umt/tasks";
|
||||
import type { Logger } from "pino";
|
||||
import type { Config } from "./config.ts";
|
||||
import { HookRegistry } from "./hooks/registry.ts";
|
||||
import type { EventBusPort } from "./http/sse.ts";
|
||||
import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts";
|
||||
import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts";
|
||||
@@ -97,24 +99,66 @@ export function buildTaskRegistry(modules: readonly ServerModule[], ctx: ServerC
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** 合并各模块的 outbox 路由;同一事件 type 只能由一个模块认领 */
|
||||
const BROADCAST_TYPES = new Set<string>(LIFECYCLE_EVENTS.map((e) => e.type));
|
||||
|
||||
/**
|
||||
* 合并各模块的 outbox 路由。生命周期广播事件(LIFECYCLE_EVENTS,ADR-006)可以有多个订阅方,按模块顺序合成一个路由:
|
||||
* 各订阅方各自返回任务(或 null),合起来交给中继逐个入队;某个订阅方的路由函数抛错只记日志,不连累别人。
|
||||
* 其它事件(内部流程,如 release.observed → resolveObservation)仍只能由一个模块认领。
|
||||
*/
|
||||
export function collectOutboxRoutes(
|
||||
modules: readonly ServerModule[],
|
||||
ctx: ServerContext,
|
||||
): Record<string, OutboxRouteFn> {
|
||||
const routes: Record<string, OutboxRouteFn> = {};
|
||||
const owner = new Map<string, string>();
|
||||
const subscribers = new Map<string, { module: string; fn: OutboxRouteFn }[]>();
|
||||
for (const m of modules) {
|
||||
for (const [type, fn] of Object.entries(m.outboxRoutes?.(ctx) ?? {})) {
|
||||
const prev = owner.get(type);
|
||||
if (prev) throw new Error(`outbox 事件 ${type} 被模块 ${prev} 与 ${m.name} 重复认领`);
|
||||
owner.set(type, m.name);
|
||||
routes[type] = fn;
|
||||
const prev = subscribers.get(type) ?? [];
|
||||
if (prev.length && !BROADCAST_TYPES.has(type))
|
||||
throw new Error(`outbox 事件 ${type} 被模块 ${prev[0]!.module} 与 ${m.name} 重复认领`);
|
||||
subscribers.set(type, [...prev, { module: m.name, fn }]);
|
||||
}
|
||||
}
|
||||
const routes: Record<string, OutboxRouteFn> = {};
|
||||
for (const [type, subs] of subscribers) {
|
||||
if (subs.length === 1) {
|
||||
routes[type] = subs[0]!.fn;
|
||||
continue;
|
||||
}
|
||||
routes[type] = (ev) => {
|
||||
const out: OutboxRoute[] = [];
|
||||
for (const s of subs) {
|
||||
let r: ReturnType<OutboxRouteFn>;
|
||||
try {
|
||||
r = s.fn(ev);
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{
|
||||
type,
|
||||
module: s.module,
|
||||
eventId: ev.id,
|
||||
err: { message: err instanceof Error ? err.message : String(err) },
|
||||
},
|
||||
"outbox 订阅方路由出错,已跳过",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (r === null) continue;
|
||||
out.push(...((Array.isArray(r) ? r : [r]) as OutboxRoute[]));
|
||||
}
|
||||
return out.length ? out : null;
|
||||
};
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
/** 链式钩子注册表:按模块顺序调 registerHooks,执行顺序即注册顺序(ADR-006) */
|
||||
export function buildHookRegistry(modules: readonly ServerModule[], ctx: ServerContext): HookRegistry {
|
||||
const hooks = new HookRegistry(ctx.logger?.child({ component: "hooks" }));
|
||||
for (const m of modules) m.registerHooks?.(hooks.forModule(m.name), ctx);
|
||||
return hooks;
|
||||
}
|
||||
|
||||
export interface BootstrapOptions {
|
||||
role: ServerRole;
|
||||
config: Config;
|
||||
@@ -158,6 +202,7 @@ export async function bootstrap(opts: BootstrapOptions): Promise<Bootstrapped> {
|
||||
};
|
||||
const tasks = buildTaskRegistry(opts.modules, ctx);
|
||||
for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx);
|
||||
ctx.hooks = buildHookRegistry(opts.modules, ctx);
|
||||
// 装配了 agent 模块(即完整模块清单)时,首批 14 个能力必须全部挂上处理器
|
||||
if (opts.modules.some((m) => m.name === "agent")) assertCapabilityCount(ctx.capabilities);
|
||||
engine.attach(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { HookPoint } from "@umt/contracts";
|
||||
import type { ServerContext } from "../modules/types.ts";
|
||||
import type { HookContext, HookValue, InterceptPoint, RewritePoint } from "./points.ts";
|
||||
import type { HookRejection } from "./registry.ts";
|
||||
|
||||
export type * from "./points.ts";
|
||||
export {
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
type HookOptions,
|
||||
type HookRegistrar,
|
||||
HookRegistry,
|
||||
type HookRejection,
|
||||
} from "./registry.ts";
|
||||
|
||||
// 业务节点调用钩子的入口。ctx.hooks 由 bootstrap 装配;测试里手搭的上下文没有它,等同于没有注册。
|
||||
|
||||
/** 这个挂点有没有钩子(上下文构造代价大时先问一下) */
|
||||
export function hasHook(ctx: Pick<ServerContext, "hooks">, point: HookPoint): boolean {
|
||||
return ctx.hooks?.has(point) ?? false;
|
||||
}
|
||||
|
||||
/** 改写:没有钩子时原样返回同一个值;accept 判定单个钩子的结果能不能用(不能用的跳过) */
|
||||
export async function rewriteHook<P extends RewritePoint>(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
point: P,
|
||||
value: HookValue<P>,
|
||||
context: HookContext<P> | (() => HookContext<P>),
|
||||
accept?: (v: HookValue<P>) => boolean,
|
||||
): Promise<HookValue<P>> {
|
||||
const h = ctx.hooks;
|
||||
if (!h?.has(point)) return value;
|
||||
return h.runRewrite(point, value, typeof context === "function" ? context() : context, accept);
|
||||
}
|
||||
|
||||
/** 拦截:返回第一个拒绝,没有钩子或都放行时返回 null */
|
||||
export async function interceptHook<P extends InterceptPoint>(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
point: P,
|
||||
context: HookContext<P> | (() => HookContext<P>),
|
||||
): Promise<HookRejection | null> {
|
||||
const h = ctx.hooks;
|
||||
if (!h?.has(point)) return null;
|
||||
return h.runIntercept(point, typeof context === "function" ? context() : context);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { HOOK_POINTS, HookPoint } from "@umt/contracts";
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
|
||||
// 链式钩子挂点的入参 / 返回值类型(挂点清单与语义在 contracts/hooks.ts,ADR-006)。
|
||||
// rewrite 挂点:value 是要改写的值,context 只读;intercept 挂点只有 context。
|
||||
|
||||
/** 资源选择的候选:只能重排或过滤,不能加入新候选(按 sourceReleaseId 映射回原资源) */
|
||||
export interface SelectionCandidate {
|
||||
sourceReleaseId: string;
|
||||
title: string;
|
||||
parsed: ParsedRelease;
|
||||
}
|
||||
|
||||
export interface HookPointTypes {
|
||||
"name.recognize": {
|
||||
value: ParsedRelease;
|
||||
context: {
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
/** 来源资源;按名称识别整理文件时为 null */
|
||||
sourceReleaseId: string | null;
|
||||
trigger: string;
|
||||
};
|
||||
};
|
||||
"organize.rename": {
|
||||
/** 目标文件名(不含目录);返回值不能含路径分隔符 */
|
||||
value: string;
|
||||
context: {
|
||||
targetPath: string;
|
||||
sourcePath: string;
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
season: number | null;
|
||||
episodes: { start: number; end: number } | null;
|
||||
};
|
||||
};
|
||||
"organize.intercept": {
|
||||
value: never;
|
||||
context: {
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
action: string;
|
||||
trigger: string;
|
||||
downloadTaskId: string | null;
|
||||
files: { src: string; dst: string; kind: string; role: "main" | "sidecar" }[];
|
||||
};
|
||||
};
|
||||
"resource.selection": {
|
||||
value: SelectionCandidate[];
|
||||
context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null };
|
||||
};
|
||||
"subscription.completion.check": {
|
||||
value: never;
|
||||
context: {
|
||||
subscriptionId: string;
|
||||
mediaEntityId: string | null;
|
||||
mediaKind: string;
|
||||
/** 本次判定完成的原因(slot_satisfied / download_accepted) */
|
||||
reason: string;
|
||||
progress: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type KindOf<P extends HookPoint> = (typeof HOOK_POINTS)[P]["kind"];
|
||||
export type RewritePoint = { [P in HookPoint]: KindOf<P> extends "rewrite" ? P : never }[HookPoint];
|
||||
export type InterceptPoint = { [P in HookPoint]: KindOf<P> extends "intercept" ? P : never }[HookPoint];
|
||||
|
||||
export type HookValue<P extends HookPoint> = HookPointTypes[P]["value"];
|
||||
export type HookContext<P extends HookPoint> = Readonly<HookPointTypes[P]["context"]>;
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
/** 返回新值;返回 undefined 表示不改 */
|
||||
export type RewriteHook<P extends RewritePoint> = (
|
||||
value: HookValue<P>,
|
||||
context: HookContext<P>,
|
||||
) => MaybePromise<HookValue<P> | undefined>;
|
||||
/** 返回拒绝原因;返回 null / undefined 表示放行 */
|
||||
export type InterceptHook<P extends InterceptPoint> = (
|
||||
context: HookContext<P>,
|
||||
) => MaybePromise<string | null | undefined>;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { HOOK_POINTS, type HookPoint } from "@umt/contracts";
|
||||
import type { Logger } from "pino";
|
||||
import type { HookContext, HookValue, InterceptHook, InterceptPoint, RewriteHook, RewritePoint } from "./points.ts";
|
||||
|
||||
// 链式钩子(ADR-006,参考 MoviePilot ChainEventType):模块启动时注册,业务节点同步调用。
|
||||
// - 按注册顺序执行(模块顺序 = SERVER_MODULES 顺序,同一模块内按调用顺序);
|
||||
// - 每个钩子单独限时(缺省 2 秒),超时或抛错只记日志、跳过这个钩子,不影响主流程;
|
||||
// - 没有注册时 has() 为 false,调用方直接用原值(零开销)。
|
||||
// 钩子可能在业务事务里被调用:只做内存里的判断与改写,不要发长耗时外部请求、不要写库。
|
||||
|
||||
export const DEFAULT_HOOK_TIMEOUT_MS = 2_000;
|
||||
|
||||
export interface HookOptions {
|
||||
/** 单次执行上限,缺省 DEFAULT_HOOK_TIMEOUT_MS */
|
||||
timeoutMs?: number;
|
||||
/** 日志里显示的名字,缺省 <模块>#<序号> */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface HookRegistrar {
|
||||
rewrite<P extends RewritePoint>(point: P, fn: RewriteHook<P>, opts?: HookOptions): void;
|
||||
intercept<P extends InterceptPoint>(point: P, fn: InterceptHook<P>, opts?: HookOptions): void;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
owner: string;
|
||||
name: string;
|
||||
timeoutMs: number;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 按挂点分表存,调用时由 run* 的类型参数约束
|
||||
fn: (...args: any[]) => unknown;
|
||||
}
|
||||
|
||||
const TIMEOUT = Symbol("timeout");
|
||||
const FAILED = Symbol("failed");
|
||||
|
||||
export interface HookRejection {
|
||||
reason: string;
|
||||
/** 拒绝的钩子名 */
|
||||
by: string;
|
||||
}
|
||||
|
||||
export class HookRegistry {
|
||||
private readonly hooks = new Map<HookPoint, Entry[]>();
|
||||
|
||||
constructor(private readonly logger?: Pick<Logger, "warn">) {}
|
||||
|
||||
/** 给某个模块的注册入口(owner 进日志) */
|
||||
forModule(owner: string): HookRegistrar {
|
||||
const add = (point: HookPoint, kind: "rewrite" | "intercept", fn: Entry["fn"], opts: HookOptions = {}) => {
|
||||
if (HOOK_POINTS[point].kind !== kind)
|
||||
throw new Error(`钩子挂点 ${point} 是 ${HOOK_POINTS[point].kind} 类,不能用 ${kind} 注册`);
|
||||
const list = this.hooks.get(point) ?? [];
|
||||
list.push({
|
||||
owner,
|
||||
name: opts.name ?? `${owner}#${list.filter((e) => e.owner === owner).length + 1}`,
|
||||
timeoutMs: opts.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
|
||||
fn,
|
||||
});
|
||||
this.hooks.set(point, list);
|
||||
};
|
||||
return {
|
||||
rewrite: (point, fn, opts) => add(point, "rewrite", fn, opts),
|
||||
intercept: (point, fn, opts) => add(point, "intercept", fn, opts),
|
||||
};
|
||||
}
|
||||
|
||||
has(point: HookPoint): boolean {
|
||||
return (this.hooks.get(point)?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 已注册的钩子(挂点 → 钩子名),给功能清单与诊断用 */
|
||||
list(): Record<string, string[]> {
|
||||
return Object.fromEntries([...this.hooks].map(([p, l]) => [p, l.map((e) => e.name)]));
|
||||
}
|
||||
|
||||
/** 依次改写;某个钩子超时 / 抛错 / 结果不被 accept 接受时跳过它,沿用上一个值 */
|
||||
async runRewrite<P extends RewritePoint>(
|
||||
point: P,
|
||||
value: HookValue<P>,
|
||||
context: HookContext<P>,
|
||||
accept?: (v: HookValue<P>) => boolean,
|
||||
): Promise<HookValue<P>> {
|
||||
let cur = value;
|
||||
for (const e of this.hooks.get(point) ?? []) {
|
||||
const r = await this.call(point, e, [cur, context]);
|
||||
if (r === undefined || r === TIMEOUT || r === FAILED) continue;
|
||||
if (accept && !accept(r as HookValue<P>)) {
|
||||
this.logger?.warn({ point, hook: e.name }, "钩子返回的结果不可用,已跳过");
|
||||
continue;
|
||||
}
|
||||
cur = r as HookValue<P>;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** 依次判定;第一个返回原因的钩子拒绝,后面的不再执行 */
|
||||
async runIntercept<P extends InterceptPoint>(point: P, context: HookContext<P>): Promise<HookRejection | null> {
|
||||
for (const e of this.hooks.get(point) ?? []) {
|
||||
const r = await this.call(point, e, [context]);
|
||||
if (typeof r === "string" && r.trim()) return { reason: r.trim(), by: e.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async call(point: HookPoint, e: Entry, args: unknown[]): Promise<unknown> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const r = await Promise.race([
|
||||
Promise.resolve().then(() => e.fn(...args)),
|
||||
new Promise<typeof TIMEOUT>((resolve) => {
|
||||
timer = setTimeout(() => resolve(TIMEOUT), e.timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (r === TIMEOUT) this.logger?.warn({ point, hook: e.name, timeoutMs: e.timeoutMs }, "钩子超时,已跳过");
|
||||
return r;
|
||||
} catch (err) {
|
||||
this.logger?.warn(
|
||||
{ point, hook: e.name, err: { message: err instanceof Error ? err.message : String(err) } },
|
||||
"钩子出错,已跳过",
|
||||
);
|
||||
return FAILED;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { CapabilityRegistry } from "@umt/agent";
|
||||
import type { EventPayload, EventType, GlobalSettings, SecretPurpose, SettingsKey } from "@umt/contracts";
|
||||
import type {
|
||||
EventPayload,
|
||||
EventType,
|
||||
FeatureManifest,
|
||||
GlobalSettings,
|
||||
SecretPurpose,
|
||||
SettingsKey,
|
||||
} from "@umt/contracts";
|
||||
import type { DB, Kysely, Transaction } from "@umt/db";
|
||||
import type { OutboxRelay, OutboxRouteFn, TaskEngine, TaskRegistry } from "@umt/tasks";
|
||||
import type { Logger } from "pino";
|
||||
import type { Config } from "../config.ts";
|
||||
import type { HookRegistrar, HookRegistry } from "../hooks/registry.ts";
|
||||
import type { AuthEnv } from "../http/auth.ts";
|
||||
import type { EventBusPort } from "../http/sse.ts";
|
||||
|
||||
@@ -72,6 +80,11 @@ export interface ServerContext {
|
||||
settings: SettingsReader;
|
||||
/** 能力注册中心(Web/Pi/CLI/MCP 共用一条 invoke 路径) */
|
||||
capabilities: CapabilityRegistry;
|
||||
/**
|
||||
* 链式钩子(ADR-006):bootstrap 装配,模块在 registerHooks 里注册;业务节点经 hooks/index.ts 的
|
||||
* rewriteHook / interceptHook 调用。测试里手搭的上下文可以没有(等同于没有注册)。
|
||||
*/
|
||||
hooks?: HookRegistry;
|
||||
}
|
||||
|
||||
export interface ServerModule {
|
||||
@@ -84,8 +97,15 @@ export interface ServerModule {
|
||||
* 同名任务只能由一个模块注册;没人注册的 §3.1-8 任务由 @umt/tasks 的空壳兜底。
|
||||
*/
|
||||
registerTasks(registry: TaskRegistry, ctx: ServerContext): void;
|
||||
/** 所有角色:outbox 事件 type → 任务路由;同一 type 只能由一个模块认领 */
|
||||
/**
|
||||
* 所有角色:outbox 事件 type → 任务路由。生命周期广播事件(ADR-006)可以被多个模块订阅,
|
||||
* 中继按模块顺序把每个订阅方的路由都投递一遍;返回 null 表示这条事件本模块不处理。
|
||||
*/
|
||||
outboxRoutes?(ctx: ServerContext): Record<string, OutboxRouteFn>;
|
||||
/** 外围功能的说明(功能清单只给智能体看,不做开关);取 contracts 的 featureManifest(id) */
|
||||
manifest?: FeatureManifest;
|
||||
/** 所有角色:注册链式钩子(同步、按注册顺序、可改写或拦截;见 hooks/registry.ts) */
|
||||
registerHooks?(hooks: HookRegistrar, ctx: ServerContext): void;
|
||||
/** 所有角色:用 contracts 的 capabilitySpec(id) 注册能力处理器 */
|
||||
registerCapabilities?(registry: CapabilityRegistry, ctx: ServerContext): void;
|
||||
/** worker / all 角色:启动轮询器、定时器等后台循环(引擎与中继已启动) */
|
||||
|
||||
@@ -18,8 +18,20 @@ import {
|
||||
/**
|
||||
* outbox → SSE 桥:这些前缀的 outbox 事件在中继投递成功后镜像到实时总线(payload 原样,带业务 id),
|
||||
* 首页、收件箱、任务、来源与下载页据此失效刷新。feed.due 是调度器内部节拍,不推给浏览器。
|
||||
* 镜像列表同时决定中继认领哪些没有路由的事件:生命周期广播事件都在这里,否则没有订阅方时会一直挂在 pending。
|
||||
*/
|
||||
const SSE_MIRROR_PREFIXES = ["feed.", "decision.", "download.", "organization.", "subscription."] as const;
|
||||
const SSE_MIRROR_PREFIXES = [
|
||||
"feed.",
|
||||
"decision.",
|
||||
"download.",
|
||||
"organization.",
|
||||
"subscription.",
|
||||
// 生命周期广播事件(ADR-006):没有订阅方时也要被中继认领(标记已投递),顺带推给浏览器
|
||||
"scrape.",
|
||||
"subtitle.",
|
||||
"media.",
|
||||
"site.",
|
||||
] as const;
|
||||
const SSE_MIRROR_EXCLUDED = new Set<string>([EventType.feedDue]);
|
||||
export const SSE_MIRRORED_OUTBOX_TYPES: readonly string[] = Object.values(EventType).filter(
|
||||
(t) => SSE_MIRROR_PREFIXES.some((p) => t.startsWith(p)) && !SSE_MIRROR_EXCLUDED.has(t),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HookRegistry, hasHook, interceptHook, rewriteHook } from "../../src/hooks/index.ts";
|
||||
|
||||
// 链式钩子(ADR-006):注册顺序、改写、拦截、抛错隔离、超时、没有注册时零开销。
|
||||
|
||||
const warnings = () => {
|
||||
const warn = vi.fn();
|
||||
return { logger: { warn } as never, warn };
|
||||
};
|
||||
const parsed = (title: string) => ({ title, raw: title, explicitIds: [] }) as unknown as ParsedRelease;
|
||||
const recognizeCtx = { title: "x", subtitle: null, sourceReleaseId: null, trigger: "observed" };
|
||||
const completionCtx = {
|
||||
subscriptionId: "s",
|
||||
mediaEntityId: null,
|
||||
mediaKind: "movie",
|
||||
reason: "slot_satisfied",
|
||||
progress: {},
|
||||
};
|
||||
|
||||
describe("链式钩子注册表", () => {
|
||||
it("按注册顺序执行(bootstrap 按模块顺序注册),每个钩子拿到上一个的结果;缺省名 <模块>#<序号>", async () => {
|
||||
const reg = new HookRegistry();
|
||||
const seen: string[] = [];
|
||||
const a = reg.forModule("a");
|
||||
const b = reg.forModule("b");
|
||||
a.rewrite("organize.rename", (name) => {
|
||||
seen.push(`a1:${name}`);
|
||||
return `${name}-a1`;
|
||||
});
|
||||
b.rewrite("organize.rename", (name) => {
|
||||
seen.push(`b:${name}`);
|
||||
return `${name}-b`;
|
||||
});
|
||||
a.rewrite("organize.rename", (name) => {
|
||||
seen.push(`a2:${name}`);
|
||||
return undefined; // 不改
|
||||
});
|
||||
const ctx = { hooks: reg };
|
||||
const out = await rewriteHook(ctx, "organize.rename", "X.mkv", {
|
||||
targetPath: "/lib/X.mkv",
|
||||
sourcePath: "/src/X.mkv",
|
||||
mediaEntityId: "e",
|
||||
mediaType: "movie",
|
||||
season: null,
|
||||
episodes: null,
|
||||
});
|
||||
expect(out).toBe("X.mkv-a1-b");
|
||||
expect(seen).toEqual(["a1:X.mkv", "b:X.mkv-a1", "a2:X.mkv-a1-b"]);
|
||||
expect(reg.list()).toEqual({ "organize.rename": ["a#1", "b#1", "a#2"] });
|
||||
});
|
||||
|
||||
it("改写生效:异步钩子改写解析结果", async () => {
|
||||
const reg = new HookRegistry();
|
||||
reg.forModule("m").rewrite("name.recognize", async (p) => ({ ...p, title: "改写后" }));
|
||||
const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("原名"), recognizeCtx);
|
||||
expect(out.title).toBe("改写后");
|
||||
});
|
||||
|
||||
it("拦截生效:第一个返回原因的钩子拒绝,后面的不再执行;都放行返回 null", async () => {
|
||||
const reg = new HookRegistry();
|
||||
const later = vi.fn(() => "不该执行");
|
||||
const m = reg.forModule("m");
|
||||
m.intercept("subscription.completion.check", () => null);
|
||||
m.intercept("subscription.completion.check", () => " 还缺特别篇 ", { name: "缺特别篇" });
|
||||
m.intercept("subscription.completion.check", later);
|
||||
expect(await interceptHook({ hooks: reg }, "subscription.completion.check", completionCtx)).toEqual({
|
||||
reason: "还缺特别篇",
|
||||
by: "缺特别篇",
|
||||
});
|
||||
expect(later).not.toHaveBeenCalled();
|
||||
|
||||
const pass = new HookRegistry();
|
||||
pass.forModule("m").intercept("subscription.completion.check", () => undefined);
|
||||
expect(await interceptHook({ hooks: pass }, "subscription.completion.check", completionCtx)).toBeNull();
|
||||
});
|
||||
|
||||
it("抛错隔离:出错的钩子跳过并记日志,前后的钩子照常生效", async () => {
|
||||
const { logger, warn } = warnings();
|
||||
const reg = new HookRegistry(logger);
|
||||
const m = reg.forModule("m");
|
||||
m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+1` }));
|
||||
m.rewrite("name.recognize", () => {
|
||||
throw new Error("插件坏了");
|
||||
});
|
||||
m.rewrite("name.recognize", async () => {
|
||||
throw new Error("异步也坏了");
|
||||
});
|
||||
m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+2` }));
|
||||
const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("t"), recognizeCtx);
|
||||
expect(out.title).toBe("t+1+2");
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn.mock.calls[0]![0]).toMatchObject({
|
||||
point: "name.recognize",
|
||||
hook: "m#2",
|
||||
err: { message: "插件坏了" },
|
||||
});
|
||||
|
||||
// 拦截类抛错按放行处理
|
||||
const r2 = new HookRegistry(logger);
|
||||
r2.forModule("m").intercept("organize.intercept", () => {
|
||||
throw new Error("x");
|
||||
});
|
||||
expect(
|
||||
await interceptHook({ hooks: r2 }, "organize.intercept", {
|
||||
mediaEntityId: "e",
|
||||
mediaType: "movie",
|
||||
action: "copy",
|
||||
trigger: "auto",
|
||||
downloadTaskId: null,
|
||||
files: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("超时:超过上限的钩子跳过(沿用上一个值),不等它跑完", async () => {
|
||||
const { logger, warn } = warnings();
|
||||
const reg = new HookRegistry(logger);
|
||||
const m = reg.forModule("slow");
|
||||
m.rewrite("organize.rename", () => new Promise<string>((r) => setTimeout(() => r("迟到.mkv"), 5_000)), {
|
||||
timeoutMs: 30,
|
||||
});
|
||||
m.rewrite("organize.rename", (n) => `${n}.ok`);
|
||||
const t0 = Date.now();
|
||||
const out = await reg.runRewrite("organize.rename", "A.mkv", {
|
||||
targetPath: "/l/A.mkv",
|
||||
sourcePath: "/s/A.mkv",
|
||||
mediaEntityId: "e",
|
||||
mediaType: "movie",
|
||||
season: null,
|
||||
episodes: null,
|
||||
});
|
||||
expect(out).toBe("A.mkv.ok");
|
||||
expect(Date.now() - t0).toBeLessThan(2_000);
|
||||
expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "slow#1", timeoutMs: 30 });
|
||||
});
|
||||
|
||||
it("没有注册时零开销:不构造上下文、原样返回同一个对象;没有注册表(手搭上下文)同样", async () => {
|
||||
const ctxBuilder = vi.fn(() => recognizeCtx);
|
||||
const p = parsed("t");
|
||||
const empty = new HookRegistry();
|
||||
// 别的挂点有钩子不影响这里
|
||||
empty.forModule("m").intercept("organize.intercept", () => "x");
|
||||
expect(hasHook({ hooks: empty }, "name.recognize")).toBe(false);
|
||||
expect(await rewriteHook({ hooks: empty }, "name.recognize", p, ctxBuilder)).toBe(p);
|
||||
expect(await rewriteHook({}, "name.recognize", p, ctxBuilder)).toBe(p);
|
||||
expect(await interceptHook({}, "subscription.completion.check", () => completionCtx)).toBeNull();
|
||||
expect(ctxBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("调用方给了 accept 时,结果不可用的钩子跳过,后面的钩子拿到的是上一个可用值", async () => {
|
||||
const { logger, warn } = warnings();
|
||||
const reg = new HookRegistry(logger);
|
||||
const m = reg.forModule("m");
|
||||
m.rewrite("organize.rename", (n) => `../${n}`);
|
||||
m.rewrite("organize.rename", (n) => `${n}.ok`);
|
||||
const c = {
|
||||
targetPath: "/a",
|
||||
sourcePath: "/b",
|
||||
mediaEntityId: "e",
|
||||
mediaType: "movie" as const,
|
||||
season: null,
|
||||
episodes: null,
|
||||
};
|
||||
expect(await rewriteHook({ hooks: reg }, "organize.rename", "A.mkv", c, (v) => !v.includes("/"))).toBe("A.mkv.ok");
|
||||
expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "m#1" });
|
||||
});
|
||||
|
||||
it("用错挂点类型注册直接报错", () => {
|
||||
const reg = new HookRegistry();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 故意绕过类型检查
|
||||
const m = reg.forModule("m") as any;
|
||||
expect(() => m.rewrite("organize.intercept", () => "x")).toThrow(/intercept 类/);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ const UNIT = [
|
||||
"test/search/work-match.test.ts",
|
||||
"test/auth.test.ts",
|
||||
"test/health.test.ts",
|
||||
"test/hooks/hook-registry.test.ts",
|
||||
"test/hooks/resource-selection.test.ts",
|
||||
"test/sources-identity/identity-differential.test.ts",
|
||||
"test/sources-identity/enrich-sources.test.ts",
|
||||
"test/invoke-limits.test.ts",
|
||||
|
||||
Reference in new issue
Block a user