@runweave/server-linux-arm64 0.0.0-stage → 0.1.2

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.
Files changed (112) hide show
  1. package/manifest.json +1993 -0
  2. package/native.tar.gz +0 -0
  3. package/package.json +22 -4
  4. package/payload/app-server/build-info.json +8 -0
  5. package/payload/app-server/configuration-compatibility.json +1 -0
  6. package/payload/app-server/index.cjs +43560 -0
  7. package/payload/app-server/index.cjs.map +1 -0
  8. package/payload/backend/activity-sqlite-worker.cjs +6730 -0
  9. package/payload/backend/build-info.json +8 -0
  10. package/payload/backend/dev-resources/control.cjs +14727 -0
  11. package/payload/backend/evolution-sqlite-worker.cjs +2073 -0
  12. package/payload/backend/index.cjs +135617 -0
  13. package/payload/backend/ios-simulators/device_shutdown.py +22 -0
  14. package/payload/backend/ios-simulators/monitor.py +204 -0
  15. package/payload/backend/ios-simulators/pool.py +600 -0
  16. package/payload/backend/scheduled-tasks-sqlite-worker.cjs +29203 -0
  17. package/payload/bin/runtime.cjs +95 -0
  18. package/payload/frontend/dist/assets/_baseUniq-B6OtH5JH.js +1 -0
  19. package/payload/frontend/dist/assets/access-CpH3nCnI.js +1 -0
  20. package/payload/frontend/dist/assets/advanced-U5MqNTD5.js +1 -0
  21. package/payload/frontend/dist/assets/appearance-Cb8XjaOT.js +1 -0
  22. package/payload/frontend/dist/assets/arc-CEnFQNMK.js +1 -0
  23. package/payload/frontend/dist/assets/architectureDiagram-Q4EWVU46-T438RyEG.js +36 -0
  24. package/payload/frontend/dist/assets/blockDiagram-DXYQGD6D-B6Mqb9og.js +132 -0
  25. package/payload/frontend/dist/assets/browser-CIEzmzuD.js +8 -0
  26. package/payload/frontend/dist/assets/c4Diagram-AHTNJAMY-CO3eA3hw.js +10 -0
  27. package/payload/frontend/dist/assets/channel-D7VgadXd.js +1 -0
  28. package/payload/frontend/dist/assets/chunk-4BX2VUAB-1vjsV_mH.js +1 -0
  29. package/payload/frontend/dist/assets/chunk-4TB4RGXK-CtMNFDLV.js +206 -0
  30. package/payload/frontend/dist/assets/chunk-55IACEB6-DVWWqMjZ.js +1 -0
  31. package/payload/frontend/dist/assets/chunk-EDXVE4YY-BBf3C6eJ.js +1 -0
  32. package/payload/frontend/dist/assets/chunk-FMBD7UC4-BUQfXSvf.js +15 -0
  33. package/payload/frontend/dist/assets/chunk-OYMX7WX6-DM1nFyON.js +231 -0
  34. package/payload/frontend/dist/assets/chunk-QZHKN3VN-Bz3CUNpU.js +1 -0
  35. package/payload/frontend/dist/assets/chunk-YZCP3GAM-Bb6CnKMv.js +1 -0
  36. package/payload/frontend/dist/assets/classDiagram-6PBFFD2Q-DHRHkXVC.js +1 -0
  37. package/payload/frontend/dist/assets/classDiagram-v2-HSJHXN6E-DHRHkXVC.js +1 -0
  38. package/payload/frontend/dist/assets/clone-BzLTnNJj.js +1 -0
  39. package/payload/frontend/dist/assets/companion-BLgWW1ka.css +1 -0
  40. package/payload/frontend/dist/assets/companion-CRS3Tmyt.js +2 -0
  41. package/payload/frontend/dist/assets/connection-ChK196PA.js +12 -0
  42. package/payload/frontend/dist/assets/connections-1OP6Izpn.js +1 -0
  43. package/payload/frontend/dist/assets/cose-bilkent-S5V4N54A-DvX1W1oQ.js +1 -0
  44. package/payload/frontend/dist/assets/css.worker-B4z49cGk.js +93 -0
  45. package/payload/frontend/dist/assets/cytoscape.esm-DxGcaOPV.js +331 -0
  46. package/payload/frontend/dist/assets/dagre-KV5264BT-BYV7l80W.js +4 -0
  47. package/payload/frontend/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  48. package/payload/frontend/dist/assets/desktop-DKYPd_Z3.js +1 -0
  49. package/payload/frontend/dist/assets/diagram-5BDNPKRD-BU_VxMcA.js +10 -0
  50. package/payload/frontend/dist/assets/diagram-G4DWMVQ6-6jnkLcPr.js +24 -0
  51. package/payload/frontend/dist/assets/diagram-MMDJMWI5-DAjbbS4T.js +43 -0
  52. package/payload/frontend/dist/assets/diagram-TYMM5635-p2T86-Pr.js +24 -0
  53. package/payload/frontend/dist/assets/editor.worker-DyuDvpVp.js +26 -0
  54. package/payload/frontend/dist/assets/erDiagram-SMLLAGMA-DcZPoxkO.js +85 -0
  55. package/payload/frontend/dist/assets/flowDiagram-DWJPFMVM-CDB-5IUB.js +162 -0
  56. package/payload/frontend/dist/assets/ganttDiagram-T4ZO3ILL-imCnxO5S.js +292 -0
  57. package/payload/frontend/dist/assets/gitGraphDiagram-UUTBAWPF-DW7bWVfm.js +106 -0
  58. package/payload/frontend/dist/assets/graph-rPoUAwm2.js +1 -0
  59. package/payload/frontend/dist/assets/html.worker-DtiGdgqp.js +470 -0
  60. package/payload/frontend/dist/assets/image-pt4OUNUX.js +1 -0
  61. package/payload/frontend/dist/assets/index-BJDOs8Sk.css +1 -0
  62. package/payload/frontend/dist/assets/index-CpU0rt1e.js +455 -0
  63. package/payload/frontend/dist/assets/index-D9-cSGu-.js +49 -0
  64. package/payload/frontend/dist/assets/infoDiagram-42DDH7IO-C6DYDEhQ.js +2 -0
  65. package/payload/frontend/dist/assets/init-Gi6I4Gst.js +1 -0
  66. package/payload/frontend/dist/assets/ishikawaDiagram-UXIWVN3A-o3dlbDPW.js +70 -0
  67. package/payload/frontend/dist/assets/journeyDiagram-VCZTEJTY-ukG3M2cI.js +139 -0
  68. package/payload/frontend/dist/assets/json.worker-leyajbqV.js +58 -0
  69. package/payload/frontend/dist/assets/kanban-definition-6JOO6SKY-CzFLztFd.js +89 -0
  70. package/payload/frontend/dist/assets/katex-DkKDou_j.js +257 -0
  71. package/payload/frontend/dist/assets/layout-C8SEMYSj.js +1 -0
  72. package/payload/frontend/dist/assets/linear-DY8vPita.js +1 -0
  73. package/payload/frontend/dist/assets/main-B_ifx48h.js +693 -0
  74. package/payload/frontend/dist/assets/main-C-ZLrzMh.css +33 -0
  75. package/payload/frontend/dist/assets/min-9StlgeN8.js +1 -0
  76. package/payload/frontend/dist/assets/mindmap-definition-QFDTVHPH-CyJisFtS.js +96 -0
  77. package/payload/frontend/dist/assets/monitoring-Bjm0etKL.js +1 -0
  78. package/payload/frontend/dist/assets/notifications-DhRLCgMU.js +1 -0
  79. package/payload/frontend/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  80. package/payload/frontend/dist/assets/pieDiagram-DEJITSTG-DHSht7rk.js +30 -0
  81. package/payload/frontend/dist/assets/quadrantDiagram-34T5L4WZ-92xIujrE.js +7 -0
  82. package/payload/frontend/dist/assets/record-content-Cy_dPSdi.js +1 -0
  83. package/payload/frontend/dist/assets/requirementDiagram-MS252O5E-CXuOqrNd.js +84 -0
  84. package/payload/frontend/dist/assets/sankeyDiagram-XADWPNL6-DR-mjY0Y.js +10 -0
  85. package/payload/frontend/dist/assets/sequenceDiagram-FGHM5R23-DmmUpAoP.js +157 -0
  86. package/payload/frontend/dist/assets/shortcuts-BNrV46nh.js +1 -0
  87. package/payload/frontend/dist/assets/stateDiagram-FHFEXIEX-CKoqQ0-j.js +1 -0
  88. package/payload/frontend/dist/assets/stateDiagram-v2-QKLJ7IA2-BfZc0BCX.js +1 -0
  89. package/payload/frontend/dist/assets/status-B__OyRqS.js +1 -0
  90. package/payload/frontend/dist/assets/suiji-nz2Jwmpc.js +1 -0
  91. package/payload/frontend/dist/assets/svg-BpvEgtI3.js +21 -0
  92. package/payload/frontend/dist/assets/tabs-BjCNEXFA.js +1 -0
  93. package/payload/frontend/dist/assets/timeline-definition-GMOUNBTQ-CHT2sQNz.js +120 -0
  94. package/payload/frontend/dist/assets/ts.worker-59MjiAqk.js +67731 -0
  95. package/payload/frontend/dist/assets/vennDiagram-DHZGUBPP-Bvnh5q4I.js +34 -0
  96. package/payload/frontend/dist/assets/wardley-RL74JXVD-DeQXKB6h.js +162 -0
  97. package/payload/frontend/dist/assets/wardleyDiagram-NUSXRM2D-jWo5oC0L.js +20 -0
  98. package/payload/frontend/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  99. package/payload/frontend/dist/assets/xychartDiagram-5P7HB3ND-Btji0cq9.js +7 -0
  100. package/payload/frontend/dist/build-info.json +8 -0
  101. package/payload/frontend/dist/companion.html +15 -0
  102. package/payload/frontend/dist/icons/apple-touch-icon.png +0 -0
  103. package/payload/frontend/dist/icons/icon-192.png +0 -0
  104. package/payload/frontend/dist/icons/icon-512.png +0 -0
  105. package/payload/frontend/dist/icons/maskable-icon-512.png +0 -0
  106. package/payload/frontend/dist/index.html +19 -0
  107. package/payload/frontend/dist/manifest.webmanifest +28 -0
  108. package/payload/frontend/dist/sw.js +1 -0
  109. package/payload/frontend/dist/workbox-632bafe5.js +1 -0
  110. package/payload/server/index.cjs +8620 -0
  111. package/payload/start.mjs +50 -0
  112. package/README.md +0 -3
