@runweave/server-linux-arm64 0.0.0-stage → 0.1.0
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/manifest.json +1993 -0
- package/native.tar.gz +0 -0
- package/package.json +17 -4
- package/payload/app-server/build-info.json +8 -0
- package/payload/app-server/configuration-compatibility.json +1 -0
- package/payload/app-server/index.cjs +43560 -0
- package/payload/app-server/index.cjs.map +1 -0
- package/payload/backend/activity-sqlite-worker.cjs +6730 -0
- package/payload/backend/build-info.json +8 -0
- package/payload/backend/dev-resources/control.cjs +14727 -0
- package/payload/backend/evolution-sqlite-worker.cjs +2073 -0
- package/payload/backend/index.cjs +135608 -0
- package/payload/backend/ios-simulators/device_shutdown.py +22 -0
- package/payload/backend/ios-simulators/monitor.py +204 -0
- package/payload/backend/ios-simulators/pool.py +600 -0
- package/payload/backend/scheduled-tasks-sqlite-worker.cjs +29203 -0
- package/payload/bin/runtime.cjs +95 -0
- package/payload/frontend/dist/assets/_baseUniq-BJ9V9bG-.js +1 -0
- package/payload/frontend/dist/assets/access-DYFqfdy3.js +1 -0
- package/payload/frontend/dist/assets/advanced-C4Sx575S.js +1 -0
- package/payload/frontend/dist/assets/appearance-Bhq5haQK.js +1 -0
- package/payload/frontend/dist/assets/arc-Bihx3tKA.js +1 -0
- package/payload/frontend/dist/assets/architectureDiagram-Q4EWVU46-63rNkvm4.js +36 -0
- package/payload/frontend/dist/assets/blockDiagram-DXYQGD6D-BlolQExt.js +132 -0
- package/payload/frontend/dist/assets/browser-CIEzmzuD.js +8 -0
- package/payload/frontend/dist/assets/c4Diagram-AHTNJAMY-DJtJ952u.js +10 -0
- package/payload/frontend/dist/assets/channel-Mm4Vb-2P.js +1 -0
- package/payload/frontend/dist/assets/chunk-4BX2VUAB-ChncjhEG.js +1 -0
- package/payload/frontend/dist/assets/chunk-4TB4RGXK-C6ipd8Rb.js +206 -0
- package/payload/frontend/dist/assets/chunk-55IACEB6-D9P1TLAl.js +1 -0
- package/payload/frontend/dist/assets/chunk-EDXVE4YY-NqBFXrAb.js +1 -0
- package/payload/frontend/dist/assets/chunk-FMBD7UC4-DDP02KGl.js +15 -0
- package/payload/frontend/dist/assets/chunk-OYMX7WX6-BH7IIwJs.js +231 -0
- package/payload/frontend/dist/assets/chunk-QZHKN3VN-BQs-0fGA.js +1 -0
- package/payload/frontend/dist/assets/chunk-YZCP3GAM-BjN5B1FZ.js +1 -0
- package/payload/frontend/dist/assets/classDiagram-6PBFFD2Q-BiLH0Poy.js +1 -0
- package/payload/frontend/dist/assets/classDiagram-v2-HSJHXN6E-BiLH0Poy.js +1 -0
- package/payload/frontend/dist/assets/clone-qIEEEAzl.js +1 -0
- package/payload/frontend/dist/assets/companion-BLgWW1ka.css +1 -0
- package/payload/frontend/dist/assets/companion-CRS3Tmyt.js +2 -0
- package/payload/frontend/dist/assets/connection-BK8OyohU.js +12 -0
- package/payload/frontend/dist/assets/connections-BMlDGuxr.js +1 -0
- package/payload/frontend/dist/assets/cose-bilkent-S5V4N54A-NPOCJBiA.js +1 -0
- package/payload/frontend/dist/assets/css.worker-B4z49cGk.js +93 -0
- package/payload/frontend/dist/assets/cytoscape.esm-DxGcaOPV.js +331 -0
- package/payload/frontend/dist/assets/dagre-KV5264BT-COIvyDWn.js +4 -0
- package/payload/frontend/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/payload/frontend/dist/assets/desktop-ByVdibdg.js +1 -0
- package/payload/frontend/dist/assets/diagram-5BDNPKRD-S_8jRdqp.js +10 -0
- package/payload/frontend/dist/assets/diagram-G4DWMVQ6-C42mxsNM.js +24 -0
- package/payload/frontend/dist/assets/diagram-MMDJMWI5-BJM6otf0.js +43 -0
- package/payload/frontend/dist/assets/diagram-TYMM5635-By5p6aCA.js +24 -0
- package/payload/frontend/dist/assets/editor.worker-DyuDvpVp.js +26 -0
- package/payload/frontend/dist/assets/erDiagram-SMLLAGMA-U0Ye_786.js +85 -0
- package/payload/frontend/dist/assets/flowDiagram-DWJPFMVM-CUkOR4uD.js +162 -0
- package/payload/frontend/dist/assets/ganttDiagram-T4ZO3ILL-CRlDiioi.js +292 -0
- package/payload/frontend/dist/assets/gitGraphDiagram-UUTBAWPF-DV4Ea1j2.js +106 -0
- package/payload/frontend/dist/assets/graph-Bn4s-NIu.js +1 -0
- package/payload/frontend/dist/assets/html.worker-DtiGdgqp.js +470 -0
- package/payload/frontend/dist/assets/image-0wCxVgo2.js +1 -0
- package/payload/frontend/dist/assets/index-BJDOs8Sk.css +1 -0
- package/payload/frontend/dist/assets/index-D9-cSGu-.js +49 -0
- package/payload/frontend/dist/assets/index-De2lxGIa.js +455 -0
- package/payload/frontend/dist/assets/infoDiagram-42DDH7IO-ut--ZHxq.js +2 -0
- package/payload/frontend/dist/assets/init-Gi6I4Gst.js +1 -0
- package/payload/frontend/dist/assets/ishikawaDiagram-UXIWVN3A-DbJ9qouY.js +70 -0
- package/payload/frontend/dist/assets/journeyDiagram-VCZTEJTY-CZ5a4nqq.js +139 -0
- package/payload/frontend/dist/assets/json.worker-leyajbqV.js +58 -0
- package/payload/frontend/dist/assets/kanban-definition-6JOO6SKY-CagFKide.js +89 -0
- package/payload/frontend/dist/assets/katex-DkKDou_j.js +257 -0
- package/payload/frontend/dist/assets/layout-DJ8E_yHX.js +1 -0
- package/payload/frontend/dist/assets/linear-Dn6DMk6q.js +1 -0
- package/payload/frontend/dist/assets/main-C-ZLrzMh.css +33 -0
- package/payload/frontend/dist/assets/main-T426svZw.js +693 -0
- package/payload/frontend/dist/assets/min-DnGEZSmi.js +1 -0
- package/payload/frontend/dist/assets/mindmap-definition-QFDTVHPH-Dy5F9V5f.js +96 -0
- package/payload/frontend/dist/assets/monitoring-BdNkTyog.js +1 -0
- package/payload/frontend/dist/assets/notifications-incoktxl.js +1 -0
- package/payload/frontend/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/payload/frontend/dist/assets/pieDiagram-DEJITSTG-yXPGxeUi.js +30 -0
- package/payload/frontend/dist/assets/quadrantDiagram-34T5L4WZ-D4cCRjRq.js +7 -0
- package/payload/frontend/dist/assets/record-content-D1WJhPcw.js +1 -0
- package/payload/frontend/dist/assets/requirementDiagram-MS252O5E-mq9zSUQY.js +84 -0
- package/payload/frontend/dist/assets/sankeyDiagram-XADWPNL6-CLpI-tDz.js +10 -0
- package/payload/frontend/dist/assets/sequenceDiagram-FGHM5R23-B-T8p6AJ.js +157 -0
- package/payload/frontend/dist/assets/shortcuts-BNrV46nh.js +1 -0
- package/payload/frontend/dist/assets/stateDiagram-FHFEXIEX-l2_t6o5K.js +1 -0
- package/payload/frontend/dist/assets/stateDiagram-v2-QKLJ7IA2-BXh3ccaZ.js +1 -0
- package/payload/frontend/dist/assets/status-CP5f7C2Y.js +1 -0
- package/payload/frontend/dist/assets/suiji-BAD1Bzfb.js +1 -0
- package/payload/frontend/dist/assets/svg-G8v0TLQu.js +21 -0
- package/payload/frontend/dist/assets/tabs-CuKPIDlT.js +1 -0
- package/payload/frontend/dist/assets/timeline-definition-GMOUNBTQ-CSaExDd9.js +120 -0
- package/payload/frontend/dist/assets/ts.worker-59MjiAqk.js +67731 -0
- package/payload/frontend/dist/assets/vennDiagram-DHZGUBPP-x-fCoh_Q.js +34 -0
- package/payload/frontend/dist/assets/wardley-RL74JXVD-B4O75ZAB.js +162 -0
- package/payload/frontend/dist/assets/wardleyDiagram-NUSXRM2D-CdXssvxH.js +20 -0
- package/payload/frontend/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/payload/frontend/dist/assets/xychartDiagram-5P7HB3ND-DwTtw7p0.js +7 -0
- package/payload/frontend/dist/build-info.json +8 -0
- package/payload/frontend/dist/companion.html +15 -0
- package/payload/frontend/dist/icons/apple-touch-icon.png +0 -0
- package/payload/frontend/dist/icons/icon-192.png +0 -0
- package/payload/frontend/dist/icons/icon-512.png +0 -0
- package/payload/frontend/dist/icons/maskable-icon-512.png +0 -0
- package/payload/frontend/dist/index.html +19 -0
- package/payload/frontend/dist/manifest.webmanifest +28 -0
- package/payload/frontend/dist/sw.js +1 -0
- package/payload/frontend/dist/workbox-632bafe5.js +1 -0
- package/payload/server/index.cjs +8620 -0
- package/payload/start.mjs +50 -0
- 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)
|