maestro-manager 0.0.0 → 0.0.1
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/README.md +178 -1
- package/dist/build-info.json +3 -0
- package/dist/cli.js +4458 -0
- package/package.json +40 -7
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4458 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
|
|
19
|
+
// src/simctl/command.ts
|
|
20
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
21
|
+
function simctlSync(args) {
|
|
22
|
+
return runSimctl(args, 30000);
|
|
23
|
+
}
|
|
24
|
+
function simctlSyncTimeout(args, timeoutMs) {
|
|
25
|
+
return runSimctl(args, timeoutMs);
|
|
26
|
+
}
|
|
27
|
+
function runSimctl(args, timeoutMs) {
|
|
28
|
+
try {
|
|
29
|
+
return execFileSync2("xcrun", ["simctl", ...args], {
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
32
|
+
timeout: timeoutMs
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new SimctlError(args, exitStatus(error), stderrText(error));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function exitStatus(error) {
|
|
39
|
+
const status = recordValue(error, "status");
|
|
40
|
+
return typeof status === "number" ? status : 1;
|
|
41
|
+
}
|
|
42
|
+
function stderrText(error) {
|
|
43
|
+
const stderr = recordValue(error, "stderr");
|
|
44
|
+
if (typeof stderr === "string")
|
|
45
|
+
return stderr;
|
|
46
|
+
if (stderr instanceof Buffer)
|
|
47
|
+
return stderr.toString("utf8");
|
|
48
|
+
return String(error);
|
|
49
|
+
}
|
|
50
|
+
function recordValue(value, key) {
|
|
51
|
+
if (!isRecord(value))
|
|
52
|
+
return;
|
|
53
|
+
return value[key];
|
|
54
|
+
}
|
|
55
|
+
function isRecord(value) {
|
|
56
|
+
return typeof value === "object" && value !== null;
|
|
57
|
+
}
|
|
58
|
+
var SimctlError;
|
|
59
|
+
var init_command = __esm(() => {
|
|
60
|
+
SimctlError = class SimctlError extends Error {
|
|
61
|
+
args;
|
|
62
|
+
exitCode;
|
|
63
|
+
stderr;
|
|
64
|
+
name = "SimctlError";
|
|
65
|
+
constructor(args, exitCode, stderr) {
|
|
66
|
+
super(`xcrun simctl ${args[0] ?? ""} failed (exit ${exitCode}): ${stderr.slice(0, 200)}`);
|
|
67
|
+
this.args = args;
|
|
68
|
+
this.exitCode = exitCode;
|
|
69
|
+
this.stderr = stderr;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// src/simctl/devices.ts
|
|
75
|
+
async function listDevices() {
|
|
76
|
+
return parseDevicesJson(simctlSync(["list", "devices", "--json"]));
|
|
77
|
+
}
|
|
78
|
+
function parseDevicesJson(raw) {
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
if (!isRecord2(parsed))
|
|
81
|
+
return [];
|
|
82
|
+
const devicesByRuntime = parsed["devices"];
|
|
83
|
+
if (!isRuntimeDeviceMap(devicesByRuntime))
|
|
84
|
+
return [];
|
|
85
|
+
const devices = [];
|
|
86
|
+
for (const [runtime, entries] of Object.entries(devicesByRuntime)) {
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const device = parseDevice(runtime, entry);
|
|
89
|
+
if (device !== null)
|
|
90
|
+
devices.push(device);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return devices;
|
|
94
|
+
}
|
|
95
|
+
function parseDevice(runtime, entry) {
|
|
96
|
+
if (!isRecord2(entry))
|
|
97
|
+
return null;
|
|
98
|
+
const name = entry["name"];
|
|
99
|
+
const udid = entry["udid"];
|
|
100
|
+
const state = entry["state"];
|
|
101
|
+
if (typeof name !== "string" || typeof udid !== "string" || typeof state !== "string") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const deviceTypeIdentifier = entry["deviceTypeIdentifier"];
|
|
105
|
+
return {
|
|
106
|
+
name,
|
|
107
|
+
udid,
|
|
108
|
+
state,
|
|
109
|
+
deviceTypeIdentifier: typeof deviceTypeIdentifier === "string" ? deviceTypeIdentifier : "",
|
|
110
|
+
isAvailable: entry["isAvailable"] === true,
|
|
111
|
+
runtime
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function isRuntimeDeviceMap(value) {
|
|
115
|
+
if (!isRecord2(value))
|
|
116
|
+
return false;
|
|
117
|
+
return Object.values(value).every(Array.isArray);
|
|
118
|
+
}
|
|
119
|
+
function isRecord2(value) {
|
|
120
|
+
return typeof value === "object" && value !== null;
|
|
121
|
+
}
|
|
122
|
+
var init_devices = __esm(() => {
|
|
123
|
+
init_command();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// src/simctl/runtime-ranking.ts
|
|
127
|
+
function resolveRuntimeFromJson(raw) {
|
|
128
|
+
const parsed = JSON.parse(raw);
|
|
129
|
+
if (!isRecord3(parsed))
|
|
130
|
+
return null;
|
|
131
|
+
const runtimes = parsed["runtimes"];
|
|
132
|
+
if (!Array.isArray(runtimes))
|
|
133
|
+
return null;
|
|
134
|
+
return resolveRuntimeFromEntries(runtimes);
|
|
135
|
+
}
|
|
136
|
+
function resolveRuntimeFromEntries(runtimes) {
|
|
137
|
+
let best = null;
|
|
138
|
+
for (const rt of runtimes) {
|
|
139
|
+
const ranked = rankRuntime(rt);
|
|
140
|
+
if (ranked === null || ranked.version.major < 18)
|
|
141
|
+
continue;
|
|
142
|
+
if (best === null || compareRuntimeVersion(ranked.version, best.version) > 0) {
|
|
143
|
+
best = ranked;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return best?.identifier ?? null;
|
|
147
|
+
}
|
|
148
|
+
function rankRuntime(rt) {
|
|
149
|
+
if (!isRecord3(rt))
|
|
150
|
+
return null;
|
|
151
|
+
const identifier = rt["identifier"];
|
|
152
|
+
if (typeof identifier !== "string" || rt["isAvailable"] !== true)
|
|
153
|
+
return null;
|
|
154
|
+
const identifierVersion = parseIdentifierVersion(identifier);
|
|
155
|
+
if (identifierVersion === null)
|
|
156
|
+
return null;
|
|
157
|
+
const rawVersion = rt["version"];
|
|
158
|
+
const runtimeVersion = typeof rawVersion === "string" ? parseRuntimeVersion(rawVersion) : null;
|
|
159
|
+
return {
|
|
160
|
+
identifier,
|
|
161
|
+
version: runtimeVersion ?? identifierVersion
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function isRecord3(value) {
|
|
165
|
+
return typeof value === "object" && value !== null;
|
|
166
|
+
}
|
|
167
|
+
function parseRuntimeVersion(version) {
|
|
168
|
+
const match = RUNTIME_VERSION_PATTERN.exec(version);
|
|
169
|
+
if (match === null)
|
|
170
|
+
return null;
|
|
171
|
+
return parseVersionParts(match);
|
|
172
|
+
}
|
|
173
|
+
function parseIdentifierVersion(identifier) {
|
|
174
|
+
const match = IOS_RUNTIME_IDENTIFIER_PATTERN.exec(identifier);
|
|
175
|
+
if (match === null)
|
|
176
|
+
return null;
|
|
177
|
+
return parseVersionParts(match);
|
|
178
|
+
}
|
|
179
|
+
function parseVersionParts(match) {
|
|
180
|
+
const major = match[1];
|
|
181
|
+
const minor = match[2];
|
|
182
|
+
const patch = match[3];
|
|
183
|
+
if (major === undefined || minor === undefined)
|
|
184
|
+
return null;
|
|
185
|
+
return {
|
|
186
|
+
major: Number.parseInt(major, 10),
|
|
187
|
+
minor: Number.parseInt(minor, 10),
|
|
188
|
+
patch: patch === undefined ? 0 : Number.parseInt(patch, 10)
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function compareRuntimeVersion(a, b) {
|
|
192
|
+
if (a.major !== b.major)
|
|
193
|
+
return a.major - b.major;
|
|
194
|
+
if (a.minor !== b.minor)
|
|
195
|
+
return a.minor - b.minor;
|
|
196
|
+
return a.patch - b.patch;
|
|
197
|
+
}
|
|
198
|
+
var IOS_RUNTIME_IDENTIFIER_PATTERN, RUNTIME_VERSION_PATTERN;
|
|
199
|
+
var init_runtime_ranking = __esm(() => {
|
|
200
|
+
IOS_RUNTIME_IDENTIFIER_PATTERN = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)(?:-(\d+))?$/;
|
|
201
|
+
RUNTIME_VERSION_PATTERN = /^(\d+)\.(\d+)(?:\.(\d+))?$/;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// src/simctl/runtime.ts
|
|
205
|
+
async function resolveRuntime() {
|
|
206
|
+
const args = ["list", "runtimes", "--json"];
|
|
207
|
+
const runtime = resolveRuntimeFromJson(simctlSync(args));
|
|
208
|
+
if (runtime === null) {
|
|
209
|
+
throw new SimctlError(args, 1, "No available iOS 18+ runtime found. Install one via Xcode > Settings > Platforms.");
|
|
210
|
+
}
|
|
211
|
+
return runtime;
|
|
212
|
+
}
|
|
213
|
+
var init_runtime = __esm(() => {
|
|
214
|
+
init_command();
|
|
215
|
+
init_runtime_ranking();
|
|
216
|
+
init_runtime_ranking();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// src/simulatorDisplay.ts
|
|
220
|
+
var DEFAULT_SIMULATOR_DISPLAY_MODE = "headless";
|
|
221
|
+
|
|
222
|
+
// src/simctl/device-lifecycle.ts
|
|
223
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
224
|
+
async function createDevice(name, deviceType, runtime) {
|
|
225
|
+
const args = ["create", name, deviceType, runtime];
|
|
226
|
+
const udid = simctlSync(args).trim();
|
|
227
|
+
if (!udid.match(/^[0-9A-F-]{36}$/i)) {
|
|
228
|
+
throw new SimctlError(args, 0, `unexpected output - expected a UDID, got: ${udid.slice(0, 80)}`);
|
|
229
|
+
}
|
|
230
|
+
return udid;
|
|
231
|
+
}
|
|
232
|
+
async function bootAndWait(udid, displayMode = DEFAULT_SIMULATOR_DISPLAY_MODE) {
|
|
233
|
+
simctlSyncTimeout(["bootstatus", udid, "-b"], 240000);
|
|
234
|
+
if (displayMode === "headed") {
|
|
235
|
+
openSimulatorApp(udid);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function shutdown(udid) {
|
|
239
|
+
try {
|
|
240
|
+
simctlSync(["shutdown", udid]);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof SimctlError)
|
|
243
|
+
return;
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function deleteDevice(udid) {
|
|
248
|
+
simctlSync(["delete", udid]);
|
|
249
|
+
}
|
|
250
|
+
async function deleteUnavailable() {
|
|
251
|
+
simctlSync(["delete", "unavailable"]);
|
|
252
|
+
}
|
|
253
|
+
function openSimulatorApp(udid) {
|
|
254
|
+
try {
|
|
255
|
+
execFileSync3("open", ["-n", "-a", "Simulator", "--args", "-CurrentDeviceUDID", udid], {
|
|
256
|
+
encoding: "utf8",
|
|
257
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
258
|
+
timeout: 30000
|
|
259
|
+
});
|
|
260
|
+
} catch (error) {
|
|
261
|
+
throw new SimulatorAppOpenError(udid, exitStatus2(error), stderrText2(error));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function exitStatus2(error) {
|
|
265
|
+
const status = recordValue2(error, "status");
|
|
266
|
+
return typeof status === "number" ? status : 1;
|
|
267
|
+
}
|
|
268
|
+
function stderrText2(error) {
|
|
269
|
+
const stderr = recordValue2(error, "stderr");
|
|
270
|
+
if (typeof stderr === "string")
|
|
271
|
+
return stderr;
|
|
272
|
+
if (stderr instanceof Buffer)
|
|
273
|
+
return stderr.toString("utf8");
|
|
274
|
+
return String(error);
|
|
275
|
+
}
|
|
276
|
+
function recordValue2(value, key) {
|
|
277
|
+
if (!isRecord4(value))
|
|
278
|
+
return;
|
|
279
|
+
return value[key];
|
|
280
|
+
}
|
|
281
|
+
function isRecord4(value) {
|
|
282
|
+
return typeof value === "object" && value !== null;
|
|
283
|
+
}
|
|
284
|
+
var SimulatorAppOpenError;
|
|
285
|
+
var init_device_lifecycle = __esm(() => {
|
|
286
|
+
init_command();
|
|
287
|
+
SimulatorAppOpenError = class SimulatorAppOpenError extends Error {
|
|
288
|
+
udid;
|
|
289
|
+
exitCode;
|
|
290
|
+
stderr;
|
|
291
|
+
name = "SimulatorAppOpenError";
|
|
292
|
+
constructor(udid, exitCode, stderr) {
|
|
293
|
+
super(`open -a Simulator failed for ${udid} (exit ${exitCode}): ${stderr.slice(0, 200)}`);
|
|
294
|
+
this.udid = udid;
|
|
295
|
+
this.exitCode = exitCode;
|
|
296
|
+
this.stderr = stderr;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// src/simctl/app-lifecycle.ts
|
|
302
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
303
|
+
import { statSync as statSync7 } from "fs";
|
|
304
|
+
async function install(udid, appPath) {
|
|
305
|
+
simctlSync(["install", udid, appPath]);
|
|
306
|
+
}
|
|
307
|
+
async function launch(udid, appId) {
|
|
308
|
+
simctlSync(["launch", udid, appId]);
|
|
309
|
+
}
|
|
310
|
+
async function openUrl(udid, url) {
|
|
311
|
+
simctlSync(["openurl", udid, url]);
|
|
312
|
+
}
|
|
313
|
+
async function terminate(udid, appId) {
|
|
314
|
+
try {
|
|
315
|
+
simctlSync(["terminate", udid, appId]);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error instanceof SimctlError)
|
|
318
|
+
return;
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function listInstalledApps(udid) {
|
|
323
|
+
return parseAppBundleIds(simctlSync(["listapps", udid]));
|
|
324
|
+
}
|
|
325
|
+
async function hasApp(udid, appId) {
|
|
326
|
+
const apps = await listInstalledApps(udid);
|
|
327
|
+
return apps.includes(appId);
|
|
328
|
+
}
|
|
329
|
+
async function installedAppMtime(udid, appId) {
|
|
330
|
+
try {
|
|
331
|
+
const containerPath = simctlSync(["get_app_container", udid, appId]).trim();
|
|
332
|
+
if (containerPath.length === 0)
|
|
333
|
+
return null;
|
|
334
|
+
return statSync7(containerPath).mtimeMs;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof Error)
|
|
337
|
+
return null;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function parseAppBundleIds(plistText) {
|
|
342
|
+
try {
|
|
343
|
+
return bundleIdsFromJsonText(plistText);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (!(error instanceof Error))
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const jsonText = execFileSync4("plutil", ["-convert", "json", "-o", "-", "-"], {
|
|
350
|
+
input: plistText,
|
|
351
|
+
encoding: "utf8",
|
|
352
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
353
|
+
});
|
|
354
|
+
return bundleIdsFromJsonText(jsonText);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (error instanceof Error)
|
|
357
|
+
return bundleIdsFromPlistStrings(plistText);
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function bundleIdsFromJsonText(jsonText) {
|
|
362
|
+
const parsed = JSON.parse(jsonText);
|
|
363
|
+
if (!isRecord5(parsed))
|
|
364
|
+
return [];
|
|
365
|
+
const bundleIds = [];
|
|
366
|
+
for (const entry of Object.values(parsed)) {
|
|
367
|
+
const bundleId = bundleIdFromEntry(entry);
|
|
368
|
+
if (bundleId !== null)
|
|
369
|
+
bundleIds.push(bundleId);
|
|
370
|
+
}
|
|
371
|
+
return bundleIds;
|
|
372
|
+
}
|
|
373
|
+
function bundleIdFromEntry(entry) {
|
|
374
|
+
if (!isRecord5(entry))
|
|
375
|
+
return null;
|
|
376
|
+
const bundleId = entry["CFBundleIdentifier"];
|
|
377
|
+
return typeof bundleId === "string" ? bundleId : null;
|
|
378
|
+
}
|
|
379
|
+
function bundleIdsFromPlistStrings(plistText) {
|
|
380
|
+
const matches = plistText.match(/<string>([\w.+-]+)<\/string>/g) ?? [];
|
|
381
|
+
return matches.map((match) => match.replace(/<\/?string>/g, "")).filter((value) => value.includes("."));
|
|
382
|
+
}
|
|
383
|
+
function isRecord5(value) {
|
|
384
|
+
return typeof value === "object" && value !== null;
|
|
385
|
+
}
|
|
386
|
+
var init_app_lifecycle = __esm(() => {
|
|
387
|
+
init_command();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// src/simctl/recording.ts
|
|
391
|
+
import { spawn } from "child_process";
|
|
392
|
+
async function recordVideoStart(udid, outPath) {
|
|
393
|
+
const simctlArgs = ["io", udid, "recordVideo", "--codec", "h264", outPath];
|
|
394
|
+
const proc = spawn("xcrun", ["simctl", ...simctlArgs], {
|
|
395
|
+
stdio: "ignore",
|
|
396
|
+
detached: false
|
|
397
|
+
});
|
|
398
|
+
const pid = proc.pid;
|
|
399
|
+
if (pid === undefined) {
|
|
400
|
+
throw new SimctlError(simctlArgs, 1, "recordVideo did not start a process");
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
pid,
|
|
404
|
+
stop: async () => {
|
|
405
|
+
try {
|
|
406
|
+
process.kill(pid, "SIGINT");
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (!(error instanceof Error))
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
await new Promise((resolve3) => {
|
|
412
|
+
const timeout = setTimeout(resolve3, 2000);
|
|
413
|
+
const finish = () => {
|
|
414
|
+
clearTimeout(timeout);
|
|
415
|
+
resolve3();
|
|
416
|
+
};
|
|
417
|
+
proc.once("exit", finish);
|
|
418
|
+
proc.once("error", finish);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
var init_recording = __esm(() => {
|
|
424
|
+
init_command();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// src/simctl.ts
|
|
428
|
+
var exports_simctl = {};
|
|
429
|
+
__export(exports_simctl, {
|
|
430
|
+
terminate: () => terminate,
|
|
431
|
+
shutdown: () => shutdown,
|
|
432
|
+
resolveRuntime: () => resolveRuntime,
|
|
433
|
+
recordVideoStart: () => recordVideoStart,
|
|
434
|
+
openUrl: () => openUrl,
|
|
435
|
+
listInstalledApps: () => listInstalledApps,
|
|
436
|
+
listDevices: () => listDevices,
|
|
437
|
+
launch: () => launch,
|
|
438
|
+
installedAppMtime: () => installedAppMtime,
|
|
439
|
+
install: () => install,
|
|
440
|
+
hasApp: () => hasApp,
|
|
441
|
+
deleteUnavailable: () => deleteUnavailable,
|
|
442
|
+
deleteDevice: () => deleteDevice,
|
|
443
|
+
createDevice: () => createDevice,
|
|
444
|
+
bootAndWait: () => bootAndWait,
|
|
445
|
+
SimctlError: () => SimctlError
|
|
446
|
+
});
|
|
447
|
+
var init_simctl = __esm(() => {
|
|
448
|
+
init_command();
|
|
449
|
+
init_devices();
|
|
450
|
+
init_runtime();
|
|
451
|
+
init_device_lifecycle();
|
|
452
|
+
init_app_lifecycle();
|
|
453
|
+
init_recording();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// src/cli.ts
|
|
457
|
+
import { resolve as resolve3 } from "path";
|
|
458
|
+
|
|
459
|
+
// src/config.ts
|
|
460
|
+
import { readFileSync, existsSync } from "fs";
|
|
461
|
+
import { join, dirname, resolve, isAbsolute } from "path";
|
|
462
|
+
import { homedir } from "os";
|
|
463
|
+
var CONFIG_FILENAME = "maestro-manager.config.json";
|
|
464
|
+
var POOL_MAX_CAP = 8;
|
|
465
|
+
var SAFE_POOL_PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
466
|
+
|
|
467
|
+
class ConfigError extends Error {
|
|
468
|
+
key;
|
|
469
|
+
constructor(key, message) {
|
|
470
|
+
super(`maestro-manager config: [${key}] ${message}`);
|
|
471
|
+
this.key = key;
|
|
472
|
+
this.name = "ConfigError";
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function expandTilde(p) {
|
|
476
|
+
return p === "~" || p.startsWith("~/") ? homedir() + p.slice(1) : p;
|
|
477
|
+
}
|
|
478
|
+
function expandAndResolve(p, configDir) {
|
|
479
|
+
const expanded = expandTilde(p);
|
|
480
|
+
if (isAbsolute(expanded))
|
|
481
|
+
return expanded;
|
|
482
|
+
return resolve(configDir, expanded);
|
|
483
|
+
}
|
|
484
|
+
function findConfigFile(startDir) {
|
|
485
|
+
let current = resolve(startDir);
|
|
486
|
+
for (;; ) {
|
|
487
|
+
const candidate = join(current, CONFIG_FILENAME);
|
|
488
|
+
if (existsSync(candidate))
|
|
489
|
+
return candidate;
|
|
490
|
+
const parent = dirname(current);
|
|
491
|
+
if (parent === current)
|
|
492
|
+
return null;
|
|
493
|
+
current = parent;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function resolveConfigPath(startDir, explicitPath) {
|
|
497
|
+
if (explicitPath !== undefined && explicitPath !== "") {
|
|
498
|
+
const p = expandAndResolve(explicitPath, startDir);
|
|
499
|
+
if (!existsSync(p)) {
|
|
500
|
+
throw new ConfigError(CONFIG_FILENAME, `--config path does not exist: ${p}`);
|
|
501
|
+
}
|
|
502
|
+
return p;
|
|
503
|
+
}
|
|
504
|
+
const envPath = process.env["MAESTRO_MANAGER_CONFIG"];
|
|
505
|
+
if (envPath !== undefined && envPath !== "") {
|
|
506
|
+
const p = expandAndResolve(envPath, startDir);
|
|
507
|
+
if (!existsSync(p)) {
|
|
508
|
+
throw new ConfigError(CONFIG_FILENAME, `MAESTRO_MANAGER_CONFIG points to a missing file: ${p}`);
|
|
509
|
+
}
|
|
510
|
+
return p;
|
|
511
|
+
}
|
|
512
|
+
const found = findConfigFile(startDir);
|
|
513
|
+
if (found === null) {
|
|
514
|
+
throw new ConfigError(CONFIG_FILENAME, `not found. Pass --config <path>, set MAESTRO_MANAGER_CONFIG=<path>, or run from a ` + `directory at/under one containing ${CONFIG_FILENAME} (searched ${startDir} up to /).`);
|
|
515
|
+
}
|
|
516
|
+
return found;
|
|
517
|
+
}
|
|
518
|
+
function describeValueType(val) {
|
|
519
|
+
if (val === null)
|
|
520
|
+
return "null";
|
|
521
|
+
if (Array.isArray(val))
|
|
522
|
+
return "array";
|
|
523
|
+
if (typeof val === "number") {
|
|
524
|
+
return Number.isFinite(val) ? `number ${val}` : `non-finite number ${String(val)}`;
|
|
525
|
+
}
|
|
526
|
+
return typeof val;
|
|
527
|
+
}
|
|
528
|
+
function isPlainObject(val) {
|
|
529
|
+
if (val === null || typeof val !== "object" || Array.isArray(val))
|
|
530
|
+
return false;
|
|
531
|
+
const prototype = Object.getPrototypeOf(val);
|
|
532
|
+
return prototype === Object.prototype || prototype === null;
|
|
533
|
+
}
|
|
534
|
+
function assertObject(val, key) {
|
|
535
|
+
if (!isPlainObject(val)) {
|
|
536
|
+
throw new ConfigError(key, `must be a plain object (got ${describeValueType(val)})`);
|
|
537
|
+
}
|
|
538
|
+
return val;
|
|
539
|
+
}
|
|
540
|
+
function assertString(val, key) {
|
|
541
|
+
if (typeof val !== "string") {
|
|
542
|
+
throw new ConfigError(key, `must be a string (got ${describeValueType(val)})`);
|
|
543
|
+
}
|
|
544
|
+
return val;
|
|
545
|
+
}
|
|
546
|
+
function assertSafePoolPrefix(prefix) {
|
|
547
|
+
if (SAFE_POOL_PREFIX_PATTERN.test(prefix))
|
|
548
|
+
return prefix;
|
|
549
|
+
throw new ConfigError("pool.prefix", "must be a single path segment starting with a letter or number and containing only letters, numbers, dots, underscores, and hyphens");
|
|
550
|
+
}
|
|
551
|
+
function assertPositiveInteger(val, key) {
|
|
552
|
+
if (typeof val !== "number" || !Number.isFinite(val) || !Number.isInteger(val) || val < 1) {
|
|
553
|
+
throw new ConfigError(key, `must be a finite positive integer (got ${describeValueType(val)})`);
|
|
554
|
+
}
|
|
555
|
+
return val;
|
|
556
|
+
}
|
|
557
|
+
function assertStringArray(val, key) {
|
|
558
|
+
if (!Array.isArray(val) || val.some((v) => typeof v !== "string")) {
|
|
559
|
+
throw new ConfigError(key, `must be an array of strings`);
|
|
560
|
+
}
|
|
561
|
+
return val;
|
|
562
|
+
}
|
|
563
|
+
function validatePool(raw) {
|
|
564
|
+
const obj = assertObject(raw, "pool");
|
|
565
|
+
const prefix = assertSafePoolPrefix(assertString(obj["prefix"], "pool.prefix"));
|
|
566
|
+
const deviceType = assertString(obj["deviceType"], "pool.deviceType");
|
|
567
|
+
const dflt = assertPositiveInteger(obj["default"], "pool.default");
|
|
568
|
+
const max = assertPositiveInteger(obj["max"], "pool.max");
|
|
569
|
+
if (max > POOL_MAX_CAP) {
|
|
570
|
+
throw new ConfigError("pool.max", `must be <= ${POOL_MAX_CAP} (got ${max}); pool.max above 4 requires downstream backend stability proof`);
|
|
571
|
+
}
|
|
572
|
+
if (max < dflt) {
|
|
573
|
+
throw new ConfigError("pool.max", `must be >= pool.default (${dflt}), but got ${max}`);
|
|
574
|
+
}
|
|
575
|
+
return { prefix, deviceType, default: dflt, max };
|
|
576
|
+
}
|
|
577
|
+
function validateApp(raw) {
|
|
578
|
+
const obj = assertObject(raw, "app");
|
|
579
|
+
const appId = assertString(obj["appId"], "app.appId");
|
|
580
|
+
const searchDirs = assertStringArray(obj["searchDirs"], "app.searchDirs");
|
|
581
|
+
const result = { appId, searchDirs };
|
|
582
|
+
if (obj["path"] !== undefined) {
|
|
583
|
+
result.path = assertString(obj["path"], "app.path");
|
|
584
|
+
}
|
|
585
|
+
if (obj["glob"] !== undefined) {
|
|
586
|
+
result.glob = assertString(obj["glob"], "app.glob");
|
|
587
|
+
}
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
function validateJava(raw) {
|
|
591
|
+
const obj = assertObject(raw, "java");
|
|
592
|
+
const home = obj["home"];
|
|
593
|
+
if (home !== null && typeof home !== "string") {
|
|
594
|
+
throw new ConfigError("java.home", `must be a string or null (got ${describeValueType(home)})`);
|
|
595
|
+
}
|
|
596
|
+
const autodetect = assertStringArray(obj["autodetect"], "java.autodetect");
|
|
597
|
+
return { home, autodetect };
|
|
598
|
+
}
|
|
599
|
+
function validateHooks(raw) {
|
|
600
|
+
const obj = assertObject(raw, "hooks");
|
|
601
|
+
const result = {};
|
|
602
|
+
if (obj["pruneAccounts"] !== undefined) {
|
|
603
|
+
result.pruneAccounts = assertString(obj["pruneAccounts"], "hooks.pruneAccounts");
|
|
604
|
+
}
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
function loadConfig(startDir, explicitPath) {
|
|
608
|
+
const configPath = resolveConfigPath(startDir, explicitPath);
|
|
609
|
+
const configDir = dirname(configPath);
|
|
610
|
+
let parsed;
|
|
611
|
+
try {
|
|
612
|
+
const text = readFileSync(configPath, { encoding: "utf8" });
|
|
613
|
+
parsed = JSON.parse(text);
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
616
|
+
throw new ConfigError(CONFIG_FILENAME, `failed to parse: ${message}`);
|
|
617
|
+
}
|
|
618
|
+
const raw = assertObject(parsed, CONFIG_FILENAME);
|
|
619
|
+
for (const key of [
|
|
620
|
+
"pool",
|
|
621
|
+
"app",
|
|
622
|
+
"flowsDir",
|
|
623
|
+
"warmupDeepLink",
|
|
624
|
+
"java",
|
|
625
|
+
"recordingsDir",
|
|
626
|
+
"hooks"
|
|
627
|
+
]) {
|
|
628
|
+
if (raw[key] === undefined) {
|
|
629
|
+
throw new ConfigError(key, `is required but missing from ${configPath}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const pool = validatePool(raw["pool"]);
|
|
633
|
+
const app = validateApp(raw["app"]);
|
|
634
|
+
const java = validateJava(raw["java"]);
|
|
635
|
+
const hooks = validateHooks(raw["hooks"]);
|
|
636
|
+
const rawFlowsDir = assertString(raw["flowsDir"], "flowsDir");
|
|
637
|
+
const warmupDeepLink = assertString(raw["warmupDeepLink"], "warmupDeepLink");
|
|
638
|
+
const rawRecordingsDir = assertString(raw["recordingsDir"], "recordingsDir");
|
|
639
|
+
const flowsDir = expandAndResolve(rawFlowsDir, configDir);
|
|
640
|
+
const recordingsDir = expandAndResolve(rawRecordingsDir, configDir);
|
|
641
|
+
const expandedApp = {
|
|
642
|
+
...app,
|
|
643
|
+
searchDirs: app.searchDirs.map((d) => expandAndResolve(d, configDir))
|
|
644
|
+
};
|
|
645
|
+
return {
|
|
646
|
+
pool,
|
|
647
|
+
app: expandedApp,
|
|
648
|
+
flowsDir,
|
|
649
|
+
warmupDeepLink,
|
|
650
|
+
java,
|
|
651
|
+
recordingsDir,
|
|
652
|
+
hooks
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/commands/up.ts
|
|
657
|
+
import { mkdirSync as mkdirSync8 } from "fs";
|
|
658
|
+
|
|
659
|
+
// src/state.ts
|
|
660
|
+
import { readFileSync as readFileSync2, writeFileSync, renameSync, mkdirSync } from "fs";
|
|
661
|
+
import { dirname as dirname2, join as join2, resolve as resolve2, relative, isAbsolute as isAbsolute2 } from "path";
|
|
662
|
+
import { homedir as homedir2 } from "os";
|
|
663
|
+
var MANAGER_STATE_DIR = ".maestro-manager";
|
|
664
|
+
|
|
665
|
+
class StatePathError extends Error {
|
|
666
|
+
constructor(root, base) {
|
|
667
|
+
super(`maestro-manager state root must stay under ${base}: ${root}`);
|
|
668
|
+
this.name = "StatePathError";
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function managerStateBase() {
|
|
672
|
+
return resolve2(homedir2(), MANAGER_STATE_DIR);
|
|
673
|
+
}
|
|
674
|
+
function assertPoolRootContained(root) {
|
|
675
|
+
const base = managerStateBase();
|
|
676
|
+
const resolvedRoot = resolve2(root);
|
|
677
|
+
const relativeRoot = relative(base, resolvedRoot);
|
|
678
|
+
if (relativeRoot === "" || relativeRoot.startsWith("..") || isAbsolute2(relativeRoot)) {
|
|
679
|
+
throw new StatePathError(resolvedRoot, base);
|
|
680
|
+
}
|
|
681
|
+
return resolvedRoot;
|
|
682
|
+
}
|
|
683
|
+
function poolRoot(config) {
|
|
684
|
+
return assertPoolRootContained(resolve2(managerStateBase(), config.pool.prefix));
|
|
685
|
+
}
|
|
686
|
+
function configSnapshotPath(config) {
|
|
687
|
+
return join2(poolRoot(config), "config-snapshot.json");
|
|
688
|
+
}
|
|
689
|
+
function poolLockDir(config) {
|
|
690
|
+
return join2(poolRoot(config), "locks", "pool.lock");
|
|
691
|
+
}
|
|
692
|
+
function simDir(config, simName) {
|
|
693
|
+
return join2(poolRoot(config), "sims", simName);
|
|
694
|
+
}
|
|
695
|
+
function simUdidPath(config, simName) {
|
|
696
|
+
return join2(simDir(config, simName), "udid");
|
|
697
|
+
}
|
|
698
|
+
function simLastUsedPath(config, simName) {
|
|
699
|
+
return join2(simDir(config, simName), "lastused");
|
|
700
|
+
}
|
|
701
|
+
function simLeaseDir(config, simName) {
|
|
702
|
+
return join2(simDir(config, simName), "lease");
|
|
703
|
+
}
|
|
704
|
+
function simLeaseRecordPath(config, simName) {
|
|
705
|
+
return join2(simLeaseDir(config, simName), "record.json");
|
|
706
|
+
}
|
|
707
|
+
function fullSuiteReservationDir(config) {
|
|
708
|
+
return join2(poolRoot(config), "reservation");
|
|
709
|
+
}
|
|
710
|
+
function fullSuiteReservationRecordPath(config) {
|
|
711
|
+
return join2(fullSuiteReservationDir(config), "record.json");
|
|
712
|
+
}
|
|
713
|
+
function waiterDir(config, runId) {
|
|
714
|
+
return join2(poolRoot(config), "waiters", runId);
|
|
715
|
+
}
|
|
716
|
+
function waiterTicketPath(config, runId) {
|
|
717
|
+
return join2(waiterDir(config, runId), "ticket.json");
|
|
718
|
+
}
|
|
719
|
+
function runDir(config, runId) {
|
|
720
|
+
return join2(poolRoot(config), "runs", runId);
|
|
721
|
+
}
|
|
722
|
+
function runRecordPath(config, runId) {
|
|
723
|
+
return join2(runDir(config, runId), "run.json");
|
|
724
|
+
}
|
|
725
|
+
function runFlowLogPath(config, runId, flow) {
|
|
726
|
+
return join2(runDir(config, runId), `${flow}.log`);
|
|
727
|
+
}
|
|
728
|
+
function runsIndexPath(config) {
|
|
729
|
+
return join2(poolRoot(config), "runs", "index.json");
|
|
730
|
+
}
|
|
731
|
+
function atomicWriteJson(filePath, value) {
|
|
732
|
+
const dir = dirname2(filePath);
|
|
733
|
+
mkdirSync(dir, { recursive: true });
|
|
734
|
+
const rand = Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0");
|
|
735
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${rand}`;
|
|
736
|
+
const json = JSON.stringify(value, null, 2);
|
|
737
|
+
writeFileSync(tmpPath, json, { encoding: "utf8" });
|
|
738
|
+
renameSync(tmpPath, filePath);
|
|
739
|
+
}
|
|
740
|
+
function readJsonOrNull(filePath) {
|
|
741
|
+
try {
|
|
742
|
+
const raw = readFileSync2(filePath, { encoding: "utf8" });
|
|
743
|
+
return JSON.parse(raw);
|
|
744
|
+
} catch {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/pool/reconcile.ts
|
|
750
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, statSync } from "fs";
|
|
751
|
+
async function reconcile(config, simctl) {
|
|
752
|
+
const devices = await simctl.listDevices();
|
|
753
|
+
const prefix = config.pool.prefix;
|
|
754
|
+
const poolDevices = devices.filter((d) => d.name.startsWith(`${prefix}-`));
|
|
755
|
+
const slots = [];
|
|
756
|
+
for (const device of poolDevices) {
|
|
757
|
+
const simName = device.name;
|
|
758
|
+
const udid = device.udid;
|
|
759
|
+
const udidPath = simUdidPath(config, simName);
|
|
760
|
+
try {
|
|
761
|
+
mkdirSync2(simDir(config, simName), { recursive: true });
|
|
762
|
+
writeFileSync2(udidPath, udid, { encoding: "utf8" });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (!(error instanceof Error)) {
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
slots.push({
|
|
769
|
+
simName,
|
|
770
|
+
udid,
|
|
771
|
+
state: device.state,
|
|
772
|
+
leased: isDirPresent(simLeaseDir(config, simName))
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
const freeSlots = slots.filter((slot) => !slot.leased).map((slot) => ({ simName: slot.simName, udid: slot.udid }));
|
|
776
|
+
return {
|
|
777
|
+
slots,
|
|
778
|
+
freeSlots,
|
|
779
|
+
leasedCount: slots.filter((slot) => slot.leased).length
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function isDirPresent(dirPath) {
|
|
783
|
+
try {
|
|
784
|
+
const st = statSync(dirPath);
|
|
785
|
+
return st.isDirectory();
|
|
786
|
+
} catch {
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// src/pool/slot-creation.ts
|
|
791
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
792
|
+
async function createGapSim(config, index, simctl) {
|
|
793
|
+
const simName = `${config.pool.prefix}-${index}`;
|
|
794
|
+
const devices = await simctl.listDevices();
|
|
795
|
+
const existing = devices.find((device) => device.name === simName);
|
|
796
|
+
const udid = existing?.udid ?? await simctl.createDevice(simName, resolveDeviceTypeId(config.pool.deviceType), await simctl.resolveRuntime());
|
|
797
|
+
mkdirSync3(simDir(config, simName), { recursive: true });
|
|
798
|
+
writeFileSync3(simUdidPath(config, simName), udid, { encoding: "utf8" });
|
|
799
|
+
return { simName, udid };
|
|
800
|
+
}
|
|
801
|
+
function resolveDeviceTypeId(deviceType) {
|
|
802
|
+
if (deviceType.startsWith("com.apple.")) {
|
|
803
|
+
return deviceType;
|
|
804
|
+
}
|
|
805
|
+
return `com.apple.CoreSimulator.SimDeviceType.${deviceType}`;
|
|
806
|
+
}
|
|
807
|
+
// src/pool/lease-selection.ts
|
|
808
|
+
import { statSync as statSync5 } from "fs";
|
|
809
|
+
|
|
810
|
+
// src/fairness.ts
|
|
811
|
+
import { readdirSync, mkdirSync as mkdirSync4, statSync as statSync2, rmSync } from "fs";
|
|
812
|
+
import { join as join3 } from "path";
|
|
813
|
+
|
|
814
|
+
// src/liveness.ts
|
|
815
|
+
import { execFileSync } from "child_process";
|
|
816
|
+
var HEARTBEAT_TTL_MS = 180000;
|
|
817
|
+
var PROBE_RETRY_ATTEMPTS = 3;
|
|
818
|
+
var PROBE_RETRY_BACKOFF_MS = 5;
|
|
819
|
+
var LIVE_OWNER = { kind: "live" };
|
|
820
|
+
function retrySync(fn, fallback, attempts = PROBE_RETRY_ATTEMPTS) {
|
|
821
|
+
for (let i = 0;i < attempts; i++) {
|
|
822
|
+
try {
|
|
823
|
+
return fn();
|
|
824
|
+
} catch {
|
|
825
|
+
if (i < attempts - 1) {
|
|
826
|
+
const end = Date.now() + PROBE_RETRY_BACKOFF_MS;
|
|
827
|
+
while (Date.now() < end) {}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return fallback;
|
|
832
|
+
}
|
|
833
|
+
function pidIsDead(pid) {
|
|
834
|
+
if (pid <= 0)
|
|
835
|
+
return true;
|
|
836
|
+
try {
|
|
837
|
+
process.kill(pid, 0);
|
|
838
|
+
return false;
|
|
839
|
+
} catch (err) {
|
|
840
|
+
if (!(err instanceof Error))
|
|
841
|
+
return false;
|
|
842
|
+
const code = errnoCode(err);
|
|
843
|
+
if (code === "EPERM") {
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
if (code === "ESRCH") {
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function processStartTime(pid) {
|
|
853
|
+
if (pid <= 0)
|
|
854
|
+
return null;
|
|
855
|
+
return retrySync(() => {
|
|
856
|
+
const output = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
857
|
+
encoding: "utf8",
|
|
858
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
859
|
+
env: { ...process.env, TZ: "UTC", LC_ALL: "C" }
|
|
860
|
+
});
|
|
861
|
+
const trimmed = output.trim();
|
|
862
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
863
|
+
}, null, PROBE_RETRY_ATTEMPTS);
|
|
864
|
+
}
|
|
865
|
+
function machineBootId() {
|
|
866
|
+
return retrySync(() => {
|
|
867
|
+
const output = execFileSync("sysctl", ["-n", "kern.bootsessionuuid"], {
|
|
868
|
+
encoding: "utf8",
|
|
869
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
870
|
+
});
|
|
871
|
+
const uuid = output.trim();
|
|
872
|
+
if (uuid.length > 0)
|
|
873
|
+
return uuid;
|
|
874
|
+
throw new Error("empty sysctl output");
|
|
875
|
+
}, "", PROBE_RETRY_ATTEMPTS);
|
|
876
|
+
}
|
|
877
|
+
function ownerLiveness(record) {
|
|
878
|
+
try {
|
|
879
|
+
if (pidIsDead(record.ownerPid)) {
|
|
880
|
+
return { kind: "stale", reason: "pid-dead" };
|
|
881
|
+
}
|
|
882
|
+
const currentBoot = machineBootId();
|
|
883
|
+
if (hasKnownIdentity(record.bootId) && currentBoot !== "" && currentBoot !== record.bootId) {
|
|
884
|
+
return { kind: "stale", reason: "boot-id-mismatch" };
|
|
885
|
+
}
|
|
886
|
+
const startTime = processStartTime(record.ownerPid);
|
|
887
|
+
if (hasKnownIdentity(record.ownerStart) && startTime !== null && startTime !== record.ownerStart) {
|
|
888
|
+
return { kind: "stale", reason: "owner-start-mismatch" };
|
|
889
|
+
}
|
|
890
|
+
return LIVE_OWNER;
|
|
891
|
+
} catch {
|
|
892
|
+
return LIVE_OWNER;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function isOwnerLive(record) {
|
|
896
|
+
return ownerLiveness(record).kind === "live";
|
|
897
|
+
}
|
|
898
|
+
function ownerIdentitiesMatch(left, right) {
|
|
899
|
+
if (left.ownerPid !== right.ownerPid) {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
if (hasKnownIdentity(left.ownerStart) && hasKnownIdentity(right.ownerStart) && left.ownerStart !== right.ownerStart) {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
if (hasKnownIdentity(left.bootId) && hasKnownIdentity(right.bootId) && left.bootId !== right.bootId) {
|
|
906
|
+
return false;
|
|
907
|
+
}
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
function hasKnownIdentity(value) {
|
|
911
|
+
return value !== undefined && value !== null && value !== "";
|
|
912
|
+
}
|
|
913
|
+
function errnoCode(error) {
|
|
914
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
915
|
+
return;
|
|
916
|
+
const { code } = error;
|
|
917
|
+
return typeof code === "string" ? code : undefined;
|
|
918
|
+
}
|
|
919
|
+
function isHeartbeatStale(record, now = Date.now(), ttlMs = HEARTBEAT_TTL_MS) {
|
|
920
|
+
try {
|
|
921
|
+
const heartbeat = Date.parse(record.heartbeatAt);
|
|
922
|
+
if (Number.isNaN(heartbeat)) {
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
return now - heartbeat > ttlMs;
|
|
926
|
+
} catch {
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// src/fairness.ts
|
|
932
|
+
function writeTicket(config, runId, ticket) {
|
|
933
|
+
const ticketPath = waiterTicketPath(config, runId);
|
|
934
|
+
mkdirSync4(waiterDir(config, runId), { recursive: true });
|
|
935
|
+
const persistedTicket = {
|
|
936
|
+
...ticket,
|
|
937
|
+
ownerStart: ticket.ownerStart ?? processStartTime(ticket.ownerPid) ?? "",
|
|
938
|
+
bootId: ticket.bootId ?? machineBootId()
|
|
939
|
+
};
|
|
940
|
+
atomicWriteJson(ticketPath, persistedTicket);
|
|
941
|
+
}
|
|
942
|
+
function removeTicket(config, runId) {
|
|
943
|
+
try {
|
|
944
|
+
rmSync(waiterDir(config, runId), { recursive: true, force: true });
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (!(error instanceof Error)) {
|
|
947
|
+
throw error;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
function heldLeaseCount(config, agent) {
|
|
952
|
+
try {
|
|
953
|
+
const simsDir = join3(poolRoot(config), "sims");
|
|
954
|
+
let entries;
|
|
955
|
+
try {
|
|
956
|
+
entries = readdirSync(simsDir);
|
|
957
|
+
} catch {
|
|
958
|
+
return 0;
|
|
959
|
+
}
|
|
960
|
+
let count = 0;
|
|
961
|
+
for (const simName of entries) {
|
|
962
|
+
const leaseDirPath = simLeaseDir(config, simName);
|
|
963
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
964
|
+
let dirExists = false;
|
|
965
|
+
try {
|
|
966
|
+
dirExists = statSync2(leaseDirPath).isDirectory();
|
|
967
|
+
} catch {
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
if (!dirExists)
|
|
971
|
+
continue;
|
|
972
|
+
const record = readJsonOrNull(recordPath);
|
|
973
|
+
if (record === null)
|
|
974
|
+
continue;
|
|
975
|
+
if (record.worktree !== agent)
|
|
976
|
+
continue;
|
|
977
|
+
if (!isOwnerLive(record))
|
|
978
|
+
continue;
|
|
979
|
+
count++;
|
|
980
|
+
}
|
|
981
|
+
return count;
|
|
982
|
+
} catch {
|
|
983
|
+
return 0;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function rankWaiters(config) {
|
|
987
|
+
try {
|
|
988
|
+
const waitersDir = join3(poolRoot(config), "waiters");
|
|
989
|
+
let runIds;
|
|
990
|
+
try {
|
|
991
|
+
runIds = readdirSync(waitersDir);
|
|
992
|
+
} catch {
|
|
993
|
+
return [];
|
|
994
|
+
}
|
|
995
|
+
const live = [];
|
|
996
|
+
for (const runId of runIds) {
|
|
997
|
+
const ticketPath = waiterTicketPath(config, runId);
|
|
998
|
+
const ticket = readJsonOrNull(ticketPath);
|
|
999
|
+
if (ticket === null)
|
|
1000
|
+
continue;
|
|
1001
|
+
if (!isOwnerLive(ticket))
|
|
1002
|
+
continue;
|
|
1003
|
+
live.push(ticket);
|
|
1004
|
+
}
|
|
1005
|
+
live.sort((a, b) => {
|
|
1006
|
+
const aHeld = heldLeaseCount(config, a.agent);
|
|
1007
|
+
const bHeld = heldLeaseCount(config, b.agent);
|
|
1008
|
+
if (aHeld !== bHeld)
|
|
1009
|
+
return aHeld - bHeld;
|
|
1010
|
+
const aTime = Date.parse(a.enqueuedAt);
|
|
1011
|
+
const bTime = Date.parse(b.enqueuedAt);
|
|
1012
|
+
return aTime - bTime;
|
|
1013
|
+
});
|
|
1014
|
+
return live;
|
|
1015
|
+
} catch {
|
|
1016
|
+
return [];
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function isBestRankedFor(config, runId) {
|
|
1020
|
+
try {
|
|
1021
|
+
const myTicketPath = waiterTicketPath(config, runId);
|
|
1022
|
+
const myTicket = readJsonOrNull(myTicketPath);
|
|
1023
|
+
if (myTicket === null)
|
|
1024
|
+
return false;
|
|
1025
|
+
if (!isOwnerLive(myTicket))
|
|
1026
|
+
return false;
|
|
1027
|
+
const ranked = rankWaiters(config);
|
|
1028
|
+
if (ranked.length === 0)
|
|
1029
|
+
return false;
|
|
1030
|
+
const top = ranked[0];
|
|
1031
|
+
if (top === undefined)
|
|
1032
|
+
return false;
|
|
1033
|
+
return top.agent === myTicket.agent && ownerIdentitiesMatch(top, myTicket);
|
|
1034
|
+
} catch {
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/leaseReservation.ts
|
|
1040
|
+
import { mkdirSync as mkdirSync6, readdirSync as readdirSync2, rmSync as rmSync5, statSync as statSync4 } from "fs";
|
|
1041
|
+
import { join as join7 } from "path";
|
|
1042
|
+
|
|
1043
|
+
// src/lock/with-pool-lock.ts
|
|
1044
|
+
import { mkdirSync as mkdirSync5, rmSync as rmSync3 } from "fs";
|
|
1045
|
+
import { join as join6 } from "path";
|
|
1046
|
+
|
|
1047
|
+
// src/lock/contracts.ts
|
|
1048
|
+
var INITIAL_BACKOFF_MS = 50;
|
|
1049
|
+
var MAX_BACKOFF_MS = 500;
|
|
1050
|
+
var LOCK_TIMEOUT_MS = 30000;
|
|
1051
|
+
var LOCK_ACQUIRE_ORPHAN_GRACE_MS = 5000;
|
|
1052
|
+
|
|
1053
|
+
// src/lock/owner-record.ts
|
|
1054
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
1055
|
+
import { join as join4 } from "path";
|
|
1056
|
+
function writeMinimalOwnerRecord(lockDir) {
|
|
1057
|
+
const ownerPath = join4(lockDir, "owner.json");
|
|
1058
|
+
const record = {
|
|
1059
|
+
pid: process.pid,
|
|
1060
|
+
acquiredAt: Date.now()
|
|
1061
|
+
};
|
|
1062
|
+
writeFileSync4(ownerPath, JSON.stringify(record, null, 2), { encoding: "utf8", flag: "wx" });
|
|
1063
|
+
}
|
|
1064
|
+
function backfillOwnerRecord(lockDir) {
|
|
1065
|
+
const ownerPath = join4(lockDir, "owner.json");
|
|
1066
|
+
const existing = readJsonOrNull(ownerPath);
|
|
1067
|
+
const acquiredAt = existing !== null && Number.isFinite(Number(existing.acquiredAt)) ? Number(existing.acquiredAt) : Date.now();
|
|
1068
|
+
const record = {
|
|
1069
|
+
pid: process.pid,
|
|
1070
|
+
acquiredAt,
|
|
1071
|
+
ownerStart: processStartTime(process.pid),
|
|
1072
|
+
bootId: machineBootId()
|
|
1073
|
+
};
|
|
1074
|
+
writeFileSync4(ownerPath, JSON.stringify(record, null, 2), { encoding: "utf8" });
|
|
1075
|
+
}
|
|
1076
|
+
function ownerRecordMatchesPid(lockDir, myPid) {
|
|
1077
|
+
const ownerPath = join4(lockDir, "owner.json");
|
|
1078
|
+
const owner = readJsonOrNull(ownerPath);
|
|
1079
|
+
return owner !== null && owner.pid === myPid;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// src/lock/steal-dead-owner.ts
|
|
1083
|
+
import { rmSync as rmSync2, renameSync as renameSync2, statSync as statSync3 } from "fs";
|
|
1084
|
+
import { randomUUID } from "crypto";
|
|
1085
|
+
import { join as join5 } from "path";
|
|
1086
|
+
|
|
1087
|
+
// src/lock/liveness.ts
|
|
1088
|
+
function isLockOwnerLive(owner) {
|
|
1089
|
+
try {
|
|
1090
|
+
if (pidIsDead(owner.pid)) {
|
|
1091
|
+
return false;
|
|
1092
|
+
}
|
|
1093
|
+
const currentBoot = machineBootId();
|
|
1094
|
+
if (owner.bootId !== undefined && owner.bootId !== "" && currentBoot !== "" && currentBoot !== owner.bootId) {
|
|
1095
|
+
return false;
|
|
1096
|
+
}
|
|
1097
|
+
if (owner.ownerStart !== undefined && owner.ownerStart !== null && owner.ownerStart !== "") {
|
|
1098
|
+
const startTime = processStartTime(owner.pid);
|
|
1099
|
+
if (startTime !== null && startTime !== owner.ownerStart) {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return true;
|
|
1104
|
+
} catch {
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/lock/steal-dead-owner.ts
|
|
1110
|
+
function tryStealDeadLock(lockDir, myPid) {
|
|
1111
|
+
const ownerPath = join5(lockDir, "owner.json");
|
|
1112
|
+
const owner = readJsonOrNull(ownerPath);
|
|
1113
|
+
if (owner === null) {
|
|
1114
|
+
const ageMs = lockDirAgeMs(lockDir, null);
|
|
1115
|
+
if (ageMs !== null && ageMs > LOCK_ACQUIRE_ORPHAN_GRACE_MS) {
|
|
1116
|
+
return atomicSteal(lockDir, myPid);
|
|
1117
|
+
}
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
if (isLockOwnerLive(owner)) {
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
return atomicSteal(lockDir, myPid);
|
|
1124
|
+
}
|
|
1125
|
+
function atomicSteal(lockDir, myPid) {
|
|
1126
|
+
const tombstone = `${lockDir}.dead.${myPid}.${randomUUID()}`;
|
|
1127
|
+
try {
|
|
1128
|
+
renameSync2(lockDir, tombstone);
|
|
1129
|
+
} catch (renameErr) {
|
|
1130
|
+
if (errnoCode2(renameErr) === "ENOENT") {
|
|
1131
|
+
return true;
|
|
1132
|
+
}
|
|
1133
|
+
if (!(renameErr instanceof Error)) {
|
|
1134
|
+
throw renameErr;
|
|
1135
|
+
}
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
try {
|
|
1139
|
+
rmSync2(tombstone, { recursive: true, force: true });
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
if (!(error instanceof Error)) {
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
function lockDirAgeMs(lockDir, owner) {
|
|
1148
|
+
if (owner !== null) {
|
|
1149
|
+
const acquired = Number(owner.acquiredAt);
|
|
1150
|
+
if (Number.isFinite(acquired) && acquired > 0) {
|
|
1151
|
+
return Date.now() - acquired;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
try {
|
|
1155
|
+
const stat = statSync3(lockDir);
|
|
1156
|
+
return Date.now() - stat.mtimeMs;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function errnoCode2(error) {
|
|
1162
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const { code } = error;
|
|
1166
|
+
return typeof code === "string" ? code : undefined;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/lock/backoff.ts
|
|
1170
|
+
function waitForNextLockAttempt(ms) {
|
|
1171
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// src/lock/with-pool-lock.ts
|
|
1175
|
+
async function withPoolLock(config, fn) {
|
|
1176
|
+
const lockDir = poolLockDir(config);
|
|
1177
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
1178
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
1179
|
+
const ownedByUs = () => ownerRecordMatchesPid(lockDir, process.pid);
|
|
1180
|
+
try {
|
|
1181
|
+
mkdirSync5(join6(lockDir, ".."), { recursive: true });
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
if (!(error instanceof Error)) {
|
|
1184
|
+
throw error;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
await acquirePoolLock({ lockDir, deadline, backoff, ownedByUs });
|
|
1188
|
+
let outcome;
|
|
1189
|
+
try {
|
|
1190
|
+
outcome = { kind: "fulfilled", value: await fn() };
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
outcome = error instanceof Error ? { kind: "rejected-error", error } : { kind: "rejected-unknown", error };
|
|
1193
|
+
}
|
|
1194
|
+
let unexpectedReleaseError = null;
|
|
1195
|
+
try {
|
|
1196
|
+
if (ownedByUs()) {
|
|
1197
|
+
rmSync3(lockDir, { recursive: true, force: true });
|
|
1198
|
+
}
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
if (!(error instanceof Error)) {
|
|
1201
|
+
unexpectedReleaseError = { error };
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (outcome.kind !== "fulfilled") {
|
|
1205
|
+
throw outcome.error;
|
|
1206
|
+
}
|
|
1207
|
+
if (unexpectedReleaseError !== null) {
|
|
1208
|
+
throw unexpectedReleaseError.error;
|
|
1209
|
+
}
|
|
1210
|
+
return outcome.value;
|
|
1211
|
+
}
|
|
1212
|
+
function tryAcquireLock(lockDir) {
|
|
1213
|
+
try {
|
|
1214
|
+
mkdirSync5(lockDir, { recursive: false });
|
|
1215
|
+
return true;
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
const code = errnoCode3(err);
|
|
1218
|
+
if (code === "EEXIST") {
|
|
1219
|
+
return false;
|
|
1220
|
+
}
|
|
1221
|
+
if (code === "ENOENT") {
|
|
1222
|
+
try {
|
|
1223
|
+
mkdirSync5(join6(lockDir, ".."), { recursive: true });
|
|
1224
|
+
mkdirSync5(lockDir, { recursive: false });
|
|
1225
|
+
return true;
|
|
1226
|
+
} catch (retryError) {
|
|
1227
|
+
if (!(retryError instanceof Error)) {
|
|
1228
|
+
throw retryError;
|
|
1229
|
+
}
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
throw err;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
async function acquirePoolLock(state) {
|
|
1237
|
+
if (tryAcquireLock(state.lockDir)) {
|
|
1238
|
+
try {
|
|
1239
|
+
writeMinimalOwnerRecord(state.lockDir);
|
|
1240
|
+
backfillOwnerRecord(state.lockDir);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
if (!(error instanceof Error)) {
|
|
1243
|
+
throw error;
|
|
1244
|
+
}
|
|
1245
|
+
if (state.ownedByUs()) {
|
|
1246
|
+
try {
|
|
1247
|
+
rmSync3(state.lockDir, { recursive: true, force: true });
|
|
1248
|
+
} catch (releaseError) {
|
|
1249
|
+
if (!(releaseError instanceof Error)) {
|
|
1250
|
+
throw releaseError;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return retryLockAcquire(state);
|
|
1255
|
+
}
|
|
1256
|
+
if (state.ownedByUs()) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
return retryLockAcquire(state);
|
|
1260
|
+
}
|
|
1261
|
+
if (tryStealDeadLock(state.lockDir, process.pid)) {
|
|
1262
|
+
return acquirePoolLock(state);
|
|
1263
|
+
}
|
|
1264
|
+
return retryLockAcquire(state);
|
|
1265
|
+
}
|
|
1266
|
+
async function retryLockAcquire(state) {
|
|
1267
|
+
if (Date.now() >= state.deadline) {
|
|
1268
|
+
throw new Error(`withPoolLock: timed out after ${LOCK_TIMEOUT_MS}ms waiting for ${state.lockDir}`);
|
|
1269
|
+
}
|
|
1270
|
+
await waitForNextLockAttempt(state.backoff);
|
|
1271
|
+
return acquirePoolLock({
|
|
1272
|
+
...state,
|
|
1273
|
+
backoff: Math.min(state.backoff * 2, MAX_BACKOFF_MS)
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
function errnoCode3(error) {
|
|
1277
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const { code } = error;
|
|
1281
|
+
return typeof code === "string" ? code : undefined;
|
|
1282
|
+
}
|
|
1283
|
+
// src/leaseReclaim.ts
|
|
1284
|
+
import { rmSync as rmSync4 } from "fs";
|
|
1285
|
+
async function reclaimIfStale(config, simName) {
|
|
1286
|
+
return withPoolLock(config, () => reclaimUnderLock(config, simName));
|
|
1287
|
+
}
|
|
1288
|
+
function reclaimUnderLock(config, simName) {
|
|
1289
|
+
const leaseDir = simLeaseDir(config, simName);
|
|
1290
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
1291
|
+
const record1 = readJsonOrNull(recordPath);
|
|
1292
|
+
if (record1 === null) {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
const shouldReclaim = !isOwnerLive(record1) && isHeartbeatStale(record1) && pidIsDead(record1.maestroPid);
|
|
1296
|
+
if (!shouldReclaim) {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
const record2 = readJsonOrNull(recordPath);
|
|
1300
|
+
if (record2 === null) {
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
if (!ownerIdentitiesMatch(record1, record2)) {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
const stillShouldReclaim = !isOwnerLive(record2) && isHeartbeatStale(record2) && pidIsDead(record2.maestroPid);
|
|
1307
|
+
if (!stillShouldReclaim) {
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
rmSync4(leaseDir, { recursive: true, force: true });
|
|
1312
|
+
return true;
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
if (error instanceof Error) {
|
|
1315
|
+
return false;
|
|
1316
|
+
}
|
|
1317
|
+
throw error;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function reclaimFullSuiteReservationUnderLock(config) {
|
|
1321
|
+
const reservationDir = fullSuiteReservationDir(config);
|
|
1322
|
+
const recordPath = fullSuiteReservationRecordPath(config);
|
|
1323
|
+
const record1 = readJsonOrNull(recordPath);
|
|
1324
|
+
if (record1 === null) {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
if (!isFullSuiteReservationReclaimable(record1)) {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
const record2 = readJsonOrNull(recordPath);
|
|
1331
|
+
if (record2 === null) {
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
if (!reservationRecordsMatch(record1, record2)) {
|
|
1335
|
+
return false;
|
|
1336
|
+
}
|
|
1337
|
+
if (!isFullSuiteReservationReclaimable(record2)) {
|
|
1338
|
+
return false;
|
|
1339
|
+
}
|
|
1340
|
+
try {
|
|
1341
|
+
rmSync4(reservationDir, { recursive: true, force: true });
|
|
1342
|
+
return true;
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
if (error instanceof Error) {
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
throw error;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
function reapOrphanUnderLock(config, simName, killMaestro) {
|
|
1351
|
+
const leaseDir = simLeaseDir(config, simName);
|
|
1352
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
1353
|
+
const record1 = readJsonOrNull(recordPath);
|
|
1354
|
+
if (record1 === null) {
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
const isOrphan = !isOwnerLive(record1) && isHeartbeatStale(record1) && record1.maestroPid > 0 && !pidIsDead(record1.maestroPid);
|
|
1358
|
+
if (!isOrphan) {
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
killMaestro(record1.maestroPid);
|
|
1362
|
+
const record2 = readJsonOrNull(recordPath);
|
|
1363
|
+
if (record2 === null) {
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
if (!ownerIdentitiesMatch(record1, record2)) {
|
|
1367
|
+
return false;
|
|
1368
|
+
}
|
|
1369
|
+
if (isOwnerLive(record2)) {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
rmSync4(leaseDir, { recursive: true, force: true });
|
|
1374
|
+
return true;
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
if (error instanceof Error) {
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
throw error;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
async function reapOrphanIfAlive(config, simName, killMaestro) {
|
|
1383
|
+
return withPoolLock(config, () => reapOrphanUnderLock(config, simName, killMaestro));
|
|
1384
|
+
}
|
|
1385
|
+
function isFullSuiteReservationReclaimable(record) {
|
|
1386
|
+
return !isOwnerLive(record) && isHeartbeatStale(record);
|
|
1387
|
+
}
|
|
1388
|
+
function reservationRecordsMatch(left, right) {
|
|
1389
|
+
return ownerIdentitiesMatch(left, right) && left.runId === right.runId && left.reservedAt === right.reservedAt;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// src/leaseReservation.ts
|
|
1393
|
+
async function reserveFullSuite(config, record) {
|
|
1394
|
+
return withPoolLock(config, () => reserveFullSuiteUnderLock(config, record));
|
|
1395
|
+
}
|
|
1396
|
+
function reserveFullSuiteUnderLock(config, record) {
|
|
1397
|
+
reclaimFullSuiteReservationUnderLock(config);
|
|
1398
|
+
const reservationDir = fullSuiteReservationDir(config);
|
|
1399
|
+
try {
|
|
1400
|
+
mkdirSync6(poolRoot(config), { recursive: true });
|
|
1401
|
+
mkdirSync6(reservationDir, { recursive: false });
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
const code = errnoCode4(error);
|
|
1404
|
+
if (code === "EEXIST") {
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1407
|
+
throw error;
|
|
1408
|
+
}
|
|
1409
|
+
try {
|
|
1410
|
+
atomicWriteJson(fullSuiteReservationRecordPath(config), record);
|
|
1411
|
+
return true;
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
try {
|
|
1414
|
+
rmSync5(reservationDir, { recursive: true, force: true });
|
|
1415
|
+
} catch (cleanupError) {
|
|
1416
|
+
if (!(cleanupError instanceof Error)) {
|
|
1417
|
+
throw cleanupError;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
throw error;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
async function releaseFullSuiteReservation(config, runId, ownerPid) {
|
|
1424
|
+
return withPoolLock(config, () => releaseFullSuiteReservationUnderLock(config, runId, ownerPid));
|
|
1425
|
+
}
|
|
1426
|
+
function releaseFullSuiteReservationUnderLock(config, runId, ownerPid) {
|
|
1427
|
+
const record = readFullSuiteReservation(config);
|
|
1428
|
+
if (record === null || record.runId !== runId || record.ownerPid !== ownerPid) {
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
try {
|
|
1432
|
+
rmSync5(fullSuiteReservationDir(config), { recursive: true, force: true });
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
if (!(error instanceof Error)) {
|
|
1435
|
+
throw error;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
function readFullSuiteReservation(config) {
|
|
1440
|
+
if (!isDirPresent2(fullSuiteReservationDir(config))) {
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
return readJsonOrNull(fullSuiteReservationRecordPath(config));
|
|
1444
|
+
}
|
|
1445
|
+
function fullSuiteReservationLeaseAccessUnderLock(config, record) {
|
|
1446
|
+
reclaimFullSuiteReservationUnderLock(config);
|
|
1447
|
+
const reservation = readFullSuiteReservation(config);
|
|
1448
|
+
if (reservation === null) {
|
|
1449
|
+
return "open";
|
|
1450
|
+
}
|
|
1451
|
+
if (reservation.runId === record.runId) {
|
|
1452
|
+
return "reserved";
|
|
1453
|
+
}
|
|
1454
|
+
return heldLeaseCountForRun(config, reservation.runId) < reservation.requestedLaneCount ? "blocked" : "open";
|
|
1455
|
+
}
|
|
1456
|
+
function heldLeaseCountForRun(config, runId) {
|
|
1457
|
+
try {
|
|
1458
|
+
const simsDir = join7(poolRoot(config), "sims");
|
|
1459
|
+
let simNames;
|
|
1460
|
+
try {
|
|
1461
|
+
simNames = readdirSync2(simsDir);
|
|
1462
|
+
} catch {
|
|
1463
|
+
return 0;
|
|
1464
|
+
}
|
|
1465
|
+
let count = 0;
|
|
1466
|
+
for (const simName of simNames) {
|
|
1467
|
+
if (!isDirPresent2(simLeaseDir(config, simName))) {
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
const record = readJsonOrNull(simLeaseRecordPath(config, simName));
|
|
1471
|
+
if (record === null || record.runId !== runId || !isOwnerLive(record)) {
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
count++;
|
|
1475
|
+
}
|
|
1476
|
+
return count;
|
|
1477
|
+
} catch {
|
|
1478
|
+
return 0;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
function isDirPresent2(dirPath) {
|
|
1482
|
+
try {
|
|
1483
|
+
return statSync4(dirPath).isDirectory();
|
|
1484
|
+
} catch {
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function errnoCode4(error) {
|
|
1489
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const { code } = error;
|
|
1493
|
+
return typeof code === "string" ? code : undefined;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// src/lease.ts
|
|
1497
|
+
import { mkdirSync as mkdirSync7, rmSync as rmSync6, writeFileSync as writeFileSync5, existsSync as existsSync2 } from "fs";
|
|
1498
|
+
function tryLease(config, simName, record) {
|
|
1499
|
+
const leaseDir = simLeaseDir(config, simName);
|
|
1500
|
+
try {
|
|
1501
|
+
mkdirSync7(simDir(config, simName), { recursive: true });
|
|
1502
|
+
} catch (error) {
|
|
1503
|
+
if (!(error instanceof Error))
|
|
1504
|
+
throw error;
|
|
1505
|
+
}
|
|
1506
|
+
try {
|
|
1507
|
+
mkdirSync7(leaseDir, { recursive: false });
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
const code = errnoCode5(err);
|
|
1510
|
+
if (code === "EEXIST")
|
|
1511
|
+
return false;
|
|
1512
|
+
if (code === "ENOENT") {
|
|
1513
|
+
try {
|
|
1514
|
+
mkdirSync7(simDir(config, simName), { recursive: true });
|
|
1515
|
+
mkdirSync7(leaseDir, { recursive: false });
|
|
1516
|
+
} catch (retryErr) {
|
|
1517
|
+
const retryCode = errnoCode5(retryErr);
|
|
1518
|
+
if (retryCode === "EEXIST")
|
|
1519
|
+
return false;
|
|
1520
|
+
throw retryErr;
|
|
1521
|
+
}
|
|
1522
|
+
} else {
|
|
1523
|
+
throw err;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
1528
|
+
atomicWriteJson(recordPath, record);
|
|
1529
|
+
} catch (writeErr) {
|
|
1530
|
+
try {
|
|
1531
|
+
rmSync6(leaseDir, { recursive: true, force: true });
|
|
1532
|
+
} catch (cleanupError) {
|
|
1533
|
+
if (!(cleanupError instanceof Error))
|
|
1534
|
+
throw cleanupError;
|
|
1535
|
+
}
|
|
1536
|
+
throw writeErr;
|
|
1537
|
+
}
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
async function release(config, simName, pid) {
|
|
1541
|
+
return withPoolLock(config, () => releaseUnderLock(config, simName, pid));
|
|
1542
|
+
}
|
|
1543
|
+
function releaseUnderLock(config, simName, pid) {
|
|
1544
|
+
const leaseDir = simLeaseDir(config, simName);
|
|
1545
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
1546
|
+
const record = readJsonOrNull(recordPath);
|
|
1547
|
+
if (record === null) {
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (record.ownerPid !== pid) {
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
touchLastUsed(config, simName);
|
|
1554
|
+
try {
|
|
1555
|
+
rmSync6(leaseDir, { recursive: true, force: true });
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
if (!(error instanceof Error))
|
|
1558
|
+
throw error;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
function refreshHeartbeat(config, simName, pid) {
|
|
1562
|
+
const leaseDir = simLeaseDir(config, simName);
|
|
1563
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
1564
|
+
return withPoolLock(config, () => {
|
|
1565
|
+
try {
|
|
1566
|
+
if (!existsSync2(leaseDir)) {
|
|
1567
|
+
return false;
|
|
1568
|
+
}
|
|
1569
|
+
const record = readJsonOrNull(recordPath);
|
|
1570
|
+
if (record === null || record.ownerPid !== pid) {
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
const updated = {
|
|
1574
|
+
...record,
|
|
1575
|
+
heartbeatAt: new Date().toISOString()
|
|
1576
|
+
};
|
|
1577
|
+
atomicWriteJson(recordPath, updated);
|
|
1578
|
+
return true;
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
if (!(error instanceof Error))
|
|
1581
|
+
throw error;
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
function setLeaseMaestroPid(config, simName, pid, maestroPid) {
|
|
1587
|
+
const leaseDir = simLeaseDir(config, simName);
|
|
1588
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
1589
|
+
return withPoolLock(config, () => {
|
|
1590
|
+
try {
|
|
1591
|
+
if (!existsSync2(leaseDir)) {
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1594
|
+
const record = readJsonOrNull(recordPath);
|
|
1595
|
+
if (record === null || record.ownerPid !== pid) {
|
|
1596
|
+
return false;
|
|
1597
|
+
}
|
|
1598
|
+
const updated = {
|
|
1599
|
+
...record,
|
|
1600
|
+
maestroPid
|
|
1601
|
+
};
|
|
1602
|
+
atomicWriteJson(recordPath, updated);
|
|
1603
|
+
return true;
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
if (!(error instanceof Error))
|
|
1606
|
+
throw error;
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
function touchLastUsed(config, simName) {
|
|
1612
|
+
const lastUsedPath = simLastUsedPath(config, simName);
|
|
1613
|
+
const epochSecs = Math.floor(Date.now() / 1000).toString();
|
|
1614
|
+
try {
|
|
1615
|
+
mkdirSync7(simDir(config, simName), { recursive: true });
|
|
1616
|
+
writeFileSync5(lastUsedPath, epochSecs, { encoding: "utf8" });
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
if (!(error instanceof Error))
|
|
1619
|
+
throw error;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function errnoCode5(error) {
|
|
1623
|
+
if (typeof error !== "object" || error === null || !("code" in error))
|
|
1624
|
+
return;
|
|
1625
|
+
const { code } = error;
|
|
1626
|
+
return typeof code === "string" ? code : undefined;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// src/pool/lease-selection.ts
|
|
1630
|
+
async function ensureSimAvailable(config, record, simctl) {
|
|
1631
|
+
return withPoolLock(config, async () => {
|
|
1632
|
+
const reservationAccess = fullSuiteReservationLeaseAccessUnderLock(config, record);
|
|
1633
|
+
if (reservationAccess === "blocked") {
|
|
1634
|
+
return "WAIT";
|
|
1635
|
+
}
|
|
1636
|
+
if (reservationAccess === "open" && rankWaiters(config).length > 0 && !isBestRankedFor(config, record.runId)) {
|
|
1637
|
+
return "WAIT";
|
|
1638
|
+
}
|
|
1639
|
+
return ensureSimAvailableUnlocked(config, record, simctl);
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
async function ensureSimAvailableUnlocked(config, record, simctl) {
|
|
1643
|
+
const devices = await simctl.listDevices();
|
|
1644
|
+
const prefix = config.pool.prefix;
|
|
1645
|
+
const poolDevices = devices.filter((device) => device.name.startsWith(`${prefix}-`));
|
|
1646
|
+
const freeSlot = await tryLeaseAnyFreeSlotUnlocked(config, record, poolDevices);
|
|
1647
|
+
if (freeSlot !== null) {
|
|
1648
|
+
return freeSlot;
|
|
1649
|
+
}
|
|
1650
|
+
if (poolDevices.length >= config.pool.max) {
|
|
1651
|
+
return "WAIT";
|
|
1652
|
+
}
|
|
1653
|
+
const usedIndices = new Set(poolDevices.map((device) => {
|
|
1654
|
+
const match = device.name.match(new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`));
|
|
1655
|
+
const indexText = match?.[1];
|
|
1656
|
+
return indexText === undefined ? null : Number.parseInt(indexText, 10);
|
|
1657
|
+
}).filter((index) => index !== null));
|
|
1658
|
+
let newIndex = 1;
|
|
1659
|
+
while (usedIndices.has(newIndex)) {
|
|
1660
|
+
newIndex++;
|
|
1661
|
+
}
|
|
1662
|
+
const { simName, udid } = await createGapSim(config, newIndex, simctl);
|
|
1663
|
+
const leased = tryLease(config, simName, record);
|
|
1664
|
+
if (!leased) {
|
|
1665
|
+
return "WAIT";
|
|
1666
|
+
}
|
|
1667
|
+
return { simName, udid };
|
|
1668
|
+
}
|
|
1669
|
+
async function tryLeaseAnyFreeSlotUnlocked(config, record, poolDevices) {
|
|
1670
|
+
for (const device of poolDevices) {
|
|
1671
|
+
const simName = device.name;
|
|
1672
|
+
if (isDirPresent3(simLeaseDir(config, simName))) {
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
if (tryLease(config, simName, record)) {
|
|
1676
|
+
return { simName, udid: device.udid };
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
function isDirPresent3(dirPath) {
|
|
1682
|
+
try {
|
|
1683
|
+
const st = statSync5(dirPath);
|
|
1684
|
+
return st.isDirectory();
|
|
1685
|
+
} catch {
|
|
1686
|
+
return false;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
function escapeRegex(s) {
|
|
1690
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1691
|
+
}
|
|
1692
|
+
// src/pool/provisioning.ts
|
|
1693
|
+
import { statSync as statSync6 } from "fs";
|
|
1694
|
+
var DEFAULT_WARM_SETTLE_MS = 4000;
|
|
1695
|
+
function createWarmSimCache() {
|
|
1696
|
+
const warmedByUdid = new Map;
|
|
1697
|
+
return {
|
|
1698
|
+
hasWarmedApp(key) {
|
|
1699
|
+
const warmed = warmedByUdid.get(key.udid);
|
|
1700
|
+
return warmed !== undefined && warmed.appPath === key.appPath && warmed.appMtimeMs === key.appMtimeMs;
|
|
1701
|
+
},
|
|
1702
|
+
rememberWarmedApp(key) {
|
|
1703
|
+
warmedByUdid.set(key.udid, {
|
|
1704
|
+
appPath: key.appPath,
|
|
1705
|
+
appMtimeMs: key.appMtimeMs
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
async function bootInstallWarmFull(opts) {
|
|
1711
|
+
await opts.simctl.bootAndWait(opts.udid, opts.displayMode);
|
|
1712
|
+
const installResult = await installOnLease(opts.config.app.appId, opts.udid, opts.appPath, opts.simctl);
|
|
1713
|
+
const cacheKey = {
|
|
1714
|
+
udid: opts.udid,
|
|
1715
|
+
appPath: opts.appPath,
|
|
1716
|
+
appMtimeMs: installResult.sourceMtimeMs
|
|
1717
|
+
};
|
|
1718
|
+
if (requiresWarmSim({
|
|
1719
|
+
warmSimCache: opts.warmSimCache,
|
|
1720
|
+
cacheKey,
|
|
1721
|
+
performedInstall: installResult.performedInstall
|
|
1722
|
+
})) {
|
|
1723
|
+
await warmSim({
|
|
1724
|
+
udid: opts.udid,
|
|
1725
|
+
config: opts.config,
|
|
1726
|
+
simctl: opts.simctl,
|
|
1727
|
+
settleMs: opts.warmSettleMs ?? DEFAULT_WARM_SETTLE_MS
|
|
1728
|
+
});
|
|
1729
|
+
opts.warmSimCache?.rememberWarmedApp(cacheKey);
|
|
1730
|
+
}
|
|
1731
|
+
const installed = await opts.simctl.hasApp(opts.udid, opts.config.app.appId);
|
|
1732
|
+
if (!installed) {
|
|
1733
|
+
throw new Error(`bootInstallWarm: bundle guard failed \u2014 ${opts.config.app.appId} not installed on ${opts.simName} (${opts.udid})`);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
async function installOnLease(appId, udid, appPath, simctl) {
|
|
1737
|
+
const sourceMtimeMs = getAppMtime(appPath);
|
|
1738
|
+
const installedMtimeMs = await simctl.installedAppMtime(udid, appId);
|
|
1739
|
+
const performedInstall = sourceMtimeMs === null || installedMtimeMs === null || sourceMtimeMs !== installedMtimeMs;
|
|
1740
|
+
if (performedInstall) {
|
|
1741
|
+
await simctl.install(udid, appPath);
|
|
1742
|
+
}
|
|
1743
|
+
return { sourceMtimeMs, performedInstall };
|
|
1744
|
+
}
|
|
1745
|
+
function requiresWarmSim(opts) {
|
|
1746
|
+
if (opts.performedInstall) {
|
|
1747
|
+
return true;
|
|
1748
|
+
}
|
|
1749
|
+
return opts.warmSimCache?.hasWarmedApp(opts.cacheKey) !== true;
|
|
1750
|
+
}
|
|
1751
|
+
async function warmSim(opts) {
|
|
1752
|
+
const appId = opts.config.app.appId;
|
|
1753
|
+
try {
|
|
1754
|
+
await opts.simctl.launch(opts.udid, appId);
|
|
1755
|
+
} catch (error) {
|
|
1756
|
+
if (!(error instanceof Error)) {
|
|
1757
|
+
throw error;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
await sleep(opts.settleMs);
|
|
1761
|
+
try {
|
|
1762
|
+
await opts.simctl.terminate(opts.udid, appId);
|
|
1763
|
+
} catch (error) {
|
|
1764
|
+
if (!(error instanceof Error)) {
|
|
1765
|
+
throw error;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
function getAppMtime(appPath) {
|
|
1770
|
+
try {
|
|
1771
|
+
const st = statSync6(appPath);
|
|
1772
|
+
return st.mtimeMs;
|
|
1773
|
+
} catch {
|
|
1774
|
+
return null;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
function sleep(ms) {
|
|
1778
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1779
|
+
}
|
|
1780
|
+
// src/commands/up.ts
|
|
1781
|
+
init_simctl();
|
|
1782
|
+
|
|
1783
|
+
// src/app-resolve/contracts.ts
|
|
1784
|
+
class AppResolveError extends Error {
|
|
1785
|
+
name = "AppResolveError";
|
|
1786
|
+
constructor(message) {
|
|
1787
|
+
super(`maestro-manager app-resolve: ${message}`);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
// src/app-resolve/bundle-metadata.ts
|
|
1791
|
+
import { existsSync as existsSync3, statSync as statSync8 } from "fs";
|
|
1792
|
+
import { join as join8 } from "path";
|
|
1793
|
+
import { spawnSync } from "child_process";
|
|
1794
|
+
function readCFBundleIdentifier(appPath) {
|
|
1795
|
+
const plistPath = join8(appPath, "Info.plist");
|
|
1796
|
+
if (!existsSync3(plistPath)) {
|
|
1797
|
+
throw new AppResolveError(`Info.plist not found in ${appPath}`);
|
|
1798
|
+
}
|
|
1799
|
+
const plutilResult = spawnSync("plutil", ["-extract", "CFBundleIdentifier", "raw", plistPath], {
|
|
1800
|
+
encoding: "utf8"
|
|
1801
|
+
});
|
|
1802
|
+
if (plutilResult.status === 0 && plutilResult.stdout) {
|
|
1803
|
+
const value = plutilResult.stdout.trim();
|
|
1804
|
+
if (value.length > 0)
|
|
1805
|
+
return value;
|
|
1806
|
+
}
|
|
1807
|
+
const plistBase = join8(appPath, "Info");
|
|
1808
|
+
const defaultsResult = spawnSync("defaults", ["read", plistBase, "CFBundleIdentifier"], {
|
|
1809
|
+
encoding: "utf8"
|
|
1810
|
+
});
|
|
1811
|
+
if (defaultsResult.status === 0 && defaultsResult.stdout) {
|
|
1812
|
+
const value = defaultsResult.stdout.trim();
|
|
1813
|
+
if (value.length > 0)
|
|
1814
|
+
return value;
|
|
1815
|
+
}
|
|
1816
|
+
throw new AppResolveError(`Could not read CFBundleIdentifier from ${plistPath}.
|
|
1817
|
+
` + `Tried: plutil -extract, defaults read.`);
|
|
1818
|
+
}
|
|
1819
|
+
function bundleMtime(appPath) {
|
|
1820
|
+
try {
|
|
1821
|
+
return statSync8(appPath).mtimeMs;
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
if (!(error instanceof Error))
|
|
1824
|
+
throw error;
|
|
1825
|
+
return 0;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
// src/app-resolve/flow-app-id.ts
|
|
1829
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
1830
|
+
function readAppIdFromFlow(flowPath) {
|
|
1831
|
+
let text;
|
|
1832
|
+
try {
|
|
1833
|
+
text = readFileSync3(flowPath, { encoding: "utf8" });
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
if (!(error instanceof Error))
|
|
1836
|
+
throw error;
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
for (const line of text.split(`
|
|
1840
|
+
`)) {
|
|
1841
|
+
const match = line.match(/^appId:\s*(.+)$/);
|
|
1842
|
+
const rawValue = match?.[1];
|
|
1843
|
+
if (rawValue === undefined)
|
|
1844
|
+
continue;
|
|
1845
|
+
const value = rawValue.trim();
|
|
1846
|
+
const withoutComment = value.replace(/\s*#.*$/, "").trim();
|
|
1847
|
+
if (withoutComment.length > 0)
|
|
1848
|
+
return withoutComment;
|
|
1849
|
+
}
|
|
1850
|
+
return null;
|
|
1851
|
+
}
|
|
1852
|
+
// src/app-resolve/resolve-app.ts
|
|
1853
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1854
|
+
|
|
1855
|
+
// src/app-resolve/candidate-search.ts
|
|
1856
|
+
import { existsSync as existsSync4, readdirSync as readdirSync3, statSync as statSync9 } from "fs";
|
|
1857
|
+
import { join as join10 } from "path";
|
|
1858
|
+
|
|
1859
|
+
// src/app-resolve/search-dirs.ts
|
|
1860
|
+
import { homedir as homedir3 } from "os";
|
|
1861
|
+
import { join as join9 } from "path";
|
|
1862
|
+
var DEFAULT_DERIVED_DATA = join9(homedir3(), "Library", "Developer", "Xcode", "DerivedData");
|
|
1863
|
+
function buildSearchDirs(searchDirs) {
|
|
1864
|
+
const dirs = new Set([DEFAULT_DERIVED_DATA]);
|
|
1865
|
+
for (const dir of searchDirs) {
|
|
1866
|
+
const expanded = dir.startsWith("~/") ? join9(homedir3(), dir.slice(2)) : dir;
|
|
1867
|
+
dirs.add(expanded);
|
|
1868
|
+
}
|
|
1869
|
+
return [...dirs];
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// src/app-resolve/candidate-search.ts
|
|
1873
|
+
async function scanForApp(appId, searchDirs) {
|
|
1874
|
+
const dirsToScan = buildSearchDirs(searchDirs);
|
|
1875
|
+
const results = [];
|
|
1876
|
+
for (const baseDir of dirsToScan) {
|
|
1877
|
+
if (!existsSync4(baseDir))
|
|
1878
|
+
continue;
|
|
1879
|
+
const candidates = findAppBundles(baseDir, 4);
|
|
1880
|
+
for (const appPath of candidates) {
|
|
1881
|
+
try {
|
|
1882
|
+
const bundleId = readCFBundleIdentifier(appPath);
|
|
1883
|
+
if (bundleId === appId) {
|
|
1884
|
+
const mtimeMs = bundleMtime(appPath);
|
|
1885
|
+
results.push({ appPath, appId: bundleId, mtimeMs });
|
|
1886
|
+
}
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
if (!(error instanceof Error))
|
|
1889
|
+
throw error;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
return results;
|
|
1894
|
+
}
|
|
1895
|
+
function findAppBundles(dir, maxDepth) {
|
|
1896
|
+
if (maxDepth < 0)
|
|
1897
|
+
return [];
|
|
1898
|
+
const results = [];
|
|
1899
|
+
let entries;
|
|
1900
|
+
try {
|
|
1901
|
+
entries = readdirSync3(dir);
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
if (!(error instanceof Error))
|
|
1904
|
+
throw error;
|
|
1905
|
+
return [];
|
|
1906
|
+
}
|
|
1907
|
+
for (const entry of entries) {
|
|
1908
|
+
if (entry.startsWith("."))
|
|
1909
|
+
continue;
|
|
1910
|
+
const fullPath = join10(dir, entry);
|
|
1911
|
+
if (entry.endsWith(".app")) {
|
|
1912
|
+
try {
|
|
1913
|
+
const st = statSync9(fullPath);
|
|
1914
|
+
if (st.isDirectory()) {
|
|
1915
|
+
results.push(fullPath);
|
|
1916
|
+
}
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
if (!(error instanceof Error))
|
|
1919
|
+
throw error;
|
|
1920
|
+
}
|
|
1921
|
+
} else if (maxDepth > 0) {
|
|
1922
|
+
try {
|
|
1923
|
+
const st = statSync9(fullPath);
|
|
1924
|
+
if (st.isDirectory()) {
|
|
1925
|
+
results.push(...findAppBundles(fullPath, maxDepth - 1));
|
|
1926
|
+
}
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
if (!(error instanceof Error))
|
|
1929
|
+
throw error;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
return results;
|
|
1934
|
+
}
|
|
1935
|
+
function rankNewestApp(candidates) {
|
|
1936
|
+
const ranked = [...candidates].sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1937
|
+
return ranked[0] ?? null;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// src/app-resolve/glob-expansion.ts
|
|
1941
|
+
import { existsSync as existsSync5, readdirSync as readdirSync4 } from "fs";
|
|
1942
|
+
import { homedir as homedir4 } from "os";
|
|
1943
|
+
import { join as join11 } from "path";
|
|
1944
|
+
function expandGlob(pattern) {
|
|
1945
|
+
const expanded = pattern.startsWith("~/") ? join11(homedir4(), pattern.slice(2)) : pattern;
|
|
1946
|
+
if (!containsSegmentGlob(expanded)) {
|
|
1947
|
+
return existsSync5(expanded) ? [expanded] : [];
|
|
1948
|
+
}
|
|
1949
|
+
if (!expanded.includes("/")) {
|
|
1950
|
+
return [];
|
|
1951
|
+
}
|
|
1952
|
+
let matches = expanded.startsWith("/") ? ["/"] : ["."];
|
|
1953
|
+
const segments = expanded.split("/").filter((segment) => segment.length > 0);
|
|
1954
|
+
for (const segment of segments) {
|
|
1955
|
+
matches = expandGlobSegment(matches, segment);
|
|
1956
|
+
if (matches.length === 0)
|
|
1957
|
+
return [];
|
|
1958
|
+
}
|
|
1959
|
+
return matches.filter((match) => existsSync5(match));
|
|
1960
|
+
}
|
|
1961
|
+
function expandGlobSegment(basePaths, segment) {
|
|
1962
|
+
const results = [];
|
|
1963
|
+
if (containsSegmentGlob(segment)) {
|
|
1964
|
+
const regex = segmentGlobToRegExp(segment);
|
|
1965
|
+
for (const basePath of basePaths) {
|
|
1966
|
+
let entries;
|
|
1967
|
+
try {
|
|
1968
|
+
entries = readdirSync4(basePath);
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
if (!(error instanceof Error))
|
|
1971
|
+
throw error;
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
for (const entry of entries) {
|
|
1975
|
+
if (regex.test(entry)) {
|
|
1976
|
+
const candidate = join11(basePath, entry);
|
|
1977
|
+
if (existsSync5(candidate))
|
|
1978
|
+
results.push(candidate);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return results;
|
|
1983
|
+
}
|
|
1984
|
+
for (const basePath of basePaths) {
|
|
1985
|
+
const candidate = join11(basePath, segment);
|
|
1986
|
+
if (existsSync5(candidate))
|
|
1987
|
+
results.push(candidate);
|
|
1988
|
+
}
|
|
1989
|
+
return results;
|
|
1990
|
+
}
|
|
1991
|
+
function containsSegmentGlob(value) {
|
|
1992
|
+
return value.includes("*") || value.includes("?");
|
|
1993
|
+
}
|
|
1994
|
+
function segmentGlobToRegExp(segment) {
|
|
1995
|
+
let source = "^";
|
|
1996
|
+
for (const char of segment) {
|
|
1997
|
+
if (char === "*") {
|
|
1998
|
+
source += "[^/]*";
|
|
1999
|
+
} else if (char === "?") {
|
|
2000
|
+
source += "[^/]";
|
|
2001
|
+
} else {
|
|
2002
|
+
source += escapeRegExp(char);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
source += "$";
|
|
2006
|
+
return new RegExp(source);
|
|
2007
|
+
}
|
|
2008
|
+
function escapeRegExp(value) {
|
|
2009
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// src/app-resolve/resolve-app.ts
|
|
2013
|
+
async function resolveApp(opts) {
|
|
2014
|
+
const { explicitApp, config, flow } = opts;
|
|
2015
|
+
if (explicitApp !== undefined) {
|
|
2016
|
+
return resolveExplicit(explicitApp);
|
|
2017
|
+
}
|
|
2018
|
+
if (config.path !== undefined && config.path !== "") {
|
|
2019
|
+
return resolveStaticPath(config.path);
|
|
2020
|
+
}
|
|
2021
|
+
if (config.glob !== undefined && config.glob !== "") {
|
|
2022
|
+
return resolveGlob(config.glob);
|
|
2023
|
+
}
|
|
2024
|
+
const appId = resolveTargetAppId({ config, flow });
|
|
2025
|
+
const candidate = rankNewestApp(await scanForApp(appId, config.searchDirs));
|
|
2026
|
+
if (candidate !== null) {
|
|
2027
|
+
return candidate;
|
|
2028
|
+
}
|
|
2029
|
+
const searched = buildSearchDirs(config.searchDirs);
|
|
2030
|
+
throw new AppResolveError(`No .app bundle found for appId "${appId}" in: ${searched.join(", ")}.
|
|
2031
|
+
` + `Fix one of:
|
|
2032
|
+
` + ` \u2022 Pass --app <path/to/Your.app> on the CLI.
|
|
2033
|
+
` + ` \u2022 Set "app.path" or "app.glob" in maestro-manager.config.json.
|
|
2034
|
+
` + ` \u2022 Build the app so it lands under ~/Library/Developer/Xcode/DerivedData or a configured searchDir.`);
|
|
2035
|
+
}
|
|
2036
|
+
function resolveExplicit(appPath) {
|
|
2037
|
+
if (!appPath.endsWith(".app")) {
|
|
2038
|
+
throw new AppResolveError(`--app path must end in ".app" (got: ${appPath})`);
|
|
2039
|
+
}
|
|
2040
|
+
if (!existsSync6(appPath)) {
|
|
2041
|
+
throw new AppResolveError(`--app path does not exist: ${appPath}`);
|
|
2042
|
+
}
|
|
2043
|
+
const appId = readCFBundleIdentifier(appPath);
|
|
2044
|
+
const mtimeMs = bundleMtime(appPath);
|
|
2045
|
+
return { appPath, appId, mtimeMs };
|
|
2046
|
+
}
|
|
2047
|
+
function resolveStaticPath(appPath) {
|
|
2048
|
+
if (!existsSync6(appPath)) {
|
|
2049
|
+
throw new AppResolveError(`config.app.path does not exist: ${appPath}.
|
|
2050
|
+
` + `Fix: update "app.path" in maestro-manager.config.json or build the app first.`);
|
|
2051
|
+
}
|
|
2052
|
+
if (!appPath.endsWith(".app")) {
|
|
2053
|
+
throw new AppResolveError(`config.app.path must end in ".app" (got: ${appPath})`);
|
|
2054
|
+
}
|
|
2055
|
+
const appId = readCFBundleIdentifier(appPath);
|
|
2056
|
+
const mtimeMs = bundleMtime(appPath);
|
|
2057
|
+
return { appPath, appId, mtimeMs };
|
|
2058
|
+
}
|
|
2059
|
+
function resolveGlob(pattern) {
|
|
2060
|
+
const matches = expandGlob(pattern);
|
|
2061
|
+
if (matches.length === 0) {
|
|
2062
|
+
throw new AppResolveError(`config.app.glob "${pattern}" matched no .app bundles.
|
|
2063
|
+
` + `Fix: update "app.glob" in maestro-manager.config.json or build the app first.`);
|
|
2064
|
+
}
|
|
2065
|
+
const candidates = [];
|
|
2066
|
+
for (const appPath of matches) {
|
|
2067
|
+
if (!appPath.endsWith(".app") || !existsSync6(appPath))
|
|
2068
|
+
continue;
|
|
2069
|
+
try {
|
|
2070
|
+
const appId = readCFBundleIdentifier(appPath);
|
|
2071
|
+
candidates.push({ appPath, appId, mtimeMs: bundleMtime(appPath) });
|
|
2072
|
+
} catch (error) {
|
|
2073
|
+
if (error instanceof AppResolveError)
|
|
2074
|
+
continue;
|
|
2075
|
+
throw error;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
const ranked = rankNewestApp(candidates);
|
|
2079
|
+
if (ranked === null) {
|
|
2080
|
+
throw new AppResolveError(`config.app.glob "${pattern}" matched paths but no valid app bundle was found.`);
|
|
2081
|
+
}
|
|
2082
|
+
return ranked;
|
|
2083
|
+
}
|
|
2084
|
+
function resolveTargetAppId(opts) {
|
|
2085
|
+
const { config, flow } = opts;
|
|
2086
|
+
if (flow !== undefined && flow !== "") {
|
|
2087
|
+
const fromFlow = readAppIdFromFlow(flow);
|
|
2088
|
+
if (fromFlow !== null)
|
|
2089
|
+
return fromFlow;
|
|
2090
|
+
}
|
|
2091
|
+
if (config.appId !== "") {
|
|
2092
|
+
return config.appId;
|
|
2093
|
+
}
|
|
2094
|
+
throw new AppResolveError(`Cannot determine appId: no flow given, and "app.appId" is empty in maestro-manager.config.json.
|
|
2095
|
+
` + `Fix: set "app.appId" in the config, or pass --app <path/to/Your.app>.`);
|
|
2096
|
+
}
|
|
2097
|
+
// src/commands/up.ts
|
|
2098
|
+
async function runUp(opts) {
|
|
2099
|
+
const { config } = opts;
|
|
2100
|
+
const simctlImpl = opts.simctlImpl ?? exports_simctl;
|
|
2101
|
+
const displayMode = opts.displayMode ?? DEFAULT_SIMULATOR_DISPLAY_MODE;
|
|
2102
|
+
mkdirSync8(poolRoot(config), { recursive: true });
|
|
2103
|
+
atomicWriteJson(configSnapshotPath(config), config);
|
|
2104
|
+
const poolState = await reconcile(config, simctlImpl);
|
|
2105
|
+
const existingNames = new Set(poolState.slots.map((s) => s.simName));
|
|
2106
|
+
const prefix = config.pool.prefix;
|
|
2107
|
+
const target = config.pool.default;
|
|
2108
|
+
const usedIndices = new Set;
|
|
2109
|
+
for (const name of existingNames) {
|
|
2110
|
+
const m = name.match(new RegExp(`^${escapeRegex2(prefix)}-(\\d+)$`));
|
|
2111
|
+
const indexText = m?.[1];
|
|
2112
|
+
if (indexText !== undefined)
|
|
2113
|
+
usedIndices.add(Number.parseInt(indexText, 10));
|
|
2114
|
+
}
|
|
2115
|
+
const simsToWarm = [];
|
|
2116
|
+
for (const slot of poolState.slots) {
|
|
2117
|
+
simsToWarm.push({ simName: slot.simName, udid: slot.udid });
|
|
2118
|
+
}
|
|
2119
|
+
for (let i = 1;simsToWarm.length < target; i++) {
|
|
2120
|
+
if (!usedIndices.has(i)) {
|
|
2121
|
+
const created = await createGapSim(config, i, simctlImpl);
|
|
2122
|
+
simsToWarm.push(created);
|
|
2123
|
+
usedIndices.add(i);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
let resolvedAppPath;
|
|
2127
|
+
if (opts.appPath !== undefined) {
|
|
2128
|
+
const resolved = await resolveApp({ explicitApp: opts.appPath, config: config.app });
|
|
2129
|
+
resolvedAppPath = resolved.appPath;
|
|
2130
|
+
} else {
|
|
2131
|
+
try {
|
|
2132
|
+
const resolved = await resolveApp({ config: config.app });
|
|
2133
|
+
resolvedAppPath = resolved.appPath;
|
|
2134
|
+
} catch {
|
|
2135
|
+
resolvedAppPath = "";
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
await Promise.all(simsToWarm.map(async ({ simName, udid }) => {
|
|
2139
|
+
process.stderr.write(`[maestro-manager] up: booting ${simName} (${udid})...
|
|
2140
|
+
`);
|
|
2141
|
+
if (resolvedAppPath !== "") {
|
|
2142
|
+
await bootInstallWarmFull({
|
|
2143
|
+
config,
|
|
2144
|
+
simName,
|
|
2145
|
+
udid,
|
|
2146
|
+
appPath: resolvedAppPath,
|
|
2147
|
+
simctl: simctlImpl,
|
|
2148
|
+
displayMode
|
|
2149
|
+
});
|
|
2150
|
+
} else {
|
|
2151
|
+
await simctlImpl.bootAndWait(udid, displayMode);
|
|
2152
|
+
}
|
|
2153
|
+
process.stderr.write(`[maestro-manager] up: ${simName} ready.
|
|
2154
|
+
`);
|
|
2155
|
+
}));
|
|
2156
|
+
process.stderr.write(`[maestro-manager] up: ${simsToWarm.length} sim(s) ready.
|
|
2157
|
+
`);
|
|
2158
|
+
}
|
|
2159
|
+
function escapeRegex2(s) {
|
|
2160
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// src/commands/test.ts
|
|
2164
|
+
import { basename as basename5 } from "path";
|
|
2165
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2166
|
+
|
|
2167
|
+
// src/maestro/argv.ts
|
|
2168
|
+
function buildMaestroInvocation(opts) {
|
|
2169
|
+
return {
|
|
2170
|
+
command: "maestro",
|
|
2171
|
+
args: ["--device", opts.udid, "test", opts.flow, ...opts.passthrough]
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
// src/maestro/java-home.ts
|
|
2175
|
+
import { execFileSync as execFileSync5 } from "child_process";
|
|
2176
|
+
import { statSync as statSync10 } from "fs";
|
|
2177
|
+
import { homedir as homedir5 } from "os";
|
|
2178
|
+
function resolveJavaHome(config) {
|
|
2179
|
+
const javaConfig = config.java;
|
|
2180
|
+
if (javaConfig.home !== null && javaConfig.home.length > 0) {
|
|
2181
|
+
const expanded = expandTilde2(javaConfig.home);
|
|
2182
|
+
if (isDirectory(expanded))
|
|
2183
|
+
return expanded;
|
|
2184
|
+
}
|
|
2185
|
+
for (const entry of javaConfig.autodetect) {
|
|
2186
|
+
const resolved = resolveAutodetectEntry(entry);
|
|
2187
|
+
if (resolved !== null)
|
|
2188
|
+
return resolved;
|
|
2189
|
+
}
|
|
2190
|
+
return null;
|
|
2191
|
+
}
|
|
2192
|
+
function expandTilde2(path) {
|
|
2193
|
+
if (path === "~" || path.startsWith("~/")) {
|
|
2194
|
+
return homedir5() + path.slice(1);
|
|
2195
|
+
}
|
|
2196
|
+
return path;
|
|
2197
|
+
}
|
|
2198
|
+
function isDirectory(path) {
|
|
2199
|
+
try {
|
|
2200
|
+
return statSync10(path).isDirectory();
|
|
2201
|
+
} catch (error) {
|
|
2202
|
+
if (error instanceof Error)
|
|
2203
|
+
return false;
|
|
2204
|
+
throw error;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
function resolveAutodetectEntry(entry) {
|
|
2208
|
+
switch (entry) {
|
|
2209
|
+
case "JAVA_HOME": {
|
|
2210
|
+
const javaHome = process.env["JAVA_HOME"];
|
|
2211
|
+
return javaHome !== undefined && isDirectory(javaHome) ? javaHome : null;
|
|
2212
|
+
}
|
|
2213
|
+
case "mise": {
|
|
2214
|
+
const temurinLatest = expandTilde2("~/.local/share/mise/installs/java/temurin-latest");
|
|
2215
|
+
if (isDirectory(temurinLatest))
|
|
2216
|
+
return temurinLatest;
|
|
2217
|
+
return commandDirectory("mise", ["where", "java"]);
|
|
2218
|
+
}
|
|
2219
|
+
case "/usr/libexec/java_home":
|
|
2220
|
+
return commandDirectory("/usr/libexec/java_home", []);
|
|
2221
|
+
default: {
|
|
2222
|
+
const expanded = expandTilde2(entry);
|
|
2223
|
+
return isDirectory(expanded) ? expanded : null;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
function commandDirectory(command, args) {
|
|
2228
|
+
try {
|
|
2229
|
+
const path = execFileSync5(command, [...args], {
|
|
2230
|
+
encoding: "utf8",
|
|
2231
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2232
|
+
timeout: 5000
|
|
2233
|
+
}).trim();
|
|
2234
|
+
return path.length > 0 && isDirectory(path) ? path : null;
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
if (error instanceof Error)
|
|
2237
|
+
return null;
|
|
2238
|
+
throw error;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
// src/maestroFailure.ts
|
|
2242
|
+
function detectFailureLine(logContent) {
|
|
2243
|
+
const failurePattern = /Assertion is false|Element .* not|FAILED/i;
|
|
2244
|
+
for (const line of logContent.split(`
|
|
2245
|
+
`)) {
|
|
2246
|
+
if (failurePattern.test(line)) {
|
|
2247
|
+
return line.slice(0, 90);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
return null;
|
|
2251
|
+
}
|
|
2252
|
+
// src/maestroRunFlow.ts
|
|
2253
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
2254
|
+
import { appendFileSync, createWriteStream, mkdirSync as mkdirSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
2255
|
+
import { dirname as dirname3 } from "path";
|
|
2256
|
+
var FLOW_TIMEOUT_MS = 460000;
|
|
2257
|
+
var TIMEOUT_EXIT_CODE = 124;
|
|
2258
|
+
var TIMEOUT_LOG_MESSAGE = `
|
|
2259
|
+
[maestro-manager] Flow timed out after 460s (exit code 124)
|
|
2260
|
+
`;
|
|
2261
|
+
var defaultRunFlowAdapters = {
|
|
2262
|
+
spawn: (command, args, options) => nodeSpawn(command, [...args], options),
|
|
2263
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
2264
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
2265
|
+
kill: (pid, signal) => {
|
|
2266
|
+
process.kill(pid, signal);
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
async function runFlow(opts) {
|
|
2270
|
+
const {
|
|
2271
|
+
udid,
|
|
2272
|
+
flow,
|
|
2273
|
+
passthrough,
|
|
2274
|
+
env: extraEnv,
|
|
2275
|
+
javaHome,
|
|
2276
|
+
logPath,
|
|
2277
|
+
detached = false,
|
|
2278
|
+
onSpawned,
|
|
2279
|
+
adapters = defaultRunFlowAdapters
|
|
2280
|
+
} = opts;
|
|
2281
|
+
const childEnv = { ...process.env };
|
|
2282
|
+
if (javaHome) {
|
|
2283
|
+
childEnv["JAVA_HOME"] = javaHome;
|
|
2284
|
+
}
|
|
2285
|
+
if (extraEnv) {
|
|
2286
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
2287
|
+
childEnv[k] = v;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
mkdirSync9(dirname3(logPath), { recursive: true });
|
|
2291
|
+
const invocation = buildMaestroInvocation({ udid, flow, passthrough });
|
|
2292
|
+
const logLines = [];
|
|
2293
|
+
let exitCode = 1;
|
|
2294
|
+
let spawnedChildPid = 0;
|
|
2295
|
+
try {
|
|
2296
|
+
const proc = adapters.spawn(invocation.command, invocation.args, {
|
|
2297
|
+
env: childEnv,
|
|
2298
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2299
|
+
detached
|
|
2300
|
+
});
|
|
2301
|
+
const procPid = proc.pid;
|
|
2302
|
+
spawnedChildPid = procPid ?? 0;
|
|
2303
|
+
const logStream = createWriteStream(logPath, { encoding: "utf8" });
|
|
2304
|
+
let acceptingLogWrites = true;
|
|
2305
|
+
const writeLog = (text) => {
|
|
2306
|
+
if (!acceptingLogWrites) {
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
logLines.push(text);
|
|
2310
|
+
logStream.write(text);
|
|
2311
|
+
};
|
|
2312
|
+
proc.stdout?.on("data", (chunk) => writeLog(chunkToUtf8(chunk)));
|
|
2313
|
+
proc.stderr?.on("data", (chunk) => writeLog(chunkToUtf8(chunk)));
|
|
2314
|
+
const waiter = createFlowProcessWaiter({
|
|
2315
|
+
proc,
|
|
2316
|
+
procPid,
|
|
2317
|
+
detached,
|
|
2318
|
+
adapters,
|
|
2319
|
+
writeLog
|
|
2320
|
+
});
|
|
2321
|
+
try {
|
|
2322
|
+
if (onSpawned) {
|
|
2323
|
+
await onSpawned(spawnedChildPid);
|
|
2324
|
+
}
|
|
2325
|
+
exitCode = await waiter.done;
|
|
2326
|
+
} catch (error) {
|
|
2327
|
+
waiter.clearTimeout();
|
|
2328
|
+
killFlowProcess(procPid, detached, adapters);
|
|
2329
|
+
await waiter.done;
|
|
2330
|
+
throw error;
|
|
2331
|
+
} finally {
|
|
2332
|
+
acceptingLogWrites = false;
|
|
2333
|
+
await new Promise((resolve3) => {
|
|
2334
|
+
logStream.end(() => resolve3());
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
} catch (error) {
|
|
2338
|
+
if (!(error instanceof Error))
|
|
2339
|
+
throw error;
|
|
2340
|
+
const errorMsg = `
|
|
2341
|
+
[maestro-manager] Spawn error: ${error.message}
|
|
2342
|
+
`;
|
|
2343
|
+
logLines.push(errorMsg);
|
|
2344
|
+
appendOrWriteLog(logPath, errorMsg);
|
|
2345
|
+
exitCode = 1;
|
|
2346
|
+
}
|
|
2347
|
+
const logContent = logLines.join("");
|
|
2348
|
+
const failureLine = detectFailureLine(logContent);
|
|
2349
|
+
const passed = exitCode === 0 && failureLine === null;
|
|
2350
|
+
return { passed, logPath, childPid: spawnedChildPid };
|
|
2351
|
+
}
|
|
2352
|
+
function createFlowProcessWaiter(opts) {
|
|
2353
|
+
let state = "running";
|
|
2354
|
+
let timerHandle = null;
|
|
2355
|
+
let exitCodeFromExit = null;
|
|
2356
|
+
const clearFlowTimeout = () => {
|
|
2357
|
+
if (timerHandle === null) {
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
opts.adapters.clearTimeout(timerHandle);
|
|
2361
|
+
timerHandle = null;
|
|
2362
|
+
};
|
|
2363
|
+
const done = new Promise((resolve3) => {
|
|
2364
|
+
const resolveOnce = (exitCode) => {
|
|
2365
|
+
if (state === "resolved") {
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
state = "resolved";
|
|
2369
|
+
clearFlowTimeout();
|
|
2370
|
+
resolve3(exitCode);
|
|
2371
|
+
};
|
|
2372
|
+
opts.proc.once("exit", (code) => {
|
|
2373
|
+
if (state !== "running") {
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
state = "exited";
|
|
2377
|
+
exitCodeFromExit = normalizeExitCode(code);
|
|
2378
|
+
clearFlowTimeout();
|
|
2379
|
+
});
|
|
2380
|
+
opts.proc.once("close", (code) => {
|
|
2381
|
+
if (state === "timed_out") {
|
|
2382
|
+
resolveOnce(TIMEOUT_EXIT_CODE);
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
resolveOnce(exitCodeFromExit ?? normalizeExitCode(code));
|
|
2386
|
+
});
|
|
2387
|
+
opts.proc.once("error", (error) => {
|
|
2388
|
+
if (state === "timed_out") {
|
|
2389
|
+
resolveOnce(TIMEOUT_EXIT_CODE);
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
opts.writeLog(`
|
|
2393
|
+
[maestro-manager] Spawn error: ${error.message}
|
|
2394
|
+
`);
|
|
2395
|
+
resolveOnce(1);
|
|
2396
|
+
});
|
|
2397
|
+
timerHandle = opts.adapters.setTimeout(() => {
|
|
2398
|
+
if (state !== "running") {
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
state = "timed_out";
|
|
2402
|
+
clearFlowTimeout();
|
|
2403
|
+
opts.writeLog(TIMEOUT_LOG_MESSAGE);
|
|
2404
|
+
killFlowProcess(opts.procPid, opts.detached, opts.adapters);
|
|
2405
|
+
}, FLOW_TIMEOUT_MS);
|
|
2406
|
+
});
|
|
2407
|
+
return { done, clearTimeout: clearFlowTimeout };
|
|
2408
|
+
}
|
|
2409
|
+
function killFlowProcess(procPid, detached, adapters) {
|
|
2410
|
+
if (procPid === undefined) {
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
const killPid = detached ? -procPid : procPid;
|
|
2414
|
+
try {
|
|
2415
|
+
adapters.kill(killPid, "SIGKILL");
|
|
2416
|
+
} catch (error) {
|
|
2417
|
+
if (error instanceof Error) {
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
throw error;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
function normalizeExitCode(code) {
|
|
2424
|
+
return code ?? 1;
|
|
2425
|
+
}
|
|
2426
|
+
function chunkToUtf8(chunk) {
|
|
2427
|
+
if (typeof chunk === "string") {
|
|
2428
|
+
return chunk;
|
|
2429
|
+
}
|
|
2430
|
+
return Buffer.from(chunk).toString("utf8");
|
|
2431
|
+
}
|
|
2432
|
+
function appendOrWriteLog(logPath, text) {
|
|
2433
|
+
mkdirSync9(dirname3(logPath), { recursive: true });
|
|
2434
|
+
try {
|
|
2435
|
+
appendFileSync(logPath, text, { encoding: "utf8" });
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
if (error instanceof Error) {
|
|
2438
|
+
writeFileSync6(logPath, text, { encoding: "utf8" });
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
throw error;
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
// src/runFlowExecutor.ts
|
|
2445
|
+
import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync8 } from "fs";
|
|
2446
|
+
import { basename as basename2, dirname as dirname4 } from "path";
|
|
2447
|
+
import { performance } from "perf_hooks";
|
|
2448
|
+
|
|
2449
|
+
// src/runLeaseAcquire.ts
|
|
2450
|
+
import { basename } from "path";
|
|
2451
|
+
var DEFAULT_FLOW_WAIT_DEADLINE_MS = 25 * 60 * 1000;
|
|
2452
|
+
var DEFAULT_POLL_INTERVAL_MS = 500;
|
|
2453
|
+
|
|
2454
|
+
class FlowWaitTimeoutError extends Error {
|
|
2455
|
+
name = "FlowWaitTimeoutError";
|
|
2456
|
+
constructor(flow, runId, deadlineMs) {
|
|
2457
|
+
super(`[maestro-manager] runWorker: flow "${basename(flow)}" waited > ${deadlineMs / 60000} min for a sim \u2014 starvation bound exceeded (runId=${runId})`);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
async function acquireLease(context, assignment) {
|
|
2461
|
+
const waitStart = Date.now();
|
|
2462
|
+
const deadlineMs = context.opts.flowWaitDeadlineMs ?? DEFAULT_FLOW_WAIT_DEADLINE_MS;
|
|
2463
|
+
const pollIntervalMs = context.opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
2464
|
+
writeTicket(context.opts.config, context.opts.runId, waiterTicket(context));
|
|
2465
|
+
return acquireLeaseAttempt({ context, assignment, waitStart, deadlineMs, pollIntervalMs });
|
|
2466
|
+
}
|
|
2467
|
+
async function acquireLeaseAttempt(opts) {
|
|
2468
|
+
if (Date.now() - opts.waitStart > opts.deadlineMs) {
|
|
2469
|
+
throw new FlowWaitTimeoutError(opts.assignment.flow, opts.context.opts.runId, opts.deadlineMs);
|
|
2470
|
+
}
|
|
2471
|
+
const leaseRecord = {
|
|
2472
|
+
ownerPid: opts.context.pid,
|
|
2473
|
+
ownerStart: opts.context.ownerStart,
|
|
2474
|
+
bootId: opts.context.bootId,
|
|
2475
|
+
worktree: opts.context.opts.agent,
|
|
2476
|
+
runId: opts.context.opts.runId,
|
|
2477
|
+
flow: basename(opts.assignment.flow),
|
|
2478
|
+
app: opts.context.opts.appResolved.appPath,
|
|
2479
|
+
leasedAt: new Date().toISOString(),
|
|
2480
|
+
heartbeatAt: new Date().toISOString(),
|
|
2481
|
+
maestroPid: 0
|
|
2482
|
+
};
|
|
2483
|
+
const leaseResult = await ensureSimAvailable(opts.context.opts.config, leaseRecord, opts.context.opts.simctl);
|
|
2484
|
+
if (leaseResult !== "WAIT") {
|
|
2485
|
+
return leaseResult;
|
|
2486
|
+
}
|
|
2487
|
+
await sleep2(opts.pollIntervalMs);
|
|
2488
|
+
return acquireLeaseAttempt(opts);
|
|
2489
|
+
}
|
|
2490
|
+
function waiterTicket(context) {
|
|
2491
|
+
return {
|
|
2492
|
+
agent: context.opts.agent,
|
|
2493
|
+
ownerPid: context.pid,
|
|
2494
|
+
ownerStart: context.ownerStart,
|
|
2495
|
+
bootId: context.bootId,
|
|
2496
|
+
enqueuedAt: new Date().toISOString(),
|
|
2497
|
+
wantCount: Math.max(1, context.opts.flows.length - context.completedCount)
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
function sleep2(ms) {
|
|
2501
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// src/runTraps.ts
|
|
2505
|
+
import { mkdirSync as mkdirSync10, rmSync as rmSync7, writeFileSync as writeFileSync7 } from "fs";
|
|
2506
|
+
var ownedLeases = new Map;
|
|
2507
|
+
var inFlightMaestroPids = new Map;
|
|
2508
|
+
var trapsInstalled = false;
|
|
2509
|
+
function installTraps() {
|
|
2510
|
+
if (trapsInstalled) {
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
trapsInstalled = true;
|
|
2514
|
+
process.on("exit", releaseOwnLeases);
|
|
2515
|
+
process.on("SIGINT", () => {
|
|
2516
|
+
releaseOwnLeases();
|
|
2517
|
+
process.exit(130);
|
|
2518
|
+
});
|
|
2519
|
+
process.on("SIGTERM", () => {
|
|
2520
|
+
releaseOwnLeases();
|
|
2521
|
+
process.exit(143);
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
function trackOwnedLease(simName, config) {
|
|
2525
|
+
ownedLeases.set(simName, config);
|
|
2526
|
+
}
|
|
2527
|
+
function clearOwnedLease(simName) {
|
|
2528
|
+
ownedLeases.delete(simName);
|
|
2529
|
+
}
|
|
2530
|
+
function trackMaestroPid(simName, childPid) {
|
|
2531
|
+
inFlightMaestroPids.set(simName, childPid);
|
|
2532
|
+
}
|
|
2533
|
+
function clearMaestroPid(simName) {
|
|
2534
|
+
inFlightMaestroPids.delete(simName);
|
|
2535
|
+
}
|
|
2536
|
+
function killMaestroGroup(childPid) {
|
|
2537
|
+
if (childPid <= 0) {
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
try {
|
|
2541
|
+
process.kill(-childPid, "SIGKILL");
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
if (error instanceof Error) {
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
throw error;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
function releaseOwnLeases() {
|
|
2550
|
+
for (const childPid of inFlightMaestroPids.values()) {
|
|
2551
|
+
killMaestroGroup(childPid);
|
|
2552
|
+
}
|
|
2553
|
+
for (const [simName, config] of ownedLeases) {
|
|
2554
|
+
tryBestEffort(() => {
|
|
2555
|
+
touchLastUsed2(config, simName);
|
|
2556
|
+
rmSync7(simLeaseDir(config, simName), { recursive: true, force: true });
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
function touchLastUsed2(config, simName) {
|
|
2561
|
+
tryBestEffort(() => {
|
|
2562
|
+
mkdirSync10(simDir(config, simName), { recursive: true });
|
|
2563
|
+
writeFileSync7(simLastUsedPath(config, simName), Math.floor(Date.now() / 1000).toString(), "utf8");
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
function tryBestEffort(action) {
|
|
2567
|
+
try {
|
|
2568
|
+
action();
|
|
2569
|
+
} catch (error) {
|
|
2570
|
+
if (error instanceof Error) {
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// src/runFlowExecutor.ts
|
|
2577
|
+
var HEARTBEAT_INTERVAL_MS = 30000;
|
|
2578
|
+
var NO_SIM_ASSIGNED = "<none>";
|
|
2579
|
+
|
|
2580
|
+
class MaestroPidHandoffError extends Error {
|
|
2581
|
+
name = "MaestroPidHandoffError";
|
|
2582
|
+
constructor(simName, childPid) {
|
|
2583
|
+
super(`[maestro-manager] failed to persist Maestro pid ${childPid} before running flow on ${simName}`);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
var defaultSimPreparer = async (opts) => {
|
|
2587
|
+
await bootInstallWarmFull({
|
|
2588
|
+
config: opts.config,
|
|
2589
|
+
simName: opts.simName,
|
|
2590
|
+
udid: opts.udid,
|
|
2591
|
+
appPath: opts.appPath,
|
|
2592
|
+
simctl: opts.simctl,
|
|
2593
|
+
displayMode: opts.displayMode,
|
|
2594
|
+
warmSimCache: opts.warmSimCache
|
|
2595
|
+
});
|
|
2596
|
+
};
|
|
2597
|
+
async function runFlowAssignment(context, assignment) {
|
|
2598
|
+
const logPath = runFlowLogPath(context.opts.config, context.opts.runId, basename2(assignment.flow));
|
|
2599
|
+
try {
|
|
2600
|
+
const lease = await acquireLease(context, assignment);
|
|
2601
|
+
return await runFlowOnLease(context, assignment, lease, logPath);
|
|
2602
|
+
} catch (error) {
|
|
2603
|
+
if (!(error instanceof Error)) {
|
|
2604
|
+
throw error;
|
|
2605
|
+
}
|
|
2606
|
+
writeFlowErrorLog(logPath, error);
|
|
2607
|
+
return { flow: basename2(assignment.flow), result: "FAIL", sim: NO_SIM_ASSIGNED, durationMs: 0 };
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
async function runFlowOnLease(context, assignment, lease, logPath) {
|
|
2611
|
+
let spawnedChildPid = 0;
|
|
2612
|
+
let flowStartedAtMs = null;
|
|
2613
|
+
let stopHeartbeat;
|
|
2614
|
+
trackOwnedLease(lease.simName, context.opts.config);
|
|
2615
|
+
try {
|
|
2616
|
+
await context.simPreparer({
|
|
2617
|
+
config: context.opts.config,
|
|
2618
|
+
simName: lease.simName,
|
|
2619
|
+
udid: lease.udid,
|
|
2620
|
+
appPath: context.opts.appResolved.appPath,
|
|
2621
|
+
simctl: context.opts.simctl,
|
|
2622
|
+
displayMode: context.opts.displayMode,
|
|
2623
|
+
warmSimCache: context.warmSimCache
|
|
2624
|
+
});
|
|
2625
|
+
stopHeartbeat = startHeartbeat(context, lease.simName);
|
|
2626
|
+
flowStartedAtMs = performance.now();
|
|
2627
|
+
const flowResult = await context.flowRunner({
|
|
2628
|
+
udid: lease.udid,
|
|
2629
|
+
flow: assignment.flow,
|
|
2630
|
+
passthrough: [...context.opts.passthrough],
|
|
2631
|
+
...context.opts.env !== undefined ? { env: context.opts.env } : {},
|
|
2632
|
+
javaHome: context.javaHome,
|
|
2633
|
+
logPath,
|
|
2634
|
+
detached: true,
|
|
2635
|
+
onSpawned: async (childPid) => {
|
|
2636
|
+
spawnedChildPid = childPid;
|
|
2637
|
+
trackMaestroPid(lease.simName, childPid);
|
|
2638
|
+
if (childPid > 0) {
|
|
2639
|
+
const handoffPersisted = await setLeaseMaestroPid(context.opts.config, lease.simName, context.pid, childPid);
|
|
2640
|
+
if (!handoffPersisted) {
|
|
2641
|
+
throw new MaestroPidHandoffError(lease.simName, childPid);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
return {
|
|
2647
|
+
flow: basename2(assignment.flow),
|
|
2648
|
+
result: flowResult.passed ? "PASS" : "FAIL",
|
|
2649
|
+
sim: lease.simName,
|
|
2650
|
+
durationMs: durationMsSince(flowStartedAtMs)
|
|
2651
|
+
};
|
|
2652
|
+
} catch (error) {
|
|
2653
|
+
if (!(error instanceof Error)) {
|
|
2654
|
+
throw error;
|
|
2655
|
+
}
|
|
2656
|
+
if (spawnedChildPid > 0) {
|
|
2657
|
+
killMaestroGroup(spawnedChildPid);
|
|
2658
|
+
}
|
|
2659
|
+
writeFlowErrorLog(logPath, error);
|
|
2660
|
+
return {
|
|
2661
|
+
flow: basename2(assignment.flow),
|
|
2662
|
+
result: "FAIL",
|
|
2663
|
+
sim: lease.simName,
|
|
2664
|
+
durationMs: flowStartedAtMs === null ? 0 : durationMsSince(flowStartedAtMs)
|
|
2665
|
+
};
|
|
2666
|
+
} finally {
|
|
2667
|
+
stopHeartbeat?.();
|
|
2668
|
+
clearOwnedLease(lease.simName);
|
|
2669
|
+
clearMaestroPid(lease.simName);
|
|
2670
|
+
if (spawnedChildPid > 0) {
|
|
2671
|
+
await setLeaseMaestroPid(context.opts.config, lease.simName, context.pid, 0);
|
|
2672
|
+
}
|
|
2673
|
+
await release(context.opts.config, lease.simName, context.pid);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
function durationMsSince(startedAtMs) {
|
|
2677
|
+
return Math.max(0, Math.round(performance.now() - startedAtMs));
|
|
2678
|
+
}
|
|
2679
|
+
function startHeartbeat(context, simName) {
|
|
2680
|
+
const heartbeatHandle = setInterval(() => {
|
|
2681
|
+
refreshHeartbeat(context.opts.config, simName, context.pid).catch(ignoreHeartbeatError);
|
|
2682
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
2683
|
+
return () => clearInterval(heartbeatHandle);
|
|
2684
|
+
}
|
|
2685
|
+
function writeFlowErrorLog(logPath, error) {
|
|
2686
|
+
try {
|
|
2687
|
+
mkdirSync11(dirname4(logPath), { recursive: true });
|
|
2688
|
+
writeFileSync8(logPath, `
|
|
2689
|
+
[maestro-manager] Flow failed: ${errorMessage(error)}
|
|
2690
|
+
`, {
|
|
2691
|
+
encoding: "utf8"
|
|
2692
|
+
});
|
|
2693
|
+
} catch (writeError) {
|
|
2694
|
+
if (writeError instanceof Error) {
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
throw writeError;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
function ignoreHeartbeatError(error) {
|
|
2701
|
+
if (error instanceof Error) {
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
throw error;
|
|
2705
|
+
}
|
|
2706
|
+
function errorMessage(error) {
|
|
2707
|
+
if (error instanceof Error) {
|
|
2708
|
+
return error.message;
|
|
2709
|
+
}
|
|
2710
|
+
return String(error);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// src/runFlowOrdering.ts
|
|
2714
|
+
import { basename as basename3 } from "path";
|
|
2715
|
+
function scheduleFlowsByDurationHistory(flows, history) {
|
|
2716
|
+
const durationByFlowName = latestDurationByFlowName(history);
|
|
2717
|
+
const candidates = flows.map((flow, index) => ({
|
|
2718
|
+
assignment: { index, flow },
|
|
2719
|
+
inputIndex: index,
|
|
2720
|
+
durationMs: durationByFlowName.get(basename3(flow)) ?? null
|
|
2721
|
+
}));
|
|
2722
|
+
const known = candidates.filter(isKnownFlowScheduleCandidate).sort(compareKnownFlows);
|
|
2723
|
+
const unknown = candidates.filter((candidate) => !isKnownFlowScheduleCandidate(candidate));
|
|
2724
|
+
return [...known, ...unknown].map((candidate) => candidate.assignment);
|
|
2725
|
+
}
|
|
2726
|
+
function readPriorRunDurationHistory(config, currentRunId) {
|
|
2727
|
+
const index = readJsonOrNull(runsIndexPath(config)) ?? [];
|
|
2728
|
+
const history = [];
|
|
2729
|
+
for (const entry of index.slice().reverse()) {
|
|
2730
|
+
if (entry.runId === currentRunId || !isCompletedRunState(entry.state)) {
|
|
2731
|
+
continue;
|
|
2732
|
+
}
|
|
2733
|
+
const record = readJsonOrNull(runRecordPath(config, entry.runId));
|
|
2734
|
+
if (record !== null && isCompletedRunState(record.state)) {
|
|
2735
|
+
history.push(record);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
return history;
|
|
2739
|
+
}
|
|
2740
|
+
function latestDurationByFlowName(history) {
|
|
2741
|
+
const durationByFlowName = new Map;
|
|
2742
|
+
for (const record of history) {
|
|
2743
|
+
for (const result of record.summary) {
|
|
2744
|
+
const flowName = basename3(result.flow);
|
|
2745
|
+
if (durationByFlowName.has(flowName) || !isEligibleDuration(result.durationMs)) {
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
durationByFlowName.set(flowName, result.durationMs);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
return durationByFlowName;
|
|
2752
|
+
}
|
|
2753
|
+
function isKnownFlowScheduleCandidate(candidate) {
|
|
2754
|
+
return candidate.durationMs !== null;
|
|
2755
|
+
}
|
|
2756
|
+
function compareKnownFlows(left, right) {
|
|
2757
|
+
const durationOrder = right.durationMs - left.durationMs;
|
|
2758
|
+
if (durationOrder !== 0) {
|
|
2759
|
+
return durationOrder;
|
|
2760
|
+
}
|
|
2761
|
+
return left.inputIndex - right.inputIndex;
|
|
2762
|
+
}
|
|
2763
|
+
function isEligibleDuration(durationMs) {
|
|
2764
|
+
return durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0;
|
|
2765
|
+
}
|
|
2766
|
+
function isCompletedRunState(state) {
|
|
2767
|
+
return state === "passed" || state === "failed";
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// src/runFlowScheduler.ts
|
|
2771
|
+
class IncompleteRunResultsError extends Error {
|
|
2772
|
+
name = "IncompleteRunResultsError";
|
|
2773
|
+
constructor(runId) {
|
|
2774
|
+
super(`[maestro-manager] runWorker: run ${runId} ended before every flow reported a result`);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
class WorkerExecutionError extends Error {
|
|
2779
|
+
name = "WorkerExecutionError";
|
|
2780
|
+
constructor(reason) {
|
|
2781
|
+
super(`[maestro-manager] runWorker: worker failed: ${errorMessage2(reason)}`);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
async function runFlowsConcurrently(opts) {
|
|
2785
|
+
if (opts.flows.length === 0) {
|
|
2786
|
+
return [];
|
|
2787
|
+
}
|
|
2788
|
+
const context = createSchedulerContext(opts);
|
|
2789
|
+
const workerCount = Math.min(effectiveConcurrency(opts.config), opts.flows.length);
|
|
2790
|
+
const workers = Array.from({ length: workerCount }, () => runFlowWorker(context));
|
|
2791
|
+
const settled = await Promise.allSettled(workers);
|
|
2792
|
+
const rejected = settled.find((result) => result.status === "rejected");
|
|
2793
|
+
if (rejected !== undefined) {
|
|
2794
|
+
throw new WorkerExecutionError(rejected.reason);
|
|
2795
|
+
}
|
|
2796
|
+
return finalResults(context);
|
|
2797
|
+
}
|
|
2798
|
+
function createSchedulerContext(opts) {
|
|
2799
|
+
const pid = process.pid;
|
|
2800
|
+
const scheduledAssignments = scheduleFlowsByDurationHistory(opts.flows, readPriorRunDurationHistory(opts.config, opts.runId));
|
|
2801
|
+
return {
|
|
2802
|
+
opts,
|
|
2803
|
+
pid,
|
|
2804
|
+
ownerStart: processStartTime(pid) ?? "",
|
|
2805
|
+
bootId: machineBootId(),
|
|
2806
|
+
javaHome: resolveJavaHome(opts.config),
|
|
2807
|
+
flowRunner: opts.flowRunner ?? runFlow,
|
|
2808
|
+
simPreparer: opts.simPreparer ?? defaultSimPreparer,
|
|
2809
|
+
warmSimCache: createWarmSimCache(),
|
|
2810
|
+
scheduledAssignments,
|
|
2811
|
+
resultsByIndex: Array.from({ length: opts.flows.length }),
|
|
2812
|
+
nextAssignmentIndex: 0,
|
|
2813
|
+
completedCount: 0
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
async function runFlowWorker(context) {
|
|
2817
|
+
const assignment = takeNextAssignment(context);
|
|
2818
|
+
if (assignment === null) {
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
const result = await runFlowAssignment(context, assignment);
|
|
2822
|
+
recordResult(context, assignment.index, result);
|
|
2823
|
+
return runFlowWorker(context);
|
|
2824
|
+
}
|
|
2825
|
+
function takeNextAssignment(context) {
|
|
2826
|
+
const assignment = context.scheduledAssignments[context.nextAssignmentIndex];
|
|
2827
|
+
if (assignment === undefined) {
|
|
2828
|
+
return null;
|
|
2829
|
+
}
|
|
2830
|
+
context.nextAssignmentIndex += 1;
|
|
2831
|
+
return assignment;
|
|
2832
|
+
}
|
|
2833
|
+
function recordResult(context, index, result) {
|
|
2834
|
+
context.resultsByIndex[index] = result;
|
|
2835
|
+
context.completedCount += 1;
|
|
2836
|
+
context.opts.onResultsChanged(completedResults(context));
|
|
2837
|
+
}
|
|
2838
|
+
function completedResults(context) {
|
|
2839
|
+
return context.resultsByIndex.filter((result) => result !== undefined);
|
|
2840
|
+
}
|
|
2841
|
+
function finalResults(context) {
|
|
2842
|
+
const results = completedResults(context);
|
|
2843
|
+
if (results.length !== context.opts.flows.length) {
|
|
2844
|
+
throw new IncompleteRunResultsError(context.opts.runId);
|
|
2845
|
+
}
|
|
2846
|
+
return results;
|
|
2847
|
+
}
|
|
2848
|
+
function effectiveConcurrency(config) {
|
|
2849
|
+
return Math.max(1, Math.min(config.pool.default, config.pool.max));
|
|
2850
|
+
}
|
|
2851
|
+
function errorMessage2(error) {
|
|
2852
|
+
if (error instanceof Error) {
|
|
2853
|
+
return error.message;
|
|
2854
|
+
}
|
|
2855
|
+
return String(error);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// src/runRecord.ts
|
|
2859
|
+
import { mkdirSync as mkdirSync12 } from "fs";
|
|
2860
|
+
function initRunRecord(opts) {
|
|
2861
|
+
const record = {
|
|
2862
|
+
owner: opts.agent,
|
|
2863
|
+
submittedAt: new Date().toISOString(),
|
|
2864
|
+
app: opts.appPath,
|
|
2865
|
+
flows: [...opts.flows],
|
|
2866
|
+
state: "queued",
|
|
2867
|
+
summary: []
|
|
2868
|
+
};
|
|
2869
|
+
mkdirSync12(runDir(opts.config, opts.runId), { recursive: true });
|
|
2870
|
+
atomicWriteJson(runRecordPath(opts.config, opts.runId), record);
|
|
2871
|
+
updateRunsIndex(opts.config, opts.runId, "queued");
|
|
2872
|
+
return record;
|
|
2873
|
+
}
|
|
2874
|
+
function updateRunRecord(config, runId, updates) {
|
|
2875
|
+
const existing = readJsonOrNull(runRecordPath(config, runId));
|
|
2876
|
+
if (existing === null) {
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
const updated = {
|
|
2880
|
+
...existing,
|
|
2881
|
+
...updates
|
|
2882
|
+
};
|
|
2883
|
+
atomicWriteJson(runRecordPath(config, runId), updated);
|
|
2884
|
+
if (updates.state !== undefined) {
|
|
2885
|
+
updateRunsIndex(config, runId, updates.state);
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
function updateRunsIndex(config, runId, state) {
|
|
2889
|
+
try {
|
|
2890
|
+
const indexPath = runsIndexPath(config);
|
|
2891
|
+
const existing = readJsonOrNull(indexPath) ?? [];
|
|
2892
|
+
const filtered = existing.filter((entry) => entry.runId !== runId);
|
|
2893
|
+
filtered.push({ runId, state, updatedAt: new Date().toISOString() });
|
|
2894
|
+
atomicWriteJson(indexPath, filtered.slice(-200));
|
|
2895
|
+
} catch (error) {
|
|
2896
|
+
if (error instanceof Error) {
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
throw error;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
// src/run.ts
|
|
2904
|
+
async function runWorker(opts) {
|
|
2905
|
+
const {
|
|
2906
|
+
runId,
|
|
2907
|
+
agent,
|
|
2908
|
+
flows,
|
|
2909
|
+
appResolved,
|
|
2910
|
+
config,
|
|
2911
|
+
passthrough,
|
|
2912
|
+
displayMode = DEFAULT_SIMULATOR_DISPLAY_MODE,
|
|
2913
|
+
env: extraEnv,
|
|
2914
|
+
simctlImpl,
|
|
2915
|
+
flowRunner,
|
|
2916
|
+
simPreparer,
|
|
2917
|
+
pollIntervalMs,
|
|
2918
|
+
flowWaitDeadlineMs
|
|
2919
|
+
} = opts;
|
|
2920
|
+
installTraps();
|
|
2921
|
+
const simctl = simctlImpl ?? await getDefaultSimctl();
|
|
2922
|
+
initRunRecord({
|
|
2923
|
+
config,
|
|
2924
|
+
runId,
|
|
2925
|
+
agent,
|
|
2926
|
+
appPath: appResolved.appPath,
|
|
2927
|
+
flows
|
|
2928
|
+
});
|
|
2929
|
+
updateRunRecord(config, runId, { state: "running" });
|
|
2930
|
+
try {
|
|
2931
|
+
const results = await runFlowsConcurrently({
|
|
2932
|
+
runId,
|
|
2933
|
+
agent,
|
|
2934
|
+
flows,
|
|
2935
|
+
appResolved,
|
|
2936
|
+
config,
|
|
2937
|
+
passthrough,
|
|
2938
|
+
displayMode,
|
|
2939
|
+
...extraEnv !== undefined ? { env: extraEnv } : {},
|
|
2940
|
+
simctl,
|
|
2941
|
+
...flowRunner !== undefined ? { flowRunner } : {},
|
|
2942
|
+
...simPreparer !== undefined ? { simPreparer } : {},
|
|
2943
|
+
...pollIntervalMs !== undefined ? { pollIntervalMs } : {},
|
|
2944
|
+
...flowWaitDeadlineMs !== undefined ? { flowWaitDeadlineMs } : {},
|
|
2945
|
+
onResultsChanged: (resultsSnapshot) => {
|
|
2946
|
+
updateRunRecord(config, runId, { summary: [...resultsSnapshot] });
|
|
2947
|
+
}
|
|
2948
|
+
});
|
|
2949
|
+
removeTicket(config, runId);
|
|
2950
|
+
const finalState = results.some((result) => result.result === "FAIL") ? "failed" : "passed";
|
|
2951
|
+
updateRunRecord(config, runId, {
|
|
2952
|
+
state: finalState,
|
|
2953
|
+
summary: [...results]
|
|
2954
|
+
});
|
|
2955
|
+
return {
|
|
2956
|
+
runId,
|
|
2957
|
+
state: finalState,
|
|
2958
|
+
results
|
|
2959
|
+
};
|
|
2960
|
+
} catch (error) {
|
|
2961
|
+
removeTicket(config, runId);
|
|
2962
|
+
updateRunRecord(config, runId, { state: "failed" });
|
|
2963
|
+
throw error;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
async function getDefaultSimctl() {
|
|
2967
|
+
const simctlModule = await Promise.resolve().then(() => (init_simctl(), exports_simctl));
|
|
2968
|
+
return simctlModule;
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// src/log-redaction.ts
|
|
2972
|
+
var REDACTED_VALUE = "<redacted>";
|
|
2973
|
+
var SENSITIVE_KEY_PATTERN = /(?:token|secret|password|auth|bearer|api[_-]?key)/i;
|
|
2974
|
+
var AUTHORIZATION_HEADER_PATTERN = /\bAuthorization\s*:\s*[^\s"',;]+(?:\s+[^\s"',;]+)?/gi;
|
|
2975
|
+
var BEARER_TOKEN_PATTERN = /\bBearer\s+[^\s"',;]+/gi;
|
|
2976
|
+
var SECRET_ASSIGNMENT_PATTERN = /(^|[\s"'[{,(])([A-Za-z0-9_.-]*(?:token|secret|password|auth|bearer|api[_-]?key)[A-Za-z0-9_.-]*)=([^\s"',\]}]+)/gi;
|
|
2977
|
+
function redactSensitiveText(text) {
|
|
2978
|
+
return text.replace(AUTHORIZATION_HEADER_PATTERN, "Authorization: <redacted>").replace(BEARER_TOKEN_PATTERN, `Bearer ${REDACTED_VALUE}`).replace(SECRET_ASSIGNMENT_PATTERN, (_match, prefix, key) => {
|
|
2979
|
+
return `${prefix}${key}=${REDACTED_VALUE}`;
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
function redactSensitiveArgs(args) {
|
|
2983
|
+
return args.map(redactSensitiveArgument);
|
|
2984
|
+
}
|
|
2985
|
+
function redactSensitiveArgument(arg) {
|
|
2986
|
+
const separatorIndex = arg.indexOf("=");
|
|
2987
|
+
if (separatorIndex <= 0) {
|
|
2988
|
+
return redactSensitiveText(arg);
|
|
2989
|
+
}
|
|
2990
|
+
const key = arg.slice(0, separatorIndex);
|
|
2991
|
+
if (SENSITIVE_KEY_PATTERN.test(key)) {
|
|
2992
|
+
return `${key}=${REDACTED_VALUE}`;
|
|
2993
|
+
}
|
|
2994
|
+
return redactSensitiveText(arg);
|
|
2995
|
+
}
|
|
2996
|
+
// src/testFlowSelection.ts
|
|
2997
|
+
import { existsSync as existsSync7, readdirSync as readdirSync5, readFileSync as readFileSync4 } from "fs";
|
|
2998
|
+
import { join as join12 } from "path";
|
|
2999
|
+
|
|
3000
|
+
// src/rerunFailed.ts
|
|
3001
|
+
import { basename as basename4 } from "path";
|
|
3002
|
+
class RerunFailedSelectionError extends Error {
|
|
3003
|
+
name = "RerunFailedSelectionError";
|
|
3004
|
+
}
|
|
3005
|
+
function selectRerunFailedFlows(opts) {
|
|
3006
|
+
const currentFlowsByName = flowLookup(opts.currentFullSuiteFlows);
|
|
3007
|
+
const source = opts.fromRunId === undefined ? selectLatestFailedFullSuiteRun(opts.config, currentFlowsByName) : selectExplicitRun(opts.config, opts.fromRunId);
|
|
3008
|
+
return {
|
|
3009
|
+
runId: source.runId,
|
|
3010
|
+
flows: failedFlowsFromRun(source, currentFlowsByName, opts.config)
|
|
3011
|
+
};
|
|
3012
|
+
}
|
|
3013
|
+
function selectExplicitRun(config, runId) {
|
|
3014
|
+
const record = readJsonOrNull(runRecordPath(config, runId));
|
|
3015
|
+
if (record === null) {
|
|
3016
|
+
throw new RerunFailedSelectionError(`rerun-failed: run ${runId} was not found in pool ${config.pool.prefix}`);
|
|
3017
|
+
}
|
|
3018
|
+
return { runId, record };
|
|
3019
|
+
}
|
|
3020
|
+
function selectLatestFailedFullSuiteRun(config, currentFlowsByName) {
|
|
3021
|
+
const index = readJsonOrNull(runsIndexPath(config)) ?? [];
|
|
3022
|
+
for (const entry of index.slice().reverse()) {
|
|
3023
|
+
if (entry.state !== "failed") {
|
|
3024
|
+
continue;
|
|
3025
|
+
}
|
|
3026
|
+
const source = selectExplicitRun(config, entry.runId);
|
|
3027
|
+
if (isFullSuiteRun(source.record, currentFlowsByName)) {
|
|
3028
|
+
return source;
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
throw new RerunFailedSelectionError(`rerun-failed: no failed full-suite runs found for pool ${config.pool.prefix}; use --from-run <runId> to select a run explicitly`);
|
|
3032
|
+
}
|
|
3033
|
+
function isFullSuiteRun(record, currentFlowsByName) {
|
|
3034
|
+
if (record.flows.length !== currentFlowsByName.size) {
|
|
3035
|
+
return false;
|
|
3036
|
+
}
|
|
3037
|
+
return record.flows.every((flow) => currentFlowsByName.has(basename4(flow)));
|
|
3038
|
+
}
|
|
3039
|
+
function failedFlowsFromRun(source, currentFlowsByName, config) {
|
|
3040
|
+
const recordFlowNames = new Set(source.record.flows.map((flow) => basename4(flow)));
|
|
3041
|
+
const failedFlows = [];
|
|
3042
|
+
for (const result of source.record.summary) {
|
|
3043
|
+
if (result.result !== "FAIL") {
|
|
3044
|
+
continue;
|
|
3045
|
+
}
|
|
3046
|
+
if (!recordFlowNames.has(result.flow)) {
|
|
3047
|
+
throw new RerunFailedSelectionError(`rerun-failed: run ${source.runId} summary references failed flow ${result.flow} that is not listed in run.json flows`);
|
|
3048
|
+
}
|
|
3049
|
+
const currentFlow = currentFlowsByName.get(result.flow);
|
|
3050
|
+
if (currentFlow === undefined) {
|
|
3051
|
+
throw new RerunFailedSelectionError(`rerun-failed: run ${source.runId} is incompatible with the current pool/config: failed flow ${result.flow} is not in ${config.flowsDir}`);
|
|
3052
|
+
}
|
|
3053
|
+
failedFlows.push(currentFlow);
|
|
3054
|
+
}
|
|
3055
|
+
if (failedFlows.length === 0) {
|
|
3056
|
+
throw new RerunFailedSelectionError(`rerun-failed: run ${source.runId} has no failed flows`);
|
|
3057
|
+
}
|
|
3058
|
+
return failedFlows;
|
|
3059
|
+
}
|
|
3060
|
+
function flowLookup(flows) {
|
|
3061
|
+
const lookup = new Map;
|
|
3062
|
+
for (const flow of flows) {
|
|
3063
|
+
lookup.set(basename4(flow), flow);
|
|
3064
|
+
}
|
|
3065
|
+
return lookup;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// src/testFlowSelection.ts
|
|
3069
|
+
function readFlowTags(flowPath) {
|
|
3070
|
+
const tags = [];
|
|
3071
|
+
let text;
|
|
3072
|
+
try {
|
|
3073
|
+
text = readFileSync4(flowPath, { encoding: "utf8" });
|
|
3074
|
+
} catch {
|
|
3075
|
+
return tags;
|
|
3076
|
+
}
|
|
3077
|
+
let inTagsBlock = false;
|
|
3078
|
+
for (const line of text.split(`
|
|
3079
|
+
`)) {
|
|
3080
|
+
if (/^tags:\s*$/.test(line)) {
|
|
3081
|
+
inTagsBlock = true;
|
|
3082
|
+
continue;
|
|
3083
|
+
}
|
|
3084
|
+
if (inTagsBlock) {
|
|
3085
|
+
const m = line.match(/^\s+-\s+(.+)$/);
|
|
3086
|
+
const tag = m?.[1];
|
|
3087
|
+
if (tag !== undefined) {
|
|
3088
|
+
tags.push(tag.trim());
|
|
3089
|
+
} else if (line.trim() !== "" && !/^\s+/.test(line)) {
|
|
3090
|
+
inTagsBlock = false;
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
return tags;
|
|
3095
|
+
}
|
|
3096
|
+
function listFlows(flowsDir) {
|
|
3097
|
+
if (!existsSync7(flowsDir))
|
|
3098
|
+
return [];
|
|
3099
|
+
try {
|
|
3100
|
+
return readdirSync5(flowsDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort().map((f) => join12(flowsDir, f));
|
|
3101
|
+
} catch {
|
|
3102
|
+
return [];
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
function filterFlowsByTags(flows, includeTags, excludeTags) {
|
|
3106
|
+
if (includeTags.length === 0 && excludeTags.length === 0)
|
|
3107
|
+
return [...flows];
|
|
3108
|
+
return flows.filter((flowPath) => {
|
|
3109
|
+
const tags = readFlowTags(flowPath);
|
|
3110
|
+
if (includeTags.length > 0) {
|
|
3111
|
+
const hasInclude = includeTags.some((t) => tags.includes(t));
|
|
3112
|
+
if (!hasInclude)
|
|
3113
|
+
return false;
|
|
3114
|
+
}
|
|
3115
|
+
if (excludeTags.length > 0) {
|
|
3116
|
+
const hasExclude = excludeTags.some((t) => tags.includes(t));
|
|
3117
|
+
if (hasExclude)
|
|
3118
|
+
return false;
|
|
3119
|
+
}
|
|
3120
|
+
return true;
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
function selectTestFlows(opts) {
|
|
3124
|
+
if (opts.fromRunId !== undefined && !opts.rerunFailed) {
|
|
3125
|
+
throw new RerunFailedSelectionError("--from-run requires --rerun-failed");
|
|
3126
|
+
}
|
|
3127
|
+
if (opts.rerunFailed) {
|
|
3128
|
+
return selectRerunFlows(opts);
|
|
3129
|
+
}
|
|
3130
|
+
const fullSuite = opts.requestedFlows.length === 0 && opts.includeTags.length === 0 && opts.excludeTags.length === 0;
|
|
3131
|
+
const baseFlows = opts.requestedFlows.length > 0 ? [...opts.requestedFlows] : listFlows(opts.config.flowsDir);
|
|
3132
|
+
return {
|
|
3133
|
+
flows: filterFlowsByTags(baseFlows, opts.includeTags, opts.excludeTags),
|
|
3134
|
+
fullSuite
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
function selectRerunFlows(opts) {
|
|
3138
|
+
if (opts.requestedFlows.length > 0) {
|
|
3139
|
+
throw new RerunFailedSelectionError("--rerun-failed cannot be combined with positional flow arguments");
|
|
3140
|
+
}
|
|
3141
|
+
if (opts.includeTags.length > 0 || opts.excludeTags.length > 0) {
|
|
3142
|
+
throw new RerunFailedSelectionError("--rerun-failed cannot be combined with --include-tags or --exclude-tags");
|
|
3143
|
+
}
|
|
3144
|
+
const selection = selectRerunFailedFlows({
|
|
3145
|
+
config: opts.config,
|
|
3146
|
+
fromRunId: opts.fromRunId,
|
|
3147
|
+
currentFullSuiteFlows: listFlows(opts.config.flowsDir)
|
|
3148
|
+
});
|
|
3149
|
+
return {
|
|
3150
|
+
flows: selection.flows,
|
|
3151
|
+
fullSuite: false,
|
|
3152
|
+
rerunSourceRunId: selection.runId
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// src/commands/test.ts
|
|
3157
|
+
async function runTest(opts) {
|
|
3158
|
+
const { config, agent, passthrough, simctlImpl, flowRunner, simPreparer } = opts;
|
|
3159
|
+
const flowSelection = selectTestFlows({
|
|
3160
|
+
config,
|
|
3161
|
+
requestedFlows: opts.flows,
|
|
3162
|
+
includeTags: opts.includeTags,
|
|
3163
|
+
excludeTags: opts.excludeTags,
|
|
3164
|
+
rerunFailed: opts.rerunFailed === true,
|
|
3165
|
+
fromRunId: opts.fromRunId
|
|
3166
|
+
});
|
|
3167
|
+
const flows = flowSelection.flows;
|
|
3168
|
+
if (flowSelection.rerunSourceRunId !== undefined) {
|
|
3169
|
+
process.stderr.write(`[maestro-manager] test: rerun-failed from ${flowSelection.rerunSourceRunId}, failedFlows=${flows.length}
|
|
3170
|
+
`);
|
|
3171
|
+
}
|
|
3172
|
+
if (flows.length === 0) {
|
|
3173
|
+
process.stderr.write(`[maestro-manager] test: no flows to run.
|
|
3174
|
+
`);
|
|
3175
|
+
return 0;
|
|
3176
|
+
}
|
|
3177
|
+
let simsCount = opts.sims ?? config.pool.default;
|
|
3178
|
+
if (simsCount > config.pool.max) {
|
|
3179
|
+
process.stderr.write(`[maestro-manager] test: --sims ${simsCount} exceeds pool.max ${config.pool.max}; clamped.
|
|
3180
|
+
`);
|
|
3181
|
+
simsCount = config.pool.max;
|
|
3182
|
+
}
|
|
3183
|
+
const appResolved = await resolveApp({
|
|
3184
|
+
explicitApp: opts.appPath,
|
|
3185
|
+
config: config.app,
|
|
3186
|
+
flow: flows[0]
|
|
3187
|
+
});
|
|
3188
|
+
const runId = `run-${Date.now()}-${randomUUID2().slice(0, 8)}`;
|
|
3189
|
+
const reservation = flowSelection.fullSuite ? fullSuiteReservationRecord({
|
|
3190
|
+
agent,
|
|
3191
|
+
runId,
|
|
3192
|
+
requestedLaneCount: simsCount
|
|
3193
|
+
}) : null;
|
|
3194
|
+
if (reservation !== null) {
|
|
3195
|
+
const reserved = await reserveFullSuite(config, reservation);
|
|
3196
|
+
if (!reserved) {
|
|
3197
|
+
process.stderr.write(`[maestro-manager] test: another full-suite reservation is active; retry later.
|
|
3198
|
+
`);
|
|
3199
|
+
return 1;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
process.stderr.write(`[maestro-manager] test: runId=${runId}, flows=${flows.length}, sims=${simsCount}, passthrough=${JSON.stringify(redactSensitiveArgs(passthrough))}
|
|
3203
|
+
`);
|
|
3204
|
+
const runWorkerOpts = {
|
|
3205
|
+
runId,
|
|
3206
|
+
agent,
|
|
3207
|
+
flows,
|
|
3208
|
+
appResolved,
|
|
3209
|
+
config: { ...config, pool: { ...config.pool, default: simsCount } },
|
|
3210
|
+
passthrough,
|
|
3211
|
+
displayMode: opts.displayMode ?? DEFAULT_SIMULATOR_DISPLAY_MODE,
|
|
3212
|
+
...simctlImpl !== undefined ? { simctlImpl } : {},
|
|
3213
|
+
...flowRunner !== undefined ? { flowRunner } : {},
|
|
3214
|
+
...simPreparer !== undefined ? { simPreparer } : {}
|
|
3215
|
+
};
|
|
3216
|
+
try {
|
|
3217
|
+
const summary = await runWorker(runWorkerOpts);
|
|
3218
|
+
printResultTable(summary.results);
|
|
3219
|
+
return summary.state === "passed" ? 0 : 1;
|
|
3220
|
+
} finally {
|
|
3221
|
+
if (reservation !== null) {
|
|
3222
|
+
await releaseFullSuiteReservation(config, reservation.runId, reservation.ownerPid);
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
function printResultTable(results) {
|
|
3227
|
+
const maxFlow = Math.max(4, ...results.map((r) => basename5(r.flow).length));
|
|
3228
|
+
const maxSim = Math.max(3, ...results.map((r) => r.sim.length));
|
|
3229
|
+
const header = `${"FLOW".padEnd(maxFlow)} ${"SIM".padEnd(maxSim)} RESULT`;
|
|
3230
|
+
const sep = "-".repeat(header.length);
|
|
3231
|
+
process.stdout.write(`
|
|
3232
|
+
${header}
|
|
3233
|
+
${sep}
|
|
3234
|
+
`);
|
|
3235
|
+
for (const r of results) {
|
|
3236
|
+
const name = basename5(r.flow).padEnd(maxFlow);
|
|
3237
|
+
const sim = r.sim.padEnd(maxSim);
|
|
3238
|
+
const mark = r.result === "PASS" ? "PASS" : "FAIL";
|
|
3239
|
+
process.stdout.write(`${name} ${sim} ${mark}
|
|
3240
|
+
`);
|
|
3241
|
+
}
|
|
3242
|
+
const passed = results.filter((r) => r.result === "PASS").length;
|
|
3243
|
+
const failed = results.filter((r) => r.result === "FAIL").length;
|
|
3244
|
+
process.stdout.write(`${sep}
|
|
3245
|
+
${passed} passed, ${failed} failed
|
|
3246
|
+
|
|
3247
|
+
`);
|
|
3248
|
+
}
|
|
3249
|
+
function fullSuiteReservationRecord(opts) {
|
|
3250
|
+
const now = new Date().toISOString();
|
|
3251
|
+
return {
|
|
3252
|
+
ownerPid: process.pid,
|
|
3253
|
+
ownerStart: processStartTime(process.pid) ?? "",
|
|
3254
|
+
bootId: machineBootId(),
|
|
3255
|
+
worktree: opts.agent,
|
|
3256
|
+
runId: opts.runId,
|
|
3257
|
+
requestedLaneCount: opts.requestedLaneCount,
|
|
3258
|
+
reservedAt: now,
|
|
3259
|
+
heartbeatAt: now
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
// src/commands/status.ts
|
|
3264
|
+
import { readdirSync as readdirSync6, statSync as statSync11 } from "fs";
|
|
3265
|
+
import { join as join13 } from "path";
|
|
3266
|
+
init_simctl();
|
|
3267
|
+
async function runStatus(opts) {
|
|
3268
|
+
const { config, simctlImpl = exports_simctl } = opts;
|
|
3269
|
+
let poolState = null;
|
|
3270
|
+
try {
|
|
3271
|
+
poolState = await reconcile(config, simctlImpl);
|
|
3272
|
+
} catch {
|
|
3273
|
+
poolState = null;
|
|
3274
|
+
}
|
|
3275
|
+
process.stdout.write(`
|
|
3276
|
+
=== Pool Sims ===
|
|
3277
|
+
`);
|
|
3278
|
+
if (poolState === null) {
|
|
3279
|
+
process.stdout.write(` (simctl unavailable \u2014 cannot list devices)
|
|
3280
|
+
`);
|
|
3281
|
+
} else if (poolState.slots.length === 0) {
|
|
3282
|
+
process.stdout.write(" (no pool sims \u2014 run `maestro-manager up` first)\n");
|
|
3283
|
+
} else {
|
|
3284
|
+
const maxName = Math.max(4, ...poolState.slots.map((s) => s.simName.length));
|
|
3285
|
+
const maxUdid = Math.max(4, ...poolState.slots.map((s) => s.udid.length));
|
|
3286
|
+
process.stdout.write(`${"NAME".padEnd(maxName)} ${"UDID".padEnd(maxUdid)} STATE LEASED
|
|
3287
|
+
`);
|
|
3288
|
+
process.stdout.write("-".repeat(maxName + maxUdid + 20) + `
|
|
3289
|
+
`);
|
|
3290
|
+
for (const slot of poolState.slots) {
|
|
3291
|
+
const leased = slot.leased ? "yes" : "no";
|
|
3292
|
+
process.stdout.write(`${slot.simName.padEnd(maxName)} ${slot.udid.padEnd(maxUdid)} ${slot.state.padEnd(10)} ${leased}
|
|
3293
|
+
`);
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
process.stdout.write(`
|
|
3297
|
+
=== Active Leases ===
|
|
3298
|
+
`);
|
|
3299
|
+
const leases = readAllLeases(config);
|
|
3300
|
+
if (leases.length === 0) {
|
|
3301
|
+
process.stdout.write(` (none)
|
|
3302
|
+
`);
|
|
3303
|
+
} else {
|
|
3304
|
+
for (const { simName, record } of leases) {
|
|
3305
|
+
const since = formatRelativeTime(record.leasedAt);
|
|
3306
|
+
const hb = formatRelativeTime(record.heartbeatAt);
|
|
3307
|
+
process.stdout.write(` ${simName}
|
|
3308
|
+
`);
|
|
3309
|
+
process.stdout.write(` worktree: ${record.worktree}
|
|
3310
|
+
`);
|
|
3311
|
+
process.stdout.write(` flow: ${record.flow}
|
|
3312
|
+
`);
|
|
3313
|
+
process.stdout.write(` pid: ${record.ownerPid}
|
|
3314
|
+
`);
|
|
3315
|
+
process.stdout.write(` leased: ${since} ago
|
|
3316
|
+
`);
|
|
3317
|
+
process.stdout.write(` heartbeat:${hb} ago
|
|
3318
|
+
`);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
process.stdout.write(`
|
|
3322
|
+
=== Full-Suite Reservation ===
|
|
3323
|
+
`);
|
|
3324
|
+
const reservation = readFullSuiteReservation(config);
|
|
3325
|
+
if (reservation === null) {
|
|
3326
|
+
process.stdout.write(` (none)
|
|
3327
|
+
`);
|
|
3328
|
+
} else {
|
|
3329
|
+
const age = formatRelativeTime(reservation.reservedAt);
|
|
3330
|
+
process.stdout.write(` owner: ${reservation.worktree}
|
|
3331
|
+
`);
|
|
3332
|
+
process.stdout.write(` run id: ${reservation.runId}
|
|
3333
|
+
`);
|
|
3334
|
+
process.stdout.write(` lanes: ${reservation.requestedLaneCount}
|
|
3335
|
+
`);
|
|
3336
|
+
process.stdout.write(` age: ${age} ago
|
|
3337
|
+
`);
|
|
3338
|
+
process.stdout.write(` pid: ${reservation.ownerPid}
|
|
3339
|
+
`);
|
|
3340
|
+
}
|
|
3341
|
+
process.stdout.write(`
|
|
3342
|
+
=== Recent Runs ===
|
|
3343
|
+
`);
|
|
3344
|
+
const indexPath = runsIndexPath(config);
|
|
3345
|
+
const index = readJsonOrNull(indexPath);
|
|
3346
|
+
if (index === null || index.length === 0) {
|
|
3347
|
+
process.stdout.write(` (no runs recorded)
|
|
3348
|
+
`);
|
|
3349
|
+
} else {
|
|
3350
|
+
const recent = index.slice(-10).reverse();
|
|
3351
|
+
for (const entry of recent) {
|
|
3352
|
+
const runRecord = readJsonOrNull(runRecordPath(config, entry.runId));
|
|
3353
|
+
const flowCount = runRecord?.flows?.length ?? "?";
|
|
3354
|
+
const summaryCount = runRecord?.summary?.length ?? "?";
|
|
3355
|
+
const since = formatRelativeTime(entry.updatedAt);
|
|
3356
|
+
process.stdout.write(` [${entry.state.padEnd(7)}] ${entry.runId} ${summaryCount}/${flowCount} flows ${since} ago
|
|
3357
|
+
`);
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
process.stdout.write(`
|
|
3361
|
+
`);
|
|
3362
|
+
}
|
|
3363
|
+
function readAllLeases(config) {
|
|
3364
|
+
const result = [];
|
|
3365
|
+
const simsDir = join13(poolRoot(config), "sims");
|
|
3366
|
+
let entries;
|
|
3367
|
+
try {
|
|
3368
|
+
entries = readdirSync6(simsDir);
|
|
3369
|
+
} catch {
|
|
3370
|
+
return result;
|
|
3371
|
+
}
|
|
3372
|
+
for (const simName of entries) {
|
|
3373
|
+
const leaseDirPath = simLeaseDir(config, simName);
|
|
3374
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
3375
|
+
let dirExists = false;
|
|
3376
|
+
try {
|
|
3377
|
+
dirExists = statSync11(leaseDirPath).isDirectory();
|
|
3378
|
+
} catch {
|
|
3379
|
+
continue;
|
|
3380
|
+
}
|
|
3381
|
+
if (!dirExists)
|
|
3382
|
+
continue;
|
|
3383
|
+
const record = readJsonOrNull(recordPath);
|
|
3384
|
+
if (record !== null) {
|
|
3385
|
+
result.push({ simName, record });
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
return result;
|
|
3389
|
+
}
|
|
3390
|
+
function formatRelativeTime(isoOrEpoch) {
|
|
3391
|
+
if (!isoOrEpoch)
|
|
3392
|
+
return "?";
|
|
3393
|
+
const t = Date.parse(isoOrEpoch);
|
|
3394
|
+
if (Number.isNaN(t))
|
|
3395
|
+
return "?";
|
|
3396
|
+
const diffMs = Date.now() - t;
|
|
3397
|
+
const secs = Math.floor(diffMs / 1000);
|
|
3398
|
+
if (secs < 60)
|
|
3399
|
+
return `${secs}s`;
|
|
3400
|
+
const mins = Math.floor(secs / 60);
|
|
3401
|
+
if (mins < 60)
|
|
3402
|
+
return `${mins}m`;
|
|
3403
|
+
const hours = Math.floor(mins / 60);
|
|
3404
|
+
return `${hours}h${mins % 60}m`;
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
// src/commands/stop.ts
|
|
3408
|
+
import { readdirSync as readdirSync7, statSync as statSync12, rmSync as rmSync8, readFileSync as readFileSync5 } from "fs";
|
|
3409
|
+
import { join as join14 } from "path";
|
|
3410
|
+
init_simctl();
|
|
3411
|
+
async function runStop(opts) {
|
|
3412
|
+
const { config, scope } = opts;
|
|
3413
|
+
const inScope = collectInScopeLeases(config, scope);
|
|
3414
|
+
if (inScope.length === 0) {
|
|
3415
|
+
process.stderr.write(`[maestro-manager] stop: no in-scope leases found.
|
|
3416
|
+
`);
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
for (const { simName, record, udid } of inScope) {
|
|
3420
|
+
const currentRecord = readMatchingLeaseRecord(config, simName, record);
|
|
3421
|
+
if (currentRecord === null) {
|
|
3422
|
+
process.stderr.write(`[maestro-manager] stop: ${simName} owner changed \u2014 skipping signal and reclaim
|
|
3423
|
+
`);
|
|
3424
|
+
continue;
|
|
3425
|
+
}
|
|
3426
|
+
if (isOwnerLive(currentRecord)) {
|
|
3427
|
+
const signalRecord = readMatchingLeaseRecord(config, simName, record);
|
|
3428
|
+
if (signalRecord === null) {
|
|
3429
|
+
process.stderr.write(`[maestro-manager] stop: ${simName} owner changed \u2014 skipping signal and reclaim
|
|
3430
|
+
`);
|
|
3431
|
+
continue;
|
|
3432
|
+
}
|
|
3433
|
+
process.stderr.write(`[maestro-manager] stop: stopping ${simName} (pid=${signalRecord.ownerPid}, flow=${signalRecord.flow})
|
|
3434
|
+
`);
|
|
3435
|
+
try {
|
|
3436
|
+
process.kill(signalRecord.ownerPid, "SIGTERM");
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
if (!(error instanceof Error)) {
|
|
3439
|
+
throw error;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
const deadline = Date.now() + 5000;
|
|
3443
|
+
while (readMatchingLiveLeaseRecord(config, simName, record) !== null && Date.now() < deadline) {
|
|
3444
|
+
await sleep3(200);
|
|
3445
|
+
}
|
|
3446
|
+
const killRecord = readMatchingLiveLeaseRecord(config, simName, record);
|
|
3447
|
+
if (killRecord !== null) {
|
|
3448
|
+
try {
|
|
3449
|
+
process.kill(killRecord.ownerPid, "SIGKILL");
|
|
3450
|
+
} catch (error) {
|
|
3451
|
+
if (!(error instanceof Error)) {
|
|
3452
|
+
throw error;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
await sleep3(500);
|
|
3456
|
+
}
|
|
3457
|
+
const maestroRecord = readMatchingLeaseRecord(config, simName, record);
|
|
3458
|
+
if (maestroRecord !== null && maestroRecord.maestroPid > 0 && !pidIsDead(maestroRecord.maestroPid)) {
|
|
3459
|
+
try {
|
|
3460
|
+
process.kill(-maestroRecord.maestroPid, "SIGKILL");
|
|
3461
|
+
} catch (error) {
|
|
3462
|
+
if (!(error instanceof Error)) {
|
|
3463
|
+
throw error;
|
|
3464
|
+
}
|
|
3465
|
+
try {
|
|
3466
|
+
process.kill(maestroRecord.maestroPid, "SIGKILL");
|
|
3467
|
+
} catch (fallbackError) {
|
|
3468
|
+
if (!(fallbackError instanceof Error)) {
|
|
3469
|
+
throw fallbackError;
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
} else {
|
|
3475
|
+
process.stderr.write(`[maestro-manager] stop: ${simName} owner is stale \u2014 skipping signal
|
|
3476
|
+
`);
|
|
3477
|
+
}
|
|
3478
|
+
await withPoolLock(config, () => {
|
|
3479
|
+
const recordNow = readJsonOrNull(simLeaseRecordPath(config, simName));
|
|
3480
|
+
if (recordNow === null)
|
|
3481
|
+
return;
|
|
3482
|
+
if (!ownerIdentitiesMatch(record, recordNow))
|
|
3483
|
+
return;
|
|
3484
|
+
if (isOwnerLive(recordNow)) {
|
|
3485
|
+
process.stderr.write(`[maestro-manager] stop: pid ${recordNow.ownerPid} still alive \u2014 skipping reclaim of ${simName}
|
|
3486
|
+
`);
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
try {
|
|
3490
|
+
rmSync8(simLeaseDir(config, simName), { recursive: true, force: true });
|
|
3491
|
+
process.stderr.write(`[maestro-manager] stop: reclaimed lease for ${simName}
|
|
3492
|
+
`);
|
|
3493
|
+
} catch (error) {
|
|
3494
|
+
if (!(error instanceof Error)) {
|
|
3495
|
+
throw error;
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
});
|
|
3499
|
+
if (udid !== "") {
|
|
3500
|
+
try {
|
|
3501
|
+
await shutdown(udid);
|
|
3502
|
+
process.stderr.write(`[maestro-manager] stop: shut down ${simName} (${udid})
|
|
3503
|
+
`);
|
|
3504
|
+
} catch (error) {
|
|
3505
|
+
if (!(error instanceof Error)) {
|
|
3506
|
+
throw error;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
function collectInScopeLeases(config, scope) {
|
|
3513
|
+
const result = [];
|
|
3514
|
+
const simsDir = join14(poolRoot(config), "sims");
|
|
3515
|
+
let entries;
|
|
3516
|
+
try {
|
|
3517
|
+
entries = readdirSync7(simsDir);
|
|
3518
|
+
} catch (error) {
|
|
3519
|
+
if (!(error instanceof Error)) {
|
|
3520
|
+
throw error;
|
|
3521
|
+
}
|
|
3522
|
+
return result;
|
|
3523
|
+
}
|
|
3524
|
+
for (const simName of entries) {
|
|
3525
|
+
const leaseDirPath = simLeaseDir(config, simName);
|
|
3526
|
+
const recordPath = simLeaseRecordPath(config, simName);
|
|
3527
|
+
let dirExists = false;
|
|
3528
|
+
try {
|
|
3529
|
+
dirExists = statSync12(leaseDirPath).isDirectory();
|
|
3530
|
+
} catch (error) {
|
|
3531
|
+
if (!(error instanceof Error)) {
|
|
3532
|
+
throw error;
|
|
3533
|
+
}
|
|
3534
|
+
continue;
|
|
3535
|
+
}
|
|
3536
|
+
if (!dirExists)
|
|
3537
|
+
continue;
|
|
3538
|
+
const record = readJsonOrNull(recordPath);
|
|
3539
|
+
if (record === null)
|
|
3540
|
+
continue;
|
|
3541
|
+
const inScope = scopeMatches(scope, record);
|
|
3542
|
+
if (!inScope)
|
|
3543
|
+
continue;
|
|
3544
|
+
const udidPath = join14(poolRoot(config), "sims", simName, "udid");
|
|
3545
|
+
let udid = "";
|
|
3546
|
+
try {
|
|
3547
|
+
udid = readFileSync5(udidPath, { encoding: "utf8" }).trim();
|
|
3548
|
+
} catch (error) {
|
|
3549
|
+
if (!(error instanceof Error)) {
|
|
3550
|
+
throw error;
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
result.push({ simName, record, udid });
|
|
3554
|
+
}
|
|
3555
|
+
return result;
|
|
3556
|
+
}
|
|
3557
|
+
function readMatchingLiveLeaseRecord(config, simName, expected) {
|
|
3558
|
+
const record = readMatchingLeaseRecord(config, simName, expected);
|
|
3559
|
+
if (record === null)
|
|
3560
|
+
return null;
|
|
3561
|
+
return isOwnerLive(record) ? record : null;
|
|
3562
|
+
}
|
|
3563
|
+
function readMatchingLeaseRecord(config, simName, expected) {
|
|
3564
|
+
const record = readJsonOrNull(simLeaseRecordPath(config, simName));
|
|
3565
|
+
if (record === null)
|
|
3566
|
+
return null;
|
|
3567
|
+
return ownerIdentitiesMatch(expected, record) ? record : null;
|
|
3568
|
+
}
|
|
3569
|
+
function scopeMatches(scope, record) {
|
|
3570
|
+
switch (scope.kind) {
|
|
3571
|
+
case "mine":
|
|
3572
|
+
return record.worktree === scope.worktree;
|
|
3573
|
+
case "worktree":
|
|
3574
|
+
return record.worktree === scope.worktree;
|
|
3575
|
+
case "all":
|
|
3576
|
+
return true;
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
function sleep3(ms) {
|
|
3580
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
// src/commands/down.ts
|
|
3584
|
+
import { rmSync as rmSync9, readdirSync as readdirSync8, readFileSync as readFileSync6 } from "fs";
|
|
3585
|
+
import { join as join15 } from "path";
|
|
3586
|
+
init_simctl();
|
|
3587
|
+
async function runDown(opts) {
|
|
3588
|
+
const { config, agent } = opts;
|
|
3589
|
+
const simctlImpl = opts.simctlImpl ?? exports_simctl;
|
|
3590
|
+
const root = poolRoot(config);
|
|
3591
|
+
process.stderr.write(`[maestro-manager] down: stopping all leases...
|
|
3592
|
+
`);
|
|
3593
|
+
await runStop({
|
|
3594
|
+
config,
|
|
3595
|
+
scope: { kind: "all" }
|
|
3596
|
+
});
|
|
3597
|
+
process.stderr.write(`[maestro-manager] down: deleting pool sims...
|
|
3598
|
+
`);
|
|
3599
|
+
let poolState = null;
|
|
3600
|
+
try {
|
|
3601
|
+
poolState = await reconcile(config, simctlImpl);
|
|
3602
|
+
} catch (error) {
|
|
3603
|
+
if (!(error instanceof Error)) {
|
|
3604
|
+
throw error;
|
|
3605
|
+
}
|
|
3606
|
+
poolState = null;
|
|
3607
|
+
}
|
|
3608
|
+
if (poolState !== null) {
|
|
3609
|
+
await Promise.all(poolState.slots.map(async (slot) => {
|
|
3610
|
+
try {
|
|
3611
|
+
process.stderr.write(`[maestro-manager] down: deleting ${slot.simName} (${slot.udid})...
|
|
3612
|
+
`);
|
|
3613
|
+
await simctlImpl.deleteDevice(slot.udid);
|
|
3614
|
+
} catch (err) {
|
|
3615
|
+
if (!(err instanceof Error)) {
|
|
3616
|
+
throw err;
|
|
3617
|
+
}
|
|
3618
|
+
process.stderr.write(`[maestro-manager] down: could not delete ${slot.simName}: ${err.message}
|
|
3619
|
+
`);
|
|
3620
|
+
}
|
|
3621
|
+
}));
|
|
3622
|
+
} else {
|
|
3623
|
+
const simsDir = join15(root, "sims");
|
|
3624
|
+
let entries;
|
|
3625
|
+
try {
|
|
3626
|
+
entries = readdirSync8(simsDir);
|
|
3627
|
+
} catch (error) {
|
|
3628
|
+
if (!(error instanceof Error)) {
|
|
3629
|
+
throw error;
|
|
3630
|
+
}
|
|
3631
|
+
entries = [];
|
|
3632
|
+
}
|
|
3633
|
+
await Promise.all(entries.map(async (simName) => {
|
|
3634
|
+
const udidPath = join15(root, "sims", simName, "udid");
|
|
3635
|
+
let udid = "";
|
|
3636
|
+
try {
|
|
3637
|
+
udid = readFileSync6(udidPath, { encoding: "utf8" }).trim();
|
|
3638
|
+
} catch (error) {
|
|
3639
|
+
if (!(error instanceof Error)) {
|
|
3640
|
+
throw error;
|
|
3641
|
+
}
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3644
|
+
if (!udid)
|
|
3645
|
+
return;
|
|
3646
|
+
try {
|
|
3647
|
+
process.stderr.write(`[maestro-manager] down: deleting ${simName} (${udid})...
|
|
3648
|
+
`);
|
|
3649
|
+
await simctlImpl.deleteDevice(udid);
|
|
3650
|
+
} catch (err) {
|
|
3651
|
+
if (!(err instanceof Error)) {
|
|
3652
|
+
throw err;
|
|
3653
|
+
}
|
|
3654
|
+
process.stderr.write(`[maestro-manager] down: could not delete ${simName}: ${err.message}
|
|
3655
|
+
`);
|
|
3656
|
+
}
|
|
3657
|
+
}));
|
|
3658
|
+
}
|
|
3659
|
+
process.stderr.write(`[maestro-manager] down: clearing state...
|
|
3660
|
+
`);
|
|
3661
|
+
const safeRoot = assertPoolRootContained(root);
|
|
3662
|
+
try {
|
|
3663
|
+
rmSync9(safeRoot, { recursive: true, force: true });
|
|
3664
|
+
process.stderr.write(`[maestro-manager] down: cleared ${safeRoot}
|
|
3665
|
+
`);
|
|
3666
|
+
} catch (err) {
|
|
3667
|
+
if (!(err instanceof Error)) {
|
|
3668
|
+
throw err;
|
|
3669
|
+
}
|
|
3670
|
+
process.stderr.write(`[maestro-manager] down: could not clear state: ${err.message}
|
|
3671
|
+
`);
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
// src/commands/clean.ts
|
|
3676
|
+
import { readdirSync as readdirSync9, statSync as statSync13, readFileSync as readFileSync7 } from "fs";
|
|
3677
|
+
import { join as join16 } from "path";
|
|
3678
|
+
import { execFileSync as execFileSync6 } from "child_process";
|
|
3679
|
+
init_simctl();
|
|
3680
|
+
async function runClean(opts) {
|
|
3681
|
+
const { config, idleShutdownMinutes, idleDeleteHours, pruneAccounts, deleteUnavailable: deleteUnavailable2 } = opts;
|
|
3682
|
+
const simctlImpl = opts.simctlImpl ?? exports_simctl;
|
|
3683
|
+
const killMaestro = opts.killMaestro ?? killMaestroGroup;
|
|
3684
|
+
process.stderr.write(`[maestro-manager] clean: reaping orphaned runs...
|
|
3685
|
+
`);
|
|
3686
|
+
const reapedCount = await reapOrphanedRuns(config, simctlImpl, killMaestro);
|
|
3687
|
+
process.stderr.write(`[maestro-manager] clean: reaped ${reapedCount} orphaned run(s).
|
|
3688
|
+
`);
|
|
3689
|
+
process.stderr.write(`[maestro-manager] clean: reclaiming stale leases...
|
|
3690
|
+
`);
|
|
3691
|
+
const reclaimedCount = await reclaimAllStaleLeases(config, simctlImpl);
|
|
3692
|
+
process.stderr.write(`[maestro-manager] clean: reclaimed ${reclaimedCount} stale lease(s).
|
|
3693
|
+
`);
|
|
3694
|
+
if (idleShutdownMinutes !== undefined || idleDeleteHours !== undefined) {
|
|
3695
|
+
await idleReap({ config, idleShutdownMinutes, idleDeleteHours, simctlImpl });
|
|
3696
|
+
}
|
|
3697
|
+
if (pruneAccounts === true) {
|
|
3698
|
+
await runPruneAccounts(config);
|
|
3699
|
+
}
|
|
3700
|
+
if (deleteUnavailable2 === true) {
|
|
3701
|
+
process.stderr.write(`[maestro-manager] clean --delete-unavailable: deleting unavailable CoreSimulator devices (GLOBAL)...
|
|
3702
|
+
`);
|
|
3703
|
+
try {
|
|
3704
|
+
await simctlImpl.deleteUnavailable();
|
|
3705
|
+
process.stderr.write(`[maestro-manager] clean --delete-unavailable: done.
|
|
3706
|
+
`);
|
|
3707
|
+
} catch (error) {
|
|
3708
|
+
if (!(error instanceof Error)) {
|
|
3709
|
+
throw error;
|
|
3710
|
+
}
|
|
3711
|
+
process.stderr.write(`[maestro-manager] clean --delete-unavailable: failed: ${error.message}
|
|
3712
|
+
`);
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
function readUdid(config, simName) {
|
|
3717
|
+
try {
|
|
3718
|
+
const raw = readFileSync7(simUdidPath(config, simName), { encoding: "utf8" }).trim();
|
|
3719
|
+
return raw.length > 0 ? raw : null;
|
|
3720
|
+
} catch (error) {
|
|
3721
|
+
if (!(error instanceof Error)) {
|
|
3722
|
+
throw error;
|
|
3723
|
+
}
|
|
3724
|
+
return null;
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
async function reapOrphanedRuns(config, simctlImpl, killMaestro) {
|
|
3728
|
+
const simsDir = join16(poolRoot(config), "sims");
|
|
3729
|
+
let entries;
|
|
3730
|
+
try {
|
|
3731
|
+
entries = readdirSync9(simsDir);
|
|
3732
|
+
} catch (error) {
|
|
3733
|
+
if (!(error instanceof Error)) {
|
|
3734
|
+
throw error;
|
|
3735
|
+
}
|
|
3736
|
+
return 0;
|
|
3737
|
+
}
|
|
3738
|
+
let count = 0;
|
|
3739
|
+
for (const simName of entries) {
|
|
3740
|
+
const leaseDirPath = simLeaseDir(config, simName);
|
|
3741
|
+
let dirExists = false;
|
|
3742
|
+
try {
|
|
3743
|
+
dirExists = statSync13(leaseDirPath).isDirectory();
|
|
3744
|
+
} catch (error) {
|
|
3745
|
+
if (!(error instanceof Error)) {
|
|
3746
|
+
throw error;
|
|
3747
|
+
}
|
|
3748
|
+
continue;
|
|
3749
|
+
}
|
|
3750
|
+
if (!dirExists)
|
|
3751
|
+
continue;
|
|
3752
|
+
const udid = readUdid(config, simName);
|
|
3753
|
+
const reaped = await reapOrphanIfAlive(config, simName, killMaestro);
|
|
3754
|
+
if (reaped) {
|
|
3755
|
+
process.stderr.write(`[maestro-manager] clean: reaped orphaned run on ${simName} (killed orphaned maestro group).
|
|
3756
|
+
`);
|
|
3757
|
+
count++;
|
|
3758
|
+
if (udid !== null) {
|
|
3759
|
+
await shutdownBestEffort(simctlImpl, simName, udid);
|
|
3760
|
+
}
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
return count;
|
|
3764
|
+
}
|
|
3765
|
+
async function shutdownBestEffort(simctlImpl, simName, udid) {
|
|
3766
|
+
try {
|
|
3767
|
+
await simctlImpl.shutdown(udid);
|
|
3768
|
+
} catch (error) {
|
|
3769
|
+
if (!(error instanceof Error)) {
|
|
3770
|
+
throw error;
|
|
3771
|
+
}
|
|
3772
|
+
process.stderr.write(`[maestro-manager] clean: could not shut down ${simName}: ${error.message}
|
|
3773
|
+
`);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
async function reclaimAllStaleLeases(config, simctlImpl) {
|
|
3777
|
+
const simsDir = join16(poolRoot(config), "sims");
|
|
3778
|
+
let entries;
|
|
3779
|
+
try {
|
|
3780
|
+
entries = readdirSync9(simsDir);
|
|
3781
|
+
} catch (error) {
|
|
3782
|
+
if (!(error instanceof Error)) {
|
|
3783
|
+
throw error;
|
|
3784
|
+
}
|
|
3785
|
+
return 0;
|
|
3786
|
+
}
|
|
3787
|
+
let count = 0;
|
|
3788
|
+
for (const simName of entries) {
|
|
3789
|
+
const leaseDirPath = simLeaseDir(config, simName);
|
|
3790
|
+
let dirExists = false;
|
|
3791
|
+
try {
|
|
3792
|
+
dirExists = statSync13(leaseDirPath).isDirectory();
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
if (!(error instanceof Error)) {
|
|
3795
|
+
throw error;
|
|
3796
|
+
}
|
|
3797
|
+
continue;
|
|
3798
|
+
}
|
|
3799
|
+
if (!dirExists)
|
|
3800
|
+
continue;
|
|
3801
|
+
const udid = readUdid(config, simName);
|
|
3802
|
+
const reclaimed = await reclaimIfStale(config, simName);
|
|
3803
|
+
if (reclaimed) {
|
|
3804
|
+
process.stderr.write(`[maestro-manager] clean: reclaimed stale lease for ${simName}
|
|
3805
|
+
`);
|
|
3806
|
+
count++;
|
|
3807
|
+
if (udid !== null) {
|
|
3808
|
+
await shutdownBestEffort(simctlImpl, simName, udid);
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
return count;
|
|
3813
|
+
}
|
|
3814
|
+
async function idleReap(opts) {
|
|
3815
|
+
const { config, idleShutdownMinutes, idleDeleteHours, simctlImpl } = opts;
|
|
3816
|
+
const simsDir = join16(poolRoot(config), "sims");
|
|
3817
|
+
let entries;
|
|
3818
|
+
try {
|
|
3819
|
+
entries = readdirSync9(simsDir);
|
|
3820
|
+
} catch (error) {
|
|
3821
|
+
if (!(error instanceof Error)) {
|
|
3822
|
+
throw error;
|
|
3823
|
+
}
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
const nowSecs = Math.floor(Date.now() / 1000);
|
|
3827
|
+
let liveDevices = [];
|
|
3828
|
+
try {
|
|
3829
|
+
const allDevices = await simctlImpl.listDevices();
|
|
3830
|
+
const prefix = config.pool.prefix;
|
|
3831
|
+
liveDevices = allDevices.filter((d) => d.name.startsWith(`${prefix}-`));
|
|
3832
|
+
} catch (error) {
|
|
3833
|
+
if (!(error instanceof Error)) {
|
|
3834
|
+
throw error;
|
|
3835
|
+
}
|
|
3836
|
+
process.stderr.write(`[maestro-manager] clean: simctl unavailable \u2014 skipping idle reap.
|
|
3837
|
+
`);
|
|
3838
|
+
return;
|
|
3839
|
+
}
|
|
3840
|
+
await Promise.all(entries.map(async (simName) => {
|
|
3841
|
+
const leaseDirPath = simLeaseDir(config, simName);
|
|
3842
|
+
let isLeased = false;
|
|
3843
|
+
try {
|
|
3844
|
+
isLeased = statSync13(leaseDirPath).isDirectory();
|
|
3845
|
+
} catch (error) {
|
|
3846
|
+
if (!(error instanceof Error)) {
|
|
3847
|
+
throw error;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
if (isLeased) {
|
|
3851
|
+
const record = readJsonOrNull(simLeaseRecordPath(config, simName));
|
|
3852
|
+
if (record !== null) {
|
|
3853
|
+
process.stderr.write(`[maestro-manager] clean: ${simName} is live-leased \u2014 skipping.
|
|
3854
|
+
`);
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
const lastUsedPath = simLastUsedPath(config, simName);
|
|
3859
|
+
let lastUsedSecs = null;
|
|
3860
|
+
try {
|
|
3861
|
+
const raw = readFileSync7(lastUsedPath, { encoding: "utf8" }).trim();
|
|
3862
|
+
const n = parseInt(raw, 10);
|
|
3863
|
+
if (!Number.isNaN(n))
|
|
3864
|
+
lastUsedSecs = n;
|
|
3865
|
+
} catch (error) {
|
|
3866
|
+
if (!(error instanceof Error)) {
|
|
3867
|
+
throw error;
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
const idleSecs = lastUsedSecs !== null ? nowSecs - lastUsedSecs : null;
|
|
3871
|
+
const device = liveDevices.find((d) => d.name === simName);
|
|
3872
|
+
if (!device)
|
|
3873
|
+
return;
|
|
3874
|
+
const { udid, state } = device;
|
|
3875
|
+
if (idleDeleteHours !== undefined && idleSecs !== null) {
|
|
3876
|
+
const idleHours = idleSecs / 3600;
|
|
3877
|
+
if (idleHours > idleDeleteHours) {
|
|
3878
|
+
process.stderr.write(`[maestro-manager] clean: ${simName} idle ${idleHours.toFixed(1)}h > ${idleDeleteHours}h \u2014 deleting.
|
|
3879
|
+
`);
|
|
3880
|
+
try {
|
|
3881
|
+
if (state === "Booted")
|
|
3882
|
+
await simctlImpl.shutdown(udid);
|
|
3883
|
+
await simctlImpl.deleteDevice(udid);
|
|
3884
|
+
} catch (err) {
|
|
3885
|
+
if (!(err instanceof Error)) {
|
|
3886
|
+
throw err;
|
|
3887
|
+
}
|
|
3888
|
+
process.stderr.write(`[maestro-manager] clean: could not delete ${simName}: ${err.message}
|
|
3889
|
+
`);
|
|
3890
|
+
}
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
if (idleShutdownMinutes !== undefined && idleSecs !== null) {
|
|
3895
|
+
const idleMinutes = idleSecs / 60;
|
|
3896
|
+
if (idleMinutes > idleShutdownMinutes && state === "Booted") {
|
|
3897
|
+
process.stderr.write(`[maestro-manager] clean: ${simName} idle ${idleMinutes.toFixed(0)}m > ${idleShutdownMinutes}m \u2014 shutting down.
|
|
3898
|
+
`);
|
|
3899
|
+
try {
|
|
3900
|
+
await simctlImpl.shutdown(udid);
|
|
3901
|
+
} catch (err) {
|
|
3902
|
+
if (!(err instanceof Error)) {
|
|
3903
|
+
throw err;
|
|
3904
|
+
}
|
|
3905
|
+
process.stderr.write(`[maestro-manager] clean: could not shut down ${simName}: ${err.message}
|
|
3906
|
+
`);
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
}));
|
|
3911
|
+
}
|
|
3912
|
+
async function runPruneAccounts(config) {
|
|
3913
|
+
const cmd = config.hooks.pruneAccounts;
|
|
3914
|
+
if (!cmd || cmd.trim() === "") {
|
|
3915
|
+
process.stderr.write(`[maestro-manager] clean --prune-accounts: no hooks.pruneAccounts configured.
|
|
3916
|
+
`);
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
process.stderr.write(`[maestro-manager] clean --prune-accounts: running configured hook.
|
|
3920
|
+
`);
|
|
3921
|
+
try {
|
|
3922
|
+
const output = execFileSync6("sh", ["-c", cmd], {
|
|
3923
|
+
encoding: "utf8",
|
|
3924
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3925
|
+
timeout: 30000
|
|
3926
|
+
});
|
|
3927
|
+
process.stdout.write(output);
|
|
3928
|
+
process.stderr.write(`[maestro-manager] clean --prune-accounts: done.
|
|
3929
|
+
`);
|
|
3930
|
+
} catch (err) {
|
|
3931
|
+
if (!(err instanceof Error)) {
|
|
3932
|
+
throw err;
|
|
3933
|
+
}
|
|
3934
|
+
const status = "status" in err && typeof err.status === "number" ? err.status : 1;
|
|
3935
|
+
const stderr = "stderr" in err && typeof err.stderr === "string" ? err.stderr : err.message;
|
|
3936
|
+
process.stderr.write(`[maestro-manager] clean --prune-accounts: command failed (exit ${status}): ${redactSensitiveText(stderr)}
|
|
3937
|
+
`);
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
// src/cliArgPrimitives.ts
|
|
3941
|
+
var SAFE_POSITIVE_INTEGER_TOKEN = /^[1-9][0-9]*$/;
|
|
3942
|
+
var MANAGER_FLAGS = new Set([
|
|
3943
|
+
"--all",
|
|
3944
|
+
"--app",
|
|
3945
|
+
"--config",
|
|
3946
|
+
"--delete-unavailable",
|
|
3947
|
+
"--exclude-tags",
|
|
3948
|
+
"--from-run",
|
|
3949
|
+
"--headed",
|
|
3950
|
+
"--headless",
|
|
3951
|
+
"--help",
|
|
3952
|
+
"--idle-delete",
|
|
3953
|
+
"--idle-shutdown",
|
|
3954
|
+
"--include-tags",
|
|
3955
|
+
"--keep",
|
|
3956
|
+
"--mine",
|
|
3957
|
+
"--prune-accounts",
|
|
3958
|
+
"--rerun-failed",
|
|
3959
|
+
"--sims",
|
|
3960
|
+
"--version",
|
|
3961
|
+
"--worktree",
|
|
3962
|
+
"-h",
|
|
3963
|
+
"-v"
|
|
3964
|
+
]);
|
|
3965
|
+
|
|
3966
|
+
class CliUsageError extends Error {
|
|
3967
|
+
name = "CliUsageError";
|
|
3968
|
+
}
|
|
3969
|
+
function splitAtDoubleDash(args) {
|
|
3970
|
+
const index = args.indexOf("--");
|
|
3971
|
+
if (index === -1)
|
|
3972
|
+
return { head: args.slice(), passthrough: [] };
|
|
3973
|
+
return { head: args.slice(0, index), passthrough: args.slice(index + 1) };
|
|
3974
|
+
}
|
|
3975
|
+
function isManagerFlagToken(token) {
|
|
3976
|
+
return token.startsWith("--") || /^-[A-Za-z]/.test(token);
|
|
3977
|
+
}
|
|
3978
|
+
function readRequiredValue(args, index, flag) {
|
|
3979
|
+
const value = args[index + 1];
|
|
3980
|
+
if (value === undefined) {
|
|
3981
|
+
throw new CliUsageError(`${flag} requires a value`);
|
|
3982
|
+
}
|
|
3983
|
+
if (MANAGER_FLAGS.has(value)) {
|
|
3984
|
+
throw new CliUsageError(`${flag} requires a value before ${value}`);
|
|
3985
|
+
}
|
|
3986
|
+
if (isManagerFlagToken(value)) {
|
|
3987
|
+
throw new CliUsageError(`unknown manager flag: ${value}`);
|
|
3988
|
+
}
|
|
3989
|
+
return value;
|
|
3990
|
+
}
|
|
3991
|
+
function rejectDuplicate(currentValue, flag) {
|
|
3992
|
+
if (currentValue !== undefined) {
|
|
3993
|
+
throw new CliUsageError(`${flag} may only be specified once`);
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
function parsePositiveInteger(flag, value) {
|
|
3997
|
+
if (!SAFE_POSITIVE_INTEGER_TOKEN.test(value)) {
|
|
3998
|
+
throw new CliUsageError(`${flag} must be a positive integer (got: ${value})`);
|
|
3999
|
+
}
|
|
4000
|
+
const parsed = Number(value);
|
|
4001
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
4002
|
+
throw new CliUsageError(`${flag} must be a positive integer (got: ${value})`);
|
|
4003
|
+
}
|
|
4004
|
+
return parsed;
|
|
4005
|
+
}
|
|
4006
|
+
function splitTags(value) {
|
|
4007
|
+
return value.split(",").map((tag) => tag.trim()).filter((tag) => tag.length > 0);
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
// src/cliArgs.ts
|
|
4011
|
+
function parseManagerArgv(args) {
|
|
4012
|
+
const { head, passthrough } = splitAtDoubleDash(args);
|
|
4013
|
+
const state = {
|
|
4014
|
+
positionals: [],
|
|
4015
|
+
passthrough,
|
|
4016
|
+
configPath: undefined,
|
|
4017
|
+
appPath: undefined,
|
|
4018
|
+
sims: undefined,
|
|
4019
|
+
includeTags: [],
|
|
4020
|
+
excludeTags: [],
|
|
4021
|
+
rerunFailed: false,
|
|
4022
|
+
fromRunId: undefined,
|
|
4023
|
+
keep: false,
|
|
4024
|
+
mine: false,
|
|
4025
|
+
all: false,
|
|
4026
|
+
worktreePath: undefined,
|
|
4027
|
+
idleShutdownMinutes: undefined,
|
|
4028
|
+
idleDeleteHours: undefined,
|
|
4029
|
+
pruneAccounts: false,
|
|
4030
|
+
deleteUnavailable: false,
|
|
4031
|
+
displayMode: DEFAULT_SIMULATOR_DISPLAY_MODE,
|
|
4032
|
+
displayModeFlag: undefined,
|
|
4033
|
+
help: false,
|
|
4034
|
+
version: false
|
|
4035
|
+
};
|
|
4036
|
+
for (let index = 0;index < head.length; index++) {
|
|
4037
|
+
const token = head[index];
|
|
4038
|
+
if (token === undefined)
|
|
4039
|
+
continue;
|
|
4040
|
+
if (!isManagerFlagToken(token)) {
|
|
4041
|
+
state.positionals.push(token);
|
|
4042
|
+
continue;
|
|
4043
|
+
}
|
|
4044
|
+
if (!MANAGER_FLAGS.has(token)) {
|
|
4045
|
+
throw new CliUsageError(`unknown manager flag: ${token}`);
|
|
4046
|
+
}
|
|
4047
|
+
switch (token) {
|
|
4048
|
+
case "--app": {
|
|
4049
|
+
rejectDuplicate(state.appPath, token);
|
|
4050
|
+
state.appPath = readRequiredValue(head, index, token);
|
|
4051
|
+
index++;
|
|
4052
|
+
break;
|
|
4053
|
+
}
|
|
4054
|
+
case "--config": {
|
|
4055
|
+
rejectDuplicate(state.configPath, token);
|
|
4056
|
+
state.configPath = readRequiredValue(head, index, token);
|
|
4057
|
+
index++;
|
|
4058
|
+
break;
|
|
4059
|
+
}
|
|
4060
|
+
case "--sims": {
|
|
4061
|
+
rejectDuplicate(state.sims, token);
|
|
4062
|
+
const value = readRequiredValue(head, index, token);
|
|
4063
|
+
state.sims = parsePositiveInteger(token, value);
|
|
4064
|
+
index++;
|
|
4065
|
+
break;
|
|
4066
|
+
}
|
|
4067
|
+
case "--include-tags": {
|
|
4068
|
+
state.includeTags.push(...splitTags(readRequiredValue(head, index, token)));
|
|
4069
|
+
index++;
|
|
4070
|
+
break;
|
|
4071
|
+
}
|
|
4072
|
+
case "--exclude-tags": {
|
|
4073
|
+
state.excludeTags.push(...splitTags(readRequiredValue(head, index, token)));
|
|
4074
|
+
index++;
|
|
4075
|
+
break;
|
|
4076
|
+
}
|
|
4077
|
+
case "--from-run": {
|
|
4078
|
+
rejectDuplicate(state.fromRunId, token);
|
|
4079
|
+
state.fromRunId = readRequiredValue(head, index, token);
|
|
4080
|
+
index++;
|
|
4081
|
+
break;
|
|
4082
|
+
}
|
|
4083
|
+
case "--worktree": {
|
|
4084
|
+
rejectDuplicate(state.worktreePath, token);
|
|
4085
|
+
state.worktreePath = readRequiredValue(head, index, token);
|
|
4086
|
+
index++;
|
|
4087
|
+
break;
|
|
4088
|
+
}
|
|
4089
|
+
case "--idle-shutdown": {
|
|
4090
|
+
rejectDuplicate(state.idleShutdownMinutes, token);
|
|
4091
|
+
const value = readRequiredValue(head, index, token);
|
|
4092
|
+
state.idleShutdownMinutes = parsePositiveInteger(token, value);
|
|
4093
|
+
index++;
|
|
4094
|
+
break;
|
|
4095
|
+
}
|
|
4096
|
+
case "--idle-delete": {
|
|
4097
|
+
rejectDuplicate(state.idleDeleteHours, token);
|
|
4098
|
+
const value = readRequiredValue(head, index, token);
|
|
4099
|
+
state.idleDeleteHours = parsePositiveInteger(token, value);
|
|
4100
|
+
index++;
|
|
4101
|
+
break;
|
|
4102
|
+
}
|
|
4103
|
+
case "--keep": {
|
|
4104
|
+
state.keep = true;
|
|
4105
|
+
break;
|
|
4106
|
+
}
|
|
4107
|
+
case "--rerun-failed": {
|
|
4108
|
+
state.rerunFailed = true;
|
|
4109
|
+
break;
|
|
4110
|
+
}
|
|
4111
|
+
case "--mine": {
|
|
4112
|
+
state.mine = true;
|
|
4113
|
+
break;
|
|
4114
|
+
}
|
|
4115
|
+
case "--all": {
|
|
4116
|
+
state.all = true;
|
|
4117
|
+
break;
|
|
4118
|
+
}
|
|
4119
|
+
case "--prune-accounts": {
|
|
4120
|
+
state.pruneAccounts = true;
|
|
4121
|
+
break;
|
|
4122
|
+
}
|
|
4123
|
+
case "--delete-unavailable": {
|
|
4124
|
+
state.deleteUnavailable = true;
|
|
4125
|
+
break;
|
|
4126
|
+
}
|
|
4127
|
+
case "--headed": {
|
|
4128
|
+
setDisplayMode(state, "headed", token);
|
|
4129
|
+
break;
|
|
4130
|
+
}
|
|
4131
|
+
case "--headless": {
|
|
4132
|
+
setDisplayMode(state, "headless", token);
|
|
4133
|
+
break;
|
|
4134
|
+
}
|
|
4135
|
+
case "--help":
|
|
4136
|
+
case "-h": {
|
|
4137
|
+
state.help = true;
|
|
4138
|
+
break;
|
|
4139
|
+
}
|
|
4140
|
+
case "--version":
|
|
4141
|
+
case "-v": {
|
|
4142
|
+
state.version = true;
|
|
4143
|
+
break;
|
|
4144
|
+
}
|
|
4145
|
+
default: {
|
|
4146
|
+
throw new CliUsageError(`unknown manager flag: ${token}`);
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
return {
|
|
4151
|
+
command: state.positionals[0],
|
|
4152
|
+
commandArgs: state.positionals.slice(1),
|
|
4153
|
+
passthrough: state.passthrough,
|
|
4154
|
+
configPath: state.configPath,
|
|
4155
|
+
appPath: state.appPath,
|
|
4156
|
+
sims: state.sims,
|
|
4157
|
+
includeTags: state.includeTags,
|
|
4158
|
+
excludeTags: state.excludeTags,
|
|
4159
|
+
rerunFailed: state.rerunFailed,
|
|
4160
|
+
fromRunId: state.fromRunId,
|
|
4161
|
+
keep: state.keep,
|
|
4162
|
+
mine: state.mine,
|
|
4163
|
+
all: state.all,
|
|
4164
|
+
worktreePath: state.worktreePath,
|
|
4165
|
+
idleShutdownMinutes: state.idleShutdownMinutes,
|
|
4166
|
+
idleDeleteHours: state.idleDeleteHours,
|
|
4167
|
+
pruneAccounts: state.pruneAccounts,
|
|
4168
|
+
deleteUnavailable: state.deleteUnavailable,
|
|
4169
|
+
displayMode: state.displayMode,
|
|
4170
|
+
help: state.help,
|
|
4171
|
+
version: state.version
|
|
4172
|
+
};
|
|
4173
|
+
}
|
|
4174
|
+
function setDisplayMode(state, displayMode, flag) {
|
|
4175
|
+
if (state.displayModeFlag !== undefined && state.displayMode !== displayMode) {
|
|
4176
|
+
throw new CliUsageError("--headed and --headless cannot be combined");
|
|
4177
|
+
}
|
|
4178
|
+
state.displayMode = displayMode;
|
|
4179
|
+
state.displayModeFlag = flag;
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
// src/version.ts
|
|
4183
|
+
import { execFileSync as execFileSync7 } from "child_process";
|
|
4184
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
4185
|
+
// package.json
|
|
4186
|
+
var package_default = {
|
|
4187
|
+
name: "maestro-manager",
|
|
4188
|
+
version: "0.0.1",
|
|
4189
|
+
description: "Daemonless iOS Maestro test runner with a shared simulator pool.",
|
|
4190
|
+
bugs: {
|
|
4191
|
+
url: "https://github.com/shipworthyai/maestro-manager/issues"
|
|
4192
|
+
},
|
|
4193
|
+
license: "MIT",
|
|
4194
|
+
author: {
|
|
4195
|
+
name: "ShipWorthy",
|
|
4196
|
+
email: "maestro-manager@shipworthy.ai",
|
|
4197
|
+
url: "https://shipworthy.ai/"
|
|
4198
|
+
},
|
|
4199
|
+
repository: {
|
|
4200
|
+
type: "git",
|
|
4201
|
+
url: "https://github.com/shipworthyai/maestro-manager.git"
|
|
4202
|
+
},
|
|
4203
|
+
bin: {
|
|
4204
|
+
"maestro-manager": "./dist/cli.js"
|
|
4205
|
+
},
|
|
4206
|
+
files: [
|
|
4207
|
+
"dist",
|
|
4208
|
+
"README.md"
|
|
4209
|
+
],
|
|
4210
|
+
type: "module",
|
|
4211
|
+
imports: {
|
|
4212
|
+
"#*": "./src/*"
|
|
4213
|
+
},
|
|
4214
|
+
scripts: {
|
|
4215
|
+
check: "bun run typecheck && bun run lint && bun run format:check && bun run test",
|
|
4216
|
+
build: "bun build ./src/cli.ts --target=bun --outfile=dist/cli.js && chmod +x dist/cli.js && bun scripts/write-build-info.mjs",
|
|
4217
|
+
prepack: "bun run check && bun run build",
|
|
4218
|
+
typecheck: "tsc --noEmit",
|
|
4219
|
+
lint: "oxlint .",
|
|
4220
|
+
format: "oxfmt .",
|
|
4221
|
+
"format:check": "oxfmt --check .",
|
|
4222
|
+
test: "bun test",
|
|
4223
|
+
transit: "exit 0"
|
|
4224
|
+
},
|
|
4225
|
+
devDependencies: {
|
|
4226
|
+
"@types/bun": "^1.3.14",
|
|
4227
|
+
"@types/node": "26.0.0",
|
|
4228
|
+
oxfmt: "0.55.0",
|
|
4229
|
+
oxlint: "1.70.0",
|
|
4230
|
+
typescript: "7.0.1-rc"
|
|
4231
|
+
},
|
|
4232
|
+
engines: {
|
|
4233
|
+
bun: ">=1.3.14",
|
|
4234
|
+
node: ">=24.16.0"
|
|
4235
|
+
},
|
|
4236
|
+
packageManager: "bun@1.3.14"
|
|
4237
|
+
};
|
|
4238
|
+
|
|
4239
|
+
// src/version.ts
|
|
4240
|
+
var PRODUCT_DESCRIPTION = "daemonless iOS Maestro test runner with shared sim pool";
|
|
4241
|
+
var UNKNOWN_COMMIT = "unknown";
|
|
4242
|
+
var SHORT_COMMIT_PATTERN = /^[0-9a-f]{7,40}$/i;
|
|
4243
|
+
var VERSION_LABEL = `${package_default.version} (${resolveShortCommit()})`;
|
|
4244
|
+
function cliVersionLabel() {
|
|
4245
|
+
return VERSION_LABEL;
|
|
4246
|
+
}
|
|
4247
|
+
function cliHelpTitle() {
|
|
4248
|
+
return `maestro-manager@${VERSION_LABEL} \u2014 ${PRODUCT_DESCRIPTION}`;
|
|
4249
|
+
}
|
|
4250
|
+
function resolveShortCommit() {
|
|
4251
|
+
return readBundledCommit() ?? readGitCommit() ?? UNKNOWN_COMMIT;
|
|
4252
|
+
}
|
|
4253
|
+
function readBundledCommit() {
|
|
4254
|
+
try {
|
|
4255
|
+
const raw = readFileSync8(new URL("./build-info.json", import.meta.url), { encoding: "utf8" });
|
|
4256
|
+
return parseCommit(raw);
|
|
4257
|
+
} catch (error) {
|
|
4258
|
+
if (error instanceof Error)
|
|
4259
|
+
return null;
|
|
4260
|
+
throw error;
|
|
4261
|
+
}
|
|
4262
|
+
}
|
|
4263
|
+
function readGitCommit() {
|
|
4264
|
+
try {
|
|
4265
|
+
const commit = execFileSync7("git", ["rev-parse", "--short=7", "HEAD"], {
|
|
4266
|
+
encoding: "utf8",
|
|
4267
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
4268
|
+
}).trim();
|
|
4269
|
+
return normalizeCommit(commit);
|
|
4270
|
+
} catch (error) {
|
|
4271
|
+
if (error instanceof Error)
|
|
4272
|
+
return null;
|
|
4273
|
+
throw error;
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
function parseCommit(raw) {
|
|
4277
|
+
try {
|
|
4278
|
+
const parsed = JSON.parse(raw);
|
|
4279
|
+
if (!isRecord6(parsed))
|
|
4280
|
+
return null;
|
|
4281
|
+
const commit = parsed["commit"];
|
|
4282
|
+
if (typeof commit !== "string")
|
|
4283
|
+
return null;
|
|
4284
|
+
return normalizeCommit(commit);
|
|
4285
|
+
} catch (error) {
|
|
4286
|
+
if (error instanceof SyntaxError)
|
|
4287
|
+
return null;
|
|
4288
|
+
throw error;
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
function normalizeCommit(commit) {
|
|
4292
|
+
const normalized = commit.trim();
|
|
4293
|
+
return SHORT_COMMIT_PATTERN.test(normalized) ? normalized : null;
|
|
4294
|
+
}
|
|
4295
|
+
function isRecord6(value) {
|
|
4296
|
+
return typeof value === "object" && value !== null;
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
// src/cli.ts
|
|
4300
|
+
var AGENT_ID = resolve3(process.cwd());
|
|
4301
|
+
function die(msg) {
|
|
4302
|
+
process.stderr.write(`[maestro-manager] error: ${msg}
|
|
4303
|
+
`);
|
|
4304
|
+
process.exit(1);
|
|
4305
|
+
}
|
|
4306
|
+
function errorMessage3(err) {
|
|
4307
|
+
return err instanceof Error ? err.message : String(err);
|
|
4308
|
+
}
|
|
4309
|
+
function printUsage() {
|
|
4310
|
+
process.stderr.write(`
|
|
4311
|
+
${cliHelpTitle()}
|
|
4312
|
+
|
|
4313
|
+
Usage:
|
|
4314
|
+
maestro-manager up [--app <path>] [--headed|--headless]
|
|
4315
|
+
maestro-manager test [--app <path>] [--sims N] [--include-tags t1[,t2...]]
|
|
4316
|
+
[--exclude-tags t1[,t2...]] [--rerun-failed [--from-run <runId>]]
|
|
4317
|
+
[--headed|--headless] [--keep]
|
|
4318
|
+
[-- <maestro passthrough args>...]
|
|
4319
|
+
maestro-manager status
|
|
4320
|
+
maestro-manager ls
|
|
4321
|
+
maestro-manager stop [--mine|--worktree <path>|--all]
|
|
4322
|
+
maestro-manager down
|
|
4323
|
+
maestro-manager clean [--idle-shutdown M] [--idle-delete H] [--prune-accounts]
|
|
4324
|
+
[--delete-unavailable]
|
|
4325
|
+
|
|
4326
|
+
Commands:
|
|
4327
|
+
up Ensure pool.default sims exist, boot + warm them (idempotent).
|
|
4328
|
+
test Run the whole flow suite in parallel (no args = all flows).
|
|
4329
|
+
status / ls Show leases, runs index, and reconciled sim states.
|
|
4330
|
+
stop SIGTERM/SIGKILL in-scope run PIDs; keep the pool.
|
|
4331
|
+
--mine (default): only this worktree's leases
|
|
4332
|
+
--worktree <p>: leases owned by that worktree
|
|
4333
|
+
--all: all leases
|
|
4334
|
+
down stop --all + delete pool sims + clear state.
|
|
4335
|
+
clean Stateless reaper: reap orphaned runs (kill orphaned maestro + shut
|
|
4336
|
+
down the sim), reclaim stale leases, idle shutdown/delete sims.
|
|
4337
|
+
--delete-unavailable: also 'simctl delete unavailable' (GLOBAL,
|
|
4338
|
+
cross-namespace \u2014 broken-runtime device cruft; off by default).
|
|
4339
|
+
|
|
4340
|
+
Simulator display:
|
|
4341
|
+
--headless Boot and run without opening Simulator.app (default).
|
|
4342
|
+
--headed Open Simulator.app for booted target simulators so runs are visible.
|
|
4343
|
+
|
|
4344
|
+
Config (cwd-independent):
|
|
4345
|
+
--config <path> explicit config file (highest priority)
|
|
4346
|
+
MAESTRO_MANAGER_CONFIG=... env var pointing at the config file
|
|
4347
|
+
(otherwise: maestro-manager.config.json is found by walking up from cwd)
|
|
4348
|
+
|
|
4349
|
+
`);
|
|
4350
|
+
}
|
|
4351
|
+
function printVersion() {
|
|
4352
|
+
process.stdout.write(`${cliVersionLabel()}
|
|
4353
|
+
`);
|
|
4354
|
+
}
|
|
4355
|
+
async function main() {
|
|
4356
|
+
let parsed;
|
|
4357
|
+
try {
|
|
4358
|
+
parsed = parseManagerArgv(process.argv.slice(2));
|
|
4359
|
+
} catch (err) {
|
|
4360
|
+
if (err instanceof CliUsageError)
|
|
4361
|
+
die(err.message);
|
|
4362
|
+
throw err;
|
|
4363
|
+
}
|
|
4364
|
+
if (parsed.version) {
|
|
4365
|
+
printVersion();
|
|
4366
|
+
process.exit(0);
|
|
4367
|
+
}
|
|
4368
|
+
if (parsed.command === undefined || parsed.help) {
|
|
4369
|
+
printUsage();
|
|
4370
|
+
process.exit(0);
|
|
4371
|
+
}
|
|
4372
|
+
const command = parsed.command;
|
|
4373
|
+
if (command === "version") {
|
|
4374
|
+
printVersion();
|
|
4375
|
+
process.exit(0);
|
|
4376
|
+
}
|
|
4377
|
+
let config;
|
|
4378
|
+
try {
|
|
4379
|
+
config = loadConfig(process.cwd(), parsed.configPath);
|
|
4380
|
+
} catch (err) {
|
|
4381
|
+
if (err instanceof ConfigError)
|
|
4382
|
+
die(err.message);
|
|
4383
|
+
throw err;
|
|
4384
|
+
}
|
|
4385
|
+
switch (command) {
|
|
4386
|
+
case "up": {
|
|
4387
|
+
await runUp({ config, appPath: parsed.appPath, displayMode: parsed.displayMode });
|
|
4388
|
+
break;
|
|
4389
|
+
}
|
|
4390
|
+
case "test": {
|
|
4391
|
+
let exitCode;
|
|
4392
|
+
try {
|
|
4393
|
+
exitCode = await runTest({
|
|
4394
|
+
config,
|
|
4395
|
+
agent: AGENT_ID,
|
|
4396
|
+
appPath: parsed.appPath,
|
|
4397
|
+
flows: parsed.commandArgs,
|
|
4398
|
+
includeTags: parsed.includeTags,
|
|
4399
|
+
excludeTags: parsed.excludeTags,
|
|
4400
|
+
rerunFailed: parsed.rerunFailed,
|
|
4401
|
+
fromRunId: parsed.fromRunId,
|
|
4402
|
+
sims: parsed.sims,
|
|
4403
|
+
keep: parsed.keep,
|
|
4404
|
+
passthrough: parsed.passthrough,
|
|
4405
|
+
displayMode: parsed.displayMode
|
|
4406
|
+
});
|
|
4407
|
+
} catch (err) {
|
|
4408
|
+
if (err instanceof RerunFailedSelectionError)
|
|
4409
|
+
die(err.message);
|
|
4410
|
+
throw err;
|
|
4411
|
+
}
|
|
4412
|
+
process.exit(exitCode);
|
|
4413
|
+
break;
|
|
4414
|
+
}
|
|
4415
|
+
case "status":
|
|
4416
|
+
case "ls": {
|
|
4417
|
+
await runStatus({ config });
|
|
4418
|
+
break;
|
|
4419
|
+
}
|
|
4420
|
+
case "stop": {
|
|
4421
|
+
const scope = parseStopScope(parsed);
|
|
4422
|
+
await runStop({ config, scope });
|
|
4423
|
+
break;
|
|
4424
|
+
}
|
|
4425
|
+
case "down": {
|
|
4426
|
+
await runDown({ config, agent: AGENT_ID });
|
|
4427
|
+
break;
|
|
4428
|
+
}
|
|
4429
|
+
case "clean": {
|
|
4430
|
+
await runClean({
|
|
4431
|
+
config,
|
|
4432
|
+
idleShutdownMinutes: parsed.idleShutdownMinutes,
|
|
4433
|
+
idleDeleteHours: parsed.idleDeleteHours,
|
|
4434
|
+
pruneAccounts: parsed.pruneAccounts,
|
|
4435
|
+
deleteUnavailable: parsed.deleteUnavailable
|
|
4436
|
+
});
|
|
4437
|
+
break;
|
|
4438
|
+
}
|
|
4439
|
+
default: {
|
|
4440
|
+
process.stderr.write(`[maestro-manager] unknown command: ${command}
|
|
4441
|
+
`);
|
|
4442
|
+
printUsage();
|
|
4443
|
+
process.exit(1);
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
function parseStopScope(parsed) {
|
|
4448
|
+
if (parsed.all)
|
|
4449
|
+
return { kind: "all" };
|
|
4450
|
+
if (parsed.worktreePath !== undefined)
|
|
4451
|
+
return { kind: "worktree", worktree: resolve3(parsed.worktreePath) };
|
|
4452
|
+
return { kind: "mine", worktree: AGENT_ID };
|
|
4453
|
+
}
|
|
4454
|
+
main().catch((err) => {
|
|
4455
|
+
process.stderr.write(`[maestro-manager] fatal: ${errorMessage3(err)}
|
|
4456
|
+
`);
|
|
4457
|
+
process.exit(1);
|
|
4458
|
+
});
|