fix(FN-0000): address workflow loop review feedback

This commit is contained in:
gsxdsm
2026-06-08 00:19:50 -07:00
parent a504238d26
commit 75867c4002
8 changed files with 106 additions and 20 deletions

View File

@@ -123,7 +123,7 @@ The **step-inversion** track makes task *steps* themselves workflow-modelable. T
`loop` repeats an inline template subgraph until a configured output condition matches or a budget is exhausted. Config:
```
```ts
{ template: { nodes, edges },
exitWhen: {
type: "output-contains", value: string, nodeId?: string

View File

@@ -85,6 +85,44 @@ describe("loop validation", () => {
expect(() => parseWorkflowIr(loopIr({ template }))).toThrow(/nested loop\/foreach/);
});
it("rejects loop nodes inside foreach templates", () => {
const ir: WorkflowIrV2 = {
version: "v2",
name: "foreach-loop-test",
columns,
nodes: [
{ id: "start", kind: "start" },
{
id: "steps",
kind: "foreach",
config: {
source: "task-steps",
template: {
nodes: [
{
id: "nested-loop",
kind: "loop",
config: {
exitWhen: { type: "output-contains", value: "DONE" },
template: loopTemplate(),
},
},
],
edges: [],
},
},
},
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "steps" },
{ from: "steps", to: "end" },
],
};
expect(() => parseWorkflowIr(ir)).toThrow(/nested loop\/foreach/);
});
it("rejects foreach-only seams and normal cycles inside loop templates", () => {
const seamTemplate = loopTemplate();
seamTemplate.nodes[0] = { id: "ask", kind: "prompt", config: { seam: "step-execute" } };
@@ -106,6 +144,9 @@ describe("loop validation", () => {
expect(() => parseWorkflowIr(loopIr({ exitWhen: { type: "output-matches", pattern: "[" } }))).toThrow(
/exitWhen.pattern is invalid/,
);
expect(() =>
parseWorkflowIr(loopIr({ exitWhen: { type: "output-matches", pattern: "(a+)+" } })),
).toThrow(/potentially unsafe/);
});
it("clamps high maxIterations and rejects invalid budgets", () => {

View File

@@ -99,6 +99,9 @@ const MAX_FOREACH_CONCURRENCY = 8;
const MAX_LOOP_ITERATIONS_CAP = 50;
const MAX_LOOP_TIMEOUT_MS = 3_600_000;
const WORKFLOW_EXTENSION_KEY_PATTERN = /^plugin:[a-z0-9]([a-z0-9-]*[a-z0-9])?:[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
const MAX_LOOP_REGEX_PATTERN_LENGTH = 256;
const LOOP_REGEX_NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[*+](?:[^()\\]|\\.)*\)\s*(?:[*+]|\{\d+,?\d*\})/;
const LOOP_REGEX_BACKREFERENCE = /\\[1-9]/;
/** The implicit step-source artifact allowed when no artifacts are declared. */
const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md";
@@ -108,6 +111,19 @@ function isStepExecuteNode(node: WorkflowIrNode): boolean {
return node.kind === "prompt" && node.config?.seam === "step-execute";
}
function assertSafeLoopRegexPattern(nodeId: string, pattern: string): void {
if (pattern.length > MAX_LOOP_REGEX_PATTERN_LENGTH) {
throw new WorkflowIrError(
`loop node '${nodeId}' exitWhen.pattern must be ${MAX_LOOP_REGEX_PATTERN_LENGTH} characters or fewer`,
);
}
if (LOOP_REGEX_BACKREFERENCE.test(pattern) || LOOP_REGEX_NESTED_QUANTIFIER.test(pattern)) {
throw new WorkflowIrError(
`loop node '${nodeId}' exitWhen.pattern uses a potentially unsafe regex construct`,
);
}
}
/** Default-workflow column ids in legacy enum order (KTD-1). */
export const DEFAULT_WORKFLOW_COLUMN_IDS = [
"triage",
@@ -374,13 +390,13 @@ function validateForeach(
);
}
// No nested foreach. Also: a template node's declared `column` must resolve to a
// No nested template groups. Also: a template node's declared `column` must resolve to a
// top-level column id (column-agent plan KTD-1) — otherwise a dangling reference
// is a silent no-binding no-op at runtime instead of a typed authoring error.
for (const inner of templateNodes) {
if (inner.kind === "foreach") {
if (inner.kind === "foreach" || inner.kind === "loop") {
throw new WorkflowIrError(
`foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`,
`foreach node '${node.id}' template may not contain nested loop/foreach ('${inner.id}')`,
);
}
if (inner.column !== undefined && !columnIds.has(inner.column)) {
@@ -505,6 +521,7 @@ function validateLoop(
`loop node '${node.id}' exitWhen.pattern is invalid: ${err instanceof Error ? err.message : String(err)}`,
);
}
assertSafeLoopRegexPattern(node.id, exitWhen.pattern);
} else {
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.type must be output-contains or output-matches`);
}
@@ -1218,8 +1235,8 @@ function validateV2(ir: WorkflowIrV2): void {
validateForeachDominance(ir.nodes, ir.edges, outgoing);
}
/** Clamp foreach `maxReworkCycles` > cap down to the cap, in place, mirroring the
* maxRetries clamp posture (KTD-5). Reject-of-<1 happens in validation. */
/** Clamp bounded workflow-node configs down to their caps, in place, mirroring
* the maxRetries clamp posture. Reject-of-<1 happens in validation. */
function clampForeachConfigs(ir: WorkflowIrV2): void {
for (const node of ir.nodes) {
if (node.kind === "loop") {
@@ -1351,9 +1368,9 @@ export function serializeWorkflowIr(ir: WorkflowIr): string {
/**
* Strip the trust-escalating `cliSkipApproval`/`autoApprove` flags from every
* node config in an IR, recursing into foreach `config.template.nodes` at any
* nesting depth (foreach-in-foreach). Mutates the passed IR in place and returns
* it alongside a `stripped` flag indicating whether anything was removed.
* node config in an IR, recursing into template-group `config.template.nodes`.
* Mutates the passed IR in place and returns it alongside a `stripped` flag
* indicating whether anything was removed.
*
* These flags bypass the CLI first-run approval gate (see executor.ts). They are
* legitimate only for workflows authored through the trusted dashboard editor /

View File

@@ -3207,12 +3207,18 @@ function InnerEditor({
nextType === "output-matches"
? String(current.pattern ?? current.value ?? "")
: String(current.value ?? current.pattern ?? "");
const nextExitWhen: Record<string, unknown> = {
...current,
type: nextType,
};
delete nextExitWhen.pattern;
delete nextExitWhen.value;
return {
...prev,
exitWhen:
nextType === "output-matches"
? { type: nextType, pattern: text }
: { type: nextType, value: text },
? { ...nextExitWhen, pattern: text }
: { ...nextExitWhen, value: text },
};
},
});

View File

@@ -493,11 +493,15 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
{ from: "retry", to: "end" },
],
};
const inserted = insertFragment([], [], parseWorkflowIr(fragment), { x: 10, y: 20 });
const inserted = insertFragment([], [], parseWorkflowIr(fragment), { x: 10, y: 20 }, {
[foreachChildFlowId("retry", "try")]: { x: 86, y: 132 },
});
const group = inserted.nodes.find((n) => n.data.kind === "loop");
const child = inserted.nodes.find((n) => n.parentId === group?.id);
expect(group).toBeTruthy();
expect(group?.type).toBe("loop");
expect(child?.position).toEqual({ x: 86, y: 132 });
expect(inserted.nodes.filter((n) => n.parentId === group?.id)).toHaveLength(1);
expect(inserted.edges).toHaveLength(0);
});

View File

@@ -165,15 +165,23 @@ export function nodeConfigSummary(
? (() => {
const condition = exitWhen as Record<string, unknown>;
const type = str(condition.type);
if (type === "output-matches") return `until matches /${str(condition.pattern)}/`;
if (type === "output-contains") return `until contains "${str(condition.value)}"`;
if (type === "output-matches") {
return t("workflowNodes.summaryLoopUntilMatches", "until matches /{{pattern}}/", {
pattern: str(condition.pattern),
});
}
if (type === "output-contains") {
return t('workflowNodes.summaryLoopUntilContains', 'until contains "{{value}}"', {
value: str(condition.value),
});
}
return "";
})()
: "";
const maxIterations =
typeof config.maxIterations === "number" && Number.isFinite(config.maxIterations)
? `${config.maxIterations}x`
: "3x";
? t("workflowNodes.summaryLoopIterations", "{{count}}x", { count: config.maxIterations })
: t("workflowNodes.summaryLoopIterations", "{{count}}x", { count: 3 });
return exit ? `${exit} · ${maxIterations}` : maxIterations;
}
case "step-review": {

View File

@@ -1005,10 +1005,15 @@ export function insertFragment(
const groupKind = editorKind(node);
template.nodes.forEach((inner, innerIdx) => {
const innerKind = editorKind(inner);
const childPos =
layout?.[foreachChildFlowId(node.id, inner.id)] ?? {
x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X,
y: FOREACH_CHILD_Y,
};
childNodes.push({
id: foreachChildFlowId(id, inner.id),
type: innerKind,
position: { x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X, y: FOREACH_CHILD_Y },
position: childPos,
parentId: id,
extent: "parent",
data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },

View File

@@ -11,6 +11,7 @@ const MAX_TIMEOUT_MS = 3_600_000;
interface LoopConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
exitWhen: WorkflowLoopConfig["exitWhen"];
exitRegex?: RegExp;
maxIterations: number;
timeoutMs: number;
}
@@ -49,9 +50,12 @@ function resolveLoopConfig(node: WorkflowIrNode): LoopConfig {
typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs)
? Math.max(1, Math.min(MAX_TIMEOUT_MS, Math.floor(cfg.timeoutMs)))
: DEFAULT_TIMEOUT_MS;
const exitRegex =
cfg.exitWhen.type === "output-matches" ? new RegExp(cfg.exitWhen.pattern, cfg.exitWhen.flags) : undefined;
return {
template: cfg.template,
exitWhen: cfg.exitWhen,
exitRegex,
maxIterations,
timeoutMs,
};
@@ -87,12 +91,13 @@ function exitNodeId(nodes: WorkflowIrNode[], edges: WorkflowIrEdge[], loopId: st
return exits[0].id;
}
function matchesExit(condition: WorkflowLoopConfig["exitWhen"], value: unknown): boolean {
function matchesExit(config: LoopConfig, value: unknown): boolean {
const text = typeof value === "string" ? value : value == null ? "" : String(value);
const condition = config.exitWhen;
if (condition.type === "output-contains") {
return text.includes(condition.value);
}
return new RegExp(condition.pattern, condition.flags).test(text);
return (config.exitRegex ?? new RegExp(condition.pattern, condition.flags)).test(text);
}
function publishIterationContext(
@@ -185,7 +190,7 @@ export async function runLoop(
...(finalValue !== undefined ? { value: String(finalValue) } : {}),
});
publishIterationContext(env.context, iterationContext);
if (matchesExit(config.exitWhen, finalValue)) {
if (matchesExit(config, finalValue)) {
env.context[`node:${loopNode.id}:loop`] = {
iterations: iteration,
exitReason: "matched",