Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e40e41555b | |||
| 5a7f417899 | |||
| d00f9df2dd | |||
| ff959be3ef | |||
| f45563ee31 | |||
| 2b8cd99100 | |||
| 1ca13e02b2 |
+22
-63
@@ -95,13 +95,14 @@ roles:
|
||||
|
||||
Only review standards compliance. Do NOT test functionality.
|
||||
If rejecting, you MUST explain the specific reason in your output.
|
||||
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
|
||||
output: "Explain your decision with specific file/line references. Frontmatter must include: status (approved or rejected)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
type: boolean
|
||||
required: [approved]
|
||||
status:
|
||||
type: string
|
||||
enum: [approved, rejected]
|
||||
required: [status]
|
||||
tester:
|
||||
description: "Functional correctness verification"
|
||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||
@@ -145,72 +146,30 @@ roles:
|
||||
5. After PR creation, clean up the worktree:
|
||||
- `cd ~/repos/workflow`
|
||||
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: status (committed or hook_failed)."
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
required: [success]
|
||||
conditions:
|
||||
insufficientInfo:
|
||||
description: "Planner determined there's not enough info to proceed"
|
||||
expression: "$last('planner').status = 'insufficient_info'"
|
||||
devFailed:
|
||||
description: "Developer failed to implement"
|
||||
expression: "$last('developer').status = 'failed'"
|
||||
rejected:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
fixCode:
|
||||
description: "Tester found code issues"
|
||||
expression: "$last('tester').status = 'fix_code'"
|
||||
fixSpec:
|
||||
description: "Tester found spec issues"
|
||||
expression: "$last('tester').status = 'fix_spec'"
|
||||
hookFailed:
|
||||
description: "Push hook failed"
|
||||
expression: "$last('committer').success = false"
|
||||
status:
|
||||
type: string
|
||||
enum: [committed, hook_failed]
|
||||
required: [status]
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
prompt: "Analyze the issue and produce an implementation plan."
|
||||
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||
planner:
|
||||
- role: "$END"
|
||||
condition: "insufficientInfo"
|
||||
prompt: "Insufficient information to proceed; end the workflow."
|
||||
- role: "developer"
|
||||
condition: null
|
||||
prompt: "Implement the plan from the planner."
|
||||
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||
ready: { role: "developer", prompt: "Implement the plan from the planner." }
|
||||
developer:
|
||||
- role: "$END"
|
||||
condition: "devFailed"
|
||||
prompt: "Development failed; end the workflow."
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
prompt: "Send the implementation to the reviewer."
|
||||
failed: { role: "$END", prompt: "Development failed; end the workflow." }
|
||||
done: { role: "reviewer", prompt: "Send the implementation to the reviewer." }
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "rejected"
|
||||
prompt: "Reviewer rejected the implementation; fix the issues."
|
||||
- role: "tester"
|
||||
condition: null
|
||||
prompt: "Review passed; run tests on the implementation."
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected the implementation; fix the issues." }
|
||||
approved: { role: "tester", prompt: "Review passed; run tests on the implementation." }
|
||||
tester:
|
||||
- role: "developer"
|
||||
condition: "fixCode"
|
||||
prompt: "Tests found code issues; return to developer."
|
||||
- role: "planner"
|
||||
condition: "fixSpec"
|
||||
prompt: "Tests found spec issues; return to planner."
|
||||
- role: "committer"
|
||||
condition: null
|
||||
prompt: "Tests passed; commit and push the changes."
|
||||
fix_code: { role: "developer", prompt: "Tests found code issues; return to developer." }
|
||||
fix_spec: { role: "planner", prompt: "Tests found spec issues; return to planner." }
|
||||
passed: { role: "committer", prompt: "Tests passed; commit and push the changes." }
|
||||
committer:
|
||||
- role: "developer"
|
||||
condition: "hookFailed"
|
||||
prompt: "Push hook failed; return to developer to fix."
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "Commit succeeded; complete the workflow."
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed; return to developer to fix." }
|
||||
committed: { role: "$END", prompt: "Commit succeeded; complete the workflow." }
|
||||
|
||||
@@ -22,6 +22,8 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
thesis:
|
||||
type: string
|
||||
keyPoints:
|
||||
@@ -30,14 +32,9 @@ roles:
|
||||
type: string
|
||||
caveats:
|
||||
type: string
|
||||
required: [thesis, keyPoints]
|
||||
conditions: {}
|
||||
required: [status, thesis, keyPoints]
|
||||
graph:
|
||||
$START:
|
||||
- role: "analyst"
|
||||
condition: null
|
||||
prompt: "Analyze the topic in the task and produce a structured summary with key points."
|
||||
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
|
||||
analyst:
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "Analysis complete. Finish the workflow."
|
||||
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
|
||||
|
||||
+15
-30
@@ -16,15 +16,16 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
required: [status, argument]
|
||||
for:
|
||||
description: "Argues for the proposition"
|
||||
goal: |
|
||||
@@ -40,38 +41,22 @@ roles:
|
||||
3. If you find yourself genuinely convinced by the other side, you may concede.
|
||||
output: |
|
||||
Provide your argument in the frontmatter.
|
||||
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
|
||||
Otherwise set status to "continue".
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["continue", "conceded"]
|
||||
argument:
|
||||
type: string
|
||||
conceded:
|
||||
type: boolean
|
||||
required: [argument, conceded]
|
||||
conditions:
|
||||
againstConceded:
|
||||
description: "The against side conceded"
|
||||
expression: "$last('against').conceded = true"
|
||||
forConceded:
|
||||
description: "The for side conceded"
|
||||
expression: "$last('for').conceded = true"
|
||||
required: [status, argument]
|
||||
graph:
|
||||
$START:
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Present your opening argument against the proposition."
|
||||
_: { role: "against", prompt: "Present your opening argument against the proposition." }
|
||||
against:
|
||||
- role: "$END"
|
||||
condition: "againstConceded"
|
||||
prompt: "The against side conceded. Debate over."
|
||||
- role: "for"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
|
||||
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
for:
|
||||
- role: "$END"
|
||||
condition: "forConceded"
|
||||
prompt: "The for side conceded. Debate over."
|
||||
- role: "against"
|
||||
condition: null
|
||||
prompt: "Counter the opposing argument. Address their points directly."
|
||||
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
|
||||
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
|
||||
|
||||
+14
-24
@@ -27,11 +27,13 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
repoPath:
|
||||
type: string
|
||||
plan:
|
||||
type: string
|
||||
required: [repoPath, plan]
|
||||
required: [status, repoPath, plan]
|
||||
developer:
|
||||
description: "Implements code changes"
|
||||
goal: "You are a developer agent. You implement code changes according to plans."
|
||||
@@ -50,13 +52,15 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
enum: ["_"]
|
||||
filesChanged:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
required: [filesChanged, summary]
|
||||
required: [status, filesChanged, summary]
|
||||
reviewer:
|
||||
description: "Reviews code changes"
|
||||
goal: "You are a code reviewer. You review implementations for correctness and quality."
|
||||
@@ -71,32 +75,18 @@ roles:
|
||||
frontmatter:
|
||||
type: object
|
||||
properties:
|
||||
approved:
|
||||
type: boolean
|
||||
status:
|
||||
enum: ["approved", "rejected"]
|
||||
comments:
|
||||
type: string
|
||||
required: [approved, comments]
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
required: [status, comments]
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
condition: null
|
||||
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
|
||||
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
|
||||
planner:
|
||||
- role: "developer"
|
||||
condition: null
|
||||
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
|
||||
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
|
||||
developer:
|
||||
- role: "reviewer"
|
||||
condition: null
|
||||
prompt: "Review the developer's implementation against the plan for correctness and quality."
|
||||
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
|
||||
reviewer:
|
||||
- role: "developer"
|
||||
condition: "notApproved"
|
||||
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
|
||||
- role: "$END"
|
||||
condition: null
|
||||
prompt: "The review passed. Complete the workflow."
|
||||
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
|
||||
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
|
||||
|
||||
@@ -70,7 +70,6 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
// Basic structure validation
|
||||
expect(workflow.name).toBe("solve-issue");
|
||||
expect(workflow.roles).toBeDefined();
|
||||
expect(workflow.conditions).toBeDefined();
|
||||
expect(workflow.graph).toBeDefined();
|
||||
|
||||
// Verify committer role exists with required fields
|
||||
@@ -82,7 +81,7 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||
});
|
||||
|
||||
test("committer frontmatter schema should require success field", async () => {
|
||||
test("committer frontmatter schema should require status field", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -91,8 +90,8 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
const frontmatter = workflow.roles.committer?.frontmatter;
|
||||
expect(frontmatter).toBeDefined();
|
||||
expect(frontmatter?.type).toBe("object");
|
||||
expect(frontmatter?.properties?.success).toBeDefined();
|
||||
expect(frontmatter?.properties?.success?.type).toBe("boolean");
|
||||
expect(frontmatter?.required).toContain("success");
|
||||
expect(frontmatter?.properties?.status).toBeDefined();
|
||||
expect(frontmatter?.properties?.status?.enum).toContain("committed");
|
||||
expect(frontmatter?.required).toContain("status");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||
name,
|
||||
description: "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||
@@ -36,7 +35,6 @@ async function createWorkflowYaml(name: string, version: string | null = null):
|
||||
name,
|
||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
const yaml = stringify(payload);
|
||||
@@ -145,7 +143,7 @@ describe("Strategy 2: File Path Resolution", () => {
|
||||
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles and graph");
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BootstrapCapableStore } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
StartEntry,
|
||||
@@ -18,6 +19,11 @@ import {
|
||||
walkChain,
|
||||
} from "./shared.js";
|
||||
|
||||
type TurnData = {
|
||||
index: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all steps in a thread (previously: thread steps)
|
||||
*/
|
||||
@@ -110,6 +116,108 @@ export async function cmdStepFork(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate step detail node from CAS store
|
||||
*/
|
||||
function loadStepDetail(store: BootstrapCapableStore, detailRef: CasRef): Record<string, unknown> {
|
||||
const detailNode = store.get(detailRef);
|
||||
if (detailNode === null) {
|
||||
fail(`detail node not found: ${detailRef}`);
|
||||
}
|
||||
return detailNode.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all turn nodes from CAS store and extract content
|
||||
*/
|
||||
function loadTurnData(store: BootstrapCapableStore, turns: unknown): TurnData[] {
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const turnData: TurnData[] = [];
|
||||
for (const turnRef of turns) {
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (typeof turn.content === "string") {
|
||||
turnData.push({
|
||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||
content: turn.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
return turnData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select turns that fit within quota, working backwards from most recent
|
||||
*/
|
||||
function selectTurnsForQuota(turnData: TurnData[], availableQuota: number): TurnData[] {
|
||||
const selectedTurns: TurnData[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let i = turnData.length - 1; i >= 0; i--) {
|
||||
const turn = turnData[i];
|
||||
if (turn === undefined) continue;
|
||||
|
||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||
const turnBlock = turnHeader + turn.content;
|
||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0;
|
||||
const addCost = turnBlock.length + separatorCost;
|
||||
|
||||
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
selectedTurns.unshift(turn);
|
||||
totalChars += addCost;
|
||||
}
|
||||
|
||||
return selectedTurns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble final markdown output from header and selected turns
|
||||
*/
|
||||
function formatStepMarkdown(
|
||||
stepHash: CasRef,
|
||||
role: string,
|
||||
agent: string,
|
||||
turnData: TurnData[],
|
||||
selectedTurns: TurnData[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`# Step ${stepHash}`);
|
||||
parts.push("");
|
||||
parts.push(`**Role:** ${role}`);
|
||||
parts.push(`**Agent:** ${agent}`);
|
||||
|
||||
if (selectedTurns.length === 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const skippedCount = turnData.length - selectedTurns.length;
|
||||
if (skippedCount > 0) {
|
||||
parts.push("");
|
||||
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
||||
}
|
||||
|
||||
for (const turn of selectedTurns) {
|
||||
parts.push("");
|
||||
parts.push(`## Turn ${turn.index + 1}`);
|
||||
parts.push("");
|
||||
parts.push(turn.content);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a step's agent turns as human-readable markdown with quota enforcement
|
||||
*/
|
||||
@@ -128,103 +236,21 @@ export async function cmdStepRead(
|
||||
}
|
||||
const payload = node.payload as StepNodePayload;
|
||||
|
||||
// Build header section
|
||||
const parts: string[] = [];
|
||||
parts.push(`# Step ${stepHash}`);
|
||||
parts.push("");
|
||||
parts.push(`**Role:** ${payload.role}`);
|
||||
parts.push(`**Agent:** ${payload.agent}`);
|
||||
|
||||
// If no detail, return metadata only
|
||||
if (payload.detail === null) {
|
||||
return parts.join("\n");
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
// Load detail node
|
||||
const detailNode = uwf.store.get(payload.detail);
|
||||
if (detailNode === null) {
|
||||
fail(`detail node not found: ${payload.detail}`);
|
||||
}
|
||||
|
||||
const detail = detailNode.payload as Record<string, unknown>;
|
||||
const turns = detail.turns;
|
||||
|
||||
// If no turns array, return metadata only
|
||||
if (!Array.isArray(turns) || turns.length === 0) {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Load all turn nodes
|
||||
type TurnData = {
|
||||
index: number;
|
||||
content: string;
|
||||
};
|
||||
const turnData: TurnData[] = [];
|
||||
for (const turnRef of turns) {
|
||||
if (typeof turnRef !== "string") {
|
||||
continue;
|
||||
}
|
||||
const turnNode = uwf.store.get(turnRef as CasRef);
|
||||
if (turnNode === null) {
|
||||
continue;
|
||||
}
|
||||
const turn = turnNode.payload as Record<string, unknown>;
|
||||
if (typeof turn.content === "string") {
|
||||
turnData.push({
|
||||
index: typeof turn.index === "number" ? turn.index : turnData.length,
|
||||
content: turn.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
const detail = loadStepDetail(uwf.store, payload.detail);
|
||||
const turnData = loadTurnData(uwf.store, detail.turns);
|
||||
|
||||
if (turnData.length === 0) {
|
||||
return parts.join("\n");
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
}
|
||||
|
||||
// Calculate header length for quota accounting
|
||||
const headerSection = parts.join("\n");
|
||||
const headerLength = headerSection.length;
|
||||
const headerSection = formatStepMarkdown(stepHash, payload.role, payload.agent, [], []);
|
||||
const BUFFER = 200;
|
||||
const availableQuota = quota - headerSection.length - BUFFER;
|
||||
const selectedTurns = selectTurnsForQuota(turnData, availableQuota);
|
||||
|
||||
// Select turns that fit within quota (working backwards from most recent)
|
||||
const BUFFER = 200; // Conservative buffer for structural overhead
|
||||
const availableQuota = quota - headerLength - BUFFER;
|
||||
|
||||
const selectedTurns: TurnData[] = [];
|
||||
let totalChars = 0;
|
||||
|
||||
for (let i = turnData.length - 1; i >= 0; i--) {
|
||||
const turn = turnData[i];
|
||||
if (turn === undefined) continue;
|
||||
|
||||
// Calculate formatted turn length
|
||||
const turnHeader = `## Turn ${turn.index + 1}\n\n`;
|
||||
const turnBlock = turnHeader + turn.content;
|
||||
const separatorCost = selectedTurns.length > 0 ? 2 : 0; // "\n\n" between turns
|
||||
const addCost = turnBlock.length + separatorCost;
|
||||
|
||||
// Check quota - but always include at least one turn
|
||||
if (totalChars + addCost > availableQuota && selectedTurns.length > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
selectedTurns.unshift(turn);
|
||||
totalChars += addCost;
|
||||
}
|
||||
|
||||
// Add skip hint if not all turns fit
|
||||
const skippedCount = turnData.length - selectedTurns.length;
|
||||
if (skippedCount > 0) {
|
||||
parts.push("");
|
||||
parts.push(`_[Earlier turns omitted due to quota. Use --quota to increase.]_`);
|
||||
}
|
||||
|
||||
// Add selected turns
|
||||
for (const turn of selectedTurns) {
|
||||
parts.push("");
|
||||
parts.push(`## Turn ${turn.index + 1}`);
|
||||
parts.push("");
|
||||
parts.push(turn.content);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
return formatStepMarkdown(stepHash, payload.role, payload.agent, turnData, selectedTurns);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
@@ -53,6 +51,7 @@ import {
|
||||
import { materializeWorkflowPayload } from "./workflow.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
const START_ROLE = "$START";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
|
||||
const PL_THREAD_START = "7HNQ4B2X";
|
||||
@@ -670,17 +669,32 @@ function formatThreadReadMarkdown(options: {
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||
const steps: StepContext[] = chronological.map((step) => ({
|
||||
role: step.role,
|
||||
output: expandOutput(uwf, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
content: null, // Moderator doesn't need content
|
||||
}));
|
||||
return { start: chain.start, steps };
|
||||
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
function resolveEvaluateArgs(
|
||||
uwf: UwfStore,
|
||||
chain: ChainState,
|
||||
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||
if (chain.headIsStart) {
|
||||
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
||||
}
|
||||
|
||||
const lastStep = chain.stepsNewestFirst[0];
|
||||
if (lastStep === undefined) {
|
||||
fail("empty step chain");
|
||||
}
|
||||
|
||||
const raw = expandOutput(uwf, lastStep.output);
|
||||
const base =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const status = typeof base.status === "string" ? base.status : "_";
|
||||
|
||||
return {
|
||||
lastRole: lastStep.role,
|
||||
lastOutput: { ...base, status },
|
||||
};
|
||||
}
|
||||
|
||||
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||
@@ -924,9 +938,9 @@ async function cmdThreadStepOnce(
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
const context = buildModeratorContext(uwf, chain);
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
|
||||
const nextResult = await evaluate(workflow, context);
|
||||
const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (!nextResult.ok) {
|
||||
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
||||
}
|
||||
@@ -976,8 +990,11 @@ async function cmdThreadStepOnce(
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
|
||||
const chainAfter = walkChain(uwfAfter, newHead);
|
||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||
const afterResult = await evaluate(workflow, contextAfter);
|
||||
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
|
||||
uwfAfter,
|
||||
chainAfter,
|
||||
);
|
||||
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
|
||||
if (!afterResult.ok) {
|
||||
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
@@ -51,20 +46,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
||||
const result: Record<string, Transition[]> = {};
|
||||
for (const [node, transitions] of Object.entries(graph)) {
|
||||
result[node] = transitions.map((t) => {
|
||||
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
|
||||
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
|
||||
/** Normalize graph: validate each status → target mapping. */
|
||||
function normalizeGraph(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
): Record<string, Record<string, Target>> {
|
||||
const result: Record<string, Record<string, Target>> = {};
|
||||
for (const [node, statusMap] of Object.entries(graph)) {
|
||||
const normalized: Record<string, Target> = {};
|
||||
for (const [status, target] of Object.entries(statusMap)) {
|
||||
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
|
||||
fail(`graph[${node}][${status}] → "${target.role}": prompt is required (non-empty string)`);
|
||||
}
|
||||
return {
|
||||
role: t.role,
|
||||
condition: t.condition ?? null,
|
||||
prompt: t.prompt,
|
||||
normalized[status] = {
|
||||
role: target.role,
|
||||
prompt: target.prompt,
|
||||
};
|
||||
});
|
||||
}
|
||||
result[node] = normalized;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -106,7 +104,6 @@ export async function materializeWorkflowPayload(
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: normalizeGraph(raw.graph),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,23 +30,12 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isConditionDefinition(value: unknown): boolean {
|
||||
function isTarget(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return typeof value.description === "string" && typeof value.expression === "string";
|
||||
}
|
||||
|
||||
function isTransition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
typeof value.prompt === "string" &&
|
||||
value.prompt.trim() !== "" &&
|
||||
(condition === null || condition === undefined || typeof condition === "string")
|
||||
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +51,7 @@ function isGraph(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(
|
||||
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,11 +90,7 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||
!isGraph(raw.graph)
|
||||
) {
|
||||
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("buildClaudeCodePrompt", () => {
|
||||
expect(result).toContain("## Task\nFix the bug");
|
||||
});
|
||||
|
||||
test("includes previous steps as history summary", () => {
|
||||
test("includes previous steps with content on first visit", () => {
|
||||
const ctx = makeCtx({
|
||||
steps: [
|
||||
{
|
||||
@@ -48,18 +48,50 @@ describe("buildClaudeCodePrompt", () => {
|
||||
agent: "hermes",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Create a plan.",
|
||||
content: "Here is my detailed plan for doing X.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = buildClaudeCodePrompt(ctx);
|
||||
expect(result).toContain("## Previous Steps");
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("Step 1: planner");
|
||||
expect(result).toContain("do X");
|
||||
// First visit should include step content
|
||||
expect(result).toContain("Here is my detailed plan for doing X.");
|
||||
});
|
||||
|
||||
test("re-entry shows steps since last visit without content", () => {
|
||||
const ctx = makeCtx({
|
||||
isFirstVisit: false,
|
||||
steps: [
|
||||
{
|
||||
role: "developer",
|
||||
output: '{"status":"done"}',
|
||||
agent: "claude-code",
|
||||
detail: "detail-1",
|
||||
edgePrompt: "Implement.",
|
||||
content: "I implemented everything.",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: '{"approved":false}',
|
||||
agent: "claude-code",
|
||||
detail: "detail-2",
|
||||
edgePrompt: "Review.",
|
||||
content: "Rejected: complexity too high, refactor cmdStepRead.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = buildClaudeCodePrompt(ctx);
|
||||
expect(result).toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("reviewer");
|
||||
expect(result).toContain("approved");
|
||||
});
|
||||
|
||||
test("omits history section when steps array is empty", () => {
|
||||
const result = buildClaudeCodePrompt(makeCtx({ steps: [] }));
|
||||
expect(result).not.toContain("## Previous Steps");
|
||||
expect(result).not.toContain("## What Happened Since Your Last Turn");
|
||||
expect(result).toContain("## Current Instruction");
|
||||
});
|
||||
|
||||
test("works without outputFormatInstruction", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Store } from "@uncaged/json-cas";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
buildContinuationPrompt,
|
||||
buildRolePrompt,
|
||||
createAgent,
|
||||
getCachedSessionId,
|
||||
@@ -18,25 +19,6 @@ const CLAUDE_COMMAND = "claude";
|
||||
const CLAUDE_MAX_TURNS = 90;
|
||||
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
||||
|
||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||
if (steps.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = ["## Previous Steps"];
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (step === undefined) {
|
||||
continue;
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`### Step ${i + 1}: ${step.role}`);
|
||||
lines.push(`Output: ${JSON.stringify(step.output)}`);
|
||||
lines.push(`Agent: ${step.agent}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Assemble system prompt, task, and prior step outputs for Claude Code. */
|
||||
export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
||||
const roleDef = ctx.workflow.roles[ctx.role];
|
||||
@@ -46,11 +28,23 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
||||
parts.push(ctx.outputFormatInstruction, "");
|
||||
}
|
||||
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
|
||||
const historyBlock = buildHistorySummary(ctx.steps);
|
||||
if (historyBlock !== "") {
|
||||
parts.push("", historyBlock);
|
||||
|
||||
if (!ctx.isFirstVisit) {
|
||||
// Re-entry (session will be resumed): show only steps since last visit, meta only
|
||||
parts.push("", buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt));
|
||||
} else if (ctx.steps.length > 0) {
|
||||
// First visit: show all steps with content for recent ones
|
||||
parts.push(
|
||||
"",
|
||||
buildContinuationPrompt(ctx.steps, ctx.role, ctx.edgePrompt, {
|
||||
includeContent: true,
|
||||
quota: 32000,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
|
||||
}
|
||||
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function createApi() {
|
||||
transitions: t.Array(
|
||||
t.Object({
|
||||
target: t.String(),
|
||||
condition: t.Union([t.String(), t.Null()]),
|
||||
status: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type { RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import YAML from "yaml";
|
||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||
|
||||
@@ -11,17 +11,12 @@ async function ensureDir() {
|
||||
}
|
||||
|
||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
const conditionMap = new Map<string, string>();
|
||||
for (const [name, def] of Object.entries(payload.conditions)) {
|
||||
conditionMap.set(name, def.expression);
|
||||
}
|
||||
|
||||
const steps: WorkFlowSteps = [];
|
||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||
const graphTransitions = payload.graph[roleName] ?? [];
|
||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
||||
target: t.role === "$END" ? "END" : t.role,
|
||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
||||
const statusMap = payload.graph[roleName] ?? {};
|
||||
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
|
||||
target: target.role === "$END" ? "END" : target.role,
|
||||
status,
|
||||
}));
|
||||
|
||||
steps.push({
|
||||
@@ -42,11 +37,7 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
|
||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
const conditions: WorkflowPayload["conditions"] = {};
|
||||
const graph: Record<string, Transition[]> = {};
|
||||
|
||||
const expressionToName = new Map<string, string>();
|
||||
let condIdx = 0;
|
||||
const graph: Record<string, Record<string, Target>> = {};
|
||||
|
||||
for (const step of steps) {
|
||||
const r = step.role;
|
||||
@@ -59,43 +50,28 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
frontmatter: "",
|
||||
};
|
||||
|
||||
const transitions: Transition[] = step.transitions.map((t) => {
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition) ?? null;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
conditions[condName] = {
|
||||
description: "",
|
||||
expression: t.condition,
|
||||
};
|
||||
}
|
||||
}
|
||||
const statusMap: Record<string, Target> = {};
|
||||
for (const t of step.transitions) {
|
||||
const targetRole = t.target === "END" ? "$END" : t.target;
|
||||
return {
|
||||
statusMap[t.status] = {
|
||||
role: targetRole,
|
||||
condition: condName,
|
||||
prompt: `Transition to ${targetRole}.`,
|
||||
};
|
||||
});
|
||||
|
||||
graph[r.name] = transitions;
|
||||
}
|
||||
graph[r.name] = statusMap;
|
||||
}
|
||||
|
||||
if (steps.length > 0) {
|
||||
const firstRole = steps[0].role.name;
|
||||
graph.$START = [
|
||||
{
|
||||
graph.$START = {
|
||||
_: {
|
||||
role: firstRole,
|
||||
condition: null,
|
||||
prompt: `Begin workflow at role ${firstRole}.`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
return { name, description, roles, conditions, graph };
|
||||
return { name, description, roles, graph };
|
||||
}
|
||||
|
||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
@@ -125,7 +101,6 @@ export async function createWorkflow(name: string, description: string): Promise
|
||||
name,
|
||||
description,
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||
|
||||
@@ -9,7 +9,7 @@ export type WorkFlowRole = {
|
||||
|
||||
export type WorkFlowTransition = {
|
||||
target: string;
|
||||
condition: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type WorkFlowStep = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
||||
import { GradientEdge, StatusEdge } from "./status";
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
status: StatusEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
|
||||
+24
-52
@@ -6,10 +6,10 @@ import {
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
import type { StatusEdge as StatusEdgeType } from "../type.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
@@ -23,7 +23,7 @@ function GradientPath({
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
hasCondition,
|
||||
hasStatus,
|
||||
selected,
|
||||
}: {
|
||||
id: string;
|
||||
@@ -32,11 +32,11 @@ function GradientPath({
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
hasCondition: boolean | null;
|
||||
hasStatus: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const showLack = !hasStatus;
|
||||
const strokeStyle = selected
|
||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
@@ -68,35 +68,20 @@ function GradientPath({
|
||||
);
|
||||
}
|
||||
|
||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
}}
|
||||
>
|
||||
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
|
||||
else
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionLabelProps = {
|
||||
condition: string | undefined;
|
||||
type StatusLabelProps = {
|
||||
status: string | undefined;
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
onSave: (value: string) => void;
|
||||
};
|
||||
|
||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
||||
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function handleBadgeClick() {
|
||||
setInputValue(condition || "");
|
||||
setInputValue(status || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
@@ -127,6 +112,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||
}, [isOpen]);
|
||||
|
||||
const displayStatus = status?.trim() || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -142,11 +129,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-1 bg-white rounded text-[10px]",
|
||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
||||
displayStatus
|
||||
? "border border-gray-300 text-black"
|
||||
: "border border-dashed text-red-500",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
if
|
||||
{displayStatus ?? "status"}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
@@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
<input
|
||||
type="text"
|
||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
||||
placeholder="输入条件"
|
||||
placeholder="输入状态"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -174,14 +163,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
);
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
export function ConditionalEdge({
|
||||
export function StatusEdge({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
@@ -190,7 +173,7 @@ export function ConditionalEdge({
|
||||
targetPosition,
|
||||
selected,
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
}: EdgeProps<StatusEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -203,13 +186,11 @@ export function ConditionalEdge({
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
|
||||
const allEdges = flow.getEdges();
|
||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
||||
const status = data?.status;
|
||||
|
||||
const condition = data?.condition;
|
||||
function handleSave(value: string) {
|
||||
model.startTransaction();
|
||||
flow.updateEdgeData(id, { condition: value });
|
||||
flow.updateEdgeData(id, { status: value });
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
@@ -222,20 +203,11 @@ export function ConditionalEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : !!condition}
|
||||
hasStatus={!!status?.trim()}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse ? (
|
||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
||||
) : (
|
||||
<ConditionLabel
|
||||
condition={condition}
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
@@ -269,7 +241,7 @@ export function GradientEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={null}
|
||||
hasStatus={true}
|
||||
selected={!!selected}
|
||||
/>
|
||||
);
|
||||
@@ -65,12 +65,12 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
|
||||
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
edge.type = "conditional";
|
||||
edge.data = { condition: "" };
|
||||
edge.type = "status";
|
||||
edge.data = { status: "" };
|
||||
|
||||
const promoted = currentEdges.map((e) => {
|
||||
if (e.source === normalized.source && e.type !== "conditional") {
|
||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
||||
if (e.source === normalized.source && e.type !== "status") {
|
||||
return { ...e, type: "status" as const, data: { status: "_" } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
@@ -34,21 +34,8 @@ export const handlers = define.memoize((use, model) => {
|
||||
return node.type === "start" || node.type === "end";
|
||||
}
|
||||
|
||||
function isFirstConditionalSibling(
|
||||
edge: { id: string; source: string; type: string | null },
|
||||
allEdges: { id: string; source: string; type: string | null }[],
|
||||
): boolean {
|
||||
if (edge.type !== "conditional") return false;
|
||||
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edge.id;
|
||||
}
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
|
||||
if (nodes.some(isProtectedNode)) return false;
|
||||
if (edges.length > 0) {
|
||||
const allEdges = use(edgesModel)[0];
|
||||
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
|
||||
}
|
||||
model.startTransaction();
|
||||
return true;
|
||||
};
|
||||
@@ -56,16 +43,14 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (deletedEdges.length > 0) {
|
||||
const currentEdges = use(edgesModel)[0];
|
||||
const sourcesToCheck = new Set(
|
||||
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
|
||||
deletedEdges.filter((e) => e.type === "status").map((e) => e.source),
|
||||
);
|
||||
|
||||
if (sourcesToCheck.size > 0) {
|
||||
let needsDowngrade = false;
|
||||
const updatedEdges = currentEdges.map((e) => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
||||
const siblings = currentEdges.filter(
|
||||
(s) => s.source === e.source && s.type === "conditional",
|
||||
);
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
|
||||
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
|
||||
if (siblings.length === 1) {
|
||||
needsDowngrade = true;
|
||||
const { data: _, ...rest } = e;
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("transIn", () => {
|
||||
});
|
||||
|
||||
it("4.3 Single step with END transition → edge to end node exists", () => {
|
||||
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
|
||||
const steps = [makeStep("A", [{ status: "_", target: "END" }])];
|
||||
const { edges } = transIn(steps);
|
||||
const endEdge = edges.find((e) => e.target === "end");
|
||||
expect(endEdge).toBeDefined();
|
||||
@@ -44,8 +44,8 @@ describe("transIn", () => {
|
||||
|
||||
it("4.4 Two steps with default transitions chain", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
makeStep("A", [{ status: "_", target: "B" }]),
|
||||
makeStep("B", [{ status: "_", target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// Should have start→A, A→B, B→end
|
||||
@@ -53,15 +53,15 @@ describe("transIn", () => {
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||
// No conditional edges
|
||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
||||
// No status edges for single default transitions
|
||||
expect(edges.every((e) => e.type !== "status")).toBe(true);
|
||||
});
|
||||
|
||||
it("4.5 Step with multiple transitions → conditional edges", () => {
|
||||
it("4.5 Step with multiple transitions → status edges", () => {
|
||||
const steps = [
|
||||
makeStep("A", [
|
||||
{ condition: null, target: "B" },
|
||||
{ condition: "x>0", target: "C" },
|
||||
{ status: "_", target: "B" },
|
||||
{ status: "approved", target: "C" },
|
||||
]),
|
||||
makeStep("B", []),
|
||||
makeStep("C", []),
|
||||
@@ -69,23 +69,35 @@ describe("transIn", () => {
|
||||
const { edges } = transIn(steps);
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
||||
// else-branch has empty condition
|
||||
const elseEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
||||
expect(outEdges.every((e) => e.type === "status")).toBe(true);
|
||||
});
|
||||
|
||||
it("4.5b Multiple transitions include expected status values", () => {
|
||||
const steps = [
|
||||
makeStep("A", [
|
||||
{ status: "_", target: "B" },
|
||||
{ status: "approved", target: "C" },
|
||||
]),
|
||||
makeStep("B", []),
|
||||
makeStep("C", []),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||
const defaultEdge = outEdges.find(
|
||||
(e) => (e as { data?: { status?: string } }).data?.status === "_",
|
||||
);
|
||||
expect(elseEdge).toBeDefined();
|
||||
// if-branch has condition
|
||||
const ifEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
||||
expect(defaultEdge).toBeDefined();
|
||||
const approvedEdge = outEdges.find(
|
||||
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
|
||||
);
|
||||
expect(ifEdge).toBeDefined();
|
||||
expect(approvedEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "END" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
makeStep("A", [{ status: "_", target: "END" }]),
|
||||
makeStep("B", [{ status: "_", target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// start→A and start→B; end has 2 incoming edges
|
||||
@@ -95,8 +107,8 @@ describe("transIn", () => {
|
||||
|
||||
it("4.7 Same role name maps to same node id across steps", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "A" }]),
|
||||
makeStep("A", [{ status: "_", target: "B" }]),
|
||||
makeStep("B", [{ status: "_", target: "A" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
const aId = edges.find((e) => e.source === "start")?.target;
|
||||
|
||||
@@ -33,13 +33,13 @@ function defaultEdge(source: string, target: string): AnyWorkEdge {
|
||||
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
||||
}
|
||||
|
||||
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
||||
function statusEdge(source: string, target: string, status: string): AnyWorkEdge {
|
||||
return {
|
||||
id: `${source}-${target}-cond`,
|
||||
id: `${source}-${target}-status`,
|
||||
source,
|
||||
target,
|
||||
type: "conditional" as const,
|
||||
data: { condition },
|
||||
type: "status" as const,
|
||||
data: { status },
|
||||
animated: true,
|
||||
} as AnyWorkEdge;
|
||||
}
|
||||
@@ -76,36 +76,36 @@ describe("validateRoleNodes (via validate)", () => {
|
||||
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.3 Empty condition on non-first conditional edge → error", () => {
|
||||
it("5.3 Empty status on status edge → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
||||
statusEdge("n1", "n2", "_"),
|
||||
statusEdge("n1", "n3", ""), // empty status → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.message.includes("状态值不能为空"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
|
||||
it("5.4 Mix of status and non-status outgoing → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", "x>0"),
|
||||
statusEdge("n1", "n2", "approved"),
|
||||
defaultEdge("n1", "n3"), // mix → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.message.includes("所有出边必须附带状态"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
|
||||
@@ -118,15 +118,15 @@ describe("validateRoleNodes (via validate)", () => {
|
||||
expect(roleErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
|
||||
it("5.6 Valid role node (1 in, 2 status out with statuses) → no errors", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch
|
||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
||||
statusEdge("n1", "n2", "_"),
|
||||
statusEdge("n1", "n3", "approved"),
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||
import { uuid } from "../utils";
|
||||
import type { WorkFlowStep } from "./type";
|
||||
|
||||
@@ -9,6 +9,7 @@ type Result = {
|
||||
|
||||
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
||||
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
||||
const DEFAULT_STATUS = "_";
|
||||
|
||||
function assignHandles(
|
||||
indices: number[],
|
||||
@@ -50,8 +51,8 @@ function buildNodeMap(
|
||||
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||
if (step.transitions.length <= 1) return step.transitions;
|
||||
return [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
|
||||
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -60,32 +61,32 @@ function buildStepEdges(
|
||||
sourceId: string,
|
||||
step: WorkFlowStep,
|
||||
nameToId: Map<string, string>,
|
||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
||||
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
|
||||
const hasMultiple = step.transitions.length > 1;
|
||||
const sorted = sortTransitions(step);
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
const primaryEdges: AnyWorkEdge[] = [];
|
||||
const statusEdges: AnyWorkEdge[] = [];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
if (hasMultiple || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
if (hasMultiple || t.status !== DEFAULT_STATUS) {
|
||||
const edge: StatusEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
type: "conditional",
|
||||
data: { condition: t.condition ?? "" },
|
||||
type: "status",
|
||||
data: { status: t.status },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
||||
else ifEdges.push(edge);
|
||||
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
|
||||
else statusEdges.push(edge);
|
||||
} else {
|
||||
elseEdges.push({
|
||||
primaryEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
@@ -95,23 +96,23 @@ function buildStepEdges(
|
||||
});
|
||||
}
|
||||
}
|
||||
return { elseEdges, ifEdges };
|
||||
return { primaryEdges, statusEdges };
|
||||
}
|
||||
|
||||
function pushStepEdges(
|
||||
edges: AnyWorkEdge[],
|
||||
elseEdges: AnyWorkEdge[],
|
||||
ifEdges: AnyWorkEdge[],
|
||||
primaryEdges: AnyWorkEdge[],
|
||||
statusEdges: AnyWorkEdge[],
|
||||
idToOrder: Map<string, number>,
|
||||
): void {
|
||||
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||
if (ifEdges.length > 0) {
|
||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
||||
const sorted = [...ifEdges].sort(
|
||||
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||
if (statusEdges.length > 0) {
|
||||
const statusHandles = ["output-top", "output-bottom"] as const;
|
||||
const sorted = [...statusEdges].sort(
|
||||
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||
);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
edges.push({ ...sorted[i], sourceHandle: statusHandles[i % statusHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,8 +165,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
||||
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
|
||||
}
|
||||
|
||||
assignTargetHandles(edges, idToOrder);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
|
||||
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
||||
|
||||
const DEFAULT_STATUS = "_";
|
||||
|
||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||
const nodeMap = new Map<string, AnyWorkNode>();
|
||||
for (const node of nodes) {
|
||||
@@ -43,7 +45,7 @@ function traverse(
|
||||
const roleNode = node as WorkNode<"role">;
|
||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
const target =
|
||||
edge.target === "end"
|
||||
@@ -52,13 +54,12 @@ function traverse(
|
||||
? (targetNode as WorkNode<"role">).data.name
|
||||
: edge.target;
|
||||
|
||||
let condition: string | null = null;
|
||||
if (edge.type === "conditional") {
|
||||
const isElse = outEdges.length >= 2 && index === 0;
|
||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
||||
}
|
||||
const status =
|
||||
edge.type === "status"
|
||||
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
|
||||
: DEFAULT_STATUS;
|
||||
|
||||
return { target, condition };
|
||||
return { target, status };
|
||||
});
|
||||
|
||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||
|
||||
export type ValidationError = {
|
||||
nodeId: string | null;
|
||||
@@ -91,10 +91,10 @@ function validateEndNode(
|
||||
}
|
||||
}
|
||||
|
||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
||||
return conditionalEdges.slice(1).some((edge) => {
|
||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
||||
return !cond;
|
||||
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
|
||||
return statusEdges.some((edge) => {
|
||||
const status = (edge as StatusEdge).data?.status?.trim();
|
||||
return !status;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
|
||||
}
|
||||
if (outEdges.length <= 1) return;
|
||||
|
||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
||||
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
||||
const statusEdges = outEdges.filter((e) => e.type === "status");
|
||||
if (statusEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
|
||||
} else if (hasEmptyStatusOnEdge(statusEdges)) {
|
||||
errors.push({ nodeId: node.id, message: "状态边的状态值不能为空" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ export type WorkNodeType = keyof NodeMap;
|
||||
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
||||
|
||||
export type ConditionalEdgeData = AnyKeyBase & {
|
||||
condition: string;
|
||||
export type StatusEdgeData = AnyKeyBase & {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
||||
export type StatusEdge = Edge<StatusEdgeData, "status">;
|
||||
export type AnyWorkEdge = StatusEdge | Edge;
|
||||
|
||||
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
execute: "制定详细的实施计划和步骤分解",
|
||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||
},
|
||||
transitions: [{ target: "developer", condition: null }],
|
||||
transitions: [{ target: "developer", status: "_" }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
execute: "编写高质量的代码实现",
|
||||
report: "输出变更文件列表和实现摘要",
|
||||
},
|
||||
transitions: [{ target: "reviewer", condition: null }],
|
||||
transitions: [{ target: "reviewer", status: "_" }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||
},
|
||||
transitions: [
|
||||
{ target: "END", condition: null },
|
||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
||||
{ target: "END", status: "approved" },
|
||||
{ target: "developer", status: "rejected" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,312 +1,122 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { evaluate } from "../src/evaluate.js";
|
||||
|
||||
const solveIssueWorkflow: WorkflowPayload = {
|
||||
name: "solve-issue",
|
||||
description: "End-to-end issue resolution",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Creates implementation plan",
|
||||
goal: "You are a planning agent.",
|
||||
capabilities: ["planning"],
|
||||
procedure: "Create a step-by-step plan.",
|
||||
output: "Output the plan and steps.",
|
||||
frontmatter: "5GWKR8TN1V3JA",
|
||||
},
|
||||
developer: {
|
||||
description: "Implements code changes",
|
||||
goal: "You are a developer agent.",
|
||||
capabilities: ["coding"],
|
||||
procedure: "Implement the plan.",
|
||||
output: "List files changed and summary.",
|
||||
frontmatter: "8CNWT4KR6D1HV",
|
||||
},
|
||||
reviewer: {
|
||||
description: "Reviews code changes",
|
||||
goal: "You are a code reviewer.",
|
||||
capabilities: ["code-review"],
|
||||
procedure: "Review the implementation.",
|
||||
output: "Approve or reject with comments.",
|
||||
frontmatter: "1VPBG9SM5E7WK",
|
||||
},
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
},
|
||||
conditions: {
|
||||
needsClarification: {
|
||||
description: "Planner requests clarification from user",
|
||||
expression: "$exists($last('planner').needsClarification)",
|
||||
},
|
||||
rejected: {
|
||||
description: "Reviewer rejected the implementation",
|
||||
expression: "$last('reviewer').approved = false",
|
||||
},
|
||||
planner: {
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Start planning from the issue in the task.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: "needsClarification",
|
||||
prompt: "Clarification is needed; hand off to developer.",
|
||||
},
|
||||
{ role: "$END", condition: null, prompt: "Planning complete; end workflow." },
|
||||
],
|
||||
developer: [
|
||||
{
|
||||
role: "reviewer",
|
||||
condition: null,
|
||||
prompt: "Implementation done; send to reviewer.",
|
||||
},
|
||||
],
|
||||
reviewer: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: "rejected",
|
||||
prompt: "Reviewer rejected; return to developer.",
|
||||
},
|
||||
{ role: "$END", condition: null, prompt: "Review passed; end workflow." },
|
||||
],
|
||||
developer: {
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||
},
|
||||
reviewer: {
|
||||
approved: { role: "$END", prompt: "Done." },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||
},
|
||||
};
|
||||
|
||||
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
||||
return {
|
||||
start: {
|
||||
workflow: "4KNM2PXR3B1QW",
|
||||
prompt: "Fix the login bug",
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (fallback)", async () => {
|
||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||
test("$START → first role (unit status _)", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
});
|
||||
});
|
||||
|
||||
test("condition match (rejected → developer)", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("status-based routing (reviewer rejected → developer)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
status: "rejected",
|
||||
comments: "missing tests",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Reviewer rejected; return to developer." },
|
||||
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||
});
|
||||
});
|
||||
|
||||
test("fallback when condition does not match → $END", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: true },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("status-based routing (reviewer approved → $END)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Review passed; end workflow." },
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing role in graph → error", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "unknown-role",
|
||||
output: {},
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("missing role in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("output expansion in context works with JSONata", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { needsClarification: true },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("missing status in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||
}
|
||||
});
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Clarification is needed; hand off to developer." },
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("$last returns most recent matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
devFailed: {
|
||||
description: "Developer failed",
|
||||
expression: "$last('developer').status = 'failed'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "Begin development.",
|
||||
},
|
||||
],
|
||||
developer: [
|
||||
{ role: "$END", condition: "devFailed", prompt: "Development failed; end." },
|
||||
{
|
||||
role: "reviewer",
|
||||
condition: null,
|
||||
prompt: "Development succeeded; review.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "failed" },
|
||||
detail: "3QNTH7WK8D2PA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
test("mustache does not HTML-escape prompt content", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
status: "rejected",
|
||||
comments: 'use <T> & "Result<T, E>" types',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Development failed; end." },
|
||||
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
|
||||
});
|
||||
});
|
||||
|
||||
test("$first returns earliest matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
firstPlanReady: {
|
||||
description: "First planner run was ready",
|
||||
expression: "$first('planner').status = 'ready'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Begin planning.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{ role: "$END", condition: "firstPlanReady", prompt: "First plan was ready; end." },
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "Plan not ready on first pass; implement.",
|
||||
},
|
||||
],
|
||||
test("triple mustache also works for unescaped output", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready", plan: "ABC123" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "revised", plan: "DEF456" },
|
||||
detail: "4RNMK6PX8B3WQ",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
comments: "<script>alert(1)</script>",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "First plan was ready; end." },
|
||||
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
|
||||
});
|
||||
});
|
||||
|
||||
test("$last returns undefined for unmatched role", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
hasReviewer: {
|
||||
description: "Reviewer has run",
|
||||
expression: "$exists($last('reviewer'))",
|
||||
test("mustache template with nested object paths", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: {
|
||||
role: "developer",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Begin planning.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{ role: "$END", condition: "hasReviewer", prompt: "Reviewer already ran; end." },
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "No reviewer yet; implement.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
// no reviewer step → $exists returns false → fallback to developer
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "No reviewer yet; implement." },
|
||||
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"jsonata": "^1.8.7"
|
||||
"mustache": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mustache": "^4.2.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,65 +1,42 @@
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import jsonata from "jsonata";
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
import mustache from "mustache";
|
||||
|
||||
import type { EvaluateResult, Result } from "./types.js";
|
||||
|
||||
// Disable HTML escaping — prompts are plain text, not HTML.
|
||||
mustache.escape = (text: string) => text;
|
||||
|
||||
const START_ROLE = "$START";
|
||||
const UNIT_STATUS = "_";
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value !== 0 && !Number.isNaN(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
type LastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
function findByRole(
|
||||
steps: ModeratorContext["steps"],
|
||||
role: string,
|
||||
direction: "first" | "last",
|
||||
): unknown {
|
||||
if (direction === "last") {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
if (steps[i].role === role) {
|
||||
return steps[i].output;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const step of steps) {
|
||||
if (step.role === role) {
|
||||
return step.output;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const target = roleTargets[status];
|
||||
if (target === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateJsonata(
|
||||
expression: string,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<unknown, Error>> {
|
||||
try {
|
||||
const expr = jsonata(expression);
|
||||
expr.registerFunction(
|
||||
"first",
|
||||
(role: string) => findByRole(context.steps, role, "first"),
|
||||
"<s:x>",
|
||||
);
|
||||
expr.registerFunction(
|
||||
"last",
|
||||
(role: string) => findByRole(context.steps, role, "last"),
|
||||
"<s:x>",
|
||||
);
|
||||
const result = await expr.evaluate(context);
|
||||
return { ok: true, value: result };
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
return { ok: true, value: { role: target.role, prompt } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -67,51 +44,3 @@ async function evaluateJsonata(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function currentRole(context: ModeratorContext): string {
|
||||
if (context.steps.length === 0) {
|
||||
return START_ROLE;
|
||||
}
|
||||
return context.steps[context.steps.length - 1].role;
|
||||
}
|
||||
|
||||
export async function evaluate(
|
||||
workflow: WorkflowPayload,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<EvaluateResult, Error>> {
|
||||
const role = currentRole(context);
|
||||
const transitions = workflow.graph[role];
|
||||
if (transitions === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${role}"`),
|
||||
};
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
if (transition.condition === null) {
|
||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
||||
}
|
||||
|
||||
const conditionDef = workflow.conditions[transition.condition];
|
||||
if (conditionDef === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`unknown condition "${transition.condition}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const evalResult = await evaluateJsonata(conditionDef.expression, context);
|
||||
if (!evalResult.ok) {
|
||||
return evalResult;
|
||||
}
|
||||
if (isTruthy(evalResult.value)) {
|
||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition matched for role "${role}"`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ConditionDefinition,
|
||||
ModelAlias,
|
||||
ModelConfig,
|
||||
ModeratorContext,
|
||||
@@ -26,12 +25,12 @@ export type {
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
StepRecord,
|
||||
Target,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
Transition,
|
||||
WorkflowConfig,
|
||||
WorkflowName,
|
||||
WorkflowPayload,
|
||||
|
||||
@@ -14,22 +14,11 @@ const ROLE_DEFINITION: JSONSchema = {
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const CONDITION_DEFINITION: JSONSchema = {
|
||||
const TARGET: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["description", "expression"],
|
||||
properties: {
|
||||
description: { type: "string" },
|
||||
expression: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const TRANSITION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["role", "condition", "prompt"],
|
||||
required: ["role", "prompt"],
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
prompt: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -38,7 +27,7 @@ const TRANSITION: JSONSchema = {
|
||||
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
title: "Workflow",
|
||||
type: "object",
|
||||
required: ["name", "description", "roles", "conditions", "graph"],
|
||||
required: ["name", "description", "roles", "graph"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
@@ -46,15 +35,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
type: "object",
|
||||
additionalProperties: ROLE_DEFINITION,
|
||||
},
|
||||
conditions: {
|
||||
type: "object",
|
||||
additionalProperties: CONDITION_DEFINITION,
|
||||
},
|
||||
graph: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: TRANSITION,
|
||||
type: "object",
|
||||
additionalProperties: TARGET,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,23 +27,16 @@ export type RoleDefinition = {
|
||||
frontmatter: CasRef;
|
||||
};
|
||||
|
||||
export type Transition = {
|
||||
export type Target = {
|
||||
role: string;
|
||||
condition: string | null;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string;
|
||||
};
|
||||
|
||||
export type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>;
|
||||
graph: Record<string, Record<string, Target>>;
|
||||
};
|
||||
|
||||
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user