Skill Tracer
Skills › Archify › archify › workflow-placement-repair.mjs

workflow-placement-repair.mjs

94 lines · archify/renderers/workflow/workflow-placement-repair.mjs at a406594

1// One optional placement proposal for a failed readable workflow.
2// Never applied to the authored input; the compiler verifies the complete result.
3export function proposeWorkflowPlacement(input) {
4 if (input.schema_version !== 2 || input.meta?.viewBox
5 || input.groups?.length || input.phases?.length
6 || !input.mainPath?.length || input.nodes.length > 64
7 || input.edges.some(e => ['via', 'channelX', 'channelY', 'labelAt'].some(k => e[k] !== undefined))) {
8 return { eligible: false, reason: 'Needs unpinned v2, complete mainPath, no authored regions, <=64 nodes.' };
9 }
10 const main = input.mainPath;
11 const mainIndex = new Map(main.map((id, i) => [id, i]));
12 const nodes = new Map(input.nodes.map(n => [n.id, n]));
13 const laneIndex = new Map(input.lanes.map((l, i) => [l.id, i]));
14 if (main.some(id => !nodes.has(id)) || new Set(main).size !== main.length)
15 return { eligible: false, reason: 'Invalid mainPath.' };
16 const out = new Map(input.nodes.map(n => [n.id, input.edges.filter(e => e.from === n.id).map(e => e.to)]));
17 function reachable(id) {
18 const found = new Set(), todo = [...out.get(id)];
19 while (todo.length) {
20 const next = todo.pop();
21 if (found.has(next) || !nodes.has(next)) continue;
22 found.add(next); todo.push(...out.get(next));
23 }
24 return found;
25 }
26 const reach = new Map(input.nodes.map(n => [n.id, reachable(n.id)]));
27 const offMain = input.nodes.filter(n => !mainIndex.has(n.id));
28 const offIds = new Set(offMain.map(n => n.id));
29 const components = [], assigned = new Set();
30 for (const n of offMain) {
31 if (assigned.has(n.id)) continue;
32 const component = [], todo = [n.id];
33 while (todo.length) {
34 const id = todo.pop();
35 if (assigned.has(id)) continue;
36 assigned.add(id); component.push(id);
37 for (const e of input.edges) {
38 if (e.from === id && offIds.has(e.to)) todo.push(e.to);
39 if (e.to === id && offIds.has(e.from)) todo.push(e.from);
40 }
41 }
42 const cyclic = component.some(id => main.some(m => reach.get(id).has(m) && reach.get(m).has(id)));
43 components.push({ ids: component, cyclic });
44 }
45 const leftRail = components.some(c => c.cyclic);
46 if (!leftRail) return { eligible: false, reason: 'No return cycle connected to the main path.' };
47 const rightRail = components.some(c => !c.cyclic && c.ids.length > 1);
48 const start = leftRail ? 1 : 0, end = rightRail ? 4 : 5;
49 const proposed = structuredClone(input), moved = new Map(proposed.nodes.map(n => [n.id, n]));
50 let col = start;
51 for (let i = 0; i < main.length; i++) {
52 const current = moved.get(main[i]), previous = moved.get(main[i - 1]);
53 if (previous && laneIndex.get(current.lane) <= laneIndex.get(previous.lane)) col = Math.min(col + 1, end);
54 current.col = col;
55 }
56 for (const component of components) {
57 for (const id of component.ids) {
58 const n = moved.get(id);
59 const parents = input.edges.filter(e => e.to === id && mainIndex.has(e.from)).map(e => moved.get(e.from));
60 n.col = component.cyclic ? 0 : component.ids.length > 1 ? 5
61 : Math.min(end, Math.max(start, ...parents.map(p => p.col + 1)));
62 // Place same-lane failure branches below their main-path source row.
63 if (component.cyclic && parents.some(p => p.lane === n.lane)) {
64 const sameLane = main.map(m => moved.get(m)).filter(m => m.lane === n.lane);
65 n.yOffset = Math.max(...sameLane.map(m => (m.yOffset || 0) + (m.height || 52) / 2)) + (n.height || 52) / 2 + 40;
66 }
67 }
68 }
69 const groups = new Map();
70 for (const n of proposed.nodes) {
71 const key = n.lane + ':' + n.col;
72 groups.set(key, [...groups.get(key) || [], n]);
73 }
74 function order(n) {
75 if (mainIndex.has(n.id)) return mainIndex.get(n.id);
76 const incoming = input.edges.filter(e => e.to === n.id && mainIndex.has(e.from)).map(e => mainIndex.get(e.from));
77 return incoming.length ? Math.min(...incoming) + 0.5 : main.length;
78 }
79 for (const group of groups.values()) {
80 if (group.length < 2) continue;
81 group.sort((a, b) => order(a) - order(b) || a.id.localeCompare(b.id));
82 const total = group.reduce((sum, n) => sum + (n.height || 52), 0) + 40 * (group.length - 1);
83 let top = -total / 2;
84 for (const n of group) {
85 n.yOffset = top + (n.height || 52) / 2;
86 top += (n.height || 52) + 40;
87 }
88 }
89 const changes = [];
90 for (let i = 0; i < input.nodes.length; i++) for (const field of ['col', 'yOffset']) {
91 if (input.nodes[i][field] !== proposed.nodes[i][field]) changes.push({op: field in input.nodes[i] ? 'replace' : 'add', path: `/nodes/${i}/${field}`, value: proposed.nodes[i][field]});
92 }
93 return {eligible: true, proposed, changes, components, rails: {left: leftRail, right: rightRail}};