Skip to content

Commit 55a1a7d

Browse files
committed
Merge: sibling Y-order + reorder-branches + clean fan-out extraction
2 parents 46cf7a9 + 8d051ea commit 55a1a7d

3 files changed

Lines changed: 189 additions & 15 deletions

File tree

components/workflow-graph.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,25 @@ const layout = (
290290
childrenOf.set(e.source, [...(childrenOf.get(e.source) ?? []), e.target]);
291291
parentsOf.set(e.target, [...(parentsOf.get(e.target) ?? []), e.source]);
292292
}
293+
294+
// SIBLING ORDER (top→bottom) follows the parent's `next` order, NOT dagre's
295+
// crossing-minimisation (which can reshuffle). We keep the exact Y-slots dagre
296+
// computed for a parent's children (so spacing + measured heights are respected),
297+
// but RE-ASSIGN those slots to the children in `next` order. Only for siblings
298+
// that belong to a single parent (true fan-out branches) — a shared join node
299+
// like the post isn't reordered.
300+
for (const [, children] of childrenOf) {
301+
const branches = children.filter(
302+
(c) => (parentsOf.get(c) ?? []).length === 1,
303+
);
304+
if (branches.length < 2) continue;
305+
// The Y-slots these branches currently occupy, top→bottom.
306+
const slots = branches.map((c) => cy.get(c) ?? 0).sort((a, b) => a - b);
307+
// `branches` is already in `next` order (childrenOf is built from edge order),
308+
// so assign the i-th branch to the i-th slot from the top.
309+
branches.forEach((c, i) => cy.set(c, slots[i] ?? cy.get(c) ?? 0));
310+
}
311+
293312
// Parents centered on their children (right→left so children settle first).
294313
for (const id of [...g.nodes()].sort((a, b) => g.node(b).x - g.node(a).x)) {
295314
const kids = childrenOf.get(id) ?? [];

lib/workflow-edit.test.ts

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,29 @@ test("add-parallel-after that would cycle is rejected (workflow unchanged)", ()
266266
assert.equal(JSON.stringify(next), before, "cycle guard kept it unchanged");
267267
});
268268

