feat: register $first/$last JSONata functions in moderator
Register custom $first(role) and $last(role) functions in the JSONata
evaluator. These search the steps array and return the matching role's
frontmatter (output) directly, replacing verbose steps[-1].output.x
expressions with semantic $last('role').field syntax.
- workflow-moderator: register functions via expr.registerFunction()
- Updated all condition expressions in .workflows/ and examples/
- Added tests for $last, $first, and unmatched role (undefined)
Fixes #376
This commit is contained in:
@@ -124,22 +124,22 @@ roles:
|
|||||||
conditions:
|
conditions:
|
||||||
insufficientInfo:
|
insufficientInfo:
|
||||||
description: "Planner determined there's not enough info to proceed"
|
description: "Planner determined there's not enough info to proceed"
|
||||||
expression: "steps[-1].output.status = 'insufficient_info'"
|
expression: "$last('planner').status = 'insufficient_info'"
|
||||||
devFailed:
|
devFailed:
|
||||||
description: "Developer failed to implement"
|
description: "Developer failed to implement"
|
||||||
expression: "steps[-1].output.status = 'failed'"
|
expression: "$last('developer').status = 'failed'"
|
||||||
rejected:
|
rejected:
|
||||||
description: "Reviewer rejected the implementation"
|
description: "Reviewer rejected the implementation"
|
||||||
expression: "steps[-1].output.approved = false"
|
expression: "$last('reviewer').approved = false"
|
||||||
fixCode:
|
fixCode:
|
||||||
description: "Tester found code issues"
|
description: "Tester found code issues"
|
||||||
expression: "steps[-1].output.status = 'fix_code'"
|
expression: "$last('tester').status = 'fix_code'"
|
||||||
fixSpec:
|
fixSpec:
|
||||||
description: "Tester found spec issues"
|
description: "Tester found spec issues"
|
||||||
expression: "steps[-1].output.status = 'fix_spec'"
|
expression: "$last('tester').status = 'fix_spec'"
|
||||||
hookFailed:
|
hookFailed:
|
||||||
description: "Push hook failed"
|
description: "Push hook failed"
|
||||||
expression: "steps[-1].output.success = false"
|
expression: "$last('committer').success = false"
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "planner"
|
- role: "planner"
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ roles:
|
|||||||
conditions:
|
conditions:
|
||||||
notApproved:
|
notApproved:
|
||||||
description: "Reviewer rejected the implementation"
|
description: "Reviewer rejected the implementation"
|
||||||
expression: "steps[-1].output.approved = false"
|
expression: "$last('reviewer').approved = false"
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "planner"
|
- role: "planner"
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ const solveIssueWorkflow: WorkflowPayload = {
|
|||||||
conditions: {
|
conditions: {
|
||||||
needsClarification: {
|
needsClarification: {
|
||||||
description: "Planner requests clarification from user",
|
description: "Planner requests clarification from user",
|
||||||
expression: "$exists(steps[-1].output.needsClarification)",
|
expression: "$exists($last('planner').needsClarification)",
|
||||||
},
|
},
|
||||||
notApproved: {
|
rejected: {
|
||||||
description: "Reviewer rejected the implementation",
|
description: "Reviewer rejected the implementation",
|
||||||
expression: "steps[-1].output.approved = false",
|
expression: "$last('reviewer').approved = false",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
graph: {
|
graph: {
|
||||||
@@ -50,7 +50,7 @@ const solveIssueWorkflow: WorkflowPayload = {
|
|||||||
],
|
],
|
||||||
developer: [{ role: "reviewer", condition: null }],
|
developer: [{ role: "reviewer", condition: null }],
|
||||||
reviewer: [
|
reviewer: [
|
||||||
{ role: "developer", condition: "notApproved" },
|
{ role: "developer", condition: "rejected" },
|
||||||
{ role: "$END", condition: null },
|
{ role: "$END", condition: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -72,7 +72,7 @@ describe("evaluate", () => {
|
|||||||
expect(result).toEqual({ ok: true, value: "planner" });
|
expect(result).toEqual({ ok: true, value: "planner" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("condition match (notApproved → developer)", async () => {
|
test("condition match (rejected → developer)", async () => {
|
||||||
const context = makeContext([
|
const context = makeContext([
|
||||||
{
|
{
|
||||||
role: "reviewer",
|
role: "reviewer",
|
||||||
@@ -126,4 +126,116 @@ describe("evaluate", () => {
|
|||||||
const result = await evaluate(solveIssueWorkflow, context);
|
const result = await evaluate(solveIssueWorkflow, context);
|
||||||
expect(result).toEqual({ ok: true, value: "developer" });
|
expect(result).toEqual({ ok: true, value: "developer" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 }],
|
||||||
|
developer: [
|
||||||
|
{ role: "$END", condition: "devFailed" },
|
||||||
|
{ role: "reviewer", condition: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
expect(result).toEqual({ ok: true, value: "$END" });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }],
|
||||||
|
planner: [
|
||||||
|
{ role: "$END", condition: "firstPlanReady" },
|
||||||
|
{ role: "developer", condition: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
expect(result).toEqual({ ok: true, value: "$END" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("$last returns undefined for unmatched role", async () => {
|
||||||
|
const workflow: WorkflowPayload = {
|
||||||
|
...solveIssueWorkflow,
|
||||||
|
conditions: {
|
||||||
|
hasReviewer: {
|
||||||
|
description: "Reviewer has run",
|
||||||
|
expression: "$exists($last('reviewer'))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
$START: [{ role: "planner", condition: null }],
|
||||||
|
planner: [
|
||||||
|
{ role: "$END", condition: "hasReviewer" },
|
||||||
|
{ role: "developer", condition: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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
|
||||||
|
expect(result).toEqual({ ok: true, value: "developer" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,12 +21,44 @@ function isTruthy(value: unknown): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async function evaluateJsonata(
|
async function evaluateJsonata(
|
||||||
expression: string,
|
expression: string,
|
||||||
context: ModeratorContext,
|
context: ModeratorContext,
|
||||||
): Promise<Result<unknown, Error>> {
|
): Promise<Result<unknown, Error>> {
|
||||||
try {
|
try {
|
||||||
const result = await jsonata(expression).evaluate(context);
|
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 };
|
return { ok: true, value: result };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user