feat(workflow): add declarative ModeratorTable type and migrate templates

Migrate workflow-template-develop and workflow-template-solve-issue
moderators to use the declarative ModeratorTable/tableToModerator
pattern. Update workflow-runtime re-exports and workflow-execute
engine to use renamed types.

Fixes #172
This commit is contained in:
2026-05-11 03:34:10 +00:00
parent 6fc97fc8c8
commit da6bcb10d6
19 changed files with 330 additions and 278 deletions
@@ -118,7 +118,12 @@ async function emitRecordsForHead(params: {
params.eventId.n++;
await params.stream.writeSSE({
event: "record",
data: JSON.stringify({ type: "workflow-result", returnCode: wf.returnCode, content: wf.summary, timestamp: null }),
data: JSON.stringify({
type: "workflow-result",
returnCode: wf.returnCode,
content: wf.summary,
timestamp: null,
}),
id: String(params.eventId.n),
});
return true;
@@ -137,7 +137,12 @@ export function createThreadRoutes(storageRoot: string): Hono {
activityTs: 0,
head: resolved.head,
};
const records = await buildThreadDetailRecords(storageRoot, resolved, runningMarkerPresent, statusRow);
const records = await buildThreadDetailRecords(
storageRoot,
resolved,
runningMarkerPresent,
statusRow,
);
return c.json({ threadId, records });
});
@@ -16,7 +16,11 @@ import type { ServeOptions } from "./types.js";
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
const HEARTBEAT_INTERVAL_MS = 60_000;
export function startServer(storageRoot: string, options: ServeOptions, agentToken: string | null): void {
export function startServer(
storageRoot: string,
options: ServeOptions,
agentToken: string | null,
): void {
const app = createApp(storageRoot, agentToken);
const server = serve({
+10 -1
View File
@@ -20,7 +20,16 @@ export function App() {
return (
<div className="flex h-screen">
<Sidebar view={view} agent={agent} onViewChange={setView} onAgentChange={setAgent} onLogout={() => { clearApiKey(); setAuthed(false); }} />
<Sidebar
view={view}
agent={agent}
onViewChange={setView}
onAgentChange={setAgent}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
@@ -44,7 +44,10 @@ export function LoginPage({ onLogin }: Props) {
}
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: "var(--color-bg)" }}>
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--color-bg)" }}
>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
@@ -1,10 +1,23 @@
import ReactMarkdown from "react-markdown";
import { useEffect, useState } from "react";
import { createHighlighter, type HighlighterGeneric, type BundledLanguage, type BundledTheme } from "shiki";
import {
createHighlighter,
type HighlighterGeneric,
type BundledLanguage,
type BundledTheme,
} from "shiki";
let highlighterPromise: Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> | null = null;
const LANGS: BundledLanguage[] = ["typescript", "javascript", "json", "yaml", "bash", "python", "markdown"];
const LANGS: BundledLanguage[] = [
"typescript",
"javascript",
"json",
"yaml",
"bash",
"python",
"markdown",
];
function getHighlighter(): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
if (highlighterPromise === null) {
@@ -32,7 +45,9 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
setHtml(null);
}
});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [code, lang]);
if (html !== null) {
@@ -46,7 +61,10 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
}
return (
<pre className="rounded overflow-x-auto text-xs my-2 p-3" style={{ background: "var(--color-bg)" }}>
<pre
className="rounded overflow-x-auto text-xs my-2 p-3"
style={{ background: "var(--color-bg)" }}
>
<code>{code}</code>
</pre>
);
@@ -100,7 +118,8 @@ export function Markdown({ content }: { content: string }) {
</blockquote>
);
},
}}>
}}
>
{content}
</ReactMarkdown>
</div>
@@ -93,9 +93,7 @@ function ResultCard({ record }: { record: WorkflowResultRecord }) {
>
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{success ? "✅" : "❌"}</span>
<span className="font-semibold text-sm">
{success ? "Completed" : "Failed"}
</span>
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
@@ -81,5 +81,12 @@ export function useHashRoute(): {
[navigate, route.agent],
);
return { view: route.view, agent: route.agent, threadId: route.threadId, setView, setAgent, setThreadId };
return {
view: route.view,
agent: route.agent,
threadId: route.threadId,
setView,
setAgent,
setThreadId,
};
}
@@ -211,17 +211,17 @@ async function maybeSupervisorHaltsThread(params: {
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
return null;
}
if (sup.value !== "stop") {
if (sup.value !== "kill") {
return null;
}
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
params.logger("M4QX8VHN", `thread ${params.threadId} killed by supervisor`);
return finalizeThread({
cas: params.cas,
bundleDir: params.bundleDir,
threadId: params.threadId,
startHash: params.startHash,
chain: params.chain,
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
completion: { returnCode: 1, summary: "killed: supervisor detected pathological behavior" },
});
}
@@ -12,12 +12,12 @@ const SUPERVISOR_MAX_REACT_ROUNDS = 4;
const supervisorDecisionSchema = z
.object({
decision: z.enum(["continue", "stop"]),
decision: z.enum(["continue", "kill"]),
})
.meta({
title: "supervisor_decision",
description:
'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.',
'Workflow supervisor decision. "continue" when the thread is making progress or following its normal role sequence; "kill" only when the thread is stuck in an infinite loop, producing no meaningful progress, or has gone off the rails. Normal workflow completion is handled by the moderator — the supervisor should NOT kill a thread just because it looks done.',
});
type SupervisorThreadContext = Record<string, never>;
@@ -63,7 +63,7 @@ export async function runSupervisor(
};
},
systemPromptForStructuredTool: (structuredToolName) =>
`You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`,
`You supervise a multi-step workflow. Your job is to detect pathological situations — NOT to decide when the workflow is "done" (that is the moderator's job). Reply with "continue" when the thread is making progress or following its normal role sequence. Reply with "kill" ONLY when the thread is stuck in an infinite loop, producing repetitive/meaningless output, or has clearly gone off the rails. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"continue"}.`,
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
});
@@ -2,7 +2,7 @@ import type { CasStore } from "@uncaged/workflow-cas";
import type { RoleOutput } from "@uncaged/workflow-runtime";
import type { Result } from "@uncaged/workflow-util";
export type SupervisorDecision = "continue" | "stop";
export type SupervisorDecision = "continue" | "kill";
export type ExecuteThreadIo = {
threadId: string;
+10 -2
View File
@@ -33,7 +33,10 @@ app.use("/api/*", async (c, next) => {
await next();
});
function checkDashboardAuth(c: { req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined }; env: Env["Bindings"] }): boolean {
function checkDashboardAuth(c: {
req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined };
env: Env["Bindings"];
}): boolean {
const bearer = c.req.header("Authorization")?.replace("Bearer ", "");
const query = c.req.query("key");
const key = bearer ?? query;
@@ -45,7 +48,12 @@ app.get("/healthz", (c) => c.json({ ok: true }));
// ── Register / heartbeat ────────────────────────────────────────────
app.post("/register", async (c) => {
const body = await c.req.json<{ name?: string; url?: string; secret?: string; agentToken?: string }>();
const body = await c.req.json<{
name?: string;
url?: string;
secret?: string;
agentToken?: string;
}>();
const { name, url, secret, agentToken } = body;
if (!name || !url) {
@@ -1,158 +1,152 @@
import { describe, expect, test } from "bun:test";
import { tableToModerator } from "../src/moderator-table.js";
import type { ModeratorContext, ModeratorTable, StartStep } from "../src/types.js";
import { END, START } from "../src/types.js";
import type {
ModeratorContext,
ModeratorTable,
RoleMeta,
StartStep,
} from "../src/types.js";
type TestMeta = {
planner: { plan: string };
coder: { code: string };
reviewer: { approved: boolean };
planner: { plan: string };
coder: { code: string };
reviewer: { approved: boolean };
};
function makeCtx(
roles: (keyof TestMeta & string)[],
): ModeratorContext<TestMeta> {
const steps = roles.map((role, i) => ({
role,
meta: {} as TestMeta[typeof role],
contentHash: `hash-${i}`,
refs: [],
timestamp: Date.now() + i,
}));
return {
threadId: "test-thread",
depth: 0,
start: {
role: START,
content: "test",
meta: { maxRounds: 10 },
timestamp: Date.now(),
} as StartStep,
steps,
};
function makeCtx(roles: (keyof TestMeta & string)[]): ModeratorContext<TestMeta> {
const steps = roles.map((role, i) => ({
role,
meta: {} as TestMeta[typeof role],
contentHash: `hash-${i}`,
refs: [],
timestamp: Date.now() + i,
}));
return {
threadId: "test-thread",
depth: 0,
start: {
role: START,
content: "test",
meta: { maxRounds: 10 },
timestamp: Date.now(),
} as StartStep,
steps,
};
}
describe("tableToModerator", () => {
test("START -> role A (FALLBACK) returns A on first call", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("planner");
});
test("START -> role A (FALLBACK) returns A on first call", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("planner");
});
test("condition true wins over FALLBACK", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "always",
description: "always true",
check: () => true,
},
role: "planner",
},
{ condition: "FALLBACK", role: "coder" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("planner");
});
test("condition true wins over FALLBACK", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "always",
description: "always true",
check: () => true,
},
role: "planner",
},
{ condition: "FALLBACK", role: "coder" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("planner");
});
test("condition false falls through to FALLBACK", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "never",
description: "always false",
check: () => false,
},
role: "planner",
},
{ condition: "FALLBACK", role: "coder" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("coder");
});
test("condition false falls through to FALLBACK", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "never",
description: "always false",
check: () => false,
},
role: "planner",
},
{ condition: "FALLBACK", role: "coder" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe("coder");
});
test("no matching transitions returns END", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "never",
description: "always false",
check: () => false,
},
role: "planner",
},
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe(END);
});
test("no matching transitions returns END", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "never",
description: "always false",
check: () => false,
},
role: "planner",
},
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx([]))).toBe(END);
});
test("multi-step: A -> FALLBACK END returns END after A", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: END }],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx(["planner"]))).toBe(END);
});
test("multi-step: A -> FALLBACK END returns END after A", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: END }],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
expect(mod(makeCtx(["planner"]))).toBe(END);
});
test("role not in table returns END", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: "coder" }],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
// coder has empty transitions array
expect(mod(makeCtx(["planner", "coder"]))).toBe(END);
});
test("role not in table returns END", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: "coder" }],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
// coder has empty transitions array
expect(mod(makeCtx(["planner", "coder"]))).toBe(END);
});
test("condition receives ctx", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "has-steps",
description: "checks ctx.steps",
check: (ctx) => ctx.steps.length > 0,
},
role: "coder",
},
{ condition: "FALLBACK", role: "planner" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
// No steps -> condition false -> FALLBACK -> planner
expect(mod(makeCtx([]))).toBe("planner");
});
test("condition receives ctx", () => {
const table: ModeratorTable<TestMeta> = {
[START]: [
{
condition: {
name: "has-steps",
description: "checks ctx.steps",
check: (ctx) => ctx.steps.length > 0,
},
role: "coder",
},
{ condition: "FALLBACK", role: "planner" },
],
planner: [],
coder: [],
reviewer: [],
};
const mod = tableToModerator(table);
// No steps -> condition false -> FALLBACK -> planner
expect(mod(makeCtx([]))).toBe("planner");
});
});
@@ -1,24 +1,22 @@
import type { Moderator, ModeratorTable, RoleMeta } from "./types.js";
import { END, START } from "./types.js";
export function tableToModerator<M extends RoleMeta>(
table: ModeratorTable<M>,
): Moderator<M> {
return (ctx) => {
const lastStep = ctx.steps.length > 0 ? ctx.steps[ctx.steps.length - 1] : null;
const currentRole: string = lastStep ? lastStep.role : START;
export function tableToModerator<M extends RoleMeta>(table: ModeratorTable<M>): Moderator<M> {
return (ctx) => {
const lastStep = ctx.steps.length > 0 ? ctx.steps[ctx.steps.length - 1] : null;
const currentRole: string = lastStep ? lastStep.role : START;
const transitions = (table as Record<string, (typeof table)[string]>)[currentRole];
if (!transitions) {
return END;
}
const transitions = (table as Record<string, (typeof table)[string]>)[currentRole];
if (!transitions) {
return END;
}
for (const transition of transitions) {
if (transition.condition === "FALLBACK" || transition.condition.check(ctx)) {
return transition.role;
}
}
for (const transition of transitions) {
if (transition.condition === "FALLBACK" || transition.condition.check(ctx)) {
return transition.role;
}
}
return END;
};
return END;
};
}
+7 -7
View File
@@ -172,21 +172,21 @@ export type WorkflowDefinition<M extends RoleMeta> = {
// ── Declarative Moderator Table ────────────────────────────────────
export type ModeratorCondition<M extends RoleMeta> = {
name: string;
description: string;
check: (ctx: ModeratorContext<M>) => boolean;
name: string;
description: string;
check: (ctx: ModeratorContext<M>) => boolean;
};
export type FALLBACK = "FALLBACK";
export type ModeratorTransition<M extends RoleMeta> = {
condition: ModeratorCondition<M> | FALLBACK;
role: (keyof M & string) | typeof END;
condition: ModeratorCondition<M> | FALLBACK;
role: (keyof M & string) | typeof END;
};
export type ModeratorTable<M extends RoleMeta> = Record<
(keyof M & string) | typeof START,
ModeratorTransition<M>[]
(keyof M & string) | typeof START,
ModeratorTransition<M>[]
>;
// ── Advance Outcome ────────────────────────────────────────────────
+5 -1
View File
@@ -9,9 +9,13 @@ export type {
ExtractContext,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
Result,
RoleDefinition,
RoleMeta,
@@ -28,4 +32,4 @@ export type {
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
export { END, START, tableToModerator } from "./types.js";
@@ -1,10 +1,10 @@
/**
* develop bundle entry — 小橘 🍊
*/
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { createExtract } from "@uncaged/workflow-execute";
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { createExtract } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
function requireEnv(name: string): string {
const value = process.env[name];
@@ -23,7 +23,8 @@ function optionalEnv(name: string): string | null {
}
const provider = {
baseUrl: optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
baseUrl:
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
};
@@ -1,8 +1,15 @@
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow-runtime";
import {
END,
type ModeratorCondition,
type ModeratorTable,
START,
tableToModerator,
} from "@uncaged/workflow-runtime";
import type { DevelopMeta } from "./roles.js";
// ── Helpers ────────────────────────────────────────────────────────
function coderFinishedAllPlannedPhases(
phases: ReadonlyArray<{ hash: string }>,
coderCompletedPhases: ReadonlyArray<string>,
@@ -22,68 +29,72 @@ function coderFinishedAllPlannedPhases(
return false;
}
function nextAfterCoder(
ctx: ModeratorContext<DevelopMeta>,
maxRounds: number,
): (keyof DevelopMeta & string) | typeof END {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return "reviewer";
}
const phases = plannerStep.meta.phases;
const coderCompletedPhases = ctx.steps
.filter((s) => s.role === "coder")
.map((s) => s.meta.completedPhase);
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
if (allDone) {
return "reviewer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
// ── Conditions ─────────────────────────────────────────────────────
export const developModerator: Moderator<DevelopMeta> = (ctx) => {
const maxRounds = ctx.start.meta.maxRounds;
if (ctx.steps.length === 0) {
return "planner";
}
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") {
return "coder";
}
if (last.role === "coder") {
return nextAfterCoder(ctx, maxRounds);
}
if (last.role === "reviewer") {
if (last.meta.status === "approved") {
return "tester";
const allPhasesComplete: ModeratorCondition<DevelopMeta> = {
name: "allPhasesComplete",
description: "All planned phases have been completed by the coder",
check: (ctx) => {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return true;
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
const phases = plannerStep.meta.phases;
if (!Array.isArray(phases)) {
return true;
}
return END;
}
if (last.role === "tester") {
if (last.meta.status === "passed") {
return "committer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
if (last.role === "committer") {
return END;
}
return END;
const coderCompletedPhases = ctx.steps
.filter((s) => s.role === "coder")
.map((s) => s.meta.completedPhase);
return coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
},
};
const hasRoundsRemaining: ModeratorCondition<DevelopMeta> = {
name: "hasRoundsRemaining",
description: "There are rounds remaining before hitting maxRounds",
check: (ctx) => ctx.steps.length < ctx.start.meta.maxRounds - 1,
};
const reviewApproved: ModeratorCondition<DevelopMeta> = {
name: "reviewApproved",
description: "The last reviewer approved the changes",
check: (ctx) => {
const last = ctx.steps[ctx.steps.length - 1];
return last.role === "reviewer" && last.meta.status === "approved";
},
};
const testsPassed: ModeratorCondition<DevelopMeta> = {
name: "testsPassed",
description: "The last tester reported tests passed",
check: (ctx) => {
const last = ctx.steps[ctx.steps.length - 1];
return last.role === "tester" && last.meta.status === "passed";
},
};
// ── Transition Table ───────────────────────────────────────────────
const table: ModeratorTable<DevelopMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: "coder" }],
coder: [
{ condition: allPhasesComplete, role: "reviewer" },
{ condition: hasRoundsRemaining, role: "coder" },
{ condition: "FALLBACK", role: END },
],
reviewer: [
{ condition: reviewApproved, role: "tester" },
{ condition: hasRoundsRemaining, role: "coder" },
{ condition: "FALLBACK", role: END },
],
tester: [
{ condition: testsPassed, role: "committer" },
{ condition: hasRoundsRemaining, role: "coder" },
{ condition: "FALLBACK", role: END },
],
committer: [{ condition: "FALLBACK", role: END }],
};
export const developModerator = tableToModerator(table);
@@ -1,26 +1,12 @@
import type { Moderator } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow-runtime";
import { END, type ModeratorTable, START, tableToModerator } from "@uncaged/workflow-runtime";
import type { SolveIssueMeta } from "./roles.js";
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
if (ctx.steps.length === 0) {
return "preparer";
}
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "preparer") {
return "developer";
}
if (last.role === "developer") {
return "submitter";
}
if (last.role === "submitter") {
return END;
}
return END;
const table: ModeratorTable<SolveIssueMeta> = {
[START]: [{ condition: "FALLBACK", role: "preparer" }],
preparer: [{ condition: "FALLBACK", role: "developer" }],
developer: [{ condition: "FALLBACK", role: "submitter" }],
submitter: [{ condition: "FALLBACK", role: END }],
};
export const solveIssueModerator = tableToModerator(table);