269+
test("reorder-branches: sets the parent's next order, keeps all edges", () => {
270+
// base: manager → [director, post]. Reorder to [post, director].
271+
const next = applyEditOp(base, {
272+
op: "reorder-branches",
273+
parentStepId: "manager",
274+
order: ["post", "director"],
275+
});
276+
const mgr = next.steps.find((s) => s.id === "manager");
277+
assert.deepEqual(mgr?.next, ["post", "director"], "order applied");
278+
});
279+
280+
test("reorder-branches: omitted children are kept (no edge dropped)", () => {
281+
const next = applyEditOp(base, {
282+
op: "reorder-branches",
283+
parentStepId: "manager",
284+
order: ["post"], // director omitted
285+
});
286+
const mgr = next.steps.find((s) => s.id === "manager");
287+
assert.ok(mgr?.next.includes("director"), "omitted child still present");
288+
assert.ok(mgr?.next.includes("post"));
289+
assert.equal(mgr?.next.length, 2, "no edge dropped or duplicated");
290+
});
291+
269292
test("rename-step: changes only the label, nothing else", () => {
270293
const next = applyEditOp(base, {
271294
op: "rename-step",
@@ -299,10 +322,8 @@ test("duplicate-step: makes a parallel twin with the same successors", () => {
299322
assert.doesNotThrow(() => ApprovalWorkflow.parse(next));
300323
});
301324

302-
test("move-step: relocates a step after the anchor, predecessors bypass it", () => {
303-
// base: manager → {director, post}; director → post. Move director after post?
304-
// post is terminal — moving director after post: post → director → (post's old
305-
// next = []). And manager bypasses director to director's old next (post).
325+
test("move-step: relocates a step after the anchor", () => {
326+
// base: manager → {director, post}; director → post. Move director after post.
306327
const next = applyEditOp(base, {
307328
op: "move-step",
308329
stepId: "director",
@@ -316,14 +337,119 @@ test("move-step: relocates a step after the anchor, predecessors bypass it", ()
316337
!mgr.next.includes("director"),
317338
"manager no longer points at director",
318339
);
319-
assert.ok(
320-
mgr.next.includes("post"),
321-
"manager bypasses to director's old next",
322-
);
323340
assert.ok(post.next.includes("director"), "post → director (new position)");
324341
assert.doesNotThrow(() => ApprovalWorkflow.parse(next));
325342
});
326343

344+
test("move-step: extracting a parallel branch into sequence leaves NO stray edge", () => {
345+
// manager → {dir, dept} (parallel), both → post. Move dept after dir ⇒ a clean
346+
// chain manager → dir → dept → post, with NO leftover manager → post bypass.
347+
const wf: TWorkflow = {
348+
name: "par",
349+
roots: ["mgr"],
350+
steps: [
351+
{
352+
id: "mgr",
353+
kind: "approval",
354+
label: "Manager",
355+
when: { kind: "always" },
356+
approverTitle: "M",
357+
approverName: "x",
358+
next: ["dir", "dept"],
359+
},
360+
{
361+
id: "dir",
362+
kind: "approval",
363+
label: "Director",
364+
when: { kind: "always" },
365+
approverTitle: "D",
366+
approverName: "y",
367+
next: ["post"],
368+
},
369+
{
370+
id: "dept",
371+
kind: "approval",
372+
label: "Dept",
373+
when: { kind: "always" },
374+
approverTitle: "H",
375+
approverName: "z",
376+
next: ["post"],
377+
},
378+
{
379+
id: "post",
380+
kind: "integration",
381+
label: "Post",
382+
when: { kind: "always" },
383+
integration: "netsuite",
384+
next: [],
385+
},
386+
],
387+
};
388+
const next = applyEditOp(wf, {
389+
op: "move-step",
390+
stepId: "dept",
391+
afterStepId: "dir",
392+
});
393+
const get = (id: string) => next.steps.find((s) => s.id === id);
394+
assert.deepEqual(
395+
get("mgr")?.next,
396+
["dir"],
397+
"manager → dir only (no stray post)",
398+
);
399+
assert.deepEqual(get("dir")?.next, ["dept"], "dir → dept");
400+
assert.deepEqual(get("dept")?.next, ["post"], "dept → post");
401+
assert.doesNotThrow(() => ApprovalWorkflow.parse(next));
402+
});
403+
404+
test("move-step: a linear-chain move still bypasses (keeps flow)", () => {
405+
// a → b → c. Move b after c: a had ONLY b, so a must bypass to b's old next (c).
406+
const wf: TWorkflow = {
407+
name: "lin",
408+
roots: ["a"],
409+
steps: [
410+
{
411+
id: "a",
412+
kind: "approval",
413+
label: "A",
414+
when: { kind: "always" },
415+
approverTitle: "A",
416+
approverName: "x",
417+
next: ["b"],
418+
},
419+
{
420+
id: "b",
421+
kind: "approval",
422+
label: "B",
423+
when: { kind: "always" },
424+
approverTitle: "B",
425+
approverName: "y",
426+
next: ["c"],
427+
},
428+
{
429+
id: "c",
430+
kind: "integration",
431+
label: "C",
432+
when: { kind: "always" },
433+
integration: "netsuite",
434+
next: [],
435+
},
436+
],
437+
};
438+
const next = applyEditOp(wf, {
439+
op: "move-step",
440+
stepId: "b",
441+
afterStepId: "c",
442+
});
443+
const get = (id: string) => next.steps.find((s) => s.id === id);
444+
assert.deepEqual(
445+
get("a")?.next,
446+
["c"],
447+
"a bypasses to c (would be orphaned otherwise)",
448+
);
449+
assert.deepEqual(get("c")?.next, ["b"], "c → b (new position)");
450+
assert.doesNotThrow(() => ApprovalWorkflow.parse(next));
451+
});
452+
327453
test("move-step keeps the graph acyclic (it unhooks before re-inserting)", () => {
328454
// move-step always detaches the step (predecessors bypass it) before re-parenting
329455
// it under the anchor, so it can't introduce a back-edge — the result stays a DAG

lib/workflow-edit.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ export const WorkflowEditOp = z.discriminatedUnion("op", [
9494
afterStepId: z.string(),
9595
})
9696
.strict(),
97+
z
98+
.object({
99+
op: z.literal("reorder-branches"),
100+
/** The parent whose parallel branches to reorder, top→bottom. */
101+
parentStepId: z.string(),
102+
/** The branch step ids in the desired top→bottom order. */
103+
order: z.array(z.string()),
104+
})
105+
.strict(),
97106
z
98107
.object({
99108
op: z.literal("insert-approval-after"),
@@ -241,14 +250,19 @@ export const applyEditOp = (wf: TWorkflow, op: WorkflowEditOp): TWorkflow => {
241250
const anchor = next.steps.find((s) => s.id === op.afterStepId);
242251
if (!moved || !anchor || moved.id === anchor.id) return next;
243252
const movedNext = [...moved.next];
244-
// 1. Unhook the moved step: its old predecessors bypass it to its successors.
253+
// 1. Unhook the moved step from its old predecessors. Only BYPASS (re-point the
254+
// predecessor at the moved step's successors) when that predecessor would
255+
// otherwise be left with NO way forward — i.e. it relied solely on the moved
256+
// step (a linear chain). When the predecessor has OTHER branches (extracting
257+
// one branch out of a fan-out), drop the edge cleanly with no bypass, so
258+
// "2 parallel → sequential" yields a clean chain, not a stray edge.
245259
for (const s of next.steps) {
246-
if (s.id === moved.id) continue;
247-
if (s.next.includes(moved.id)) {
248-
s.next = [
249-
...new Set([...s.next.filter((n) => n !== moved.id), ...movedNext]),
250-
];
251-
}
260+
if (s.id === moved.id || !s.next.includes(moved.id)) continue;
261+
const withoutMoved = s.next.filter((n) => n !== moved.id);
262+
s.next =
263+
withoutMoved.length === 0
264+
? [...new Set(movedNext)] // sole path forward → bypass to keep flow
265+
: withoutMoved; // had other branches → clean drop
252266
}
253267
next.roots = next.roots.filter((r) => r !== moved.id);
254268
// 2. Re-insert after the anchor: anchor → moved → anchor's old successors.
@@ -257,6 +271,19 @@ export const applyEditOp = (wf: TWorkflow, op: WorkflowEditOp): TWorkflow => {
257271
return wouldCycle(next) ? wf : next;
258272
}
259273

274+
case "reorder-branches": {
275+
const parent = next.steps.find((s) => s.id === op.parentStepId);
276+
if (!parent) return next;
277+
// Reorder `next` to match the requested top→bottom order. Keep only ids that
278+
// are actually current children; append any current child the model omitted so
279+
// we never silently drop an edge.
280+
const current = new Set(parent.next);
281+
const ordered = op.order.filter((id) => current.has(id));
282+
const rest = parent.next.filter((id) => !ordered.includes(id));
283+
parent.next = [...ordered, ...rest];
284+
return next;
285+
}
286+
260287
case "add-approval": {
261288
const id = uniqueId(next, slug(op.label));
262289
const post = postStepId(next);
@@ -436,6 +463,7 @@ export const WORKFLOW_EDIT_SYSTEM_PROMPT = `You translate a plain-language instr
436463
- rename-step: change a step's title/label (by "stepId", set "label"). Use for "rename X to Y" / "call it Z".
437464
- duplicate-step: copy an existing step (by "stepId", set "label" for the copy). Use for "duplicate X" / "add another like X".
438465
- move-step: relocate an existing step to immediately AFTER another (by "stepId" + "afterStepId"). Use for "move X after Y" / "put X before the post".
466+
- reorder-branches: change the TOP→BOTTOM order of a parent's parallel branches (by "parentStepId" + "order": the branch step ids in the wanted order). Use for "swap the two reviews" / "put the IT review above the director review". This only changes the visual order of parallel branches, nothing structural.
439467
- insert-approval-after: insert a new approval gate IMMEDIATELY AFTER one existing step (use "afterStepId"). Use this for "add a step between X and Y" or "after the manager, add …". Same label/approverTitle/amountOver/department fields as add-approval.
440468
- add-parallel-after: a new approval gate that runs only once ALL of the given steps have been approved (use "afterStepIds": a list). Use this for "after the two reviews, require a final sign-off" / "a step that waits for both X and Y". Same label/approverTitle/amountOver/department fields.
441469
- none: if the instruction doesn't map to any of the above, or asks for something already true — give a short "reason".
@@ -484,6 +512,7 @@ Return { "ops": [ ... ] } where each op is one of:
484512
- rename-step { stepId, label }: change a step's title.
485513
- duplicate-step { stepId, label }: copy an existing step.
486514
- move-step { stepId, afterStepId }: relocate an existing step to after another.
515+
- reorder-branches { parentStepId, order: [..] }: set the top→bottom order of a parent's parallel branches ("swap the two reviews").
487516
- insert-approval-after { afterStepId, label, approverTitle, amountOver|null, department|null }: insert a gate immediately AFTER one step.
488517
- add-parallel-after { afterStepIds: [..], label, approverTitle, amountOver|null, department|null }: a gate that runs once ALL the listed steps are approved (use for "after X and Y, a step that waits on both").
489518
- none { reason }: only if NOTHING in the instruction maps to a supported edit.

0 commit comments

Comments
 (0)