- TaskEngine port + OpenWorkflowEngine ([email protected].1, schema openworkflow): enqueue/signal/cancel/get/list/startWorker/stop - defineTask registry with zod input schema; 9 stub task definitions (§3.1-8) - TaskContext: step.run/sleep/waitForFact, redacting logger, traceId, attemptToken, abortSignal driven by PG cancel flag - signal persists fact to tasks_fact first; waitForFact checks DB before waiting - OutboxRelay: FOR UPDATE SKIP LOCKED, idempotencyKey outbox:<id> - withAttemptToken fencing on tasks_op_ledger (own migration, merged in W2) - tests: kill -9 crash recovery (child worker), cancel, idempotency, concurrent relays, early fact, lease-loss fencing Co-Authored-By: Claude Fable 5.1 <[email protected]>
78 lines
2.9 KiB
TypeScript
78 lines
2.9 KiB
TypeScript
import type { z } from "zod";
|
|
import { UnknownTaskError } from "./errors";
|
|
import type { RetryPolicy, TaskContext } from "./types";
|
|
|
|
export interface TaskDefinition<S extends z.ZodType = z.ZodType, O = unknown> {
|
|
name: string;
|
|
/** 正整数;不兼容改动 +1,并在 worker 上同时注册新旧版本直到旧 run 清空 */
|
|
version: number;
|
|
inputSchema: S;
|
|
/** 步骤默认重试策略(步骤可单独覆盖)。外部网络类建议 5 次 1s→60s,文件类 3 次 */
|
|
retryPolicy?: Partial<RetryPolicy>;
|
|
handler: (ctx: TaskContext<z.output<S>>) => Promise<O>;
|
|
}
|
|
|
|
export function defineTask<S extends z.ZodType, O>(def: TaskDefinition<S, O>): TaskDefinition<S, O> {
|
|
if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(def.name)) throw new Error(`invalid task name: ${def.name}`);
|
|
if (!Number.isInteger(def.version) || def.version < 1) throw new Error(`invalid task version: ${def.version}`);
|
|
return def;
|
|
}
|
|
|
|
export const engineVersion = (version: number) => `v${version}`;
|
|
export const parseEngineVersion = (v: string | null): number | null => {
|
|
const m = v ? /^v(\d+)$/.exec(v) : null;
|
|
return m ? Number(m[1]) : null;
|
|
};
|
|
|
|
export class TaskRegistry {
|
|
private readonly defs = new Map<string, Map<number, TaskDefinition>>();
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: 注册表按名存放异构的任务定义
|
|
register(...defs: TaskDefinition<any, any>[]): this {
|
|
for (const def of defs) {
|
|
let byVersion = this.defs.get(def.name);
|
|
if (!byVersion) {
|
|
byVersion = new Map();
|
|
this.defs.set(def.name, byVersion);
|
|
}
|
|
if (byVersion.has(def.version)) throw new Error(`task ${def.name}@${def.version} already registered`);
|
|
byVersion.set(def.version, def as TaskDefinition);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
get(name: string, version: number): TaskDefinition {
|
|
const def = this.defs.get(name)?.get(version);
|
|
if (!def) throw new UnknownTaskError(name, version);
|
|
return def;
|
|
}
|
|
|
|
/** 新 run 一律用最高版本 */
|
|
latest(name: string): TaskDefinition {
|
|
const byVersion = this.defs.get(name);
|
|
if (!byVersion || byVersion.size === 0) throw new UnknownTaskError(name);
|
|
return byVersion.get(Math.max(...byVersion.keys()))!;
|
|
}
|
|
|
|
names(): string[] {
|
|
return [...this.defs.keys()].sort();
|
|
}
|
|
|
|
all(): TaskDefinition[] {
|
|
return [...this.defs.values()].flatMap((m) => [...m.values()]);
|
|
}
|
|
|
|
/** 按 StartWorkerOptions.versions 选择:"name@2" / "@1" / "name"(该任务全部版本) */
|
|
select(selectors?: string[]): TaskDefinition[] {
|
|
if (!selectors || selectors.length === 0) return this.all();
|
|
const picked = this.all().filter((d) =>
|
|
selectors.some((s) => {
|
|
const [name, ver] = s.split("@");
|
|
return (!name || name === d.name) && (ver === undefined || ver === "" || Number(ver) === d.version);
|
|
}),
|
|
);
|
|
if (picked.length === 0) throw new Error(`no task definitions match ${selectors.join(",")}`);
|
|
return picked;
|
|
}
|
|
}
|