Files
UHD_Media_Tool/apps/server/src/context.ts
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

172 lines
6.4 KiB
TypeScript

import { type EventType, parseEventPayload } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import {
createDefaultRegistry,
type LogSink,
OpenWorkflowEngine,
type OutboxRelay,
type OutboxRouteFn,
type TaskEngine,
TaskRegistry,
} from "@umt/tasks";
import type { Logger } from "pino";
import type { Config } from "./config.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";
import type { OutboxPort, ServerContext, ServerModule, ServerRole } from "./modules/types.ts";
// 进程装配:ServerContext、任务注册表、outbox 路由。api / worker / all 三种角色共用。
/** 引擎在任务注册表建好后才能连接;先给模块一个会转发的句柄 */
export class DeferredTaskEngine implements TaskEngine {
private inner: TaskEngine | null = null;
attach(engine: TaskEngine) {
this.inner = engine;
}
get ready(): boolean {
return this.inner !== null;
}
private get e(): TaskEngine {
if (!this.inner) throw new Error("任务引擎尚未就绪(模块不要在 register* 阶段调用引擎)");
return this.inner;
}
enqueue: TaskEngine["enqueue"] = (...a) => this.e.enqueue(...a);
signal: TaskEngine["signal"] = (...a) => this.e.signal(...a);
cancel: TaskEngine["cancel"] = (...a) => this.e.cancel(...a);
get: TaskEngine["get"] = (...a) => this.e.get(...a);
list: TaskEngine["list"] = (...a) => this.e.list(...a);
startWorker: TaskEngine["startWorker"] = (...a) => this.e.startWorker(...a);
stop: TaskEngine["stop"] = async () => {
await this.inner?.stop();
};
}
export function createOutboxPort(): OutboxPort & { setRelay(r: OutboxRelay): void } {
let relay: OutboxRelay | null = null;
return {
get relay() {
return relay;
},
setRelay(r) {
relay = r;
},
async emit(executor, type, payload, opts = {}) {
if (parseEventPayload(type as EventType, payload) === null) {
throw new Error(`outbox 事件 ${type} 的 payload 不符合 contracts 定义`);
}
await sql`insert into outbox_event (type, payload, idempotency_key)
values (${type}, ${JSON.stringify(payload)}::jsonb, ${opts.idempotencyKey ?? null})
on conflict (idempotency_key) where idempotency_key is not null do nothing`.execute(executor);
},
};
}
/** 把 pino logger 接到 @umt/tasks 的 LogSink(tasks 自己已脱敏,这里再经 pino 的脱敏) */
export function tasksLogSink(logger: Logger): LogSink {
// r.fields 里已带 component(tasks.engine / tasks.outbox)
return (r) => logger[r.level](r.fields, r.msg);
}
/**
* 任务注册表:各模块注册自己的任务;没人注册的 §3.1-8 任务用 @umt/tasks 的空壳兜底
* (outbox 路由到它们时不至于报"未知任务")。同名任务被两个模块注册会在这里报错。
*/
export function buildTaskRegistry(modules: readonly ServerModule[], ctx: ServerContext): TaskRegistry {
const registry = new TaskRegistry();
const owner = new Map<string, string>();
for (const m of modules) {
const own = new TaskRegistry();
m.registerTasks(own, ctx);
for (const name of own.names()) {
const prev = owner.get(name);
if (prev) throw new Error(`任务 ${name} 被模块 ${prev} 与 ${m.name} 重复注册`);
owner.set(name, m.name);
}
registry.register(...own.all());
}
for (const stub of createDefaultRegistry().all()) if (!registry.has(stub.name)) registry.register(stub);
return registry;
}
/** 合并各模块的 outbox 路由;同一事件 type 只能由一个模块认领 */
export function collectOutboxRoutes(
modules: readonly ServerModule[],
ctx: ServerContext,
): Record<string, OutboxRouteFn> {
const routes: Record<string, OutboxRouteFn> = {};
const owner = new Map<string, string>();
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;
}
}
return routes;
}
export interface BootstrapOptions {
role: ServerRole;
config: Config;
logger: Logger;
db: Kysely<DB>;
events: EventBusPort;
modules: readonly ServerModule[];
/** 连接引擎时是否跑引擎迁移;默认只有 all 角色跑(api/worker 分进程时由 --role migrate 预先迁移) */
engineMigrations?: boolean;
/** 仅测试覆盖:引擎表 schema(默认 openworkflow) */
engineSchema?: string;
/** 仅测试覆盖:不连接真实引擎 */
engine?: TaskEngine;
}
export interface Bootstrapped {
ctx: ServerContext;
tasks: TaskRegistry;
outbox: ReturnType<typeof createOutboxPort>;
engine: DeferredTaskEngine;
}
/** 建 ServerContext → 模块注册任务与能力 → 连接引擎。路由与 worker 由调用方按角色启动 */
export async function bootstrap(opts: BootstrapOptions): Promise<Bootstrapped> {
const { config, logger, db } = opts;
const engine = new DeferredTaskEngine();
const outbox = createOutboxPort();
const settings = createSettingsReader({ db, logger, events: opts.events });
const ctx: ServerContext = {
role: opts.role,
db,
config,
logger,
events: opts.events,
engine,
outbox,
secrets: createSecretStore({ db, masterKey: config.SECRET_MASTER_KEY }),
settings,
// 授权(automation_grant / approval + 自动化总开关)、审计(audit_event)、幂等键与操作账本在这里注入
capabilities: createCapabilityRegistry({ logger, db, settings }),
};
const tasks = buildTaskRegistry(opts.modules, ctx);
for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx);
// 装配了 agent 模块(即完整模块清单)时,首批 14 个能力必须全部挂上处理器
if (opts.modules.some((m) => m.name === "agent")) assertCapabilityCount(ctx.capabilities);
engine.attach(
opts.engine ??
(await OpenWorkflowEngine.connect({
databaseUrl: config.DATABASE_URL,
db,
registry: tasks,
logSink: tasksLogSink(logger),
runMigrations: opts.engineMigrations ?? opts.role === "all",
...(opts.engineSchema ? { schema: opts.engineSchema } : {}),
})),
);
return { ctx, tasks, outbox, engine };
}