@saadnvd1/agent-os 0.3.523 → 0.3.524

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * An iOS simulator a task can drive, on the Mac only. The project declares a
3
+ * `simulator` block in agentos.json; `aos sim open` makes the task its own
4
+ * device (cloned from a base one), runs the project's start command to put
5
+ * the app on it, and `aos sim <command>` drives it through agent-device.
6
+ * Optional: nothing in the merge, review or release paths reads any of this.
7
+ */
8
+
9
+ import { spawn, type ChildProcess } from "child_process";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { db, type Session } from "../db";
13
+ import { isRemoteHost } from "../hosts";
14
+ import { simulatorAvailable } from "./available";
15
+ import { withPorts, type ProjectConfig } from "../project-config";
16
+ import { sessionConfig, sessionProjectEnv } from "../project-config/session";
17
+ import {
18
+ buildArgs,
19
+ callTool,
20
+ openArgs,
21
+ ensureInstalled,
22
+ screenshotPath,
23
+ shotsDir,
24
+ simulatorDir,
25
+ type CallResult,
26
+ } from "./agent-device";
27
+ import {
28
+ allLeases,
29
+ bootedCount,
30
+ dropLease,
31
+ getLease,
32
+ maxBooted,
33
+ planSweep,
34
+ putLease,
35
+ touchLease,
36
+ type Lease,
37
+ type SessionFacts,
38
+ } from "./lease";
39
+ import {
40
+ bootDevice,
41
+ cloneDevice,
42
+ deleteDevice,
43
+ deviceNameFor,
44
+ ensureBase,
45
+ listDevices,
46
+ recentLog,
47
+ shutdownDevice,
48
+ } from "./simctl";
49
+
50
+ const START_TIMEOUT_MS = 25 * 60_000;
51
+ const DEFAULT_MODEL = "iPhone 17";
52
+
53
+ export class SimulatorError extends Error {
54
+ constructor(
55
+ message: string,
56
+ readonly status = 409
57
+ ) {
58
+ super(message);
59
+ }
60
+ }
61
+
62
+ export { simulatorAvailable };
63
+
64
+ const session = (id: string) =>
65
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
66
+ | Session
67
+ | undefined;
68
+
69
+ function declared(sessionId: string): {
70
+ config: NonNullable<ProjectConfig["simulator"]>;
71
+ ports: Record<string, number>;
72
+ cwd: string;
73
+ } {
74
+ const s = session(sessionId);
75
+ if (!s) throw new SimulatorError("Session not found", 404);
76
+ if (isRemoteHost(s.host_id))
77
+ throw new SimulatorError(
78
+ "This task runs on another machine; its simulator is driven there.",
79
+ 409
80
+ );
81
+ const loaded = sessionConfig(sessionId);
82
+ const config = loaded.config.simulator;
83
+ if (!config)
84
+ throw new SimulatorError(
85
+ "This project doesn't declare a simulator (a `simulator` block in agentos.json).",
86
+ 400
87
+ );
88
+ if (!simulatorAvailable())
89
+ throw new SimulatorError(
90
+ "The iOS simulator is not available on this machine (a Mac with Xcode is needed). Skip simulator checks and say so in the PR."
91
+ );
92
+ return {
93
+ config,
94
+ ports: loaded.ports ?? {},
95
+ cwd: s.worktree_path || s.working_directory,
96
+ };
97
+ }
98
+
99
+ export interface SimulatorStatus {
100
+ state: Lease["state"] | "none";
101
+ udid: string | null;
102
+ error: string | null;
103
+ shots: string[];
104
+ }
105
+
106
+ function recentShots(sessionId: string, n = 6): string[] {
107
+ try {
108
+ return fs
109
+ .readdirSync(shotsDir(sessionId))
110
+ .filter((f) => f.endsWith(".png"))
111
+ .sort()
112
+ .slice(-n)
113
+ .map((f) => path.join(shotsDir(sessionId), f));
114
+ } catch {
115
+ return [];
116
+ }
117
+ }
118
+
119
+ export function simulatorStatus(sessionId: string): SimulatorStatus {
120
+ const lease = getLease(sessionId);
121
+ return {
122
+ state: lease?.state ?? "none",
123
+ udid: lease?.udid ?? null,
124
+ error: lease?.error ?? null,
125
+ shots: recentShots(sessionId),
126
+ };
127
+ }
128
+
129
+ const tail = (file: string, lines = 15) => {
130
+ try {
131
+ return fs
132
+ .readFileSync(file, "utf8")
133
+ .trim()
134
+ .split("\n")
135
+ .slice(-lines)
136
+ .join("\n");
137
+ } catch {
138
+ return "";
139
+ }
140
+ };
141
+
142
+ // The start command of each task bringing its simulator up, so closing the
143
+ // simulator can stop it.
144
+ const starts = new Map<string, ChildProcess>();
145
+
146
+ // The whole group: the project's build and Metro are children of the shell.
147
+ export function killGroup(child: ChildProcess, signal: NodeJS.Signals) {
148
+ try {
149
+ if (child.pid) process.kill(-child.pid, signal);
150
+ } catch {
151
+ child.kill(signal);
152
+ }
153
+ }
154
+
155
+ function runStart(
156
+ sessionId: string,
157
+ command: string,
158
+ cwd: string,
159
+ udid: string,
160
+ ports: Record<string, number>
161
+ ): Promise<void> {
162
+ fs.mkdirSync(simulatorDir(sessionId), { recursive: true });
163
+ const logFile = path.join(simulatorDir(sessionId), "start.log");
164
+ const log = fs.openSync(logFile, "w");
165
+ return new Promise((resolve, reject) => {
166
+ const child = spawn(withPorts(command, ports), {
167
+ cwd,
168
+ shell: true,
169
+ detached: true,
170
+ stdio: ["ignore", log, log],
171
+ env: {
172
+ ...process.env,
173
+ ...sessionProjectEnv(sessionId),
174
+ SIMULATOR_UDID: udid,
175
+ },
176
+ });
177
+ starts.set(sessionId, child);
178
+ const timer = setTimeout(() => {
179
+ killGroup(child, "SIGTERM");
180
+ setTimeout(() => killGroup(child, "SIGKILL"), 10_000).unref();
181
+ }, START_TIMEOUT_MS);
182
+ child.on("error", (error) => {
183
+ clearTimeout(timer);
184
+ starts.delete(sessionId);
185
+ fs.closeSync(log);
186
+ reject(error);
187
+ });
188
+ child.on("close", (code) => {
189
+ clearTimeout(timer);
190
+ if (starts.get(sessionId) === child) starts.delete(sessionId);
191
+ fs.closeSync(log);
192
+ if (code === 0) resolve();
193
+ else reject(new Error(`start exited ${code}:\n${tail(logFile)}`.trim()));
194
+ });
195
+ });
196
+ }
197
+
198
+ // Bring-ups running in this process. A lease still 'creating' or 'starting'
199
+ // without one was cut off by a restart. The token tells a bring-up whether
200
+ // it was closed (or replaced) while it ran.
201
+ const attempts = new Map<string, symbol>();
202
+
203
+ class Cancelled extends Error {}
204
+
205
+ async function bringUp(
206
+ sessionId: string,
207
+ token: symbol,
208
+ config: NonNullable<ProjectConfig["simulator"]>,
209
+ ports: Record<string, number>,
210
+ cwd: string
211
+ ): Promise<void> {
212
+ const name = deviceNameFor(sessionId);
213
+ let made: string | undefined;
214
+ // Every step is followed by this: closed meanwhile, it stops and tidies up.
215
+ const live = () => {
216
+ if (attempts.get(sessionId) !== token) throw new Cancelled();
217
+ };
218
+ try {
219
+ await ensureInstalled();
220
+ live();
221
+ const base = await ensureBase(config.device ?? DEFAULT_MODEL);
222
+ live();
223
+ const existing = (await listDevices()).find((d) => d.name === name);
224
+ live();
225
+ made = existing?.udid ?? (await cloneDevice(base, name));
226
+ const udid = made;
227
+ live();
228
+ putLease(sessionId, { name, udid, state: "starting" });
229
+ await bootDevice(udid);
230
+ live();
231
+ // The XCTest runner builds once per Xcode version, then is cached; it
232
+ // runs alongside the project's own build.
233
+ const runner = callTool(
234
+ [
235
+ "prepare",
236
+ "ios-runner",
237
+ "--platform",
238
+ "ios",
239
+ "--udid",
240
+ udid,
241
+ "--timeout",
242
+ "900000",
243
+ ],
244
+ 15 * 60_000
245
+ );
246
+ await runStart(sessionId, config.start, cwd, udid, ports);
247
+ live();
248
+ const prepared = await runner;
249
+ live();
250
+ if (prepared.code !== 0)
251
+ throw new Error(`the XCTest runner didn't start: ${prepared.stderr}`);
252
+ if (config.app) {
253
+ const opened = await callTool(
254
+ openArgs({ udid, sessionId, app: config.app })
255
+ );
256
+ live();
257
+ if (opened.code !== 0)
258
+ throw new Error(`couldn't attach to ${config.app}: ${opened.stderr}`);
259
+ }
260
+ putLease(sessionId, { name, udid, state: "ready" });
261
+ attempts.delete(sessionId);
262
+ } catch (error) {
263
+ if (error instanceof Cancelled || attempts.get(sessionId) !== token) {
264
+ // Closed while coming up: a device made after the close is not kept
265
+ // unless the close only shut the task's simulator down.
266
+ if (made) {
267
+ const device = { udid: made, name };
268
+ const kept = getLease(sessionId);
269
+ await (kept ? shutdownDevice(device) : deleteDevice(device)).catch(
270
+ () => {}
271
+ );
272
+ }
273
+ return;
274
+ }
275
+ const message = error instanceof Error ? error.message : String(error);
276
+ console.error(`[simulator] ${sessionId}: ${message}`);
277
+ attempts.delete(sessionId);
278
+ putLease(sessionId, { name, state: "failed", error: message });
279
+ }
280
+ }
281
+
282
+ // Starts the task's simulator in the background; poll simulatorStatus.
283
+ export function openSimulator(sessionId: string): SimulatorStatus {
284
+ const { config, ports, cwd } = declared(sessionId);
285
+ const lease = getLease(sessionId);
286
+ if (lease && (lease.state === "creating" || lease.state === "starting"))
287
+ return simulatorStatus(sessionId);
288
+ if (lease?.state === "ready") {
289
+ touchLease(sessionId);
290
+ return simulatorStatus(sessionId);
291
+ }
292
+ if (bootedCount(sessionId) >= maxBooted())
293
+ throw new SimulatorError(
294
+ `${maxBooted()} simulators are already booted for other tasks; try again in a few minutes.`
295
+ );
296
+ putLease(sessionId, { name: deviceNameFor(sessionId), state: "creating" });
297
+ const token = Symbol(sessionId);
298
+ attempts.set(sessionId, token);
299
+ void bringUp(sessionId, token, config, ports, cwd);
300
+ return simulatorStatus(sessionId);
301
+ }
302
+
303
+ // Boots a shut-down device again (idle past the limit) when the cap allows.
304
+ async function wake(sessionId: string, lease: Lease): Promise<void> {
305
+ if (!lease.udid) throw new SimulatorError("The simulator has no device.");
306
+ if (bootedCount(sessionId) >= maxBooted())
307
+ throw new SimulatorError(
308
+ `${maxBooted()} simulators are already booted for other tasks; try again in a few minutes.`
309
+ );
310
+ await bootDevice(lease.udid);
311
+ putLease(sessionId, { name: lease.name, udid: lease.udid, state: "ready" });
312
+ }
313
+
314
+ export async function runSimulator(
315
+ sessionId: string,
316
+ args: string[]
317
+ ): Promise<CallResult & { screenshot: string | null }> {
318
+ const { config, cwd } = declared(sessionId);
319
+ let lease = getLease(sessionId);
320
+ if (!lease || lease.state === "failed")
321
+ throw new SimulatorError("No simulator is open. Run `aos sim open`.");
322
+ if (lease.state !== "ready" && lease.state !== "shutdown")
323
+ throw new SimulatorError(
324
+ "The simulator is still starting. Run `aos sim status`."
325
+ );
326
+ if (lease.state === "shutdown") {
327
+ await wake(sessionId, lease);
328
+ lease = getLease(sessionId)!;
329
+ }
330
+ let argv: string[];
331
+ try {
332
+ argv = buildArgs(args, {
333
+ udid: lease.udid!,
334
+ sessionId,
335
+ app: config.app,
336
+ cwd,
337
+ });
338
+ } catch (error) {
339
+ throw new SimulatorError((error as Error).message, 400);
340
+ }
341
+ const shot = screenshotPath(argv);
342
+ if (shot) fs.mkdirSync(path.dirname(shot), { recursive: true });
343
+ touchLease(sessionId);
344
+ const result = await callTool(argv);
345
+ touchLease(sessionId);
346
+ return { ...result, screenshot: shot && fs.existsSync(shot) ? shot : null };
347
+ }
348
+
349
+ export async function simulatorLogs(sessionId: string, last: string) {
350
+ const { config } = declared(sessionId);
351
+ const lease = getLease(sessionId);
352
+ if (!lease?.udid) throw new SimulatorError("No simulator is open.");
353
+ if (!config.app)
354
+ throw new SimulatorError("The project doesn't name its app.");
355
+ if (!/^\d+[smh]$/.test(last))
356
+ throw new SimulatorError("--last takes a duration like 2m", 400);
357
+ touchLease(sessionId);
358
+ // The app's process name is its bundle id's last part by default.
359
+ return recentLog(lease.udid, config.app.split(".").pop() ?? config.app, last);
360
+ }
361
+
362
+ // Ends the task's simulator. `remove` deletes the device (the task is over);
363
+ // otherwise it is only shut down and comes back on the next call. Never
364
+ // throws and never waits for its caller: the agent's end must not hang on it.
365
+ export async function releaseSimulator(
366
+ sessionId: string,
367
+ opts: { remove: boolean }
368
+ ): Promise<void> {
369
+ const lease = getLease(sessionId);
370
+ if (!lease) return;
371
+ // A bring-up still running stops at its next step; so does its build.
372
+ attempts.delete(sessionId);
373
+ const start = starts.get(sessionId);
374
+ if (start) killGroup(start, "SIGTERM");
375
+ try {
376
+ if (lease.udid) {
377
+ await callTool([
378
+ "close",
379
+ "--platform",
380
+ "ios",
381
+ "--udid",
382
+ lease.udid,
383
+ "--session",
384
+ `aos-${sessionId.replace(/-/g, "").slice(0, 8)}`,
385
+ ]).catch(() => {});
386
+ if (opts.remove)
387
+ await deleteDevice({ udid: lease.udid, name: lease.name });
388
+ else await shutdownDevice({ udid: lease.udid, name: lease.name });
389
+ }
390
+ if (opts.remove) {
391
+ dropLease(sessionId);
392
+ fs.rmSync(simulatorDir(sessionId), { recursive: true, force: true });
393
+ } else putLease(sessionId, { name: lease.name, state: "shutdown" });
394
+ } catch (error) {
395
+ console.error(`[simulator] release ${sessionId}:`, error);
396
+ }
397
+ }
398
+
399
+ // Runs on the Mac only, and only does anything where a lease exists.
400
+ export function releaseOnEnd(sessionId: string, opts: { remove: boolean }) {
401
+ if (!getLease(sessionId)) return;
402
+ void releaseSimulator(sessionId, opts);
403
+ }
404
+
405
+ const ENDED = new Set(["merged", "dropped", "done", "moved"]);
406
+
407
+ // Tidies leases and devices left by tasks that ended or crashed: ended tasks'
408
+ // devices go, idle ones are shut down, and this instance's unclaimed devices
409
+ // are deleted.
410
+ export async function sweepSimulators(): Promise<void> {
411
+ if (!simulatorAvailable()) return;
412
+ const devices = await listDevices();
413
+ const leases = allLeases();
414
+ const sessions = new Map<string, SessionFacts>();
415
+ for (const l of leases) {
416
+ const s = session(l.session_id);
417
+ sessions.set(l.session_id, {
418
+ ended: !s || !!s.archived_at || ENDED.has(s.task_status ?? ""),
419
+ });
420
+ }
421
+ const plan = planSweep(
422
+ leases,
423
+ devices,
424
+ sessions,
425
+ Date.now(),
426
+ undefined,
427
+ new Set(attempts.keys())
428
+ );
429
+ // Bring-ups a restart cut off: their device is shut down and the slot freed.
430
+ for (const i of plan.interrupted) {
431
+ if (i.device)
432
+ await shutdownDevice(i.device).catch((e) =>
433
+ console.error("[simulator] sweep shutdown:", e)
434
+ );
435
+ putLease(i.sessionId, {
436
+ name: i.name,
437
+ state: "failed",
438
+ error: "interrupted by a restart; run `aos sim open` again",
439
+ });
440
+ }
441
+ for (const r of plan.remove) {
442
+ await deleteDevice(r.device).catch((e) =>
443
+ console.error("[simulator] sweep delete:", e)
444
+ );
445
+ if (r.sessionId) {
446
+ dropLease(r.sessionId);
447
+ fs.rmSync(simulatorDir(r.sessionId), { recursive: true, force: true });
448
+ }
449
+ }
450
+ for (const s of plan.shutdown) {
451
+ await shutdownDevice(s).catch((e) =>
452
+ console.error("[simulator] sweep shutdown:", e)
453
+ );
454
+ const lease = getLease(s.sessionId);
455
+ if (lease) putLease(s.sessionId, { name: lease.name, state: "shutdown" });
456
+ }
457
+ for (const id of plan.dropLeases) dropLease(id);
458
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The simulator a task holds: one row per task, and the plan for what to
3
+ * shut down or delete. The plan is pure so every branch is tested without
4
+ * simctl.
5
+ */
6
+
7
+ import { db } from "../db";
8
+ import { baseName, deviceNameFor, ownsDevice, type SimDevice } from "./simctl";
9
+
10
+ export type LeaseState =
11
+ | "creating"
12
+ | "starting"
13
+ | "ready"
14
+ | "failed"
15
+ | "shutdown";
16
+
17
+ export interface Lease {
18
+ session_id: string;
19
+ udid: string | null;
20
+ name: string;
21
+ state: LeaseState;
22
+ error: string | null;
23
+ created_at: number;
24
+ last_used_at: number;
25
+ }
26
+
27
+ export const IDLE_MS = 15 * 60_000;
28
+
29
+ // Booted at once, across all tasks. A booted simulator and its Metro are
30
+ // gigabytes and a burst of CPU, so the Mac is not left to take as many as
31
+ // tasks ask for.
32
+ export const maxBooted = () => {
33
+ const n = Number(process.env.AGENTOS_SIMULATOR_MAX);
34
+ return Number.isInteger(n) && n >= 1 ? n : 2;
35
+ };
36
+
37
+ export const getLease = (sessionId: string): Lease | undefined =>
38
+ db
39
+ .prepare(`SELECT * FROM simulator_leases WHERE session_id = ?`)
40
+ .get(sessionId) as Lease | undefined;
41
+
42
+ export const allLeases = (): Lease[] =>
43
+ db.prepare(`SELECT * FROM simulator_leases`).all() as Lease[];
44
+
45
+ export function putLease(
46
+ sessionId: string,
47
+ fields: {
48
+ name?: string;
49
+ udid?: string | null;
50
+ state: LeaseState;
51
+ error?: string | null;
52
+ },
53
+ now = Date.now()
54
+ ): void {
55
+ db.prepare(
56
+ `INSERT INTO simulator_leases (session_id, udid, name, state, error, created_at, last_used_at)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?)
58
+ ON CONFLICT(session_id) DO UPDATE SET
59
+ udid = COALESCE(excluded.udid, udid),
60
+ state = excluded.state,
61
+ error = excluded.error,
62
+ last_used_at = excluded.last_used_at`
63
+ ).run(
64
+ sessionId,
65
+ fields.udid ?? null,
66
+ fields.name ?? "",
67
+ fields.state,
68
+ fields.error ?? null,
69
+ now,
70
+ now
71
+ );
72
+ }
73
+
74
+ export const touchLease = (sessionId: string, now = Date.now()) =>
75
+ db
76
+ .prepare(
77
+ `UPDATE simulator_leases SET last_used_at = ? WHERE session_id = ?`
78
+ )
79
+ .run(now, sessionId);
80
+
81
+ export const dropLease = (sessionId: string) =>
82
+ db
83
+ .prepare(`DELETE FROM simulator_leases WHERE session_id = ?`)
84
+ .run(sessionId);
85
+
86
+ // Leases that hold a booted (or booting) device, other than `except`.
87
+ export const bootedCount = (except?: string): number =>
88
+ (
89
+ db
90
+ .prepare(
91
+ `SELECT COUNT(*) AS n FROM simulator_leases
92
+ WHERE state IN ('creating', 'starting', 'ready') AND session_id != ?`
93
+ )
94
+ .get(except ?? "") as { n: number }
95
+ ).n;
96
+
97
+ export interface SessionFacts {
98
+ // Gone, done, dropped, merged, moved or archived: nothing will use it again.
99
+ ended: boolean;
100
+ }
101
+
102
+ export interface SweepPlan {
103
+ // Devices to delete, with the lease to drop (null for a device with none).
104
+ remove: {
105
+ device: { udid: string; name: string };
106
+ sessionId: string | null;
107
+ }[];
108
+ shutdown: { udid: string; name: string; sessionId: string }[];
109
+ // Leases left 'creating' or 'starting' by a bring-up that is no longer
110
+ // running (a restart): to be marked failed, their device shut down.
111
+ interrupted: {
112
+ sessionId: string;
113
+ name: string;
114
+ device: { udid: string; name: string } | null;
115
+ }[];
116
+ // Leases whose device is gone.
117
+ dropLeases: string[];
118
+ }
119
+
120
+ export function planSweep(
121
+ leases: Lease[],
122
+ devices: SimDevice[],
123
+ sessions: Map<string, SessionFacts>,
124
+ now: number,
125
+ idleMs = IDLE_MS,
126
+ // Tasks whose bring-up is running in this process right now.
127
+ bringingUp: ReadonlySet<string> = new Set()
128
+ ): SweepPlan {
129
+ const plan: SweepPlan = {
130
+ remove: [],
131
+ shutdown: [],
132
+ interrupted: [],
133
+ dropLeases: [],
134
+ };
135
+ const byName = new Map(devices.map((d) => [d.name, d]));
136
+ const leased = new Set(leases.map((l) => l.name));
137
+ for (const id of bringingUp) leased.add(deviceNameFor(id));
138
+ for (const lease of leases) {
139
+ const device = byName.get(lease.name);
140
+ const ended = sessions.get(lease.session_id)?.ended ?? true;
141
+ const coming = lease.state === "creating" || lease.state === "starting";
142
+ if (!ended && coming) {
143
+ if (!bringingUp.has(lease.session_id))
144
+ plan.interrupted.push({
145
+ sessionId: lease.session_id,
146
+ name: lease.name,
147
+ device: device ? { udid: device.udid, name: device.name } : null,
148
+ });
149
+ continue;
150
+ }
151
+ if (!device) {
152
+ plan.dropLeases.push(lease.session_id);
153
+ } else if (ended || lease.state === "failed") {
154
+ plan.remove.push({ device, sessionId: lease.session_id });
155
+ } else if (
156
+ device.state === "Booted" &&
157
+ lease.state !== "starting" &&
158
+ now - lease.last_used_at > idleMs
159
+ ) {
160
+ plan.shutdown.push({ ...device, sessionId: lease.session_id });
161
+ }
162
+ }
163
+ // This instance's devices that no lease names: a database reset, or a crash
164
+ // between making a device and recording it. The base is kept.
165
+ for (const d of devices)
166
+ if (ownsDevice(d.name) && d.name !== baseName() && !leased.has(d.name))
167
+ plan.remove.push({ device: d, sessionId: null });
168
+ return plan;
169
+ }