feat(server): ServerModule skeleton with shared ServerContext, worker runs engine + outbox relay (B6)

modules/{auth-settings,sources-identity,acquisition-organize,agent} placeholders listed in
SERVER_MODULES; ServerModule registers routes (under /api/v1, authenticated), tasks,
outbox routes, capabilities and background loops. bootstrap() builds ServerContext (db,
config, logger, events, engine, outbox writer, secrets placeholder, settings reader,
capability registry), fills unregistered tasks with stubs and rejects duplicate task or
outbox ownership. worker starts the OpenWorkflow worker, OutboxRelay and module start().
--role migrate also migrates the engine schema; api/worker connect without migrating.
tasks: TaskRegistry.has, migrateEngineSchema.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 19:30:55 +08:00
1 parent ef3998767c
commit 9858b8494d
23 files changed
+829 -22

No files matched your search

+2 -1
View File
@@ -15,6 +15,7 @@
| `OpenWorkflowEngine.connect({ databaseUrl, db, registry })` | `TaskEngine` 实现:`enqueue / signal / cancel / get / list / startWorker / stop` |
| `defineTask` / `TaskRegistry` / `createDefaultRegistry()` | 任务定义与注册表;默认注册表里有 §3.1-8 的 9 个任务(目前是空壳) |
| `OutboxRelay` | 用 `FOR UPDATE SKIP LOCKED` 认领 `outbox_event` 中 `status=pending` 且 `next_attempt_at` 已到的行,按 type 路由后 enqueue(幂等键取行的 `idempotency_key`,缺省 `outbox:<id>`),成功标记 `dispatched` + `published_at`;失败 attempts+1 指数退避(上限 5 分钟),达 `maxAttempts` 置 `dead`;多进程并发时不会重复 |
| `migrateEngineSchema(databaseUrl, schema?)` | 只跑引擎自己的迁移后断开(部署时 `--role migrate`) |
| `withAttemptToken(db, opKey, fn, { commit })` | 操作账本 fencing,表是 `tasks_op_ledger` |
| 表 | `tasks_run_meta`、`tasks_fact`、`tasks_op_ledger` 由 `packages/db` 迁移 `0013_tasks_tables` 建立(类型见 `@umt/db` 的 `DB`);本包不再自带迁移,只通过注入的 Kysely 访问 |
@@ -50,7 +51,7 @@
- 引擎幂等键只在 24h 内有效,而且不比较输入。长期去重靠业务表的唯一约束。
- `priority` 只记录在 `tasks_run_meta` 和 run 输入里,引擎仍按 `available_at` 先后认领,不支持真正的优先级调度。
- 取消只阻止后续步骤,不撤销已完成的外部动作。在已取消的 run 上,handler 抛出的错误会被引擎拒收,只在 worker 日志里出现。
- `runMigrations` 默认 true(幂等)。等并入统一迁移后,启动时改成 false。
- 引擎表(openworkflow schema)由 `migrateEngineSchema(databaseUrl)` 迁移:server 的 `--role migrate` 调用它;`--role all` 单进程连接时顺带迁移,`api`/`worker` 分进程以 `runMigrations:false` 连接。
## 测试
+1
View File
@@ -36,6 +36,7 @@ export {
} from "./op-ledger.ts";
export {
ENGINE_SCHEMA,
migrateEngineSchema,
OpenWorkflowEngine,
type OpenWorkflowEngineOptions,
signalName,
+10 -1
View File
@@ -41,7 +41,7 @@ export interface OpenWorkflowEngineOptions {
registry: TaskRegistry;
/** 固定 "openworkflow";只允许测试覆盖 */
schema?: string;
/** 默认 true(幂等)。W2 并入统一迁移后启动时传 false */
/** 默认 true(幂等)。部署时由 migrateEngineSchema 预先迁移,多进程启动传 false */
runMigrations?: boolean;
logSink?: LogSink;
/** handler 执行期间轮询取消标志的间隔,默认 1000ms */
@@ -50,6 +50,15 @@ export interface OpenWorkflowEngineOptions {
export const signalName = (runId: string, name: string) => `umt:${runId}:${name}`;
/**
* 只跑引擎自己的迁移(openworkflow schema)后断开。部署时由 `--role migrate` 调用,
* 之后 api / worker 进程以 runMigrations:false 连接,避免多进程同时迁移。
*/
export async function migrateEngineSchema(databaseUrl: string, schema = ENGINE_SCHEMA): Promise<void> {
const backend = await BackendPostgres.connect(databaseUrl, { schema, runMigrations: true });
await backend.stop();
}
export class OpenWorkflowEngine implements TaskEngine {
private readonly ow: OpenWorkflow;
private readonly workers: Worker[] = [];
+5
View File
@@ -54,6 +54,11 @@ export class TaskRegistry {
return byVersion.get(Math.max(...byVersion.keys()))!;
}
/** 是否已注册该任务(任一版本) */
has(name: string): boolean {
return (this.defs.get(name)?.size ?? 0) > 0;
}
names(): string[] {
return [...this.defs.keys()].sort();
}
+2
View File
@@ -7,6 +7,8 @@ describe("TaskRegistry", () => {
const reg = createDefaultRegistry();
expect(reg.names()).toEqual([...TASK_NAMES].sort());
for (const n of TASK_NAMES) expect(reg.latest(n).version).toBe(1);
expect(reg.has("ingestFeed")).toBe(true);
expect(reg.has("nope")).toBe(false);
});
it("latest 取最高版本;select 按 name@version / @version 过滤", () => {