Files
2026-07-13 13:39:12 +08:00

341 lines
8.2 KiB
TypeScript

/**
* Deterministic FSM for Multi-Step Workflows
*
* Orchestrates plan -> review -> execute -> verify using rules, not LLM decisions.
* Risk-based phase skipping: high=all phases, medium=skip planner, low=execute+test only.
*/
export type Phase =
| "classify"
| "plan"
| "plan_review"
| "execute"
| "code_review"
| "quality_review"
| "security"
| "test"
| "output_review"
| "done"
| "failed"
| "paused";
export type RiskLevel = "low" | "medium" | "high";
export type Verdict = "approve" | "approve_with_notes" | "request_changes" | "reject" | "block";
export interface PhaseRecord {
phase: Phase;
enteredAt: string;
exitedAt: string | null;
verdict: Verdict | null;
provider: string | null;
model: string | null;
retryCount: number;
notes: string | null;
}
export interface WorkflowContext {
id: string;
currentPhase: Phase;
risk: RiskLevel;
lastVerdict: Verdict | null;
retries: Record<string, number>;
maxRetries: number;
testsPass: boolean;
history: PhaseRecord[];
createdAt: string;
metadata: Record<string, unknown>;
}
interface Transition {
from: Phase;
to: Phase;
condition: (ctx: WorkflowContext) => boolean;
description: string;
}
const HIGH_KEYWORDS = [
"schema",
"migration",
"deploy",
"delete",
"drop",
"env",
"database",
"refactor",
"security",
"auth",
"production",
"secrets",
"credentials",
"permission",
];
const MED_KEYWORDS = [
"endpoint",
"feature",
"service",
"model",
"api",
"integration",
"webhook",
"middleware",
"route",
];
export function classifyRisk(desc: string): RiskLevel {
const l = desc.toLowerCase();
if (HIGH_KEYWORDS.some((k) => l.includes(k))) return "high";
if (MED_KEYWORDS.some((k) => l.includes(k))) return "medium";
return "low";
}
const PHASE_ORDER: Phase[] = [
"classify",
"plan",
"plan_review",
"execute",
"code_review",
"quality_review",
"security",
"test",
"output_review",
];
const T: Transition[] = [
{
from: "classify",
to: "plan",
condition: (c) => c.risk === "high",
description: "High risk -> full planning",
},
{
from: "classify",
to: "execute",
condition: (c) => c.risk === "medium",
description: "Medium risk -> skip planner",
},
{
from: "classify",
to: "execute",
condition: (c) => c.risk === "low",
description: "Low risk -> direct execute",
},
{ from: "plan", to: "plan_review", condition: () => true, description: "Plan -> review" },
{
from: "plan_review",
to: "execute",
condition: (c) => c.lastVerdict === "approve" || c.lastVerdict === "approve_with_notes",
description: "Plan approved -> execute",
},
{
from: "plan_review",
to: "plan",
condition: (c) =>
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
(c.retries["plan"] ?? 0) < c.maxRetries,
description: "Plan rejected -> retry",
},
{
from: "plan_review",
to: "failed",
condition: (c) =>
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
(c.retries["plan"] ?? 0) >= c.maxRetries,
description: "Plan rejected max retries",
},
{
from: "execute",
to: "code_review",
condition: (c) => c.risk !== "low",
description: "Non-low -> code review",
},
{
from: "execute",
to: "test",
condition: (c) => c.risk === "low",
description: "Low -> skip reviews",
},
{
from: "code_review",
to: "quality_review",
condition: (c) => c.lastVerdict === "approve" || c.lastVerdict === "approve_with_notes",
description: "Code approved -> quality",
},
{
from: "code_review",
to: "execute",
condition: (c) =>
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
(c.retries["execute"] ?? 0) < c.maxRetries,
description: "Code rejected -> re-execute",
},
{
from: "code_review",
to: "failed",
condition: (c) =>
(c.lastVerdict === "reject" || c.lastVerdict === "request_changes") &&
(c.retries["execute"] ?? 0) >= c.maxRetries,
description: "Code rejected max retries",
},
{
from: "quality_review",
to: "security",
condition: (c) => c.risk === "high",
description: "High -> security audit",
},
{
from: "quality_review",
to: "test",
condition: (c) => c.risk !== "high",
description: "Non-high -> skip security",
},
{
from: "security",
to: "failed",
condition: (c) => c.lastVerdict === "block",
description: "Security BLOCK -> failed",
},
{
from: "security",
to: "test",
condition: (c) => c.lastVerdict !== "block",
description: "Security passed -> test",
},
{
from: "test",
to: "output_review",
condition: (c) => c.testsPass,
description: "Tests pass -> output review",
},
{
from: "test",
to: "execute",
condition: (c) => !c.testsPass && (c.retries["execute"] ?? 0) < c.maxRetries,
description: "Tests fail -> re-execute",
},
{
from: "test",
to: "failed",
condition: (c) => !c.testsPass && (c.retries["execute"] ?? 0) >= c.maxRetries,
description: "Tests fail max retries",
},
{
from: "output_review",
to: "done",
condition: () => true,
description: "Output reviewed -> done",
},
];
export function createWorkflow(
id: string,
description: string,
opts?: { maxRetries?: number; metadata?: Record<string, unknown> }
): WorkflowContext {
const risk = classifyRisk(description);
return {
id,
currentPhase: "classify",
risk,
lastVerdict: null,
retries: {},
maxRetries: opts?.maxRetries ?? 3,
testsPass: false,
history: [
{
phase: "classify",
enteredAt: new Date().toISOString(),
exitedAt: null,
verdict: null,
provider: null,
model: null,
retryCount: 0,
notes: `Risk: ${risk}`,
},
],
createdAt: new Date().toISOString(),
metadata: opts?.metadata ?? {},
};
}
export function advance(
ctx: WorkflowContext,
result?: {
verdict?: Verdict;
testsPass?: boolean;
provider?: string;
model?: string;
notes?: string;
}
): Phase | null {
if (result?.verdict != null) ctx.lastVerdict = result.verdict;
if (result?.testsPass != null) ctx.testsPass = result.testsPass;
const cur = ctx.history[ctx.history.length - 1];
if (cur) {
cur.exitedAt = new Date().toISOString();
cur.verdict = result?.verdict ?? null;
cur.provider = result?.provider ?? null;
cur.model = result?.model ?? null;
cur.notes = result?.notes ?? null;
}
for (const t of T) {
if (t.from === ctx.currentPhase && t.condition(ctx)) {
const fi = PHASE_ORDER.indexOf(t.from),
ti = PHASE_ORDER.indexOf(t.to);
if (ti >= 0 && fi >= 0 && ti <= fi) ctx.retries[t.to] = (ctx.retries[t.to] ?? 0) + 1;
ctx.currentPhase = t.to;
ctx.history.push({
phase: t.to,
enteredAt: new Date().toISOString(),
exitedAt: null,
verdict: null,
provider: null,
model: null,
retryCount: ctx.retries[t.to] ?? 0,
notes: t.description,
});
return t.to;
}
}
return null;
}
export function pause(ctx: WorkflowContext, reason: string): void {
ctx.currentPhase = "paused";
ctx.history.push({
phase: "paused",
enteredAt: new Date().toISOString(),
exitedAt: null,
verdict: null,
provider: null,
model: null,
retryCount: 0,
notes: reason,
});
}
export function resume(ctx: WorkflowContext, phase: Phase): void {
const p = ctx.history[ctx.history.length - 1];
if (p) p.exitedAt = new Date().toISOString();
ctx.currentPhase = phase;
ctx.history.push({
phase,
enteredAt: new Date().toISOString(),
exitedAt: null,
verdict: null,
provider: null,
model: null,
retryCount: ctx.retries[phase] ?? 0,
notes: "Resumed",
});
}
export function isTerminated(ctx: WorkflowContext): boolean {
return ctx.currentPhase === "done" || ctx.currentPhase === "failed";
}
export function getPhaseSequence(ctx: WorkflowContext): Phase[] {
return ctx.history.map((r) => r.phase);
}
export function getLLMCallCount(ctx: WorkflowContext): number {
const sys: Phase[] = ["classify", "paused", "done", "failed"];
return ctx.history.filter((r) => !sys.includes(r.phase) && r.exitedAt != null).length;
}