Skip to content

Commit 46cf7a9

Browse files
committed
Merge: rename / duplicate / move-step edit ops
2 parents 7a62982 + e5c24a4 commit 46cf7a9

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

lib/workflow-edit.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,87 @@ 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("rename-step: changes only the label, nothing else", () => {
270+
const next = applyEditOp(base, {
271+
op: "rename-step",
272+
stepId: "director",
273+
label: "CFO review",
274+
});
275+
const dir = next.steps.find((s) => s.id === "director");
276+
assert.equal(dir?.label, "CFO review");
277+
// condition + approver + edges untouched
278+
assert.equal(whenOf(next, "director"), directorWhenText);
279+
assert.equal(
280+
dir?.kind === "approval" ? dir.approverName : null,
281+
"Jordan Ellis",
282+
);
283+
});
284+
285+
test("duplicate-step: makes a parallel twin with the same successors", () => {
286+
const next = applyEditOp(base, {
287+
op: "duplicate-step",
288+
stepId: "director",
289+
label: "Second director review",
290+
});
291+
const copy = next.steps.find((s) => s.label === "Second director review");
292+
const mgr = next.steps.find((s) => s.id === "manager");
293+
assert.ok(copy && mgr);
294+
// same payload as the original
295+
assert.equal(whenOf(next, copy.id), directorWhenText);
296+
// everyone who pointed at the original now also points at the copy
297+
assert.ok(mgr.next.includes(copy.id), "manager → copy (parallel)");
298+
assert.deepEqual(copy.next, ["post"], "copy keeps the original's successors");
299+
assert.doesNotThrow(() => ApprovalWorkflow.parse(next));
300+
});
301+
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).
306+
const next = applyEditOp(base, {
307+
op: "move-step",
308+
stepId: "director",
309+
afterStepId: "post",
310+
});
311+
const mgr = next.steps.find((s) => s.id === "manager");
312+
const post = next.steps.find((s) => s.id === "post");
313+
const dir = next.steps.find((s) => s.id === "director");
314+
assert.ok(mgr && post && dir);
315+
assert.ok(
316+
!mgr.next.includes("director"),
317+
"manager no longer points at director",
318+
);
319+
assert.ok(
320+
mgr.next.includes("post"),
321+
"manager bypasses to director's old next",
322+
);
323+
assert.ok(post.next.includes("director"), "post → director (new position)");
324+
assert.doesNotThrow(() => ApprovalWorkflow.parse(next));
325+
});
326+
327+
test("move-step keeps the graph acyclic (it unhooks before re-inserting)", () => {
328+
// move-step always detaches the step (predecessors bypass it) before re-parenting
329+
// it under the anchor, so it can't introduce a back-edge — the result stays a DAG
330+
// whatever the source/anchor. A self-move (anchor === step) is a no-op.
331+
const self = applyEditOp(base, {
332+
op: "move-step",
333+
stepId: "director",
334+
afterStepId: "director",
335+
});
336+
assert.equal(
337+
JSON.stringify(self),
338+
JSON.stringify(base),
339+
"moving a step after itself is a no-op",
340+
);
341+
// A real move still validates as a sound DAG (no cycle introduced).
342+
const moved = applyEditOp(base, {
343+
op: "move-step",
344+
stepId: "director",
345+
afterStepId: "manager",
346+
});
347+
assert.doesNotThrow(() => ApprovalWorkflow.parse(moved));
348+
});
349+
269350
test("set-threshold: changes only the targeted gate's amount", () => {
270351
const next = applyEditOp(base, {
271352
op: "set-threshold",

lib/workflow-edit.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,32 @@ export const WorkflowEditOp = z.discriminatedUnion("op", [
6868
})
6969
.strict(),
7070
z.object({ op: z.literal("remove-step"), stepId: z.string() }).strict(),
71+
z
72+
.object({
73+
op: z.literal("rename-step"),
74+
stepId: z.string(),
75+
/** The new display label/title for the step. */
76+
label: z.string(),
77+
})
78+
.strict(),
79+
z
80+
.object({
81+
op: z.literal("duplicate-step"),
82+
/** The existing step to copy (its kind/condition/approver/integration). */
83+
stepId: z.string(),
84+
/** Label for the copy. */
85+
label: z.string(),
86+
})
87+
.strict(),
88+
z
89+
.object({
90+
op: z.literal("move-step"),
91+
/** The existing step to relocate. */
92+
stepId: z.string(),
93+
/** Place it immediately AFTER this step (re-wiring its edges). */
94+
afterStepId: z.string(),
95+
})
96+
.strict(),
7197
z
7298
.object({
7399
op: z.literal("insert-approval-after"),
@@ -182,6 +208,55 @@ export const applyEditOp = (wf: TWorkflow, op: WorkflowEditOp): TWorkflow => {
182208
return next;
183209
}
184210

211+
case "rename-step": {
212+
const step = next.steps.find((s) => s.id === op.stepId);
213+
if (step) step.label = op.label;
214+
return next;
215+
}
216+
217+
case "duplicate-step": {
218+
const orig = next.steps.find((s) => s.id === op.stepId);
219+
if (!orig) return next;
220+
const id = uniqueId(next, slug(op.label));
221+
// A parallel twin: same payload + same successors, and everyone who pointed
222+
// at the original now also points at the copy (and roots, if it was a root).
223+
// `orig` is already a deep clone (whole workflow was cloned above); copy its
224+
// `next`/`when` so the twin doesn't share array/object refs.
225+
const copy: WorkflowStep = {
226+
...orig,
227+
id,
228+
label: op.label,
229+
next: [...orig.next],
230+
when: structuredClone(orig.when),
231+
};
232+
for (const s of next.steps)
233+
if (s.next.includes(orig.id)) s.next = [...s.next, id];
234+
if (next.roots.includes(orig.id)) next.roots = [...next.roots, id];
235+
next.steps.push(copy);
236+
return wouldCycle(next) ? wf : next;
237+
}
238+
239+
case "move-step": {
240+
const moved = next.steps.find((s) => s.id === op.stepId);
241+
const anchor = next.steps.find((s) => s.id === op.afterStepId);
242+
if (!moved || !anchor || moved.id === anchor.id) return next;
243+
const movedNext = [...moved.next];
244+
// 1. Unhook the moved step: its old predecessors bypass it to its successors.
245+
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+
}
252+
}
253+
next.roots = next.roots.filter((r) => r !== moved.id);
254+
// 2. Re-insert after the anchor: anchor → moved → anchor's old successors.
255+
moved.next = [...anchor.next];
256+
anchor.next = [moved.id];
257+
return wouldCycle(next) ? wf : next;
258+
}
259+
185260
case "add-approval": {
186261
const id = uniqueId(next, slug(op.label));
187262
const post = postStepId(next);
@@ -358,6 +433,9 @@ export const WORKFLOW_EDIT_SYSTEM_PROMPT = `You translate a plain-language instr
358433
- set-threshold: change an existing approval step's amount threshold. Use the step's "stepId" from the current workflow.
359434
- set-approver: set the person on an existing approval step (by "stepId").
360435
- remove-step: remove a step by "stepId".
436+
- rename-step: change a step's title/label (by "stepId", set "label"). Use for "rename X to Y" / "call it Z".
437+
- duplicate-step: copy an existing step (by "stepId", set "label" for the copy). Use for "duplicate X" / "add another like X".
438+
- move-step: relocate an existing step to immediately AFTER another (by "stepId" + "afterStepId"). Use for "move X after Y" / "put X before the post".
361439
- 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.
362440
- 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.
363441
- none: if the instruction doesn't map to any of the above, or asks for something already true — give a short "reason".
@@ -403,6 +481,9 @@ Return { "ops": [ ... ] } where each op is one of:
403481
- set-threshold { stepId, amountOver }: change an existing gate's amount threshold.
404482
- set-approver { stepId, approverName }: set the person on a gate.
405483
- remove-step { stepId }.
484+
- rename-step { stepId, label }: change a step's title.
485+
- duplicate-step { stepId, label }: copy an existing step.
486+
- move-step { stepId, afterStepId }: relocate an existing step to after another.
406487
- insert-approval-after { afterStepId, label, approverTitle, amountOver|null, department|null }: insert a gate immediately AFTER one step.
407488
- 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").
408489
- none { reason }: only if NOTHING in the instruction maps to a supported edit.

0 commit comments

Comments
 (0)