@@ -0,0 +1,600 @@
1
+ #!/usr/bin/env python3
2
+ """Two shared simulator slots with durable, safely reclaimable task ownership."""
3
+ import argparse
4
+ from contextlib import contextmanager
5
+ from datetime import datetime, timezone
6
+ import fcntl
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import urllib.request
17
+ import uuid
18
+ import runpy
19
+ recycled_group = runpy.run_path(str(Path(__file__).with_name("process_group.py")))["recycled_group"]
20
+
21
+ BASE = Path.home() / ".runweave"
22
+ APPS = {"runweave": "com.runweave.app.native", "suiji": "com.runweave.suiji"}
23
+ IDLE_SECONDS = 30 * 60
24
+
25
+
26
+ class PoolError(RuntimeError):
27
+ def __init__(self, code, detail, exit_code=4):
28
+ super().__init__(detail)
29
+ self.code, self.exit_code = code, exit_code
30
+
31
+
32
+ def fail(code, detail, exit_code=4):
33
+ raise PoolError(code, detail, exit_code)
34
+
35
+
36
+ def now():
37
+ return datetime.now(timezone.utc).isoformat()
38
+
39
+
40
+ def capture(args, cwd=None):
41
+ result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, timeout=30)
42
+ if result.returncode:
43
+ fail("command_failed", result.stderr.strip() or result.stdout.strip(), 1)
44
+ return result.stdout.strip()
45
+
46
+
47
+ def read(path):
48
+ return json.loads(Path(path).read_text())
49
+
50
+
51
+ def write(path, value):
52
+ path = Path(path)
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
55
+ try:
56
+ with temporary.open("x") as stream:
57
+ os.chmod(temporary, 0o600)
58
+ json.dump(value, stream, ensure_ascii=False, indent=2)
59
+ stream.write("\n")
60
+ stream.flush()
61
+ os.fsync(stream.fileno())
62
+ temporary.replace(path)
63
+ finally:
64
+ temporary.unlink(missing_ok=True)
65
+
66
+
67
+ def project(cwd=None):
68
+ root = Path(capture(["git", "rev-parse", "--show-toplevel"], cwd)).resolve()
69
+ common = Path(capture(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], root)).resolve()
70
+ return root, hashlib.sha256(os.fsencode(common)).hexdigest()
71
+
72
+
73
+ def registry(repository):
74
+ return BASE / "ios-simulators" / repository / "pool.json"
75
+
76
+
77
+ def udid_path(udid):
78
+ if not re.fullmatch(r"[A-Fa-f0-9-]{36}", udid):
79
+ fail("invalid_argument", "Expected a simulator UDID", 2)
80
+ return BASE / "native-device" / "locks" / udid.upper()
81
+
82
+
83
+ @contextmanager
84
+ def guard(name):
85
+ path = BASE / "ios-simulators" / "guards" / name
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ # Never unlink guard files: another process may already have this inode open.
88
+ with path.open("a+") as stream:
89
+ os.chmod(path, 0o600)
90
+ try:
91
+ fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
92
+ except BlockingIOError:
93
+ owner = owner_at(name) if re.fullmatch(r"[A-Fa-f0-9-]{36}", name) else None
94
+ fail("device_busy", json.dumps(owner, ensure_ascii=False) if owner else
95
+ "Another device or registry operation is in progress", 3)
96
+ try:
97
+ yield
98
+ finally:
99
+ fcntl.flock(stream, fcntl.LOCK_UN)
100
+
101
+
102
+ def devices():
103
+ data = json.loads(capture(["xcrun", "simctl", "list", "devices", "--json"]))
104
+ return {d["udid"]: {**d, "runtime": runtime}
105
+ for runtime, items in data["devices"].items() for d in items}
106
+
107
+
108
+ def check_external_runner(udid):
109
+ # Keep comm last: macOS truncates it when args follows, even for Xcode's standard path.
110
+ for line in capture(["ps", "-ww", "-axo", "pid=,comm="]).splitlines():
111
+ fields = line.strip().split(None, 1)
112
+ if len(fields) != 2 or Path(fields[1]).name not in ("xcodebuild", "xctest"):
113
+ continue
114
+ result = subprocess.run(["ps", "-ww", "-p", fields[0], "-o", "args="],
115
+ text=True, capture_output=True, timeout=10)
116
+ # The process may have exited since enumeration. Never match a shell's command text.
117
+ if result.returncode == 0 and udid.lower() in result.stdout.lower():
118
+ fail("device_busy", f"Existing native runner PID {fields[0]} targets {udid}; coordinate with its owner", 3)
119
+
120
+
121
+ def load_pool(repository):
122
+ try:
123
+ pool = read(registry(repository))
124
+ except FileNotFoundError:
125
+ fail("pool_unconfigured", "Adopt two existing devices before starting a task", 2)
126
+ except (ValueError, OSError):
127
+ fail("pool_unconfigured", "Unreadable pool registry; inspect it before adoption", 2)
128
+ if (pool.get("schemaVersion") != 1 or pool.get("repositoryId") != repository
129
+ or set(pool.get("slots", {})) != set(APPS)):
130
+ fail("pool_unconfigured", "Unknown or invalid pool schema", 2)
131
+ ids = [pool["slots"][app].get("udid") for app in APPS]
132
+ if not all(isinstance(value, str) for value in ids) or len(set(ids)) != 2:
133
+ fail("pool_unconfigured", "Pool must contain two different UDIDs", 2)
134
+ for value in ids:
135
+ udid_path(value)
136
+ return pool
137
+
138
+
139
+ def owner_at(udid):
140
+ path = udid_path(udid)
141
+ if not path.exists():
142
+ return None
143
+ try:
144
+ owner = read(path / "owner.json")
145
+ if not isinstance(owner, dict):
146
+ raise ValueError("invalid owner")
147
+ return owner
148
+ except (ValueError, OSError):
149
+ return {"code": "owner_identity_unknown"}
150
+
151
+
152
+ def identity(pid):
153
+ result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], text=True, capture_output=True)
154
+ return result.stdout.strip() if result.returncode == 0 else None
155
+
156
+
157
+ def group_alive(pgid):
158
+ try:
159
+ os.killpg(pgid, 0)
160
+ return True
161
+ except ProcessLookupError:
162
+ return False
163
+ except PermissionError:
164
+ return True
165
+
166
+
167
+ def unsettled(owner):
168
+ for child in owner.get("children", []):
169
+ if child.get("state") == "spawn_failed":
170
+ continue
171
+ if child.get("state") == "spawning" or not child.get("pid"):
172
+ return True
173
+ if group_alive(child["pid"]):
174
+ if child.get("state") == "exited" and recycled_group(child["pid"], child.get("startIdentity")):
175
+ continue
176
+ return True
177
+ return False
178
+
179
+
180
+ def idle_seconds(owner):
181
+ activity = [owner.get("lastActivityAt"), owner.get("startedAt"),
182
+ (owner.get("operation") or {}).get("endedAt")]
183
+ activity.extend(child.get("endedAt") for child in owner.get("children", []))
184
+ try:
185
+ latest = max(datetime.fromisoformat(value) for value in activity if value)
186
+ return max(0, (datetime.now(timezone.utc) - latest).total_seconds())
187
+ except (TypeError, ValueError):
188
+ return None
189
+
190
+
191
+ def active_operation(owner):
192
+ operation = owner.get("operation") or {}
193
+ return (not operation.get("endedAt") and operation.get("pid") and operation.get("startIdentity")
194
+ and identity(operation["pid"]) == operation.get("startIdentity"))
195
+
196
+
197
+ def save_owner(owner):
198
+ current = owner_at(owner["udid"])
199
+ if not current or current.get("lease") != owner["lease"]:
200
+ fail("lease_mismatch", "The device no longer belongs to this task")
201
+ write(udid_path(owner["udid"]) / "owner.json", owner)
202
+
203
+
204
+ def task(root, app=None, udid=None, allow_finished=False):
205
+ root = Path(root).resolve()
206
+ try:
207
+ lease = read(root / "simulator-lease.json")
208
+ except (OSError, ValueError):
209
+ fail("lease_mismatch", "Start a simulator task before using this task directory", 2)
210
+ worktree, repository = project(root)
211
+ if (lease.get("schemaVersion") != 1 or lease.get("repositoryId") != repository
212
+ or lease.get("worktree") != str(worktree) or lease.get("taskDir") != str(root)):
213
+ fail("lease_mismatch", "Task directory does not match its worktree/project")
214
+ if app and app not in (lease["app"], APPS[lease["app"]]):
215
+ fail("lease_mismatch", "App does not match the task", 2)
216
+ if udid and udid.upper() != lease["udid"]:
217
+ fail("unmanaged_simulator", "UDID does not match the task's fixed slot", 2)
218
+ if allow_finished and lease.get("finishedAt"):
219
+ return lease
220
+ pool = load_pool(repository)
221
+ slot = lease.get("slot", lease["app"])
222
+ if slot not in APPS or pool["slots"][slot]["udid"] != lease["udid"]:
223
+ fail("lease_mismatch", "Task's device is no longer the registered slot")
224
+ owner = owner_at(lease["udid"])
225
+ if not owner or owner.get("lease") != lease["lease"] or any(
226
+ owner.get(key) != lease.get(key) for key in ("worktree", "repositoryId", "taskDir", "app", "slot")):
227
+ fail("lease_mismatch", "Task does not own this device")
228
+ if owner.get("finishedAt") and not allow_finished:
229
+ fail("lease_mismatch", "Task has already finished")
230
+ return owner
231
+
232
+
233
+ class Operation:
234
+ def __init__(self, owner):
235
+ self.owner = owner
236
+
237
+ def run(self, args, *, env=None, cwd=None, capture_output=False, timeout=None):
238
+ # Keep the safety boundary here: installed skill wrappers also use this
239
+ # operation, even when they predate the graceful shutdown protocol.
240
+ if args[:3] == ["agent-device", "daemon", "stop"] and "--clean" in args:
241
+ root = Path(self.owner["taskDir"])
242
+ state_index = args.index("--state-dir") + 1 if "--state-dir" in args else len(args)
243
+ if state_index >= len(args) or Path(args[state_index]).resolve() != root / "state":
244
+ fail("lease_mismatch", "Daemon cleanup must target this task's state directory")
245
+ if capture(["agent-device", "--version"]) != "0.21.3":
246
+ fail("cleanup_incomplete", "Cleanup requires agent-device 0.21.3")
247
+ drain_runner(root, self.owner, env or os.environ)
248
+ child = {"program": Path(args[0]).name, "state": "spawning", "startedAt": now()}
249
+ self.owner.setdefault("children", []).append(child)
250
+ save_owner(self.owner)
251
+ # No arguments persisted here: UI text and credentials must not enter ownership records.
252
+ try:
253
+ process = subprocess.Popen(args, cwd=cwd, env=env, start_new_session=True,
254
+ stdout=subprocess.PIPE if capture_output else None,
255
+ stderr=subprocess.STDOUT if capture_output else None, text=True)
256
+ except OSError:
257
+ child.update(state="spawn_failed", endedAt=now())
258
+ save_owner(self.owner)
259
+ raise
260
+ child.update(state="running", pid=process.pid, startIdentity=identity(process.pid))
261
+ save_owner(self.owner)
262
+ try:
263
+ output, _ = process.communicate(timeout=timeout)
264
+ except subprocess.TimeoutExpired:
265
+ # Do not pretend the operation or its descendants have ended.
266
+ fail("cleanup_incomplete", "Owned command timed out; inspect its recorded process group")
267
+ child.update(state="exited", exit=process.returncode, endedAt=now())
268
+ save_owner(self.owner)
269
+ return subprocess.CompletedProcess(args, process.returncode, output, None)
270
+
271
+
272
+ @contextmanager
273
+ def operation(root, app=None, udid=None):
274
+ initial = task(root, app, udid)
275
+ with guard(initial["udid"]):
276
+ owner = task(root, app, udid)
277
+ if unsettled(owner):
278
+ fail("cleanup_incomplete", "A prior operation still has live or unknown children")
279
+ owner["operation"] = {"pid": os.getpid(), "startIdentity": identity(os.getpid()), "startedAt": now()}
280
+ owner["lastActivityAt"] = now()
281
+ save_owner(owner)
282
+ op = Operation(owner)
283
+ try:
284
+ yield op
285
+ finally:
286
+ owner["operation"]["endedAt"] = now()
287
+ owner["lastActivityAt"] = now()
288
+ save_owner(owner)
289
+
290
+
291
+ def adopt(runweave, suiji):
292
+ _, repository = project()
293
+ selected = {"runweave": runweave.upper(), "suiji": suiji.upper()}
294
+ if len(set(selected.values())) != 2:
295
+ fail("invalid_argument", "Choose two different existing simulators", 2)
296
+ available = devices()
297
+ with guard(repository):
298
+ path = registry(repository)
299
+ previous = load_pool(repository) if path.exists() else None
300
+ all_ids = set(selected.values()) | ({s["udid"] for s in previous["slots"].values()} if previous else set())
301
+ from contextlib import ExitStack
302
+ with ExitStack() as stack:
303
+ for udid in sorted(all_ids):
304
+ udid_path(udid)
305
+ stack.enter_context(guard(udid))
306
+ if owner_at(udid):
307
+ fail("device_busy", f"{udid} has an owner; adoption refused", 3)
308
+ slots = {}
309
+ for app, udid in selected.items():
310
+ device = available.get(udid)
311
+ if not device or not device.get("isAvailable"):
312
+ fail("pool_device_missing", f"Unavailable simulator: {udid}", 2)
313
+ check_external_runner(udid)
314
+ slots[app] = {"udid": udid, "runtime": device["runtime"]}
315
+ if previous:
316
+ write(path.with_suffix(".previous.json"), previous)
317
+ pool = {"schemaVersion": 1, "repositoryId": repository, "slots": slots}
318
+ write(path, pool)
319
+ return pool
320
+
321
+
322
+ def start(app, root, slot=None):
323
+ worktree, repository = project()
324
+ root = Path(root).resolve()
325
+ if not root.is_relative_to(worktree) or root == worktree:
326
+ fail("invalid_argument", "Task directory must be inside the current worktree", 2)
327
+ anchor = root
328
+ while not anchor.exists():
329
+ anchor = anchor.parent
330
+ if project(anchor)[0] != worktree:
331
+ fail("lease_mismatch", "Task path belongs to another linked worktree", 2)
332
+ if root.exists():
333
+ fail("invalid_argument", "Use a new task directory; existing tasks are never overwritten", 2)
334
+ with guard(repository):
335
+ pool = load_pool(repository)
336
+ choices = [slot] if slot else [app, next(name for name in APPS if name != app)]
337
+ available = devices()
338
+
339
+ def reserve(name):
340
+ udid = pool["slots"][name]["udid"]
341
+ with guard(udid):
342
+ device = available.get(udid)
343
+ if not device or not device.get("isAvailable") or owner_at(udid):
344
+ return None
345
+ check_external_runner(udid)
346
+ if root.exists():
347
+ fail("invalid_argument", "Use a new task directory; existing tasks are never overwritten", 2)
348
+ owner = {"schemaVersion": 1, "kind": "simulator-pool", "lease": uuid.uuid4().hex,
349
+ "repositoryId": repository, "worktree": str(worktree), "app": app, "slot": name,
350
+ "udid": udid, "taskDir": str(root), "startedAt": now(), "lastActivityAt": now(),
351
+ "children": [], "automationPending": False}
352
+ try:
353
+ udid_path(udid).mkdir(parents=True)
354
+ except FileExistsError:
355
+ fail("device_busy", "Another device tool owns this UDID", 3)
356
+ # A failure after mkdir deliberately leaves unknown ownership, never silently unlocks.
357
+ write(udid_path(udid) / "owner.json", owner)
358
+ root.mkdir(parents=True)
359
+ write(root / "simulator-lease.json", owner)
360
+ return owner
361
+
362
+ for name in choices:
363
+ try:
364
+ owner = reserve(name)
365
+ if owner:
366
+ return owner
367
+ except PoolError as error:
368
+ if error.code != "device_busy":
369
+ raise
370
+
371
+ for name in choices:
372
+ udid = pool["slots"][name]["udid"]
373
+ current = owner_at(udid)
374
+ age = idle_seconds(current) if current else None
375
+ if age is None or age < IDLE_SECONDS or active_operation(current) or unsettled(current):
376
+ continue
377
+ try:
378
+ finish(current["taskDir"], recovering=True, idle_lease=current["lease"])
379
+ except (KeyError, PoolError):
380
+ # Unknown ownership or incomplete cleanup must keep the old lease.
381
+ continue
382
+ try:
383
+ owner = reserve(name)
384
+ if owner:
385
+ return owner
386
+ except PoolError as error:
387
+ if error.code != "device_busy":
388
+ raise
389
+ occupied = {name: owner_at(pool["slots"][name]["udid"]) for name in choices}
390
+ if any(occupied.values()):
391
+ fail("device_busy", json.dumps(occupied, ensure_ascii=False), 3)
392
+ fail("pool_device_missing", "No registered simulator is available", 2)
393
+
394
+
395
+ def drain_runner(root, owner, env):
396
+ """Finish XCTest before agent-device 0.21.3's --clean signals its process tree.
397
+
398
+ This pinned runner protocol shuts down the test, not the simulator. Never
399
+ send to a port without checking its durable task and process identities.
400
+ """
401
+ lease_dir = Path(env.get("AGENT_DEVICE_IOS_RUNNER_LEASE_DIR") or
402
+ Path.home() / ".agent-device/apple-runner/leases")
403
+ path = lease_dir / f"{owner['udid']}.json"
404
+ report = {"at": now(), "udid": owner["udid"], "status": "blocked"}
405
+ try:
406
+ if not path.exists():
407
+ check_external_runner(owner["udid"])
408
+ report["status"] = "no_runner_lease"
409
+ return
410
+ lease = read(path)
411
+ if (not isinstance(lease, dict) or lease.get("schemaVersion") != 1 or lease.get("deviceId") != owner["udid"]
412
+ or lease.get("ownerStateDir") != str(root / "state")):
413
+ raise ValueError("Runner lease does not belong to this task")
414
+ pid, started = lease.get("runnerPid"), lease.get("runnerStartTime")
415
+ if type(pid) is not int or pid <= 0 or not isinstance(started, str) or not started:
416
+ raise ValueError("Runner process identity is incomplete")
417
+ current = identity(pid)
418
+ if current is None:
419
+ check_external_runner(owner["udid"])
420
+ report["status"] = "already_exited"
421
+ return
422
+ if current != started:
423
+ raise ValueError("Runner PID has been reused")
424
+ args = capture(["ps", "-ww", "-p", str(pid), "-o", "args="])
425
+ xctestrun = lease.get("xctestrunPath")
426
+ if ("xcodebuild" not in args or "AgentDeviceRunner" not in args
427
+ or owner["udid"] not in args or not isinstance(xctestrun, str)
428
+ or not xctestrun or xctestrun not in args):
429
+ raise ValueError("Runner command does not match its lease")
430
+ port = lease.get("port")
431
+ if type(port) is not int or not 0 < port < 65536:
432
+ raise ValueError("Invalid runner port")
433
+ report.update(runnerPid=pid, runnerStartTime=started)
434
+ request = urllib.request.Request(
435
+ f"http://127.0.0.1:{port}/command",
436
+ data=json.dumps({"command": "shutdown", "commandId": "pool-" + uuid.uuid4().hex}).encode(),
437
+ headers={"Content-Type": "application/json"})
438
+ # Loopback must never inherit an HTTP proxy from the user's shell.
439
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
440
+ with opener.open(request, timeout=10) as response:
441
+ data = json.load(response)
442
+ if not isinstance(data, dict) or data.get("ok") is not True:
443
+ raise ValueError("Runner refused graceful shutdown")
444
+ deadline = time.monotonic() + 15
445
+ while identity(pid) == started:
446
+ if time.monotonic() >= deadline:
447
+ raise TimeoutError("XCTest did not exit after graceful shutdown")
448
+ time.sleep(0.1)
449
+ report["status"] = "graceful_exit"
450
+ except (OSError, ValueError, PoolError) as error:
451
+ report.update(status="blocked", error=str(error))
452
+ fail("cleanup_incomplete", f"Runner shutdown blocked; keep lease and inspect {root}/runner-shutdown.json")
453
+ finally:
454
+ write(root / "runner-shutdown.json", report)
455
+
456
+
457
+ def finish(root, recovering=False, idle_lease=None, keep_booted=False, verify_owner=None):
458
+ initial = task(root, allow_finished=True)
459
+ with guard(initial["udid"]):
460
+ current = owner_at(initial["udid"])
461
+ if initial.get("finishedAt") and (not current or current.get("lease") != initial["lease"]):
462
+ return {"code": "already_finished", "lease": initial["lease"]}
463
+ owner = task(root, allow_finished=True)
464
+ if verify_owner:
465
+ verify_owner(owner)
466
+ if idle_lease and (owner["lease"] != idle_lease or active_operation(owner)
467
+ or (idle_seconds(owner) or 0) < IDLE_SECONDS):
468
+ fail("device_busy", "Lease is active or changed before idle recovery", 3)
469
+ if unsettled(owner):
470
+ fail("cleanup_incomplete", "A prior operation still has live or unknown children")
471
+ op = Operation(owner)
472
+ if owner.get("automationPending"):
473
+ version = capture(["agent-device", "--version"])
474
+ if version != "0.21.3":
475
+ fail("cleanup_incomplete", "Cleanup requires agent-device 0.21.3")
476
+ state = str(Path(root).resolve() / "state")
477
+ result = op.run(["agent-device", "daemon", "stop", "--state-dir", state, "--clean"],
478
+ capture_output=True, timeout=60)
479
+ write(Path(root) / "simulator-cleanup.json", {"at": now(), "exit": result.returncode, "output": result.stdout})
480
+ if result.returncode:
481
+ fail("cleanup_incomplete", f"Task daemon cleanup failed; see {root}/simulator-cleanup.json")
482
+ report = Path(state) / "daemon-shutdown.json"
483
+ if report.exists():
484
+ data = read(report)
485
+ if data.get("providerReleases", {}).get("pending") or data.get("claims", {}).get("orphaned"):
486
+ fail("cleanup_incomplete", "agent-device retained runner resources; inspect daemon-shutdown.json")
487
+ owner["automationPending"] = False
488
+ save_owner(owner)
489
+ if unsettled(owner):
490
+ fail("cleanup_incomplete", "Owned child process group has not exited")
491
+ check_external_runner(owner["udid"])
492
+ if not keep_booted:
493
+ from device_shutdown import shutdown_simulator
494
+ shutdown_simulator(owner["udid"])
495
+ owner["finishedAt"] = now()
496
+ owner["recovered"] = recovering
497
+ save_owner(owner)
498
+ write(Path(root) / "simulator-lease.json", owner)
499
+ shutil.rmtree(udid_path(initial["udid"]))
500
+ return {"code": "finished", "lease": initial["lease"], "udid": initial["udid"]}
501
+
502
+
503
+ def status():
504
+ root, repository = project()
505
+ pool = load_pool(repository)
506
+ available = devices()
507
+ slots = {}
508
+ for app, slot in pool["slots"].items():
509
+ owner = owner_at(slot["udid"])
510
+ device = available.get(slot["udid"])
511
+ active = owner and active_operation(owner)
512
+ state = "missing" if not device or not device.get("isAvailable") else (
513
+ "blocked" if owner and (owner.get("schemaVersion") != 1 or (unsettled(owner) and not active))
514
+ else "busy" if owner else "free")
515
+ age = idle_seconds(owner) if owner else None
516
+ slots[app] = {**slot, "state": state, "deviceState": device and device["state"],
517
+ "idleSeconds": round(age) if age is not None else None,
518
+ "idleExpired": bool(state == "busy" and age is not None and age >= IDLE_SECONDS and not active),
519
+ "owner": owner}
520
+ ids = {slot["udid"] for slot in slots.values()}
521
+ return {"repositoryId": repository, "worktree": str(root), "slots": slots,
522
+ "unmanagedDevices": [{"udid": d["udid"], "name": d["name"], "state": d["state"]}
523
+ for d in available.values() if d["udid"] not in ids]}
524
+
525
+
526
+ def main():
527
+ os.umask(0o077)
528
+ parser = argparse.ArgumentParser(description=__doc__)
529
+ sub = parser.add_subparsers(dest="command", required=True)
530
+ sub.add_parser("status").add_argument("--json", action="store_true")
531
+ adopt_parser = sub.add_parser("adopt")
532
+ adopt_parser.add_argument("--runweave", required=True)
533
+ adopt_parser.add_argument("--suiji", required=True)
534
+ adopt_parser.add_argument("--json", action="store_true")
535
+ start_parser = sub.add_parser("start")
536
+ start_parser.add_argument("--app", choices=APPS, required=True)
537
+ start_parser.add_argument("--task-dir", required=True)
538
+ start_parser.add_argument("--slot", choices=APPS, help="Explicitly borrow a registered slot; App identity is unchanged")
539
+ start_parser.add_argument("--json", action="store_true")
540
+ finish_parser = sub.add_parser("finish")
541
+ finish_parser.add_argument("--task-dir", required=True)
542
+ finish_parser.add_argument("--keep-booted", action="store_true")
543
+ finish_parser.add_argument("--json", action="store_true")
544
+ recover = sub.add_parser("recover")
545
+ recover.add_argument("--lease", required=True)
546
+ recover.add_argument("--udid", required=True)
547
+ recover.add_argument("--json", action="store_true")
548
+ for command in ("build", "run", "doctor"):
549
+ command_parser = sub.add_parser(command)
550
+ command_parser.add_argument("--app", choices=APPS, required=True)
551
+ command_parser.add_argument("--simulator")
552
+ command_parser.add_argument("--task-dir")
553
+ command_parser.add_argument("--configuration", choices=("Debug", "Profile", "Release"), default="Debug")
554
+ execute = sub.add_parser("exec", help="Run a simulator XCTest command inside an existing task")
555
+ execute.add_argument("--task-dir", required=True)
556
+ execute.add_argument("args", nargs=argparse.REMAINDER)
557
+ options = parser.parse_args()
558
+ if options.command == "status":
559
+ result = status()
560
+ elif options.command == "adopt":
561
+ result = adopt(options.runweave, options.suiji)
562
+ elif options.command == "start":
563
+ result = start(options.app, options.task_dir, options.slot)
564
+ elif options.command == "finish":
565
+ result = finish(options.task_dir, keep_booted=options.keep_booted)
566
+ elif options.command == "recover":
567
+ owner = owner_at(options.udid)
568
+ if not owner or owner.get("lease") != options.lease or owner.get("kind") != "simulator-pool":
569
+ fail("lease_mismatch", "Only the explicitly identified pool lease can be recovered")
570
+ result = finish(owner["taskDir"], recovering=True)
571
+ elif options.command == "exec":
572
+ args = options.args[1:] if options.args[:1] == ["--"] else options.args
573
+ with operation(options.task_dir) as op:
574
+ # Fixed XCTest entry: callers cannot override the managed destination or parallelism.
575
+ if not args or Path(args[0]).name != "xcodebuild" or any(
576
+ arg.startswith(("-destination", "-parallel-testing", "-maximum-concurrent-test", "-cloned")) for arg in args[1:]):
577
+ fail("invalid_argument", "exec accepts xcodebuild with no destination/parallelism overrides", 2)
578
+ args += ["-destination", f"platform=iOS Simulator,id={op.owner['udid']}",
579
+ "-parallel-testing-enabled", "NO", "-maximum-concurrent-test-simulator-destinations", "1"]
580
+ result = op.run(args, cwd=op.owner["worktree"])
581
+ if result.returncode:
582
+ fail("command_failed", f"xcodebuild exited {result.returncode}", 1)
583
+ result = {"code": "executed", "udid": op.owner["udid"]}
584
+ else:
585
+ from build import run_app
586
+ result = run_app(options)
587
+ print(json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2))
588
+
589
+
590
+ if __name__ == "__main__":
591
+ # build.py imports this module; keep one set of error and operation types.
592
+ sys.modules["pool"] = sys.modules[__name__]
593
+ try:
594
+ main()
595
+ except PoolError as error:
596
+ print(json.dumps({"ok": False, "code": error.code, "detail": str(error)}, ensure_ascii=False), file=sys.stderr)
597
+ sys.exit(error.exit_code)
598
+ except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
599
+ print(json.dumps({"ok": False, "code": "operation_failed", "detail": str(error)}, ensure_ascii=False), file=sys.stderr)
600
+ sys.exit(1)