diff --git a/packages/workflow-protocol/__tests__/jsonata-moderator.test.ts b/packages/workflow-protocol/__tests__/jsonata-moderator.test.ts new file mode 100644 index 0000000..461ccc4 --- /dev/null +++ b/packages/workflow-protocol/__tests__/jsonata-moderator.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test"; + +import type { ModeratorRule } from "../src/jsonata-moderator.js"; +import { evaluateModerator } from "../src/jsonata-moderator.js"; +import type { ModeratorContext, StartStep } from "../src/types.js"; +import { END, START } from "../src/types.js"; + +// ── Helpers ───────────────────────────────────────────────────────── + +const BASE_START: StartStep = { + role: START, + content: "test task", + meta: {}, + timestamp: 1000, + parentState: null, +}; + +function makeCtx( + steps: Array<{ role: string; meta: Record }>, + overrides: Partial = {}, +): ModeratorContext { + return { + threadId: "01JTESTTHREADID000", + depth: 0, + bundleHash: "TESTHASH00001", + start: BASE_START, + steps: steps.map((s, i) => ({ + ...s, + contentHash: `hash-${i}`, + refs: [], + timestamp: 2000 + i, + })) as ModeratorContext["steps"], + ...overrides, + }; +} + +// ── Step 1: FALLBACK rule (when=null) always matches ──────────────── + +describe("Step 1: FALLBACK rule", () => { + test("when=null always matches → returns rule.to", async () => { + const rules: ModeratorRule[] = [{ from: START, to: "planner", when: null }]; + const result = await evaluateModerator(rules, makeCtx([])); + expect(result).toBe("planner"); + }); +}); + +// ── Step 2: No matching rules → __end__ ───────────────────────────── + +describe("Step 2: no matching rules", () => { + test("empty steps, no rule for __start__ → __end__", async () => { + const rules: ModeratorRule[] = [{ from: "planner", to: "coder", when: null }]; + const result = await evaluateModerator(rules, makeCtx([])); + expect(result).toBe(END); + }); + + test("current state has no matching from rules → __end__", async () => { + const rules: ModeratorRule[] = [{ from: START, to: "planner", when: null }]; + const result = await evaluateModerator(rules, makeCtx([{ role: "planner", meta: {} }])); + expect(result).toBe(END); + }); +}); + +// ── Step 3: JSONata condition — truthy → match ──────────────────── + +describe("Step 3: JSONata condition evaluation", () => { + test("truthy expression matches and returns rule.to", async () => { + const rules: ModeratorRule[] = [ + { + from: "planner", + to: END, + when: "steps[role='planner'].meta.status = 'aborted'", + }, + { from: "planner", to: "coder", when: null }, + ]; + const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]); + expect(await evaluateModerator(rules, ctx)).toBe(END); + }); + + test("falsy expression skips to next rule (FALLBACK)", async () => { + const rules: ModeratorRule[] = [ + { + from: "planner", + to: END, + when: "steps[role='planner'].meta.status = 'aborted'", + }, + { from: "planner", to: "coder", when: null }, + ]; + const ctx = makeCtx([{ role: "planner", meta: { status: "planned" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); +}); + +// ── Step 4: plannerAborted condition ───────────────────────────── + +describe("Step 4: plannerAborted", () => { + const plannerAbortedRules: ModeratorRule[] = [ + { + from: "planner", + to: END, + when: "steps[role='planner'].meta.status = 'aborted'", + }, + { from: "planner", to: "coder", when: null }, + ]; + + test("planner aborted → __end__", async () => { + const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]); + expect(await evaluateModerator(plannerAbortedRules, ctx)).toBe(END); + }); + + test("planner planned → coder", async () => { + const ctx = makeCtx([{ role: "planner", meta: { status: "planned", phases: [] } }]); + expect(await evaluateModerator(plannerAbortedRules, ctx)).toBe("coder"); + }); +}); + +// ── Step 5: reviewApproved / testsPassed (negative index) ──────── + +describe("Step 5: last-step conditions", () => { + const rules: ModeratorRule[] = [ + { + from: "reviewer", + to: "tester", + when: "steps[-1].meta.status = 'approved'", + }, + { from: "reviewer", to: "coder", when: null }, + { + from: "tester", + to: "committer", + when: "steps[-1].meta.status = 'passed'", + }, + { from: "tester", to: "coder", when: null }, + ]; + + test("reviewer approved → tester", async () => { + const ctx = makeCtx([{ role: "reviewer", meta: { status: "approved" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("tester"); + }); + + test("reviewer changes-requested → coder", async () => { + const ctx = makeCtx([{ role: "reviewer", meta: { status: "changes-requested" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); + + test("tester passed → committer", async () => { + const ctx = makeCtx([{ role: "tester", meta: { status: "passed" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("committer"); + }); + + test("tester failed → coder", async () => { + const ctx = makeCtx([{ role: "tester", meta: { status: "failed" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); +}); + +// ── Step 6: allPhasesComplete condition ────────────────────────── + +describe("Step 6: allPhasesComplete", () => { + // The JSONata expression checks if all planned phase hashes appear in coder completedPhase values. + // Approximation using $count approach: + // Use JSONata 'in' operator inside $filter to check if each planned phase hash + // appears in the coder completedPhase values. + const allPhasesExpr = + "$count(steps[role='planner'].meta.phases) = 0 or $count(steps[role='planner'].meta.phases ~> $filter(function($p) { $p.hash in steps[role='coder'].meta.completedPhase })) >= $count(steps[role='planner'].meta.phases)"; + + const rules: ModeratorRule[] = [ + { from: "coder", to: "reviewer", when: allPhasesExpr }, + { from: "coder", to: "coder", when: null }, + ]; + + test("no phases (empty array) → all complete → reviewer", async () => { + const ctx = makeCtx([ + { role: "planner", meta: { status: "planned", phases: [] } }, + { role: "coder", meta: { completedPhase: "PHASE001" } }, + ]); + expect(await evaluateModerator(rules, ctx)).toBe("reviewer"); + }); + + test("all phases completed → reviewer", async () => { + const ctx = makeCtx([ + { + role: "planner", + meta: { + status: "planned", + phases: [{ hash: "PHASE001" }, { hash: "PHASE002" }], + }, + }, + { role: "coder", meta: { completedPhase: "PHASE001" } }, + { role: "coder", meta: { completedPhase: "PHASE002" } }, + ]); + expect(await evaluateModerator(rules, ctx)).toBe("reviewer"); + }); + + test("not all phases completed → coder (loop)", async () => { + const ctx = makeCtx([ + { + role: "planner", + meta: { + status: "planned", + phases: [{ hash: "PHASE001" }, { hash: "PHASE002" }], + }, + }, + { role: "coder", meta: { completedPhase: "PHASE001" } }, + ]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); +}); + +// ── Step 7: Full develop workflow routing table ─────────────────── + +describe("Step 7: full develop workflow routing", () => { + const allPhasesExpr = + "$count(steps[role='planner'].meta.phases) = 0 or $count(steps[role='planner'].meta.phases ~> $filter(function($p) { $p.hash in steps[role='coder'].meta.completedPhase })) >= $count(steps[role='planner'].meta.phases)"; + + const rules: ModeratorRule[] = [ + { from: START, to: "planner", when: null }, + { from: "planner", to: END, when: "steps[role='planner'].meta.status = 'aborted'" }, + { from: "planner", to: "coder", when: null }, + { from: "coder", to: "reviewer", when: allPhasesExpr }, + { from: "coder", to: "coder", when: null }, + { from: "reviewer", to: "tester", when: "steps[-1].meta.status = 'approved'" }, + { from: "reviewer", to: "coder", when: null }, + { from: "tester", to: "committer", when: "steps[-1].meta.status = 'passed'" }, + { from: "tester", to: "coder", when: null }, + { from: "committer", to: END, when: null }, + ]; + + test("initial state → planner", async () => { + expect(await evaluateModerator(rules, makeCtx([]))).toBe("planner"); + }); + + test("planner planned, no phases → coder", async () => { + const ctx = makeCtx([{ role: "planner", meta: { status: "planned", phases: [] } }]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); + + test("planner aborted → __end__", async () => { + const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]); + expect(await evaluateModerator(rules, ctx)).toBe(END); + }); + + test("coder completed single phase → reviewer", async () => { + const ctx = makeCtx([ + { role: "planner", meta: { status: "planned", phases: [{ hash: "PH1" }] } }, + { role: "coder", meta: { completedPhase: "PH1" } }, + ]); + expect(await evaluateModerator(rules, ctx)).toBe("reviewer"); + }); + + test("coder incomplete phases → coder", async () => { + const ctx = makeCtx([ + { + role: "planner", + meta: { status: "planned", phases: [{ hash: "PH1" }, { hash: "PH2" }] }, + }, + { role: "coder", meta: { completedPhase: "PH1" } }, + ]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); + + test("reviewer approved → tester", async () => { + const ctx = makeCtx([{ role: "reviewer", meta: { status: "approved" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("tester"); + }); + + test("reviewer rejected → coder", async () => { + const ctx = makeCtx([{ role: "reviewer", meta: { status: "changes-requested" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); + + test("tester passed → committer", async () => { + const ctx = makeCtx([{ role: "tester", meta: { status: "passed" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("committer"); + }); + + test("tester failed → coder", async () => { + const ctx = makeCtx([{ role: "tester", meta: { status: "failed" } }]); + expect(await evaluateModerator(rules, ctx)).toBe("coder"); + }); + + test("committer done → __end__", async () => { + const ctx = makeCtx([{ role: "committer", meta: {} }]); + expect(await evaluateModerator(rules, ctx)).toBe(END); + }); +}); diff --git a/packages/workflow-protocol/package.json b/packages/workflow-protocol/package.json index 8262a95..bb81851 100644 --- a/packages/workflow-protocol/package.json +++ b/packages/workflow-protocol/package.json @@ -28,5 +28,8 @@ }, "publishConfig": { "access": "public" + }, + "dependencies": { + "jsonata": "^2.2.0" } } diff --git a/packages/workflow-protocol/src/index.ts b/packages/workflow-protocol/src/index.ts index 9e30a79..9ae5b42 100644 --- a/packages/workflow-protocol/src/index.ts +++ b/packages/workflow-protocol/src/index.ts @@ -54,3 +54,8 @@ export { END, START } from "./types.js"; // ── Constructor functions ────────────────────────────────────────── export { err, ok } from "./result.js"; + +// ── JSONata moderator ────────────────────────────────────────────── + +export type { ModeratorRule } from "./jsonata-moderator.js"; +export { evaluateModerator } from "./jsonata-moderator.js"; diff --git a/packages/workflow-protocol/src/jsonata-moderator.ts b/packages/workflow-protocol/src/jsonata-moderator.ts new file mode 100644 index 0000000..bc3144a --- /dev/null +++ b/packages/workflow-protocol/src/jsonata-moderator.ts @@ -0,0 +1,66 @@ +import jsonata from "jsonata"; + +import type { ModeratorContext, RoleMeta, StartStep } from "./types.js"; +import { END, START } from "./types.js"; + +// ── Types ─────────────────────────────────────────────────────────── + +export type ModeratorRule = { + from: string; + to: string; + when: string | null; +}; + +type JsonataContext = { + threadId: string; + depth: number; + start: StartStep; + steps: ReadonlyArray<{ role: string; meta: Record }>; +}; + +// ── Evaluator ─────────────────────────────────────────────────────── + +/** + * Evaluate a JSONata-based moderator rule set against the given thread context. + * Returns the next role name or '__end__'. + */ +export async function evaluateModerator( + rules: ReadonlyArray, + context: ModeratorContext, +): Promise { + const lastStep = context.steps.length > 0 ? context.steps[context.steps.length - 1] : null; + const currentState: string = lastStep ? lastStep.role : START; + + const matching = rules.filter((r) => r.from === currentState); + + const jsonataCtx: JsonataContext = { + threadId: context.threadId, + depth: context.depth, + start: context.start, + steps: context.steps as ReadonlyArray<{ role: string; meta: Record }>, + }; + + for (const rule of matching) { + if (rule.when === null) { + return rule.to; + } + + const expr = jsonata(rule.when); + const result = await expr.evaluate(jsonataCtx); + + if (result) { + return rule.to; + } + } + + return END; +} + +// ── Context helper ────────────────────────────────────────────────── + +/** Build a ModeratorContext from its constituent parts (convenience for tests / callers). */ +export function makeModeratorContext( + ctx: ModeratorContext, +): ModeratorContext { + return ctx; +}