@bastani/atomic 0.5.0-3 → 0.5.0-5
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 +138 -59
- package/package.json +1 -1
- package/src/cli.ts +0 -2
- package/src/commands/cli/chat/index.ts +28 -8
- package/src/commands/cli/init/index.ts +7 -10
- 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-contexts.ts +13 -4
- 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 +8 -15
- 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
|
@@ -92,14 +92,20 @@ function buildLauncherScript(
|
|
|
92
92
|
cmd: string,
|
|
93
93
|
args: string[],
|
|
94
94
|
projectRoot: string,
|
|
95
|
+
envVars: Record<string, string> = {},
|
|
95
96
|
): { script: string; ext: string } {
|
|
96
97
|
const isWin = process.platform === "win32";
|
|
98
|
+
const envEntries = Object.entries(envVars);
|
|
97
99
|
|
|
98
100
|
if (isWin) {
|
|
99
101
|
// PowerShell: use array splatting for safe arg passing
|
|
100
102
|
const argList = args.map((a) => `"${escPwsh(a)}"`).join(", ");
|
|
103
|
+
const envLines = envEntries.map(
|
|
104
|
+
([key, value]) => `$env:${key} = "${escPwsh(value)}"`,
|
|
105
|
+
);
|
|
101
106
|
const script = [
|
|
102
107
|
`Set-Location "${escPwsh(projectRoot)}"`,
|
|
108
|
+
...envLines,
|
|
103
109
|
argList.length > 0
|
|
104
110
|
? `& "${escPwsh(cmd)}" @(${argList})`
|
|
105
111
|
: `& "${escPwsh(cmd)}"`,
|
|
@@ -111,9 +117,13 @@ function buildLauncherScript(
|
|
|
111
117
|
const quotedArgs = args
|
|
112
118
|
.map((a) => `"${escBash(a)}"`)
|
|
113
119
|
.join(" ");
|
|
120
|
+
const envLines = envEntries.map(
|
|
121
|
+
([key, value]) => `export ${key}="${escBash(value)}"`,
|
|
122
|
+
);
|
|
114
123
|
const script = [
|
|
115
124
|
"#!/bin/bash",
|
|
116
125
|
`cd "${escBash(projectRoot)}"`,
|
|
126
|
+
...envLines,
|
|
117
127
|
`exec "${escBash(cmd)}" ${quotedArgs}`,
|
|
118
128
|
].join("\n");
|
|
119
129
|
return { script, ext: "sh" };
|
|
@@ -162,15 +172,16 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
|
|
|
162
172
|
// ── Build argv ──
|
|
163
173
|
const args = buildAgentArgs(agentType, passthroughArgs);
|
|
164
174
|
const cmd = [config.cmd, ...args];
|
|
175
|
+
const envVars = config.env_vars;
|
|
165
176
|
|
|
166
177
|
// ── Inside tmux: spawn inline in the current pane ──
|
|
167
178
|
if (isInsideTmux()) {
|
|
168
|
-
return spawnDirect(cmd, projectRoot);
|
|
179
|
+
return spawnDirect(cmd, projectRoot, envVars);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
// ── No TTY: tmux attach requires a real terminal ──
|
|
172
183
|
if (!process.stdin.isTTY) {
|
|
173
|
-
return spawnDirect(cmd, projectRoot);
|
|
184
|
+
return spawnDirect(cmd, projectRoot, envVars);
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
// ── Ensure tmux is available ──
|
|
@@ -184,7 +195,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
|
|
|
184
195
|
}
|
|
185
196
|
if (!isTmuxInstalled()) {
|
|
186
197
|
// No tmux available — fall back to direct spawn
|
|
187
|
-
return spawnDirect(cmd, projectRoot);
|
|
198
|
+
return spawnDirect(cmd, projectRoot, envVars);
|
|
188
199
|
}
|
|
189
200
|
}
|
|
190
201
|
|
|
@@ -194,7 +205,12 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
|
|
|
194
205
|
|
|
195
206
|
const sessionsDir = join(homedir(), ".atomic", "sessions", "chat");
|
|
196
207
|
await mkdir(sessionsDir, { recursive: true });
|
|
197
|
-
const { script, ext } = buildLauncherScript(
|
|
208
|
+
const { script, ext } = buildLauncherScript(
|
|
209
|
+
config.cmd,
|
|
210
|
+
args,
|
|
211
|
+
projectRoot,
|
|
212
|
+
envVars,
|
|
213
|
+
);
|
|
198
214
|
const launcherPath = join(sessionsDir, `${windowName}.${ext}`);
|
|
199
215
|
await writeFile(launcherPath, script, { mode: 0o755 });
|
|
200
216
|
|
|
@@ -218,7 +234,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
|
|
|
218
234
|
// If tmux attach itself failed (e.g. lost TTY), clean up and fall back
|
|
219
235
|
if (exitCode !== 0) {
|
|
220
236
|
try { killSession(windowName); } catch {}
|
|
221
|
-
return spawnDirect(cmd, projectRoot);
|
|
237
|
+
return spawnDirect(cmd, projectRoot, envVars);
|
|
222
238
|
}
|
|
223
239
|
|
|
224
240
|
return exitCode;
|
|
@@ -228,7 +244,7 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
|
|
|
228
244
|
console.error(
|
|
229
245
|
`${COLORS.yellow}Warning: Failed to create tmux session (${message}). Falling back to direct spawn.${COLORS.reset}`
|
|
230
246
|
);
|
|
231
|
-
return spawnDirect(cmd, projectRoot);
|
|
247
|
+
return spawnDirect(cmd, projectRoot, envVars);
|
|
232
248
|
}
|
|
233
249
|
}
|
|
234
250
|
|
|
@@ -236,11 +252,15 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
|
|
|
236
252
|
* Spawn the agent CLI directly with inherited stdio.
|
|
237
253
|
* Used when not inside tmux.
|
|
238
254
|
*/
|
|
239
|
-
async function spawnDirect(
|
|
255
|
+
async function spawnDirect(
|
|
256
|
+
cmd: string[],
|
|
257
|
+
projectRoot: string,
|
|
258
|
+
envVars: Record<string, string> = {},
|
|
259
|
+
): Promise<number> {
|
|
240
260
|
const proc = Bun.spawn(cmd, {
|
|
241
261
|
stdio: ["inherit", "inherit", "inherit"],
|
|
242
262
|
cwd: projectRoot,
|
|
243
|
-
env: { ...process.env },
|
|
263
|
+
env: { ...process.env, ...envVars },
|
|
244
264
|
});
|
|
245
265
|
|
|
246
266
|
return await proc.exited;
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getAgentKeys,
|
|
22
22
|
isValidAgent,
|
|
23
23
|
SCM_CONFIG,
|
|
24
|
+
SCM_SKILLS_BY_TYPE,
|
|
24
25
|
type SourceControlType,
|
|
25
26
|
getScmKeys,
|
|
26
27
|
isValidScm,
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
getTemplateAgentFolder,
|
|
36
37
|
} from "@/services/config/atomic-global-config.ts";
|
|
37
38
|
import {
|
|
38
|
-
getScmPrefix,
|
|
39
39
|
installLocalScmSkills,
|
|
40
40
|
reconcileScmVariants,
|
|
41
41
|
syncProjectScmSkills,
|
|
@@ -167,8 +167,6 @@ interface InitOptions {
|
|
|
167
167
|
/** Pre-selected source control type (skip SCM selection prompt) */
|
|
168
168
|
preSelectedScm?: SourceControlType;
|
|
169
169
|
configNotFoundMessage?: string;
|
|
170
|
-
/** Force overwrite of preserved files (bypass preservation/merge logic) */
|
|
171
|
-
force?: boolean;
|
|
172
170
|
/** Auto-confirm all prompts (non-interactive mode for CI/testing) */
|
|
173
171
|
yes?: boolean;
|
|
174
172
|
/**
|
|
@@ -330,10 +328,7 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
|
|
|
330
328
|
const targetFolder = join(targetDir, agent.folder);
|
|
331
329
|
const folderExists = await pathExists(targetFolder);
|
|
332
330
|
|
|
333
|
-
|
|
334
|
-
const shouldForce = options.force ?? false;
|
|
335
|
-
|
|
336
|
-
if (folderExists && !shouldForce && !autoConfirm) {
|
|
331
|
+
if (folderExists && !autoConfirm) {
|
|
337
332
|
const update = await confirm({
|
|
338
333
|
message: `${agent.folder} already exists. Update source control skills?`,
|
|
339
334
|
initialValue: true,
|
|
@@ -404,9 +399,11 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
|
|
|
404
399
|
// skip the network-backed skills CLI in that case to keep dev iteration
|
|
405
400
|
// fast and offline-friendly.
|
|
406
401
|
if (import.meta.dir.includes("node_modules")) {
|
|
402
|
+
const skillsToInstall = SCM_SKILLS_BY_TYPE[scmType];
|
|
403
|
+
const skillsLabel = skillsToInstall.join(", ");
|
|
407
404
|
const skillsSpinner = spinner();
|
|
408
405
|
skillsSpinner.start(
|
|
409
|
-
`Installing ${
|
|
406
|
+
`Installing ${skillsLabel} locally for ${agent.name}...`,
|
|
410
407
|
);
|
|
411
408
|
const skillsResult = await installLocalScmSkills({
|
|
412
409
|
scmType,
|
|
@@ -415,11 +412,11 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
|
|
|
415
412
|
});
|
|
416
413
|
if (skillsResult.success) {
|
|
417
414
|
skillsSpinner.stop(
|
|
418
|
-
`Installed ${
|
|
415
|
+
`Installed ${skillsLabel} locally for ${agent.name}`,
|
|
419
416
|
);
|
|
420
417
|
} else {
|
|
421
418
|
skillsSpinner.stop(
|
|
422
|
-
`Skipped local ${
|
|
419
|
+
`Skipped local ${skillsLabel} install (${skillsResult.details})`,
|
|
423
420
|
);
|
|
424
421
|
}
|
|
425
422
|
}
|
|
@@ -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
|
});
|