Compare commits

..

13 Commits

Author SHA1 Message Date
xiaoju da6bcb10d6 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
2026-05-11 06:25:39 +00:00
xiaoju 6fc97fc8c8 feat(workflow-protocol): add declarative moderator table types and tableToModerator
Add ModeratorCondition, FALLBACK, ModeratorTransition, ModeratorTable types
and tableToModerator converter function. Export from workflow-protocol and
re-export from workflow-runtime for backward compat.

Refs #172
2026-05-11 06:22:24 +00:00
xiaoju 93d9821f64 docs: update CLI skill with serve command, thread status, defaults, env vars
小橘 <xiaoju@shazhou.work>
2026-05-10 01:57:42 +00:00
xiaoju 29367cbe31 chore: remove stray bundle artifacts from repo
小橘 <xiaoju@shazhou.work>
2026-05-10 01:44:40 +00:00
xiaoju ec397aecd3 chore: remove stray bundle artifacts from repo
小橘 <xiaoju@shazhou.work>
2026-05-10 01:42:18 +00:00
xiaoju 2e9d939f8e fix: thread detail API returns correct status instead of source
小橘 <xiaoju@shazhou.work>
2026-05-10 01:39:09 +00:00
xiaoju 064a24f093 fix: no-ctl threads should be failed, not active
小橘 <xiaoju@shazhou.work>
2026-05-10 01:36:14 +00:00
xiaoju fede623a82 dashboard: remove 'All agents' dropdown option, auto-select first agent
小橘 <xiaoju@shazhou.work>
2026-05-09 13:26:11 +00:00
xiaoju 2a52b930b9 chore: raise default maxRounds from 5 to 10 (CLI, matches API default)
小橘 <xiaoju@shazhou.work>
2026-05-09 13:17:57 +00:00
xiaoju bf2f790e6e fix: detect crashed threads even when .running marker is already gone
Check worker PID liveness as final fallback — if worker is dead
and thread has no __end__ node, it crashed.

小橘 <xiaoju@shazhou.work>
2026-05-09 12:52:39 +00:00
xiaoju 08a79b77db fix: SSE sends 'done' event for non-running threads, frontend stops reconnecting
- routes-live: emit 'done' event before closing SSE for non-running threads
- use-sse: handle 'done' event — set completed, disconnect, stop reconnect
- Prevents 'Live' badge flash on failed/completed threads

小橘 <xiaoju@shazhou.work>
2026-05-09 12:49:20 +00:00
xiaoju 22a6200b69 fix: close SSE stream for non-running threads, fix Live badge
- routes-live: check .running marker before keeping SSE open;
  if thread is not running, emit existing records and close
- thread-detail: only show Live badge when connected AND not completed

小橘 <xiaoju@shazhou.work>
2026-05-09 12:45:58 +00:00
xiaoju 7e7f6aa6d6 fix: detect crashed threads by checking worker PID liveness
When .running marker exists but no __end__ in CAS chain,
check if the worker process is actually alive. Dead PID
means the worker crashed without cleanup → status 'failed'.

Fixes #170

