@bastani/atomic 0.5.0-3 → 0.5.0-4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.atomic/workflows/hello/claude/index.ts +22 -25
- package/.atomic/workflows/hello/copilot/index.ts +41 -31
- package/.atomic/workflows/hello/opencode/index.ts +40 -40
- package/.atomic/workflows/hello-parallel/claude/index.ts +54 -54
- package/.atomic/workflows/hello-parallel/copilot/index.ts +89 -70
- package/.atomic/workflows/hello-parallel/opencode/index.ts +77 -77
- package/.atomic/workflows/ralph/claude/index.ts +128 -93
- package/.atomic/workflows/ralph/copilot/index.ts +212 -112
- package/.atomic/workflows/ralph/helpers/prompts.ts +45 -2
- package/.atomic/workflows/ralph/opencode/index.ts +174 -111
- package/README.md +62 -53
- package/package.json +1 -1
- package/src/commands/cli/chat/index.ts +28 -8
- package/src/commands/cli/init/index.ts +6 -4
- package/src/commands/cli/init/scm.ts +27 -10
- package/src/sdk/components/connectors.test.ts +45 -0
- package/src/sdk/components/layout.test.ts +321 -0
- package/src/sdk/components/layout.ts +51 -15
- package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
- package/src/sdk/components/orchestrator-panel-store.ts +24 -0
- package/src/sdk/components/orchestrator-panel.tsx +21 -0
- package/src/sdk/components/session-graph-panel.tsx +3 -9
- package/src/sdk/components/statusline.tsx +4 -6
- package/src/sdk/define-workflow.test.ts +71 -0
- package/src/sdk/define-workflow.ts +42 -39
- package/src/sdk/errors.ts +1 -1
- package/src/sdk/index.ts +4 -1
- package/src/sdk/providers/claude.ts +1 -1
- package/src/sdk/providers/copilot.ts +5 -3
- package/src/sdk/providers/opencode.ts +5 -3
- package/src/sdk/runtime/executor.ts +512 -301
- package/src/sdk/runtime/loader.ts +2 -2
- package/src/sdk/runtime/tmux.ts +31 -2
- package/src/sdk/types.ts +93 -20
- package/src/sdk/workflows.ts +7 -4
- package/src/services/config/definitions.ts +39 -2
- package/src/services/config/settings.ts +0 -6
- package/src/services/system/skills.ts +3 -7
- package/.atomic/workflows/package-lock.json +0 -31
- package/.atomic/workflows/package.json +0 -8
|
@@ -2,7 +2,11 @@ import { join } from "path";
|
|
|
2
2
|
import { readdir } from "fs/promises";
|
|
3
3
|
import { copyFile, pathExists, ensureDir } from "@/services/system/copy.ts";
|
|
4
4
|
import { getOppositeScriptExtension } from "@/services/system/detect.ts";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
SCM_SKILLS_BY_TYPE,
|
|
7
|
+
type AgentKey,
|
|
8
|
+
type SourceControlType,
|
|
9
|
+
} from "@/services/config/index.ts";
|
|
6
10
|
|
|
7
11
|
export const SCM_PREFIX_BY_TYPE: Record<SourceControlType, "gh-" | "sl-"> = {
|
|
8
12
|
github: "gh-",
|
|
@@ -127,14 +131,21 @@ export interface InstallLocalScmSkillsOptions {
|
|
|
127
131
|
|
|
128
132
|
export interface InstallLocalScmSkillsResult {
|
|
129
133
|
success: boolean;
|
|
134
|
+
/** The explicit skill names that were requested (e.g. `["gh-commit", "gh-create-pr"]`). */
|
|
135
|
+
skills: readonly string[];
|
|
130
136
|
/** Non-empty when `success` is false. */
|
|
131
137
|
details: string;
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
/**
|
|
135
|
-
* Install the SCM skill variants (gh
|
|
136
|
-
* project via `npx skills add`. The `-g`
|
|
137
|
-
* the skills are installed per-project
|
|
141
|
+
* Install the SCM skill variants (e.g. `gh-commit`, `gh-create-pr` for
|
|
142
|
+
* GitHub) locally into the current project via `npx skills add`. The `-g`
|
|
143
|
+
* flag is intentionally omitted so the skills are installed per-project
|
|
144
|
+
* (in the given `cwd`).
|
|
145
|
+
*
|
|
146
|
+
* Each skill is passed explicitly with `--skill <name>` — the skills CLI
|
|
147
|
+
* does not support glob patterns like `gh-*`, which would either fail or
|
|
148
|
+
* fall back to installing the entire skill set.
|
|
138
149
|
*
|
|
139
150
|
* This is best-effort: callers should treat a failed result as a warning,
|
|
140
151
|
* not as a fatal error.
|
|
@@ -144,13 +155,15 @@ export async function installLocalScmSkills(
|
|
|
144
155
|
): Promise<InstallLocalScmSkillsResult> {
|
|
145
156
|
const { scmType, agentKey, cwd } = options;
|
|
146
157
|
|
|
158
|
+
const skills = SCM_SKILLS_BY_TYPE[scmType];
|
|
159
|
+
|
|
147
160
|
const npxPath = Bun.which("npx");
|
|
148
161
|
if (!npxPath) {
|
|
149
|
-
return { success: false, details: "npx not found on PATH" };
|
|
162
|
+
return { success: false, skills, details: "npx not found on PATH" };
|
|
150
163
|
}
|
|
151
164
|
|
|
152
|
-
const pattern = `${getScmPrefix(scmType)}*`;
|
|
153
165
|
const agentFlag = SKILLS_AGENT_BY_KEY[agentKey];
|
|
166
|
+
const skillFlags = skills.flatMap((skill) => ["--skill", skill]);
|
|
154
167
|
|
|
155
168
|
try {
|
|
156
169
|
const proc = Bun.spawn({
|
|
@@ -160,8 +173,7 @@ export async function installLocalScmSkills(
|
|
|
160
173
|
"skills",
|
|
161
174
|
"add",
|
|
162
175
|
SKILLS_REPO,
|
|
163
|
-
|
|
164
|
-
pattern,
|
|
176
|
+
...skillFlags,
|
|
165
177
|
"-a",
|
|
166
178
|
agentFlag,
|
|
167
179
|
"-y",
|
|
@@ -177,13 +189,18 @@ export async function installLocalScmSkills(
|
|
|
177
189
|
proc.exited,
|
|
178
190
|
]);
|
|
179
191
|
if (exitCode === 0) {
|
|
180
|
-
return { success: true, details: "" };
|
|
192
|
+
return { success: true, skills, details: "" };
|
|
181
193
|
}
|
|
182
194
|
const details = stderr.trim().length > 0 ? stderr.trim() : stdout.trim();
|
|
183
|
-
return {
|
|
195
|
+
return {
|
|
196
|
+
success: false,
|
|
197
|
+
skills,
|
|
198
|
+
details: details || `exit code ${exitCode}`,
|
|
199
|
+
};
|
|
184
200
|
} catch (error) {
|
|
185
201
|
return {
|
|
186
202
|
success: false,
|
|
203
|
+
skills,
|
|
187
204
|
details: error instanceof Error ? error.message : String(error),
|
|
188
205
|
};
|
|
189
206
|
}
|
|
@@ -659,3 +659,48 @@ describe("buildMergeConnector", () => {
|
|
|
659
659
|
expect(barLine[84]).toBe("┤");
|
|
660
660
|
});
|
|
661
661
|
});
|
|
662
|
+
|
|
663
|
+
// ─── Integration tests: connectors with computeLayout-produced nodes ──────
|
|
664
|
+
|
|
665
|
+
describe("integration with computeLayout", () => {
|
|
666
|
+
test("connector from orchestrator to reclassified orphan child", () => {
|
|
667
|
+
const { computeLayout } = require("./layout.ts");
|
|
668
|
+
const layout = computeLayout([
|
|
669
|
+
{ name: "orchestrator", status: "running", parents: [], startedAt: null, endedAt: null },
|
|
670
|
+
{ name: "orphan", status: "pending", parents: ["nonexistent"], startedAt: null, endedAt: null },
|
|
671
|
+
]);
|
|
672
|
+
const orch = layout.map["orchestrator"];
|
|
673
|
+
expect(orch.children).toHaveLength(1);
|
|
674
|
+
const conn = buildConnector(orch, layout.rowH, mockTheme);
|
|
675
|
+
expect(conn).not.toBeNull();
|
|
676
|
+
expect(conn!.height).toBeGreaterThan(0);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("connector from parent to reclassified merge-to-single-parent node", () => {
|
|
680
|
+
const { computeLayout } = require("./layout.ts");
|
|
681
|
+
const layout = computeLayout([
|
|
682
|
+
{ name: "orchestrator", status: "running", parents: [], startedAt: null, endedAt: null },
|
|
683
|
+
{ name: "A", status: "pending", parents: ["orchestrator"], startedAt: null, endedAt: null },
|
|
684
|
+
{ name: "M", status: "pending", parents: ["A", "nonexistent"], startedAt: null, endedAt: null },
|
|
685
|
+
]);
|
|
686
|
+
const nodeA = layout.map["A"];
|
|
687
|
+
expect(nodeA.children).toHaveLength(1);
|
|
688
|
+
expect(nodeA.children[0].name).toBe("M");
|
|
689
|
+
const conn = buildConnector(nodeA, layout.rowH, mockTheme);
|
|
690
|
+
expect(conn).not.toBeNull();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("merge connector still works for valid merge nodes", () => {
|
|
694
|
+
const { computeLayout } = require("./layout.ts");
|
|
695
|
+
const layout = computeLayout([
|
|
696
|
+
{ name: "A", status: "pending", parents: [], startedAt: null, endedAt: null },
|
|
697
|
+
{ name: "B", status: "pending", parents: [], startedAt: null, endedAt: null },
|
|
698
|
+
{ name: "M", status: "pending", parents: ["A", "B"], startedAt: null, endedAt: null },
|
|
699
|
+
]);
|
|
700
|
+
const nodeM = layout.map["M"];
|
|
701
|
+
expect(nodeM.parents).toEqual(["A", "B"]);
|
|
702
|
+
const mergeConn = buildMergeConnector(nodeM, layout.rowH, layout.map, mockTheme);
|
|
703
|
+
expect(mergeConn).not.toBeNull();
|
|
704
|
+
expect(mergeConn!.height).toBeGreaterThan(0);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
@@ -921,4 +921,325 @@ describe("computeLayout", () => {
|
|
|
921
921
|
});
|
|
922
922
|
}
|
|
923
923
|
});
|
|
924
|
+
|
|
925
|
+
// ─── Edge cases: parent normalization ─────────
|
|
926
|
+
|
|
927
|
+
describe("missing parent fallback", () => {
|
|
928
|
+
test("session with missing parent falls back to orchestrator child", () => {
|
|
929
|
+
const r = computeLayout([
|
|
930
|
+
session("orchestrator"),
|
|
931
|
+
session("orphan", ["nonexistent"]),
|
|
932
|
+
]);
|
|
933
|
+
// orphan should be a child of orchestrator, not a root
|
|
934
|
+
expect(r.roots).toHaveLength(1);
|
|
935
|
+
expect(r.roots[0]!.name).toBe("orchestrator");
|
|
936
|
+
expect(r.map["orchestrator"]!.children).toHaveLength(1);
|
|
937
|
+
expect(r.map["orchestrator"]!.children[0]!.name).toBe("orphan");
|
|
938
|
+
expect(r.map["orphan"]!.depth).toBe(1);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("session with missing parent and no orchestrator becomes root", () => {
|
|
942
|
+
const r = computeLayout([
|
|
943
|
+
session("A"),
|
|
944
|
+
session("orphan", ["nonexistent"]),
|
|
945
|
+
]);
|
|
946
|
+
// Without orchestrator, orphan becomes a root alongside A
|
|
947
|
+
expect(r.roots).toHaveLength(2);
|
|
948
|
+
expect(r.roots.map((n) => n.name)).toContain("orphan");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("preserves raw parents array on LayoutNode even when normalized", () => {
|
|
952
|
+
const r = computeLayout([
|
|
953
|
+
session("orchestrator"),
|
|
954
|
+
session("orphan", ["nonexistent"]),
|
|
955
|
+
]);
|
|
956
|
+
// raw parents metadata should be unchanged
|
|
957
|
+
expect(r.map["orphan"]!.parents).toEqual(["nonexistent"]);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("multiple orphans all fall back to orchestrator", () => {
|
|
961
|
+
const r = computeLayout([
|
|
962
|
+
session("orchestrator"),
|
|
963
|
+
session("a", ["ghost-1"]),
|
|
964
|
+
session("b", ["ghost-2"]),
|
|
965
|
+
]);
|
|
966
|
+
expect(r.roots).toHaveLength(1);
|
|
967
|
+
expect(r.map["orchestrator"]!.children).toHaveLength(2);
|
|
968
|
+
expect(r.map["a"]!.depth).toBe(1);
|
|
969
|
+
expect(r.map["b"]!.depth).toBe(1);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("valid parent takes priority over orchestrator fallback", () => {
|
|
973
|
+
const r = computeLayout([
|
|
974
|
+
session("orchestrator"),
|
|
975
|
+
session("step-1", ["orchestrator"]),
|
|
976
|
+
session("child", ["step-1"]),
|
|
977
|
+
]);
|
|
978
|
+
// child should be under step-1, not orchestrator
|
|
979
|
+
expect(r.map["step-1"]!.children).toHaveLength(1);
|
|
980
|
+
expect(r.map["step-1"]!.children[0]!.name).toBe("child");
|
|
981
|
+
expect(r.map["child"]!.depth).toBe(2);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("orchestrator itself does not get reparented to itself", () => {
|
|
985
|
+
const r = computeLayout([session("orchestrator")]);
|
|
986
|
+
expect(r.roots).toHaveLength(1);
|
|
987
|
+
expect(r.roots[0]!.name).toBe("orchestrator");
|
|
988
|
+
expect(r.map["orchestrator"]!.children).toHaveLength(0);
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe("merge node with missing parents", () => {
|
|
993
|
+
test("merge with one missing parent reclassified as single-parent child", () => {
|
|
994
|
+
const r = computeLayout([
|
|
995
|
+
session("orchestrator"),
|
|
996
|
+
session("A", ["orchestrator"]),
|
|
997
|
+
session("M", ["A", "nonexistent"]),
|
|
998
|
+
]);
|
|
999
|
+
// M should become a single-parent child of A (not a merge node)
|
|
1000
|
+
expect(r.map["A"]!.children).toHaveLength(1);
|
|
1001
|
+
expect(r.map["A"]!.children[0]!.name).toBe("M");
|
|
1002
|
+
expect(r.map["M"]!.depth).toBe(2);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("merge with all missing parents falls back to orchestrator child", () => {
|
|
1006
|
+
const r = computeLayout([
|
|
1007
|
+
session("orchestrator"),
|
|
1008
|
+
session("M", ["ghost-1", "ghost-2"]),
|
|
1009
|
+
]);
|
|
1010
|
+
// All parents missing → falls back to orchestrator
|
|
1011
|
+
expect(r.map["orchestrator"]!.children).toHaveLength(1);
|
|
1012
|
+
expect(r.map["orchestrator"]!.children[0]!.name).toBe("M");
|
|
1013
|
+
expect(r.map["M"]!.depth).toBe(1);
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
test("merge with duplicate parents after filtering is deduplicated", () => {
|
|
1017
|
+
const r = computeLayout([
|
|
1018
|
+
session("orchestrator"),
|
|
1019
|
+
session("A", ["orchestrator"]),
|
|
1020
|
+
session("M", ["A", "A"]),
|
|
1021
|
+
]);
|
|
1022
|
+
// Duplicate "A" deduplicated → single parent → tree child of A
|
|
1023
|
+
expect(r.map["A"]!.children).toHaveLength(1);
|
|
1024
|
+
expect(r.map["A"]!.children[0]!.name).toBe("M");
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
describe("chained merge node ordering", () => {
|
|
1029
|
+
test("merge nodes in reverse order get correct depths", () => {
|
|
1030
|
+
// M2 depends on M1, but M2 appears first in the sessions array
|
|
1031
|
+
const r = computeLayout([
|
|
1032
|
+
session("A"),
|
|
1033
|
+
session("B"),
|
|
1034
|
+
session("C"),
|
|
1035
|
+
session("M2", ["M1", "C"]), // M2 before M1 in array
|
|
1036
|
+
session("M1", ["A", "B"]),
|
|
1037
|
+
]);
|
|
1038
|
+
expect(r.map["M1"]!.depth).toBe(1);
|
|
1039
|
+
expect(r.map["M2"]!.depth).toBe(2); // must be deeper than M1
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
test("indirect merge dependency: A,B → M1 → X; C,X → M2", () => {
|
|
1043
|
+
const r = computeLayout([
|
|
1044
|
+
session("A"),
|
|
1045
|
+
session("B"),
|
|
1046
|
+
session("C"),
|
|
1047
|
+
session("M1", ["A", "B"]),
|
|
1048
|
+
session("X", ["M1"]),
|
|
1049
|
+
session("M2", ["C", "X"]),
|
|
1050
|
+
]);
|
|
1051
|
+
expect(r.map["M1"]!.depth).toBe(1);
|
|
1052
|
+
expect(r.map["X"]!.depth).toBe(2);
|
|
1053
|
+
expect(r.map["M2"]!.depth).toBe(3);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("indirect merge dependency in forward session order", () => {
|
|
1057
|
+
const r = computeLayout([
|
|
1058
|
+
session("A"),
|
|
1059
|
+
session("B"),
|
|
1060
|
+
session("C"),
|
|
1061
|
+
session("M1", ["A", "B"]),
|
|
1062
|
+
session("X", ["M1"]),
|
|
1063
|
+
session("M2", ["C", "X"]),
|
|
1064
|
+
]);
|
|
1065
|
+
expect(r.map["M1"]!.depth).toBe(1);
|
|
1066
|
+
expect(r.map["X"]!.depth).toBe(2);
|
|
1067
|
+
expect(r.map["M2"]!.depth).toBe(3);
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
describe("deeply nested tree", () => {
|
|
1072
|
+
test("four levels of nesting with orchestrator", () => {
|
|
1073
|
+
const r = computeLayout([
|
|
1074
|
+
session("orchestrator"),
|
|
1075
|
+
session("step-1", ["orchestrator"]),
|
|
1076
|
+
session("step-2", ["step-1"]),
|
|
1077
|
+
session("step-3", ["step-2"]),
|
|
1078
|
+
session("step-4", ["step-3"]),
|
|
1079
|
+
]);
|
|
1080
|
+
expect(r.map["orchestrator"]!.depth).toBe(0);
|
|
1081
|
+
expect(r.map["step-1"]!.depth).toBe(1);
|
|
1082
|
+
expect(r.map["step-2"]!.depth).toBe(2);
|
|
1083
|
+
expect(r.map["step-3"]!.depth).toBe(3);
|
|
1084
|
+
expect(r.map["step-4"]!.depth).toBe(4);
|
|
1085
|
+
// All share x (single chain)
|
|
1086
|
+
const x0 = r.map["orchestrator"]!.x;
|
|
1087
|
+
for (const name of ["step-1", "step-2", "step-3", "step-4"]) {
|
|
1088
|
+
expect(r.map[name]!.x).toBe(x0);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
describe("merge node as single parent", () => {
|
|
1094
|
+
test("child of merge node is placed correctly", () => {
|
|
1095
|
+
const r = computeLayout([
|
|
1096
|
+
session("A"),
|
|
1097
|
+
session("B"),
|
|
1098
|
+
session("M", ["A", "B"]),
|
|
1099
|
+
session("child", ["M"]),
|
|
1100
|
+
]);
|
|
1101
|
+
expect(r.map["M"]!.depth).toBe(1);
|
|
1102
|
+
expect(r.map["child"]!.depth).toBe(2);
|
|
1103
|
+
// child should be directly below M
|
|
1104
|
+
expect(r.map["child"]!.x).toBe(r.map["M"]!.x);
|
|
1105
|
+
});
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
describe("wide fan-out from non-root node", () => {
|
|
1109
|
+
test("multiple children of a mid-tree node", () => {
|
|
1110
|
+
const r = computeLayout([
|
|
1111
|
+
session("orchestrator"),
|
|
1112
|
+
session("parent", ["orchestrator"]),
|
|
1113
|
+
session("c1", ["parent"]),
|
|
1114
|
+
session("c2", ["parent"]),
|
|
1115
|
+
session("c3", ["parent"]),
|
|
1116
|
+
]);
|
|
1117
|
+
expect(r.map["parent"]!.depth).toBe(1);
|
|
1118
|
+
expect(r.map["c1"]!.depth).toBe(2);
|
|
1119
|
+
expect(r.map["c2"]!.depth).toBe(2);
|
|
1120
|
+
expect(r.map["c3"]!.depth).toBe(2);
|
|
1121
|
+
// parent centered over children
|
|
1122
|
+
const parentX = r.map["parent"]!.x;
|
|
1123
|
+
const c1X = r.map["c1"]!.x;
|
|
1124
|
+
const c3X = r.map["c3"]!.x;
|
|
1125
|
+
expect(parentX).toBe(Math.round((c1X + c3X) / 2));
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// ─── Extended invariants for new topologies ───
|
|
1130
|
+
|
|
1131
|
+
describe("layout invariants (extended edge cases)", () => {
|
|
1132
|
+
const EDGE_TOPOLOGIES = [
|
|
1133
|
+
{
|
|
1134
|
+
name: "missing parent → orchestrator fallback",
|
|
1135
|
+
sessions: () => [
|
|
1136
|
+
session("orchestrator"),
|
|
1137
|
+
session("orphan", ["nonexistent"]),
|
|
1138
|
+
],
|
|
1139
|
+
},
|
|
1140
|
+
{
|
|
1141
|
+
name: "merge with missing parent reclassified",
|
|
1142
|
+
sessions: () => [
|
|
1143
|
+
session("orchestrator"),
|
|
1144
|
+
session("A", ["orchestrator"]),
|
|
1145
|
+
session("M", ["A", "nonexistent"]),
|
|
1146
|
+
],
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: "chained merges in reverse order",
|
|
1150
|
+
sessions: () => [
|
|
1151
|
+
session("A"),
|
|
1152
|
+
session("B"),
|
|
1153
|
+
session("C"),
|
|
1154
|
+
session("M2", ["M1", "C"]),
|
|
1155
|
+
session("M1", ["A", "B"]),
|
|
1156
|
+
],
|
|
1157
|
+
},
|
|
1158
|
+
{
|
|
1159
|
+
name: "indirect merge dependency",
|
|
1160
|
+
sessions: () => [
|
|
1161
|
+
session("A"),
|
|
1162
|
+
session("B"),
|
|
1163
|
+
session("C"),
|
|
1164
|
+
session("M1", ["A", "B"]),
|
|
1165
|
+
session("X", ["M1"]),
|
|
1166
|
+
session("M2", ["C", "X"]),
|
|
1167
|
+
],
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
name: "deeply nested (5 levels)",
|
|
1171
|
+
sessions: () => [
|
|
1172
|
+
session("orchestrator"),
|
|
1173
|
+
session("s1", ["orchestrator"]),
|
|
1174
|
+
session("s2", ["s1"]),
|
|
1175
|
+
session("s3", ["s2"]),
|
|
1176
|
+
session("s4", ["s3"]),
|
|
1177
|
+
],
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
name: "wide fan-out from non-root",
|
|
1181
|
+
sessions: () => [
|
|
1182
|
+
session("orchestrator"),
|
|
1183
|
+
session("parent", ["orchestrator"]),
|
|
1184
|
+
session("c1", ["parent"]),
|
|
1185
|
+
session("c2", ["parent"]),
|
|
1186
|
+
session("c3", ["parent"]),
|
|
1187
|
+
],
|
|
1188
|
+
},
|
|
1189
|
+
];
|
|
1190
|
+
|
|
1191
|
+
for (const topo of EDGE_TOPOLOGIES) {
|
|
1192
|
+
test(`[${topo.name}] all x values are >= PAD`, () => {
|
|
1193
|
+
const r = computeLayout(topo.sessions());
|
|
1194
|
+
for (const node of Object.values(r.map)) {
|
|
1195
|
+
expect(node.x).toBeGreaterThanOrEqual(PAD);
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
test(`[${topo.name}] all y values are >= PAD`, () => {
|
|
1200
|
+
const r = computeLayout(topo.sessions());
|
|
1201
|
+
for (const node of Object.values(r.map)) {
|
|
1202
|
+
expect(node.y).toBeGreaterThanOrEqual(PAD);
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
test(`[${topo.name}] width = max(node.x + NODE_W) + PAD`, () => {
|
|
1207
|
+
const r = computeLayout(topo.sessions());
|
|
1208
|
+
const rightmost = Math.max(...Object.values(r.map).map((n) => n.x + NODE_W));
|
|
1209
|
+
expect(r.width).toBe(rightmost + PAD);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
test(`[${topo.name}] height = max(node.y + NODE_H) + PAD`, () => {
|
|
1213
|
+
const r = computeLayout(topo.sessions());
|
|
1214
|
+
const bottommost = Math.max(...Object.values(r.map).map((n) => n.y + NODE_H));
|
|
1215
|
+
expect(r.height).toBe(bottommost + PAD);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test(`[${topo.name}] nodes at same depth share the same y`, () => {
|
|
1219
|
+
const r = computeLayout(topo.sessions());
|
|
1220
|
+
const byDepth: Record<number, number[]> = {};
|
|
1221
|
+
for (const node of Object.values(r.map)) {
|
|
1222
|
+
(byDepth[node.depth] ??= []).push(node.y);
|
|
1223
|
+
}
|
|
1224
|
+
for (const ys of Object.values(byDepth)) {
|
|
1225
|
+
const first = ys[0]!;
|
|
1226
|
+
for (const y of ys) expect(y).toBe(first);
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
test(`[${topo.name}] no horizontal overlap between nodes at same depth`, () => {
|
|
1231
|
+
const r = computeLayout(topo.sessions());
|
|
1232
|
+
const byDepth: Record<number, number[]> = {};
|
|
1233
|
+
for (const node of Object.values(r.map)) {
|
|
1234
|
+
(byDepth[node.depth] ??= []).push(node.x);
|
|
1235
|
+
}
|
|
1236
|
+
for (const xs of Object.values(byDepth)) {
|
|
1237
|
+
xs.sort((a, b) => a - b);
|
|
1238
|
+
for (let i = 1; i < xs.length; i++) {
|
|
1239
|
+
expect(xs[i]! - xs[i - 1]!).toBeGreaterThanOrEqual(NODE_W + H_GAP);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
924
1245
|
});
|
|
@@ -72,6 +72,32 @@ function resolveOverlaps(map: Record<string, LayoutNode>): void {
|
|
|
72
72
|
|
|
73
73
|
// ─── Layout Computation ───────────────────────────
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Compute effective parents for each session by filtering out references
|
|
77
|
+
* to sessions that don't exist in the map and deduplicating. Orphaned
|
|
78
|
+
* sessions (all parents missing) fall back to the "orchestrator" node
|
|
79
|
+
* when one is present, instead of becoming disconnected roots.
|
|
80
|
+
*/
|
|
81
|
+
function normalizeParents(
|
|
82
|
+
sessions: SessionData[],
|
|
83
|
+
map: Record<string, LayoutNode>,
|
|
84
|
+
): Map<string, string[]> {
|
|
85
|
+
const hasOrchestrator = "orchestrator" in map;
|
|
86
|
+
const effective = new Map<string, string[]>();
|
|
87
|
+
|
|
88
|
+
for (const s of sessions) {
|
|
89
|
+
const valid = [...new Set(s.parents)].filter((p) => p in map);
|
|
90
|
+
if (valid.length > 0) {
|
|
91
|
+
effective.set(s.name, valid);
|
|
92
|
+
} else if (hasOrchestrator && s.name !== "orchestrator") {
|
|
93
|
+
effective.set(s.name, ["orchestrator"]);
|
|
94
|
+
} else {
|
|
95
|
+
effective.set(s.name, []);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return effective;
|
|
99
|
+
}
|
|
100
|
+
|
|
75
101
|
export function computeLayout(sessions: SessionData[]): LayoutResult {
|
|
76
102
|
const map: Record<string, LayoutNode> = {};
|
|
77
103
|
const roots: LayoutNode[] = [];
|
|
@@ -92,27 +118,36 @@ export function computeLayout(sessions: SessionData[]): LayoutResult {
|
|
|
92
118
|
};
|
|
93
119
|
}
|
|
94
120
|
|
|
95
|
-
//
|
|
121
|
+
// Normalize parents: filter missing refs, dedupe, orchestrator fallback
|
|
122
|
+
const effective = normalizeParents(sessions, map);
|
|
123
|
+
|
|
124
|
+
// Classify using effective parents (preserves LayoutNode.parents as raw metadata)
|
|
96
125
|
for (const s of sessions) {
|
|
97
|
-
|
|
126
|
+
const ep = effective.get(s.name) ?? [];
|
|
127
|
+
if (ep.length > 1) {
|
|
98
128
|
mergeNodes.push(map[s.name]!);
|
|
99
|
-
} else if (
|
|
100
|
-
map[
|
|
129
|
+
} else if (ep.length === 1 && map[ep[0]!]) {
|
|
130
|
+
map[ep[0]!]!.children.push(map[s.name]!);
|
|
101
131
|
} else {
|
|
102
132
|
roots.push(map[s.name]!);
|
|
103
133
|
}
|
|
104
134
|
}
|
|
105
135
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
136
|
+
// Memoized depth resolution — handles tree children, merge nodes,
|
|
137
|
+
// indirect dependencies, and arbitrary session ordering.
|
|
138
|
+
const depthCache = new Map<string, number>();
|
|
139
|
+
function resolveDepth(name: string): number {
|
|
140
|
+
if (depthCache.has(name)) return depthCache.get(name)!;
|
|
141
|
+
depthCache.set(name, 0); // guard against cycles
|
|
142
|
+
const ep = effective.get(name) ?? [];
|
|
143
|
+
if (ep.length === 0) return 0;
|
|
144
|
+
const maxParentDepth = Math.max(...ep.map((p) => resolveDepth(p)));
|
|
145
|
+
const depth = maxParentDepth + 1;
|
|
146
|
+
depthCache.set(name, depth);
|
|
147
|
+
return depth;
|
|
109
148
|
}
|
|
110
|
-
for (const
|
|
111
|
-
|
|
112
|
-
// Merge nodes: depth = max(parent depths) + 1, then recurse into children
|
|
113
|
-
for (const m of mergeNodes) {
|
|
114
|
-
const maxParentDepth = Math.max(...m.parents.map((p) => map[p]?.depth ?? 0));
|
|
115
|
-
setDepth(m, maxParentDepth + 1);
|
|
149
|
+
for (const s of sessions) {
|
|
150
|
+
map[s.name]!.depth = resolveDepth(s.name);
|
|
116
151
|
}
|
|
117
152
|
|
|
118
153
|
const rowH: Record<number, number> = {};
|
|
@@ -149,9 +184,10 @@ export function computeLayout(sessions: SessionData[]): LayoutResult {
|
|
|
149
184
|
firstRoot = false;
|
|
150
185
|
}
|
|
151
186
|
|
|
152
|
-
// Place merge nodes centered under all parents (and their sub-trees)
|
|
187
|
+
// Place merge nodes centered under all effective parents (and their sub-trees)
|
|
153
188
|
for (const m of mergeNodes) {
|
|
154
|
-
const
|
|
189
|
+
const ep = effective.get(m.name) ?? [];
|
|
190
|
+
const parentCenters = ep.map((p) => (map[p]?.x ?? 0) + Math.floor(NODE_W / 2));
|
|
155
191
|
const avgCenter = Math.round(parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length);
|
|
156
192
|
|
|
157
193
|
if (m.children.length > 0) {
|