@effectstream/orchestrator 0.100.9

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.
@@ -0,0 +1,276 @@
1
+ import type { ProcessConfig } from "./config.ts";
2
+ import type { ProcessManager } from "./process-manager.ts";
3
+
4
+ export type RunOptions = {
5
+ /**
6
+ * If set, only these processes (plus all their transitive dependencies)
7
+ * will be launched. All other configs are ignored.
8
+ */
9
+ only?: string[];
10
+ /**
11
+ * Names of processes to skip (even if they appear as dependencies of others).
12
+ * Use with care—skipping a dependency may break its dependents.
13
+ */
14
+ except?: string[];
15
+ /**
16
+ * If true, processes are launched one at a time in dependency order
17
+ * instead of in parallel waves. Useful when binaries conflict at the OS level.
18
+ */
19
+ serial?: boolean;
20
+ /**
21
+ * If true, --only launches only the named processes without pulling in
22
+ * their transitive dependencies. Dependencies are assumed to be already running.
23
+ */
24
+ noDeps?: boolean;
25
+ onTaskStart?: (name: string, logFile: string | null) => void;
26
+ /** Called for background processes (waitToExit: false) once launched. */
27
+ onTaskRunning?: (name: string) => void;
28
+ onTaskDone?: (name: string) => void;
29
+ onTaskFailed?: (name: string, code: number) => void;
30
+ /** Called when a critical process fails and the whole run should stop. */
31
+ onShutdown?: (reason: string) => void;
32
+ };
33
+
34
+ /**
35
+ * Returns `configs` filtered to only include the named processes and all of
36
+ * their transitive dependencies (preserving original order).
37
+ */
38
+ export function resolveWithDependencies(
39
+ all: ProcessConfig[],
40
+ names: string[],
41
+ ): ProcessConfig[] {
42
+ const map = new Map(all.map((p) => [p.name, p]));
43
+ const resolved = new Set<string>();
44
+
45
+ function resolve(name: string, chain: string[] = []) {
46
+ if (resolved.has(name)) return;
47
+ if (chain.includes(name)) {
48
+ throw new Error(
49
+ `Circular dependency detected: ${[...chain, name].join(" → ")}`,
50
+ );
51
+ }
52
+ const p = map.get(name);
53
+ if (!p) throw new Error(`Process "${name}" not found in config`);
54
+ for (const dep of p.dependsOn ?? []) {
55
+ resolve(dep, [...chain, name]);
56
+ }
57
+ resolved.add(name);
58
+ }
59
+
60
+ for (const name of names) resolve(name);
61
+ return all.filter((p) => resolved.has(p.name));
62
+ }
63
+
64
+ /**
65
+ * Runs processes in dependency order using `manager`.
66
+ *
67
+ * - All processes whose dependencies are satisfied are launched IN PARALLEL
68
+ * as a "wave". This avoids slow sequential freePort/spawn calls blocking
69
+ * unrelated processes.
70
+ * - Processes with `waitToExit: true` must exit (successfully) before their
71
+ * dependents can start.
72
+ * - Processes with `waitToExit: false` (default) are considered "done" for
73
+ * dependency purposes as soon as they are launched.
74
+ * - The function resolves once every process has been launched (and all
75
+ * `waitToExit` ones have finished).
76
+ */
77
+ export async function runProcesses(
78
+ allConfigs: ProcessConfig[],
79
+ manager: ProcessManager,
80
+ options: RunOptions = {},
81
+ ): Promise<void> {
82
+ // Apply filters
83
+ let configs = allConfigs;
84
+
85
+ if (options.only && options.only.length > 0) {
86
+ if (options.noDeps) {
87
+ const onlySet = new Set(options.only);
88
+ configs = allConfigs.filter((p) => onlySet.has(p.name));
89
+ } else {
90
+ configs = resolveWithDependencies(allConfigs, options.only);
91
+ }
92
+ } else {
93
+ // When no --only filter, exclude autoStart: false processes
94
+ configs = configs.filter((p) => p.autoStart !== false);
95
+ }
96
+
97
+ if (options.except && options.except.length > 0) {
98
+ const skip = new Set(options.except);
99
+ configs = configs.filter((p) => !skip.has(p.name));
100
+ }
101
+
102
+ // Build task graph
103
+ const configMap = new Map(configs.map((c) => [c.name, c]));
104
+
105
+ // Pre-seed "done" with processes already managed (from earlier start calls).
106
+ // A process that is currently running or has exited successfully counts as done
107
+ // for dependency resolution, so new processes can depend on already-launched ones.
108
+ const done = new Set<string>();
109
+ for (const p of manager.getAll()) {
110
+ if (p.status === "running" || p.status === "done") {
111
+ done.add(p.name);
112
+ }
113
+ }
114
+
115
+ // Validate that all declared dependencies are present in the run set or already done
116
+ if (!options.noDeps) {
117
+ for (const config of configs) {
118
+ for (const dep of config.dependsOn ?? []) {
119
+ if (!configMap.has(dep) && !done.has(dep)) {
120
+ throw new Error(
121
+ `Process "${config.name}" depends on "${dep}" which is not in the run set and not already running. ` +
122
+ `If you used --only, you may need to include "${dep}" too, or start it first.`,
123
+ );
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ // In no-deps mode, treat all dependencies as already satisfied
130
+ if (options.noDeps) {
131
+ for (const config of configs) {
132
+ for (const dep of config.dependsOn ?? []) {
133
+ done.add(dep);
134
+ }
135
+ }
136
+ }
137
+
138
+ const waitingToExit = new Set<string>();
139
+ const pending = new Set(configs.map((c) => c.name));
140
+
141
+ // Don't re-launch processes that are already running
142
+ for (const name of [...pending]) {
143
+ if (manager.isRunning(name)) {
144
+ pending.delete(name);
145
+ done.add(name);
146
+ }
147
+ }
148
+
149
+ // wakePending absorbs a wake() that fires before waitForWake() is called,
150
+ // so the scheduler never gets stuck after a fast-exiting waitToExit process.
151
+ let wakeResolve: (() => void) | null = null;
152
+ let wakePending = false;
153
+ const wake = () => {
154
+ if (wakeResolve) {
155
+ const r = wakeResolve;
156
+ wakeResolve = null;
157
+ r();
158
+ } else {
159
+ wakePending = true;
160
+ }
161
+ };
162
+ const waitForWake = () => {
163
+ if (wakePending) {
164
+ wakePending = false;
165
+ return Promise.resolve();
166
+ }
167
+ return new Promise<void>((resolve) => {
168
+ wakeResolve = resolve;
169
+ });
170
+ };
171
+
172
+ let shutdownRequested = false;
173
+
174
+ while (pending.size > 0 || waitingToExit.size > 0) {
175
+ if (shutdownRequested) break;
176
+
177
+ // Collect every pending task whose dependencies are all satisfied
178
+ let wave = [...pending].filter((name) => {
179
+ const config = configMap.get(name)!;
180
+ return (config.dependsOn ?? []).every((d) => done.has(d));
181
+ });
182
+
183
+ // In serial mode, launch only one process at a time
184
+ if (options.serial && wave.length > 1) {
185
+ wave = [wave[0]];
186
+ }
187
+
188
+ if (wave.length > 0) {
189
+ // Remove from pending before any async work to prevent double-launch
190
+ for (const name of wave) pending.delete(name);
191
+
192
+ // Launch all tasks in this wave in parallel.
193
+ // Each task is isolated in its own try/catch so one launch failure
194
+ // does not prevent other tasks in the same wave from starting.
195
+ await Promise.all(
196
+ wave.map(async (name) => {
197
+ if (shutdownRequested) return;
198
+
199
+ const config = configMap.get(name)!;
200
+
201
+ let waitForExit: Promise<number>;
202
+ let logFile: string | null;
203
+ try {
204
+ ({ waitForExit, logFile } = await manager.launch(config));
205
+ } catch (err: any) {
206
+ options.onTaskFailed?.(name, -1);
207
+ if (config.critical !== false) {
208
+ shutdownRequested = true;
209
+ options.onShutdown?.(`Failed to launch "${name}": ${err?.message ?? err}`);
210
+ wake();
211
+ }
212
+ return;
213
+ }
214
+
215
+ // onTaskStart is called after launch so manager.get(name) is valid
216
+ options.onTaskStart?.(name, logFile);
217
+
218
+ if (config.waitToExit === true) {
219
+ waitingToExit.add(name);
220
+
221
+ waitForExit.then((code) => {
222
+ waitingToExit.delete(name);
223
+
224
+ if (code === 0) {
225
+ done.add(name);
226
+ options.onTaskDone?.(name);
227
+ } else {
228
+ options.onTaskFailed?.(name, code);
229
+
230
+ if (config.critical !== false) {
231
+ shutdownRequested = true;
232
+ options.onShutdown?.(
233
+ `Process "${name}" exited with code ${code}`,
234
+ );
235
+ } else {
236
+ done.add(name);
237
+ }
238
+ }
239
+
240
+ wake();
241
+ });
242
+ } else {
243
+ // Background — immediately "done" for dep resolution
244
+ done.add(name);
245
+ options.onTaskRunning?.(name);
246
+
247
+ waitForExit.then((code) => {
248
+ if (code !== 0 && config.critical !== false && !manager.isStopping(name)) {
249
+ options.onTaskFailed?.(name, code);
250
+ shutdownRequested = true;
251
+ options.onShutdown?.(
252
+ `Background process "${name}" exited unexpectedly with code ${code}`,
253
+ );
254
+ wake();
255
+ }
256
+ });
257
+ }
258
+ }),
259
+ );
260
+ } else if (waitingToExit.size > 0) {
261
+ // All ready tasks are launched; wait for a waitToExit one to finish
262
+ await waitForWake();
263
+ } else {
264
+ // pending > 0 but nothing is ready and nothing is waiting — circular dep
265
+ const blocked = [...pending].map((n) => {
266
+ const missing = (configMap.get(n)!.dependsOn ?? []).filter(
267
+ (d) => !done.has(d),
268
+ );
269
+ return ` "${n}" is waiting for: ${missing.join(", ")}`;
270
+ });
271
+ throw new Error(
272
+ `Circular dependency or unresolvable dependency detected:\n${blocked.join("\n")}`,
273
+ );
274
+ }
275
+ }
276
+ }