Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/dom/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function extractFromHost(
path: number[];
iteration: number;
ralphId?: string;
parentIsRalph: boolean;
parallelStack: { id: string; max?: number }[];
/**
* Stack of active <Worktree> contexts (outermost -> innermost).
Expand All @@ -144,7 +145,7 @@ export function extractFromHost(
const worktreeStack = ctx.worktreeStack;

if (node.tag === "smithers:ralph") {
if (ralphId) {
if (ctx.parentIsRalph) {
throw new Error("Nested <Ralph> is not supported.");
}
const id = resolveStableId(node.rawProps?.id, "ralph", ctx.path);
Expand Down Expand Up @@ -319,13 +320,14 @@ export function extractFromHost(
path: nextPath,
iteration,
ralphId,
parentIsRalph: node.tag === "smithers:ralph",
parallelStack: nextParallelStack,
worktreeStack: nextWorktreeStack,
});
}
}

walk(root, { path: [], iteration: 0, parallelStack: [], worktreeStack: [] });
walk(root, { path: [], iteration: 0, parentIsRalph: false, parallelStack: [], worktreeStack: [] });

return { xml: toXmlNode(root), tasks, mountedTaskIds };
}
10 changes: 5 additions & 5 deletions src/engine/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,22 @@ export function buildPlanTree(xml: XmlNode | null): {

function walk(
node: XmlNode,
ctx: { path: number[]; inRalph: boolean },
ctx: { path: number[]; parentIsRalph: boolean },
): PlanNode | null {
if (node.kind === "text") return null;
const tag = node.tag;

if (ctx.inRalph && tag === "smithers:ralph") {
if (ctx.parentIsRalph && tag === "smithers:ralph") {
throw new Error("Nested <Ralph> is not supported.");
}

const children: PlanNode[] = [];
let elementIndex = 0;
const isRalph = tag === "smithers:ralph";
for (const child of node.children) {
const nextPath =
child.kind === "element" ? [...ctx.path, elementIndex++] : ctx.path;
const nextInRalph = ctx.inRalph || tag === "smithers:ralph";
const built = walk(child, { path: nextPath, inRalph: nextInRalph });
const built = walk(child, { path: nextPath, parentIsRalph: isRalph });
if (built) children.push(built);
}

Expand Down Expand Up @@ -133,7 +133,7 @@ export function buildPlanTree(xml: XmlNode | null): {
return { kind: "group", children };
}

const plan = walk(xml, { path: [], inRalph: false });
const plan = walk(xml, { path: [], parentIsRalph: false });
return { plan, ralphs };
}

Expand Down
165 changes: 165 additions & 0 deletions tests/nested-ralph-bug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Reproduction for https://github.com/jjhub-ai/smithers/issues/111
*
* Nested <Loop>/<Ralph> separated by a structural node (<Sequence>, <Parallel>,
* etc.) should be allowed. Direct nesting (<Ralph><Ralph>) should remain rejected.
*/
import { describe, expect, test } from "bun:test";
import { buildPlanTree } from "../src/engine/scheduler";
import { extractFromHost, type HostElement } from "../src/dom/extract";
import { el } from "./helpers";

// ── Minimal HostElement factory for extract.ts tests ─────────────────
function hostEl(
tag: string,
rawProps: Record<string, any> = {},
children: HostElement[] = [],
): HostElement {
const props: Record<string, string> = {};
for (const [k, v] of Object.entries(rawProps)) {
if (typeof v === "string") props[k] = v;
}
return { kind: "element", tag, props, rawProps, children };
}

// ═══════════════════════════════════════════════════════════════════════
// 1. scheduler.ts – buildPlanTree
// ═══════════════════════════════════════════════════════════════════════
describe("issue #111 – buildPlanTree nested ralph", () => {
test("direct nesting is rejected", () => {
const xml = el("smithers:ralph", { id: "outer" }, [
el("smithers:ralph", { id: "inner" }, [
el("smithers:task", { id: "t1" }),
]),
]);
expect(() => buildPlanTree(xml)).toThrow("Nested <Ralph>");
});

test("ralph > sequence > ralph is allowed", () => {
const xml = el("smithers:ralph", { id: "outer" }, [
el("smithers:sequence", {}, [
el("smithers:ralph", { id: "inner" }, [
el("smithers:task", { id: "t1" }),
]),
]),
]);
const { plan, ralphs } = buildPlanTree(xml);
expect(plan).toBeDefined();
expect(ralphs).toHaveLength(2);
expect(ralphs.map((r) => r.id).sort()).toEqual(["inner", "outer"]);
});

test("ralph > parallel > ralph is allowed", () => {
const xml = el("smithers:ralph", { id: "outer" }, [
el("smithers:parallel", {}, [
el("smithers:ralph", { id: "inner" }, [
el("smithers:task", { id: "t1" }),
]),
]),
]);
const { plan, ralphs } = buildPlanTree(xml);
expect(plan).toBeDefined();
expect(ralphs).toHaveLength(2);
});

test("ralph > worktree (group) > ralph is allowed", () => {
const xml = el("smithers:ralph", { id: "outer" }, [
el("smithers:worktree", { id: "wt", path: "/tmp/test" }, [
el("smithers:ralph", { id: "inner" }, [
el("smithers:task", { id: "t1" }),
]),
]),
]);
const { plan, ralphs } = buildPlanTree(xml);
expect(plan).toBeDefined();
expect(ralphs).toHaveLength(2);
});

test("three levels: ralph > sequence > ralph > sequence > ralph", () => {
const xml = el("smithers:ralph", { id: "L1" }, [
el("smithers:sequence", {}, [
el("smithers:ralph", { id: "L2" }, [
el("smithers:sequence", {}, [
el("smithers:ralph", { id: "L3" }, [
el("smithers:task", { id: "t1" }),
]),
]),
]),
]),
]);
const { ralphs } = buildPlanTree(xml);
expect(ralphs).toHaveLength(3);
});

test("sibling ralphs inside sequence are allowed", () => {
const xml = el("smithers:sequence", {}, [
el("smithers:ralph", { id: "a" }, [
el("smithers:task", { id: "t1" }),
]),
el("smithers:ralph", { id: "b" }, [
el("smithers:task", { id: "t2" }),
]),
]);
const { ralphs } = buildPlanTree(xml);
expect(ralphs).toHaveLength(2);
});
});

// ═══════════════════════════════════════════════════════════════════════
// 2. extract.ts – extractFromHost
// ═══════════════════════════════════════════════════════════════════════
describe("issue #111 – extractFromHost nested ralph", () => {
test("direct nesting is rejected", () => {
const root = hostEl("smithers:ralph", { id: "outer" }, [
hostEl("smithers:ralph", { id: "inner" }, [
hostEl("smithers:task", {
id: "t1",
output: "out",
__smithersPayload: "data",
}),
]),
]);
expect(() => extractFromHost(root)).toThrow("Nested <Ralph>");
});

test("ralph > sequence > ralph is allowed", () => {
const root = hostEl("smithers:ralph", { id: "outer" }, [
hostEl("smithers:sequence", {}, [
hostEl("smithers:ralph", { id: "inner" }, [
hostEl("smithers:task", {
id: "t1",
output: "out",
__smithersPayload: "data",
}),
]),
]),
]);
const result = extractFromHost(root);
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].ralphId).toBe("inner");
});

test("inner task gets innermost ralphId", () => {
const root = hostEl("smithers:ralph", { id: "outer" }, [
hostEl("smithers:sequence", {}, [
hostEl("smithers:ralph", { id: "inner" }, [
hostEl("smithers:task", {
id: "t1",
output: "out",
__smithersPayload: "data",
}),
]),
hostEl("smithers:task", {
id: "t2",
output: "out",
__smithersPayload: "data",
}),
]),
]);
const result = extractFromHost(root);
const t1 = result.tasks.find((t) => t.nodeId === "t1")!;
const t2 = result.tasks.find((t) => t.nodeId === "t2")!;
expect(t1.ralphId).toBe("inner");
expect(t2.ralphId).toBe("outer");
});
});
Loading