- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
import { sql } from "kysely";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import type { OpenWorkflowEngine } from "../src/index.ts";
|
|
import { createTestRegistry } from "./fixtures/tasks.ts";
|
|
import {
|
|
connectDb,
|
|
createSideEffectTable,
|
|
dropSchemas,
|
|
pgAvailable,
|
|
resetSchemas,
|
|
setupEngine,
|
|
sideEffects,
|
|
type TestDb,
|
|
testSchemas,
|
|
waitFor,
|
|
} from "./helpers.ts";
|
|
|
|
const schemas = testSchemas("engine");
|
|
const hasPg = await pgAvailable();
|
|
|
|
describe.skipIf(!hasPg)("OpenWorkflowEngine(进程内 worker)", () => {
|
|
let db: TestDb;
|
|
let engine: OpenWorkflowEngine;
|
|
|
|
beforeAll(async () => {
|
|
await resetSchemas(schemas);
|
|
db = connectDb(schemas.biz);
|
|
await createSideEffectTable(db);
|
|
engine = await setupEngine(db, schemas, createTestRegistry(db));
|
|
await engine.startWorker({ concurrency: 4 });
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await engine?.stop();
|
|
await db?.destroy();
|
|
await dropSchemas(schemas);
|
|
}, 60_000);
|
|
|
|
it("enqueue 幂等:同 key 返回同一 run,不同 key 新建;traceId 进入 handler", async () => {
|
|
const a = await engine.enqueue("echo", { n: 1 }, { idempotencyKey: "k-1", traceId: "t-1", priority: 5 });
|
|
const b = await engine.enqueue("echo", { n: 2 }, { idempotencyKey: "k-1", traceId: "t-2" });
|
|
const c = await engine.enqueue("echo", { n: 3 }, { idempotencyKey: "k-2", traceId: "t-3" });
|
|
expect(a.deduplicated).toBe(false);
|
|
expect(b).toEqual({ runId: a.runId, deduplicated: true });
|
|
expect(c.runId).not.toBe(a.runId);
|
|
|
|
const done = await waitFor(async () => {
|
|
const v = await engine.get(a.runId);
|
|
return v?.status === "completed" ? v : null;
|
|
}, 15_000);
|
|
expect(done.output).toEqual({ n: 1, trace: "t-1" });
|
|
expect(done).toMatchObject({ taskName: "echo", version: 1, traceId: "t-1", priority: 5, idempotencyKey: "k-1" });
|
|
|
|
const listed = await engine.list({ taskName: "echo" });
|
|
expect(listed.map((r) => r.runId).sort()).toEqual([a.runId, c.runId].sort());
|
|
}, 30_000);
|
|
|
|
it("enqueue 拒绝不合 schema 的输入与未注册任务", async () => {
|
|
await expect(engine.enqueue("echo", { n: "x" }, { idempotencyKey: "bad", traceId: "t" })).rejects.toThrow();
|
|
await expect(engine.enqueue("nope", {}, { idempotencyKey: "bad2", traceId: "t" })).rejects.toThrow(
|
|
/not registered/,
|
|
);
|
|
});
|
|
|
|
it("取消:进行中的长步骤经 abortSignal 中止,后续步骤不执行", async () => {
|
|
const { runId } = await engine.enqueue("cancellable", {}, { idempotencyKey: "c-1", traceId: "t" });
|
|
await waitFor(async () => (await sideEffects(db, runId)).some((s) => s.step === "long"), 15_000, "long start");
|
|
expect(await engine.cancel(runId, "user requested")).toBe(true);
|
|
|
|
await waitFor(
|
|
async () => (await sideEffects(db, runId)).some((s) => s.note === "aborted"),
|
|
5_000,
|
|
"abort propagated",
|
|
);
|
|
const v = await engine.get(runId);
|
|
expect(v?.status).toBe("canceled");
|
|
expect(v?.cancelReason).toBe("user requested");
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
expect((await sideEffects(db, runId)).map((s) => s.step)).not.toContain("after");
|
|
// 已取消再取消:幂等
|
|
expect(await engine.cancel(runId)).toBe(true);
|
|
}, 30_000);
|
|
|
|
it("取消已完成的 run 返回 false", async () => {
|
|
const { runId } = await engine.enqueue("echo", { n: 9 }, { idempotencyKey: "c-done", traceId: "t" });
|
|
await waitFor(async () => (await engine.get(runId))?.status === "completed", 15_000);
|
|
expect(await engine.cancel(runId)).toBe(false);
|
|
}, 30_000);
|
|
|
|
it("waitForFact:事实先于等待到达不丢失;等待中到达的事实经信号唤醒", async () => {
|
|
const { runId } = await engine.enqueue("waitFacts", {}, { idempotencyKey: "f-1", traceId: "t" });
|
|
// 此刻 handler 还在 before 步骤(1.5s),没有等待者,引擎信号会丢,但事实已落库
|
|
const early = await engine.signal(runId, "a", { approved: true });
|
|
expect(early.delivered).toBe(0);
|
|
|
|
await waitFor(async () => (await sideEffects(db, runId)).some((s) => s.step === "got-a"), 15_000, "got-a");
|
|
await waitFor(async () => (await engine.get(runId))?.status === "waiting", 10_000, "waiting on b");
|
|
const late = await engine.signal(runId, "b", 42);
|
|
expect(late.delivered).toBe(1);
|
|
|
|
const v = await waitFor(async () => {
|
|
const r = await engine.get(runId);
|
|
return r?.status === "completed" ? r : null;
|
|
}, 15_000);
|
|
expect(v.output).toEqual({ a: { approved: true }, b: 42 });
|
|
const facts = await sql<{ n: string }>`SELECT count(*) n FROM tasks_fact WHERE run_id = ${runId}`.execute(db);
|
|
expect(Number(facts.rows[0]!.n)).toBe(2);
|
|
}, 45_000);
|
|
});
|