小橘 <xiaoju@shazhou.work>
2026-05-09 12:38:18 +00:00
28 changed files with 550 additions and 158 deletions
+1
View File
@@ -4,3 +4,4 @@ bun.lock
*.tgz
tsconfig.tsbuildinfo
.npmrc
@@ -1,4 +1,4 @@
import { statSync, watch } from "node:fs";
import { existsSync, statSync, watch } from "node:fs";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import {
@@ -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;
@@ -307,6 +312,18 @@ export function createLiveRoutes(storageRoot: string): Hono {
return;
}
// If thread is not actively running, emit all records and close — don't keep SSE open
const runningPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.running`);
if (!existsSync(runningPath)) {
eventId.n++;
await stream.writeSSE({
event: "done",
data: JSON.stringify({ reason: "not-running" }),
id: String(eventId.n),
});
return;
}
const controller = new AbortController();
let completed = false;
@@ -6,7 +6,7 @@ import { getGlobalCasDir } from "@uncaged/workflow-util";
import { Hono } from "hono";
import { pathExists } from "../../fs-utils.js";
import type { ResolvedThreadRecord } from "../../thread-scan.js";
import type { HistoricalThreadRow, ResolvedThreadRecord } from "../../thread-scan.js";
import {
listHistoricalThreads,
listRunningThreads,
@@ -36,6 +36,8 @@ async function readStartInfo(
async function buildThreadDetailRecords(
storageRoot: string,
resolved: ResolvedThreadRecord,
runningMarkerPresent: boolean,
statusRow: HistoricalThreadRow,
): Promise<unknown[]> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
@@ -43,13 +45,15 @@ async function buildThreadDetailRecords(
const { name: workflowName, prompt } = await readStartInfo(cas, resolved.start);
const status = await resolveThreadListStatus(storageRoot, statusRow, runningMarkerPresent);
const records: unknown[] = [
{
type: "thread-start",
workflow: workflowName ?? "unknown",
prompt: prompt ?? null,
threadId: resolved.threadId,
status: resolved.source,
status,
timestamp: null,
},
];
@@ -123,7 +127,22 @@ export function createThreadRoutes(storageRoot: string): Hono {
if (resolved === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const records = await buildThreadDetailRecords(storageRoot, resolved);
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
const runningMarkerPresent = await pathExists(runningPath);
const statusRow = {
threadId: resolved.threadId,
hash: resolved.bundleHash,
workflowName: null,
source: resolved.source,
activityTs: 0,
head: resolved.head,
};
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({
+1 -1
View File
@@ -34,7 +34,7 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
let name: string | undefined;
let prompt = "";
let maxRounds = 5;
let maxRounds = 10;
let i = 0;
const first = argv[0];
+24 -1
View File
@@ -85,6 +85,12 @@ ${commandSections.join("\n\n")}
| \`run\` | \`thread run\` | Shortcut to start a thread |
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
### serve
| Command | Args | Description |
|---------|------|-------------|
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
## Typical Workflow
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
@@ -92,6 +98,21 @@ ${commandSections.join("\n\n")}
3. \`uncaged-workflow live --latest\` — attach and watch output
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
## Thread Status
| Status | Meaning |
|--------|---------|
| \`running\` | Worker process is alive (\`.running\` marker + live PID) |
| \`active\` | In \`threads.json\` but not currently running (paused or waiting) |
| \`completed\` | Finished with \`returnCode === 0\` (has \`__end__\` frame in CAS) |
| \`failed\` | Finished with non-zero return code, or worker crashed (dead PID / no ctl) |
## Defaults
| Setting | CLI | HTTP API |
|---------|-----|----------|
| \`maxRounds\` | 10 | 10 |
## Exit Codes
| Code | Meaning |
@@ -103,7 +124,9 @@ ${commandSections.join("\n\n")}
| Variable | Description |
|----------|-------------|
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
| \`WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Same as above (takes priority) |
| \`WORKFLOW_LLM_API_KEY\` | API key for LLM calls during workflow execution |
`;
}
+23
View File
@@ -11,6 +11,7 @@ import { END } from "@uncaged/workflow-runtime";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
import { readWorkerCtl } from "./worker-spawn.js";
async function readWorkflowNameFromStartHash(
storageRoot: string,
@@ -217,8 +218,30 @@ export async function resolveThreadListStatus(
return "completed";
}
if (runningMarkerPresent) {
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
if (ctlResult.ok) {
try {
process.kill(ctlResult.value.pid, 0);
return "running";
} catch {
// Worker PID is dead but .running marker remains — crashed thread
return "failed";
}
}
return "running";
}
// No .running marker + no __end__ + source "active" → check if worker is dead (crashed)
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
if (!ctlResult.ok) {
// No ctl file means worker never registered or was already cleaned up — dead thread
return "failed";
}
try {
process.kill(ctlResult.value.pid, 0);
} catch {
// Worker PID is dead, thread never finished — crashed
return "failed";
}
return "active";
}
+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={{
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect } from "react";
import type { AgentEndpoint } from "../api.ts";
import { listAgents } from "../api.ts";
import { useFetch } from "../hooks.ts";
@@ -13,9 +13,16 @@ type Props = {
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
const { status, data } = useFetch(() => listAgents(), []);
const [expanded, setExpanded] = useState(true);
const agents: AgentEndpoint[] = status === "ok" ? data : [];
// Auto-select first agent when none is selected
useEffect(() => {
if (agent === null && agents.length > 0) {
onAgentChange(agents[0].name);
}
}, [agent, agents, onAgentChange]);
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
@@ -36,49 +43,38 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }:
</div>
{/* Agent selector */}
<div className="border-b" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full text-left px-4 py-2 text-xs font-medium"
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
<label
className="block text-xs font-medium mb-1"
style={{ color: "var(--color-text-muted)" }}
htmlFor="agent-select"
>
{expanded ? "▾" : "▸"} Agents
{agent && (
<span className="ml-2 text-xs" style={{ color: "var(--color-accent)" }}>
({agent})
</span>
Agent
</label>
<select
id="agent-select"
className="w-full rounded px-2 py-1.5 text-xs"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
value={agent ?? ""}
onChange={(e) => onAgentChange(e.target.value || null)}
disabled={status === "loading"}
>
{status === "loading" ? (
<option value="">Loading</option>
) : agents.length === 0 ? (
<option value="">No agents online</option>
) : (
agents.map((a) => (
<option key={a.name} value={a.name}>
{a.status === "online" ? "🟢" : "🔴"} {a.name}
</option>
))
)}
</button>
{expanded && (
<div className="px-2 pb-2 space-y-0.5">
{agents.length === 0 && (
<p className="text-xs px-2 py-1" style={{ color: "var(--color-text-muted)" }}>
{status === "loading" ? "Loading..." : "No agents online"}
</p>
)}
{agents.map((a) => (
<button
type="button"
key={a.name}
onClick={() => onAgentChange(a.name)}
className="w-full text-left px-3 py-1.5 rounded text-xs transition-colors flex items-center gap-2"
style={{
background: agent === a.name ? "var(--color-accent-dim)" : "transparent",
color: agent === a.name ? "#fff" : "var(--color-text-muted)",
}}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background: a.status === "online" ? "var(--color-success)" : "var(--color-error)",
}}
/>
{a.name}
</button>
))}
</div>
)}
</select>
</div>
{/* View navigation */}
@@ -80,7 +80,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
<span>{threadId}</span>
{sse.connected && (
{sse.connected && !sse.completed && (
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
@@ -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,
};
}
@@ -148,6 +148,16 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
}),
);
es.addEventListener("done", () => {
if (cancelled) {
return;
}
completedRef.current = true;
setCompleted(true);
setConnected(false);
cleanupEs();
});
es.onerror = () => {
if (cancelled || completedRef.current) {
return;
@@ -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) {
@@ -0,0 +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";
type TestMeta = {
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,
};
}
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("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("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("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");
});
});
+8
View File
@@ -17,9 +17,13 @@ export type {
ExtractContext,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
ProviderConfig,
ResolvedModel,
Result,
@@ -47,3 +51,7 @@ export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { err, ok } from "./result.js";
// ── Moderator Table ────────────────────────────────────────────────
export { tableToModerator } from "./moderator-table.js";
@@ -0,0 +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;
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;
}
}
return END;
};
}
+22
View File
@@ -169,6 +169,28 @@ export type WorkflowDefinition<M extends RoleMeta> = {
moderator: Moderator<M>;
};
// ── Declarative Moderator Table ────────────────────────────────────
export type ModeratorCondition<M extends RoleMeta> = {
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;
};
export type ModeratorTable<M extends RoleMeta> = Record<
(keyof M & string) | typeof START,
ModeratorTransition<M>[]
>;
// ── Advance Outcome ────────────────────────────────────────────────
export type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
+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";
+7 -1
View File
@@ -11,9 +11,15 @@ export type {
ExtractContext,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
Moderator,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
ProviderConfig,
ResolvedModel,
Result,
RoleDefinition,
RoleMeta,
@@ -31,4 +37,4 @@ export type {
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
export { END, START } from "@uncaged/workflow-protocol";
export { END, START, tableToModerator } from "@uncaged/workflow-protocol";
@@ -0,0 +1,44 @@
/**
* develop bundle entry — 小橘 🍊
*/
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];
if (value === undefined || value === "") {
throw new Error(`missing required env var: ${name}`);
}
return value;
}
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const provider = {
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",
};
const agent = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
});
const extract = createExtract(provider);
const wf = createWorkflow(developWorkflowDefinition, { agent }, extract);
export const descriptor = buildDevelopDescriptor();
export const run = wf.run;
@@ -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);