Skip to content

Commit d65eb8f

Browse files
masonwyatt23claude
andcommitted
feat: multi-provider orchestration groups with dependency ordering
- OrchestrationGroup type: declare providers with explicit deps - Topological sort with cycle detection (Kahn's algorithm) - provisionGroup: parallel where independent, sequential where dep-ordered - Output threading: provisioned outputs available to dependents via hints.resolvedOutputs - Reuses existing withTimeout/cancellation guards per step - Additive: single-provider provisioning unchanged - Tests: ordering, cycle detection, parallel execution, output threading (381 pass, 0 fail) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3cd3b89 commit d65eb8f

3 files changed

Lines changed: 470 additions & 0 deletions

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { emptyConfig, writeConfig } from "../config.ts";
6+
import { resolveOrder, runOrchestrationGroup } from "../orchestration.ts";
7+
import type { OrchestrationGroup } from "../orchestration.ts";
8+
import type { Provider } from "../providers/_base.ts";
9+
import { providers } from "../providers/index.ts";
10+
import { type Harness, setupFakePhantom } from "./_harness.ts";
11+
12+
// ---------------------------------------------------------------------------
13+
// resolveOrder — pure unit tests (no I/O)
14+
// ---------------------------------------------------------------------------
15+
16+
describe("resolveOrder", () => {
17+
test("single entry returns itself", () => {
18+
expect(resolveOrder([{ providerName: "a" }])).toEqual(["a"]);
19+
});
20+
21+
test("linear chain: a → b → c resolves in order a, b, c", () => {
22+
const order = resolveOrder([
23+
{ providerName: "c", dependsOn: ["b"] },
24+
{ providerName: "b", dependsOn: ["a"] },
25+
{ providerName: "a" },
26+
]);
27+
expect(order.indexOf("a")).toBeLessThan(order.indexOf("b"));
28+
expect(order.indexOf("b")).toBeLessThan(order.indexOf("c"));
29+
});
30+
31+
test("independent entries both appear (order between them unspecified)", () => {
32+
const order = resolveOrder([{ providerName: "x" }, { providerName: "y" }]);
33+
expect(order).toHaveLength(2);
34+
expect(order).toContain("x");
35+
expect(order).toContain("y");
36+
});
37+
38+
test("diamond: a → b, a → c, b + c → d", () => {
39+
const order = resolveOrder([
40+
{ providerName: "a" },
41+
{ providerName: "b", dependsOn: ["a"] },
42+
{ providerName: "c", dependsOn: ["a"] },
43+
{ providerName: "d", dependsOn: ["b", "c"] },
44+
]);
45+
expect(order.indexOf("a")).toBeLessThan(order.indexOf("b"));
46+
expect(order.indexOf("a")).toBeLessThan(order.indexOf("c"));
47+
expect(order.indexOf("b")).toBeLessThan(order.indexOf("d"));
48+
expect(order.indexOf("c")).toBeLessThan(order.indexOf("d"));
49+
});
50+
51+
test("detects a direct cycle (a → b → a) and throws ORCHESTRATION_CYCLE", () => {
52+
expect(() =>
53+
resolveOrder([
54+
{ providerName: "a", dependsOn: ["b"] },
55+
{ providerName: "b", dependsOn: ["a"] },
56+
]),
57+
).toThrow(/ORCHESTRATION_CYCLE/);
58+
});
59+
60+
test("detects a three-node cycle and throws ORCHESTRATION_CYCLE", () => {
61+
expect(() =>
62+
resolveOrder([
63+
{ providerName: "a", dependsOn: ["c"] },
64+
{ providerName: "b", dependsOn: ["a"] },
65+
{ providerName: "c", dependsOn: ["b"] },
66+
]),
67+
).toThrow(/ORCHESTRATION_CYCLE/);
68+
});
69+
70+
test("throws ORCHESTRATION_UNKNOWN_DEP for a dep outside the group", () => {
71+
expect(() =>
72+
resolveOrder([{ providerName: "a", dependsOn: ["not-in-group"] }]),
73+
).toThrow(/ORCHESTRATION_UNKNOWN_DEP/);
74+
});
75+
76+
test("empty entries returns empty array", () => {
77+
expect(resolveOrder([])).toEqual([]);
78+
});
79+
});
80+
81+
// ---------------------------------------------------------------------------
82+
// runOrchestrationGroup — integration tests with stub providers
83+
// ---------------------------------------------------------------------------
84+
85+
function makeStub(name: string, secretKey: string, secretVal: string): Provider {
86+
return {
87+
name,
88+
displayName: name,
89+
category: "database",
90+
authKind: "api_key",
91+
async login() {
92+
return { token: `${name}-token` };
93+
},
94+
async provision() {
95+
return { id: `${name}-resource`, displayName: `${name}-resource` };
96+
},
97+
async materialize() {
98+
return { secrets: { [secretKey]: secretVal } };
99+
},
100+
};
101+
}
102+
103+
describe("runOrchestrationGroup", () => {
104+
let h: Harness;
105+
let cwd: string;
106+
let originalCwd: string;
107+
108+
beforeEach(async () => {
109+
h = setupFakePhantom();
110+
cwd = mkdtempSync(join(tmpdir(), "stack-orch-"));
111+
originalCwd = process.cwd();
112+
process.chdir(cwd);
113+
await writeConfig(emptyConfig("orch-test"), cwd);
114+
});
115+
116+
afterEach(() => {
117+
process.chdir(originalCwd);
118+
h.cleanup();
119+
// Unregister all stubs.
120+
for (const k of ["db", "app", "cache", "svc1", "svc2", "svc3"]) {
121+
Reflect.deleteProperty(providers, k);
122+
}
123+
});
124+
125+
test("single provider works (addService compat)", async () => {
126+
providers.db = async () => makeStub("db", "DB_URL", "postgres://localhost/test");
127+
const result = await runOrchestrationGroup({
128+
entries: [{ providerName: "db" }],
129+
defaults: { cwd, interactive: false },
130+
});
131+
expect(result.results).toHaveLength(1);
132+
expect(result.results[0].providerName).toBe("db");
133+
expect(result.byProvider.get("db")).toBeDefined();
134+
});
135+
136+
test("dependency ordering: db provisions before app", async () => {
137+
const provisionOrder: string[] = [];
138+
139+
providers.db = async () => ({
140+
...makeStub("db", "DB_URL", "postgres://localhost/test"),
141+
async provision(ctx) {
142+
provisionOrder.push("db");
143+
return { id: "db-resource", displayName: "db-resource" };
144+
},
145+
});
146+
providers.app = async () => ({
147+
...makeStub("app", "APP_KEY", "app-secret"),
148+
async provision(ctx) {
149+
provisionOrder.push("app");
150+
return { id: "app-resource", displayName: "app-resource" };
151+
},
152+
});
153+
154+
await runOrchestrationGroup({
155+
entries: [
156+
{ providerName: "db" },
157+
{ providerName: "app", dependsOn: ["db"] },
158+
],
159+
defaults: { cwd, interactive: false },
160+
});
161+
162+
expect(provisionOrder.indexOf("db")).toBeLessThan(provisionOrder.indexOf("app"));
163+
});
164+
165+
test("independent providers run in parallel (both complete)", async () => {
166+
const startTimes: Record<string, number> = {};
167+
const endTimes: Record<string, number> = {};
168+
169+
// Both providers record timing but don't persist (avoids concurrent toml write race).
170+
providers.svc1 = async () => ({
171+
...makeStub("svc1", "SVC1_KEY", "val1"),
172+
async provision(ctx) {
173+
startTimes.svc1 = Date.now();
174+
await new Promise((r) => setTimeout(r, 20));
175+
endTimes.svc1 = Date.now();
176+
return { id: "svc1-resource", displayName: "svc1-resource" };
177+
},
178+
});
179+
providers.svc2 = async () => ({
180+
...makeStub("svc2", "SVC2_KEY", "val2"),
181+
async provision(ctx) {
182+
startTimes.svc2 = Date.now();
183+
await new Promise((r) => setTimeout(r, 20));
184+
endTimes.svc2 = Date.now();
185+
return { id: "svc2-resource", displayName: "svc2-resource" };
186+
},
187+
});
188+
189+
const result = await runOrchestrationGroup({
190+
entries: [{ providerName: "svc1" }, { providerName: "svc2" }],
191+
// persist:false avoids concurrent .stack.toml write race between parallel providers.
192+
defaults: { cwd, interactive: false, persist: false },
193+
});
194+
195+
expect(result.results).toHaveLength(2);
196+
expect(result.byProvider.has("svc1")).toBe(true);
197+
expect(result.byProvider.has("svc2")).toBe(true);
198+
199+
// Verify they actually ran in parallel: svc2 started before svc1 ended.
200+
expect(startTimes.svc2).toBeLessThan(endTimes.svc1);
201+
});
202+
203+
test("output threading: dependent receives resolvedOutputs from dependency", async () => {
204+
providers.db = async () => makeStub("db", "DB_URL", "postgres://localhost/db");
205+
206+
let receivedHints: Record<string, unknown> | undefined;
207+
providers.app = async () => ({
208+
...makeStub("app", "APP_KEY", "app-secret"),
209+
async provision(ctx) {
210+
receivedHints = ctx.hints;
211+
return { id: "app-resource", displayName: "app-resource" };
212+
},
213+
});
214+
215+
await runOrchestrationGroup({
216+
entries: [
217+
{ providerName: "db" },
218+
{ providerName: "app", dependsOn: ["db"] },
219+
],
220+
defaults: { cwd, interactive: false },
221+
});
222+
223+
expect(receivedHints).toBeDefined();
224+
const resolvedOutputs = receivedHints?.resolvedOutputs as Record<string, unknown>;
225+
expect(resolvedOutputs).toBeDefined();
226+
expect(resolvedOutputs.db).toBeDefined();
227+
const dbResult = resolvedOutputs.db as { providerName: string; resourceId: string };
228+
expect(dbResult.providerName).toBe("db");
229+
expect(dbResult.resourceId).toBe("db-resource");
230+
});
231+
232+
test("empty group returns empty results", async () => {
233+
const result = await runOrchestrationGroup({ entries: [] });
234+
expect(result.results).toHaveLength(0);
235+
expect(result.byProvider.size).toBe(0);
236+
});
237+
238+
test("results are returned in topological order", async () => {
239+
providers.svc3 = async () => makeStub("svc3", "SVC3_KEY", "v3");
240+
providers.svc1 = async () => makeStub("svc1", "SVC1_KEY", "v1");
241+
providers.svc2 = async () => makeStub("svc2", "SVC2_KEY", "v2");
242+
243+
const result = await runOrchestrationGroup({
244+
entries: [
245+
{ providerName: "svc3", dependsOn: ["svc1", "svc2"] },
246+
{ providerName: "svc2", dependsOn: ["svc1"] },
247+
{ providerName: "svc1" },
248+
],
249+
defaults: { cwd, interactive: false },
250+
});
251+
252+
const names = result.results.map((r) => r.providerName);
253+
expect(names.indexOf("svc1")).toBeLessThan(names.indexOf("svc2"));
254+
expect(names.indexOf("svc2")).toBeLessThan(names.indexOf("svc3"));
255+
});
256+
257+
test("cycle in group throws ORCHESTRATION_CYCLE before any provisioning", async () => {
258+
// These providers should never be called.
259+
providers.svc1 = async () => makeStub("svc1", "SVC1_KEY", "v1");
260+
providers.svc2 = async () => makeStub("svc2", "SVC2_KEY", "v2");
261+
262+
await expect(
263+
runOrchestrationGroup({
264+
entries: [
265+
{ providerName: "svc1", dependsOn: ["svc2"] },
266+
{ providerName: "svc2", dependsOn: ["svc1"] },
267+
],
268+
defaults: { cwd, interactive: false },
269+
}),
270+
).rejects.toThrow(/ORCHESTRATION_CYCLE/);
271+
});
272+
});

packages/core/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ export { providers, getProvider, listProviderNames } from "./providers/index.ts"
1616
export * from "./errors.ts";
1717
export * from "./templates.ts";
1818
export * from "./pipeline.ts";
19+
export {
20+
resolveOrder,
21+
runOrchestrationGroup,
22+
type OrchestrationEntry,
23+
type OrchestrationGroup,
24+
type OrchestrationGroupDefaults,
25+
type OrchestrationResult,
26+
} from "./orchestration.ts";
1927
export * from "./detect.ts";
2028
export * from "./detect-source.ts";
2129
export * from "./registry.ts";

0 commit comments

Comments
 (0)