preflite 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +144 -0
  3. package/dist/adapter-spi/artifact/index.js +1 -0
  4. package/dist/adapter-spi/command/index.js +1 -0
  5. package/dist/adapter-spi/install/index.js +1 -0
  6. package/dist/adapter-spi/lifecycle/index.js +1 -0
  7. package/dist/adapter-spi/resource/index.js +1 -0
  8. package/dist/adapter-spi/snapshot/index.js +1 -0
  9. package/dist/application/agent/AgentCommandPollLoop.js +48 -0
  10. package/dist/application/agent/AgentRuntimeService.js +27 -0
  11. package/dist/application/app-package/AppPackageApplicationService.js +97 -0
  12. package/dist/application/artifact/ArtifactApplicationService.js +13 -0
  13. package/dist/application/debug/DebugApplicationService.js +117 -0
  14. package/dist/application/health/HealthMetricsService.js +47 -0
  15. package/dist/application/lease/LeaseApplicationService.js +79 -0
  16. package/dist/application/query/ObservationQueryService.js +48 -0
  17. package/dist/application/reporter/ReporterApplicationService.js +41 -0
  18. package/dist/application/resource/ResourceOccupationReleaseService.js +49 -0
  19. package/dist/application/resource/ResourceRegistryService.js +113 -0
  20. package/dist/application/session/SessionApplicationService.js +39 -0
  21. package/dist/application/task/TaskApplicationService.js +378 -0
  22. package/dist/client/agentAppPackageClient.js +91 -0
  23. package/dist/domain/agent/AgentNode.js +12 -0
  24. package/dist/domain/agent/AgentRuntime.js +6 -0
  25. package/dist/domain/artifact/ArtifactPipeline.js +6 -0
  26. package/dist/domain/artifact/ArtifactRef.js +12 -0
  27. package/dist/domain/event/AgentEvent.js +10 -0
  28. package/dist/domain/event/Reporter.js +6 -0
  29. package/dist/domain/health/HealthMetrics.js +8 -0
  30. package/dist/domain/lease/Lease.js +28 -0
  31. package/dist/domain/lease/LeaseManager.js +6 -0
  32. package/dist/domain/repositories/index.js +1 -0
  33. package/dist/domain/resource/DeviceDetails.js +23 -0
  34. package/dist/domain/resource/DeviceResource.js +16 -0
  35. package/dist/domain/resource/ResourceRegistry.js +6 -0
  36. package/dist/domain/runtime/interfaces.js +1 -0
  37. package/dist/domain/session/BaseSession.js +16 -0
  38. package/dist/domain/session/DebugSession.js +3 -0
  39. package/dist/domain/session/ExecutionSession.js +3 -0
  40. package/dist/domain/session/SessionManager.js +8 -0
  41. package/dist/domain/task/TaskRecord.js +14 -0
  42. package/dist/domain/task/TaskSpec.js +12 -0
  43. package/dist/infrastructure/adapters/AdapterRegistry.js +10 -0
  44. package/dist/infrastructure/adapters/BridgeAdapters.js +6 -0
  45. package/dist/infrastructure/adapters/android/AndroidResourceAdapter.js +51 -0
  46. package/dist/infrastructure/adapters/deviceDetailsProbe.js +229 -0
  47. package/dist/infrastructure/adapters/harmony/HarmonyResourceAdapter.js +40 -0
  48. package/dist/infrastructure/adapters/ios/IOSResourceAdapter.js +182 -0
  49. package/dist/infrastructure/adapters/real/ShellCommandAndSnapshot.js +41 -0
  50. package/dist/infrastructure/airtest/AirtestRuntime.js +168 -0
  51. package/dist/infrastructure/app-package/AppPackageUrlCache.js +191 -0
  52. package/dist/infrastructure/app-package/appPackageDownloadDir.js +13 -0
  53. package/dist/infrastructure/bootstrap/BuildRuntimeContext.js +88 -0
  54. package/dist/infrastructure/cache/DirCapacityWatchdog.js +150 -0
  55. package/dist/infrastructure/config/agentConfigFile.js +96 -0
  56. package/dist/infrastructure/device/DeviceAppPackageOps.js +146 -0
  57. package/dist/infrastructure/ios/IOSWdaWatchdog.js +207 -0
  58. package/dist/infrastructure/live-debug/LiveDebugSessionManager.js +74 -0
  59. package/dist/infrastructure/live-debug/RuntimeLiveDebugAdapters.js +19 -0
  60. package/dist/infrastructure/midscene/DebugRuntimeImpl.js +533 -0
  61. package/dist/infrastructure/midscene/MidsceneRuntimeMock.js +22 -0
  62. package/dist/infrastructure/midscene/MidsceneRuntimeReal.js +552 -0
  63. package/dist/infrastructure/midscene/executionDumpWatcher.js +219 -0
  64. package/dist/infrastructure/midscene/videoRecorder.js +365 -0
  65. package/dist/infrastructure/midscene/zipReportDir.js +36 -0
  66. package/dist/infrastructure/persistence/InMemoryRepositories.js +94 -0
  67. package/dist/infrastructure/resilience/DeliveryIdDeduper.js +26 -0
  68. package/dist/infrastructure/system/CommandRunner.js +128 -0
  69. package/dist/infrastructure/transport/http/AgentEventHttpIngestClient.js +52 -0
  70. package/dist/infrastructure/transport/http/CallbackOutboxStore.js +106 -0
  71. package/dist/infrastructure/transport/http/PlatformCallbackClient.js +113 -0
  72. package/dist/infrastructure/transport/http/PlatformCommandPollClient.js +89 -0
  73. package/dist/infrastructure/transport/http/ResilientPlatformCallbackClient.js +117 -0
  74. package/dist/infrastructure/transport/midscenePaths.js +28 -0
  75. package/dist/infrastructure/transport/ws/ResilientWsOrHttpEventPublisher.js +29 -0
  76. package/dist/infrastructure/transport/ws/WsClient.js +182 -0
  77. package/dist/infrastructure/transport/ws/WsEventPublisher.js +36 -0
  78. package/dist/interfaces/http/HttpServer.js +227 -0
  79. package/dist/interfaces/websocket/AgentWsGateway.js +227 -0
  80. package/dist/main.js +368 -0
  81. package/dist/mcp/agentHttpClient.js +82 -0
  82. package/dist/mcp/agentRuntime.js +184 -0
  83. package/dist/mcp/cli.js +36 -0
  84. package/dist/mcp/doctor.js +124 -0
  85. package/dist/mcp/evidence.js +57 -0
  86. package/dist/mcp/exploration/index.js +129 -0
  87. package/dist/mcp/exploration/sessionManager.js +122 -0
  88. package/dist/mcp/exploration/tools-atomic.js +34 -0
  89. package/dist/mcp/exploration/tools-intelligent.js +33 -0
  90. package/dist/mcp/exploration/tools-session.js +276 -0
  91. package/dist/mcp/exploration/types.js +1 -0
  92. package/dist/mcp/flowStepEvents.js +114 -0
  93. package/dist/mcp/liveViewer.js +156 -0
  94. package/dist/mcp/reportReader.js +157 -0
  95. package/dist/mcp/runManager.js +161 -0
  96. package/dist/mcp/runSummary.js +72 -0
  97. package/dist/mcp/runtimeInstall.js +44 -0
  98. package/dist/mcp/server.js +260 -0
  99. package/dist/mcp/setup.js +185 -0
  100. package/dist/mcp/types.js +1 -0
  101. package/dist/mcp/userConfig.js +45 -0
  102. package/dist/mcp/visual-flow/codegen.js +576 -0
  103. package/dist/mcp/visual-flow/index.js +14 -0
  104. package/dist/mcp/visual-flow/types.js +5 -0
  105. package/dist/mcp/visual-flow/validate.js +617 -0
  106. package/dist/protocol-contracts/commands/envelope.js +24 -0
  107. package/dist/protocol-contracts/commands/index.js +1 -0
  108. package/dist/protocol-contracts/dto/index.js +1 -0
  109. package/dist/protocol-contracts/events/index.js +1 -0
  110. package/dist/protocol-contracts/queries/index.js +1 -0
  111. package/dist/shared-kernel/enums/index.js +70 -0
  112. package/dist/shared-kernel/errors/index.js +21 -0
  113. package/dist/shared-kernel/ids/index.js +18 -0
  114. package/dist/shared-kernel/time/index.js +3 -0
  115. package/dist/shared-kernel/value-objects/index.js +1 -0
  116. package/dist/utils/appPackageLocalPath.js +75 -0
  117. package/dist/utils/deviceResourceRouting.js +24 -0
  118. package/dist/utils/harmonyAgentDebugDevice.js +60 -0
  119. package/dist/utils/harmonyHdcDeviceId.js +134 -0
  120. package/dist/utils/iosAgentDebugDevice.js +72 -0
  121. package/dist/utils/iosMjpegCapture.js +90 -0
  122. package/dist/utils/liveDebugForegroundParse.js +71 -0
  123. package/dist/utils/midscene-device-session.js +353 -0
  124. package/dist/utils/midscene-task-cache-env.js +15 -0
  125. package/dist/utils/midsceneReportConstants.js +49 -0
  126. package/dist/utils/seedMidsceneTaskCache.js +61 -0
  127. package/dist/utils/task-runners/context/androidTaskRunnerContext.js +1 -0
  128. package/dist/utils/task-runners/context/harmonyTaskRunnerContext.js +1 -0
  129. package/dist/utils/task-runners/context/iosTaskRunnerContext.js +1 -0
  130. package/dist/utils/task-runners/runAndroidNativeAppTask.js +29 -0
  131. package/dist/utils/task-runners/runHarmonyNativeAppTask.js +36 -0
  132. package/dist/utils/task-runners/runIosNativeAppTask.js +30 -0
  133. package/dist/utils/task-runners/taskAppPackage.js +20 -0
  134. package/dist/utils/wrapper/resolveTaskRunnerImport.js +11 -0
  135. package/dist/utils/wrapper/wrapAndroidTaskScript.js +38 -0
  136. package/dist/utils/wrapper/wrapHarmonyTaskScript.js +42 -0
  137. package/dist/utils/wrapper/wrapIosTaskScript.js +30 -0
  138. package/package.json +46 -0
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ /** 显式路径;未设置时使用 `process.cwd()/config.yaml`。 */
5
+ export function resolveAgentConfigPath() {
6
+ const fromEnv = process.env.AGENT_CONFIG_PATH?.trim();
7
+ if (fromEnv)
8
+ return path.resolve(fromEnv);
9
+ return path.resolve(process.cwd(), "config.yaml");
10
+ }
11
+ function scalarToEnvString(value) {
12
+ if (value === null || value === undefined)
13
+ return undefined;
14
+ if (typeof value === "boolean")
15
+ return value ? "true" : "false";
16
+ if (typeof value === "number" && Number.isFinite(value))
17
+ return String(value);
18
+ if (typeof value === "string")
19
+ return value;
20
+ return undefined;
21
+ }
22
+ /** 仅接受顶层的标量键;嵌套对象会被跳过并打日志。 */
23
+ export function flattenYamlConfigToEnv(doc) {
24
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
25
+ return {};
26
+ const out = {};
27
+ for (const [k, raw] of Object.entries(doc)) {
28
+ const key = k.trim();
29
+ if (!key)
30
+ continue;
31
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
32
+ console.warn(`[config] 跳过非标量配置项(请改为顶层 UPPER_SNAKE_CASE): ${key}`);
33
+ continue;
34
+ }
35
+ if (Array.isArray(raw)) {
36
+ console.warn(`[config] 跳过数组配置项: ${key}`);
37
+ continue;
38
+ }
39
+ const s = scalarToEnvString(raw);
40
+ if (s === undefined)
41
+ continue;
42
+ out[key] = s;
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * 读取单一 `config.yaml`,将其中出现的键写入 `process.env`(覆盖已有值)。
48
+ * 文件不存在时静默跳过;解析失败时打错误并 `process.exit(1)`。
49
+ */
50
+ export function applyAgentConfigFileToProcessEnv() {
51
+ const configPath = resolveAgentConfigPath();
52
+ if (!fs.existsSync(configPath)) {
53
+ return;
54
+ }
55
+ let raw;
56
+ try {
57
+ raw = fs.readFileSync(configPath, "utf8");
58
+ }
59
+ catch (e) {
60
+ console.error(`[config] 无法读取配置文件: ${configPath}`, e);
61
+ process.exit(1);
62
+ }
63
+ let parsed;
64
+ try {
65
+ parsed = parseYaml(raw);
66
+ }
67
+ catch (e) {
68
+ console.error(`[config] YAML 解析失败: ${configPath}`, e);
69
+ process.exit(1);
70
+ }
71
+ const flat = flattenYamlConfigToEnv(parsed);
72
+ const n = Object.keys(flat).length;
73
+ for (const [k, v] of Object.entries(flat)) {
74
+ process.env[k] = v;
75
+ }
76
+ if (n > 0) {
77
+ console.info(`[config] 已从 ${configPath} 加载 ${n} 项环境变量`);
78
+ }
79
+ }
80
+ /**
81
+ * 若 `AGENT_HTTP_TOKEN` / `PLATFORM_WS_TOKEN` / `PLATFORM_AGENT_CALLBACK_TOKEN` 中仅部分填写
82
+ *(与平台共用同一 Bearer 时常见),将已填项回填到未填项,便于只配一处。
83
+ *
84
+ * 回填顺序:HTTP 缺省 ← WS → 回调;WS 缺省 ← HTTP → 回调;回调缺省 ← HTTP → WS。
85
+ */
86
+ export function applyMutualAgentAuthTokenFallbackToProcessEnv() {
87
+ const h = process.env.AGENT_HTTP_TOKEN?.trim() ?? "";
88
+ const w = process.env.PLATFORM_WS_TOKEN?.trim() ?? "";
89
+ const c = process.env.PLATFORM_AGENT_CALLBACK_TOKEN?.trim() ?? "";
90
+ if (!h && (w || c))
91
+ process.env.AGENT_HTTP_TOKEN = w || c;
92
+ if (!w && (h || c))
93
+ process.env.PLATFORM_WS_TOKEN = h || c;
94
+ if (!c && (h || w))
95
+ process.env.PLATFORM_AGENT_CALLBACK_TOKEN = h || w;
96
+ }
@@ -0,0 +1,146 @@
1
+ import { PlatformType } from "../../shared-kernel/enums/index.js";
2
+ import { buildAndroidAdbCliPrefix, buildHarmonyHdcShellPrefix, shSingleQuote } from "../adapters/deviceDetailsProbe.js";
3
+ /**
4
+ * 卸载 CLI 非零退出且输出表明「包未安装 / 不存在」时视为幂等成功(与手动多卸一次一致)。
5
+ * 用于单测与各端卸载容错;连接类错误仍应失败。
6
+ */
7
+ export function isUninstallTargetAbsentOk(platform, stdout, stderr) {
8
+ const t = `${stdout}\n${stderr}`.toLowerCase();
9
+ switch (platform) {
10
+ case PlatformType.ANDROID:
11
+ return (t.includes("failure [not installed") ||
12
+ t.includes("unknown package") ||
13
+ t.includes("package unknown") ||
14
+ t.includes("delete_failed_unknown_package") ||
15
+ t.includes("package not installed"));
16
+ case PlatformType.IOS: {
17
+ if (t.includes("could not connect to lockdownd") ||
18
+ t.includes("could not start com.apple.mobile.installation_proxy") ||
19
+ t.includes("could not connect to device")) {
20
+ return false;
21
+ }
22
+ return (t.includes("not installed on the device") ||
23
+ t.includes("application not installed") ||
24
+ t.includes("app not installed") ||
25
+ (t.includes("uninstall") && t.includes("not installed")) ||
26
+ (t.includes("uninstall") && t.includes("not found")) ||
27
+ (t.includes("bundle identifier") && t.includes("not found")) ||
28
+ t.includes("application not found") ||
29
+ t.includes("no suitable application") ||
30
+ t.includes("appnotfound"));
31
+ }
32
+ case PlatformType.HARMONY:
33
+ return (t.includes("17700001") ||
34
+ t.includes("bundle name does not exist") ||
35
+ t.includes("specified bundle name is not found") ||
36
+ t.includes("the specified bundle name is not found") ||
37
+ t.includes("包不存在") ||
38
+ t.includes("应用未安装") ||
39
+ (t.includes("bundle") && t.includes("not found")) ||
40
+ (t.includes("bundle") && t.includes("does not exist")));
41
+ default:
42
+ return false;
43
+ }
44
+ }
45
+ export class DeviceAppPackageOps {
46
+ runner;
47
+ config;
48
+ constructor(runner, config = {}) {
49
+ this.runner = runner;
50
+ this.config = config;
51
+ }
52
+ ideviceinstaller() {
53
+ const exe = this.config.ideviceinstallerExe?.trim() || "ideviceinstaller";
54
+ return /[^\w@%+=:,./-]/.test(exe) || exe.includes(" ") ? `"${exe.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : exe;
55
+ }
56
+ async installAndroid(serial, apkPath, timeoutMs) {
57
+ const adb = buildAndroidAdbCliPrefix(this.config.adbHost, this.config.adbPort);
58
+ const cmd = `${adb} -s ${shSingleQuote(serial)} install -r ${shSingleQuote(apkPath)}`;
59
+ const res = await this.runner.run(cmd, timeoutMs);
60
+ if (!res.ok) {
61
+ throw new Error(`adb install failed (exit ${res.exitCode}): ${res.stderr || res.stdout}`);
62
+ }
63
+ }
64
+ async uninstallAndroid(serial, packageName, timeoutMs) {
65
+ const adb = buildAndroidAdbCliPrefix(this.config.adbHost, this.config.adbPort);
66
+ const cmd = `${adb} -s ${shSingleQuote(serial)} uninstall ${shSingleQuote(packageName)}`;
67
+ const res = await this.runner.run(cmd, timeoutMs);
68
+ if (!res.ok && !isUninstallTargetAbsentOk(PlatformType.ANDROID, res.stdout, res.stderr)) {
69
+ throw new Error(`adb uninstall failed (exit ${res.exitCode}): ${res.stderr || res.stdout}`);
70
+ }
71
+ }
72
+ async installIos(udid, ipaPath, timeoutMs) {
73
+ const exe = this.ideviceinstaller();
74
+ const cmd = `${exe} -u ${shSingleQuote(udid)} install ${shSingleQuote(ipaPath)}`;
75
+ const res = await this.runner.run(cmd, timeoutMs);
76
+ if (!res.ok) {
77
+ throw new Error(`ideviceinstaller install failed (exit ${res.exitCode}): ${res.stderr || res.stdout}`);
78
+ }
79
+ }
80
+ async uninstallIos(udid, bundleId, timeoutMs) {
81
+ const exe = this.ideviceinstaller();
82
+ const cmd = `${exe} -u ${shSingleQuote(udid)} uninstall ${shSingleQuote(bundleId)}`;
83
+ const res = await this.runner.run(cmd, timeoutMs);
84
+ if (!res.ok && !isUninstallTargetAbsentOk(PlatformType.IOS, res.stdout, res.stderr)) {
85
+ throw new Error(`ideviceinstaller uninstall failed (exit ${res.exitCode}): ${res.stderr || res.stdout}`);
86
+ }
87
+ }
88
+ async installHarmony(deviceId, hapPath, timeoutMs) {
89
+ const hdc = buildHarmonyHdcShellPrefix(this.config.harmonyHdcPath, this.config.harmonyHdcHost, this.config.harmonyHdcPort);
90
+ const cmd = `${hdc} -t ${shSingleQuote(deviceId)} install -r ${shSingleQuote(hapPath)}`;
91
+ const res = await this.runner.run(cmd, timeoutMs);
92
+ if (!res.ok) {
93
+ throw new Error(`hdc install failed (exit ${res.exitCode}): ${res.stderr || res.stdout}`);
94
+ }
95
+ }
96
+ async uninstallHarmony(deviceId, bundleId, timeoutMs) {
97
+ const hdc = buildHarmonyHdcShellPrefix(this.config.harmonyHdcPath, this.config.harmonyHdcHost, this.config.harmonyHdcPort);
98
+ const cmd = `${hdc} -t ${shSingleQuote(deviceId)} uninstall ${shSingleQuote(bundleId)}`;
99
+ const res = await this.runner.run(cmd, timeoutMs);
100
+ if (!res.ok && !isUninstallTargetAbsentOk(PlatformType.HARMONY, res.stdout, res.stderr)) {
101
+ throw new Error(`hdc uninstall failed (exit ${res.exitCode}): ${res.stderr || res.stdout}`);
102
+ }
103
+ }
104
+ async install(platform, paths, packagePath, timeoutMs) {
105
+ switch (platform) {
106
+ case PlatformType.ANDROID:
107
+ if (!paths.serial)
108
+ throw new Error("missing android serial");
109
+ await this.installAndroid(paths.serial, packagePath, timeoutMs);
110
+ return;
111
+ case PlatformType.IOS:
112
+ if (!paths.udid)
113
+ throw new Error("missing ios udid");
114
+ await this.installIos(paths.udid, packagePath, timeoutMs);
115
+ return;
116
+ case PlatformType.HARMONY:
117
+ if (!paths.deviceId)
118
+ throw new Error("missing harmony device id");
119
+ await this.installHarmony(paths.deviceId, packagePath, timeoutMs);
120
+ return;
121
+ default:
122
+ throw new Error(`unsupported platform for install: ${platform}`);
123
+ }
124
+ }
125
+ async uninstall(platform, paths, bundleId, timeoutMs) {
126
+ switch (platform) {
127
+ case PlatformType.ANDROID:
128
+ if (!paths.serial)
129
+ throw new Error("missing android serial");
130
+ await this.uninstallAndroid(paths.serial, bundleId, timeoutMs);
131
+ return;
132
+ case PlatformType.IOS:
133
+ if (!paths.udid)
134
+ throw new Error("missing ios udid");
135
+ await this.uninstallIos(paths.udid, bundleId, timeoutMs);
136
+ return;
137
+ case PlatformType.HARMONY:
138
+ if (!paths.deviceId)
139
+ throw new Error("missing harmony device id");
140
+ await this.uninstallHarmony(paths.deviceId, bundleId, timeoutMs);
141
+ return;
142
+ default:
143
+ throw new Error(`unsupported platform for uninstall: ${platform}`);
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,207 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ import { parseBootedSimulators, parseIOSDevices } from "../adapters/ios/IOSResourceAdapter.js";
5
+ function extractDeviceId(resourceLike) {
6
+ const idx = resourceLike.indexOf(":");
7
+ return idx >= 0 ? resourceLike.slice(idx + 1) : resourceLike;
8
+ }
9
+ function normalizePortRange(startRaw, endRaw) {
10
+ const fallbackStart = 8200;
11
+ const fallbackEnd = 8399;
12
+ const start = Number.isFinite(startRaw) && startRaw > 0 ? Math.floor(startRaw) : fallbackStart;
13
+ const end = Number.isFinite(endRaw) && endRaw > 0 ? Math.floor(endRaw) : fallbackEnd;
14
+ return start <= end ? { start, end } : { start: end, end: start };
15
+ }
16
+ function renderTemplate(template, udid, wdaPort) {
17
+ return template
18
+ .replaceAll("{resourceId}", `ios:${udid}`)
19
+ .replaceAll("{deviceId}", extractDeviceId(udid))
20
+ .replaceAll("{wdaPort}", String(wdaPort));
21
+ }
22
+ export class IOSWdaWatchdog {
23
+ commandRunner;
24
+ options;
25
+ timer = null;
26
+ pollInFlight = null;
27
+ running = false;
28
+ stopped = false;
29
+ pollCount = 0;
30
+ portsByUdid = new Map();
31
+ constructor(commandRunner, options) {
32
+ this.commandRunner = commandRunner;
33
+ this.options = options;
34
+ }
35
+ async start() {
36
+ if (this.running)
37
+ return;
38
+ this.running = true;
39
+ this.stopped = false;
40
+ console.info(`[IOSWdaWatchdog] start intervalMs=${this.options.intervalMs} portRange=${this.options.portRangeStart}-${this.options.portRangeEnd} stateFile=${this.options.stateFilePath}`);
41
+ await this.loadPortMapState();
42
+ await this.pollOnce();
43
+ this.timer = setInterval(() => {
44
+ void this.pollOnce();
45
+ }, this.options.intervalMs);
46
+ this.timer.unref();
47
+ }
48
+ async stop() {
49
+ if (!this.running || this.stopped)
50
+ return;
51
+ console.info("[IOSWdaWatchdog] stopping");
52
+ this.stopped = true;
53
+ this.running = false;
54
+ if (this.timer) {
55
+ clearInterval(this.timer);
56
+ this.timer = null;
57
+ }
58
+ try {
59
+ if (this.pollInFlight) {
60
+ await this.pollInFlight;
61
+ }
62
+ }
63
+ catch (error) {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ console.warn(`[IOSWdaWatchdog] pending poll failed while stopping: ${message}`);
66
+ }
67
+ const stopResult = await this.commandRunner.run(this.options.stopCommand, Math.max(5_000, this.options.startupTimeoutMs));
68
+ if (!stopResult.ok) {
69
+ const stderr = stopResult.stderr.trim();
70
+ console.warn(`[IOSWdaWatchdog] stop command failed: ${stderr || "unknown error"}`);
71
+ return;
72
+ }
73
+ console.info("[IOSWdaWatchdog] stop command finished");
74
+ }
75
+ async pollOnce() {
76
+ if (!this.running || this.stopped)
77
+ return;
78
+ if (this.pollInFlight)
79
+ return this.pollInFlight;
80
+ this.pollInFlight = this.doPoll();
81
+ try {
82
+ await this.pollInFlight;
83
+ }
84
+ finally {
85
+ this.pollInFlight = null;
86
+ }
87
+ }
88
+ async doPoll() {
89
+ const pollId = ++this.pollCount;
90
+ const startedAt = Date.now();
91
+ console.info(`[IOSWdaWatchdog] poll#${pollId} start`);
92
+ const onlineUdids = [];
93
+ // 1) Real iOS devices via xctrace
94
+ const discovered = await this.commandRunner.run(this.options.discoveryCommand, 15_000);
95
+ if (!discovered.ok) {
96
+ const stderr = discovered.stderr.trim();
97
+ console.warn(`[IOSWdaWatchdog] poll#${pollId} discovery command failed: ${stderr || "unknown error"}`);
98
+ }
99
+ else {
100
+ const onlineDevices = parseIOSDevices(discovered.stdout);
101
+ onlineUdids.push(...onlineDevices.map((item) => item.udid));
102
+ }
103
+ // 2) Booted simulators via simctl
104
+ const simctlResult = await this.commandRunner.run("xcrun simctl list devices booted", 10_000);
105
+ if (simctlResult.ok) {
106
+ const simDevices = parseBootedSimulators(simctlResult.stdout);
107
+ onlineUdids.push(...simDevices.map((item) => item.udid));
108
+ }
109
+ const uniqueUdids = [...new Set(onlineUdids)];
110
+ console.info(`[IOSWdaWatchdog] poll#${pollId} onlineDevices=${uniqueUdids.length}`);
111
+ const nextPortsByUdid = new Map();
112
+ const usedPorts = new Set();
113
+ const devicePlans = [];
114
+ if (uniqueUdids.length === 0) {
115
+ console.info(`[IOSWdaWatchdog] poll#${pollId} no online iOS devices`);
116
+ }
117
+ for (const udid of uniqueUdids) {
118
+ try {
119
+ const wdaPort = await this.resolvePortForDevice(udid, usedPorts);
120
+ // Reserve port immediately to avoid assigning the same port in this poll.
121
+ usedPorts.add(wdaPort);
122
+ devicePlans.push({ udid, wdaPort });
123
+ }
124
+ catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ console.warn(`[IOSWdaWatchdog] poll#${pollId} skip udid=${udid} reason=${message}`);
127
+ }
128
+ }
129
+ await Promise.all(devicePlans.map(async ({ udid, wdaPort }) => {
130
+ console.info(`[IOSWdaWatchdog] poll#${pollId} keepalive udid=${udid} port=${wdaPort}`);
131
+ const startCommand = renderTemplate(this.options.startCommandTemplate, udid, wdaPort);
132
+ const startResult = await this.commandRunner.run(startCommand, this.options.startupTimeoutMs);
133
+ if (!startResult.ok) {
134
+ const stderr = startResult.stderr.trim();
135
+ const stdout = startResult.stdout.trim();
136
+ const looksLikeInteractivePrompt = /password\s*:/i.test(`${stdout}\n${stderr}`);
137
+ if (looksLikeInteractivePrompt) {
138
+ console.warn(`[IOSWdaWatchdog] poll#${pollId} start blocked by interactive prompt udid=${udid} port=${wdaPort} (Password prompt detected)`);
139
+ }
140
+ console.warn(`[IOSWdaWatchdog] poll#${pollId} start failed udid=${udid} port=${wdaPort} exitCode=${startResult.exitCode} err=${stderr.slice(0, 300)}`);
141
+ return;
142
+ }
143
+ nextPortsByUdid.set(udid, wdaPort);
144
+ console.info(`[IOSWdaWatchdog] poll#${pollId} keepalive ok udid=${udid} port=${wdaPort}`);
145
+ }));
146
+ this.portsByUdid.clear();
147
+ for (const [udid, port] of nextPortsByUdid.entries()) {
148
+ this.portsByUdid.set(udid, port);
149
+ }
150
+ await this.persistPortMapState();
151
+ const durationMs = Date.now() - startedAt;
152
+ console.info(`[IOSWdaWatchdog] poll#${pollId} done kept=${nextPortsByUdid.size} durationMs=${durationMs}`);
153
+ }
154
+ async resolvePortForDevice(udid, usedPorts) {
155
+ const { start, end } = normalizePortRange(this.options.portRangeStart, this.options.portRangeEnd);
156
+ const existing = this.portsByUdid.get(udid);
157
+ if (existing != null && existing >= start && existing <= end && !usedPorts.has(existing)) {
158
+ return existing;
159
+ }
160
+ for (let port = start; port <= end; port += 1) {
161
+ if (usedPorts.has(port))
162
+ continue;
163
+ if (!(await this.canBindPort(port)))
164
+ continue;
165
+ return port;
166
+ }
167
+ throw new Error(`[IOSWdaWatchdog] no available WDA port in range ${start}-${end}`);
168
+ }
169
+ async canBindPort(port) {
170
+ return new Promise((resolve) => {
171
+ const server = net.createServer();
172
+ server.unref();
173
+ server.once("error", () => resolve(false));
174
+ server.listen(port, "127.0.0.1", () => {
175
+ server.close(() => resolve(true));
176
+ });
177
+ });
178
+ }
179
+ async loadPortMapState() {
180
+ try {
181
+ const raw = await readFile(this.options.stateFilePath, "utf8");
182
+ const parsed = JSON.parse(raw);
183
+ const portsByUdid = parsed.portsByUdid;
184
+ if (!portsByUdid || typeof portsByUdid !== "object")
185
+ return;
186
+ this.portsByUdid.clear();
187
+ for (const [udid, value] of Object.entries(portsByUdid)) {
188
+ const port = Number(value);
189
+ if (!Number.isFinite(port) || port <= 0)
190
+ continue;
191
+ this.portsByUdid.set(udid, Math.floor(port));
192
+ }
193
+ }
194
+ catch {
195
+ // Ignore missing or malformed state file, will rebuild on next poll.
196
+ }
197
+ }
198
+ async persistPortMapState() {
199
+ const payload = {
200
+ version: 1,
201
+ updatedAt: new Date().toISOString(),
202
+ portsByUdid: Object.fromEntries(Array.from(this.portsByUdid.entries()).sort(([a], [b]) => a.localeCompare(b))),
203
+ };
204
+ await mkdir(path.dirname(this.options.stateFilePath), { recursive: true });
205
+ await writeFile(this.options.stateFilePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
206
+ }
207
+ }
@@ -0,0 +1,74 @@
1
+ const DEFAULT_FRAME_INTERVAL_MS = 1000;
2
+ const MIN_FRAME_INTERVAL_MS = 1;
3
+ export class LiveDebugSessionManager {
4
+ frameSource;
5
+ inputInjector;
6
+ sessions = new Map();
7
+ constructor(frameSource, inputInjector) {
8
+ this.frameSource = frameSource;
9
+ this.inputInjector = inputInjector;
10
+ }
11
+ async start(params) {
12
+ await this.stop(params.sessionId);
13
+ const frameIntervalMs = this.normalizeFrameInterval(params.frameIntervalMs);
14
+ const session = {
15
+ sessionId: params.sessionId,
16
+ resourceId: params.resourceId,
17
+ frameIntervalMs,
18
+ pumping: false,
19
+ timer: setInterval(() => {
20
+ void this.tick(params.sessionId);
21
+ }, frameIntervalMs),
22
+ onFrame: params.onFrame,
23
+ onState: params.onState,
24
+ };
25
+ this.sessions.set(params.sessionId, session);
26
+ await session.onState("STARTED");
27
+ await this.tick(params.sessionId);
28
+ }
29
+ async stop(sessionId) {
30
+ const session = this.sessions.get(sessionId);
31
+ if (!session)
32
+ return;
33
+ clearInterval(session.timer);
34
+ this.sessions.delete(sessionId);
35
+ await session.onState("STOPPED");
36
+ }
37
+ async sendInput(sessionId, input) {
38
+ const session = this.sessions.get(sessionId);
39
+ if (!session) {
40
+ throw new Error(`live debug session not found: ${sessionId}`);
41
+ }
42
+ const result = await this.inputInjector.send(session.resourceId, input);
43
+ return { output: result.output, resourceId: session.resourceId };
44
+ }
45
+ normalizeFrameInterval(frameIntervalMs) {
46
+ if (!Number.isFinite(frameIntervalMs) || (frameIntervalMs ?? 0) <= 0) {
47
+ return DEFAULT_FRAME_INTERVAL_MS;
48
+ }
49
+ return Math.max(MIN_FRAME_INTERVAL_MS, Math.floor(frameIntervalMs));
50
+ }
51
+ async tick(sessionId) {
52
+ const session = this.sessions.get(sessionId);
53
+ if (!session || session.pumping)
54
+ return;
55
+ session.pumping = true;
56
+ try {
57
+ const frame = await this.frameSource.capture(session.resourceId);
58
+ await session.onFrame(frame);
59
+ }
60
+ catch (error) {
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ try {
63
+ await session.onState("FAILED", message);
64
+ }
65
+ catch (reportError) {
66
+ const reportMessage = reportError instanceof Error ? reportError.message : String(reportError);
67
+ console.error(`[LiveDebugSessionManager] tick failed and could not emit FAILED state: original=${message}; report=${reportMessage}`);
68
+ }
69
+ }
70
+ finally {
71
+ session.pumping = false;
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,19 @@
1
+ import { asResourceId } from "../../shared-kernel/ids/index.js";
2
+ export class RuntimeLiveDebugFrameSource {
3
+ runtime;
4
+ constructor(runtime) {
5
+ this.runtime = runtime;
6
+ }
7
+ async capture(resourceId) {
8
+ return this.runtime.captureLiveFrame(asResourceId(resourceId));
9
+ }
10
+ }
11
+ export class RuntimeLiveDebugInputInjector {
12
+ runtime;
13
+ constructor(runtime) {
14
+ this.runtime = runtime;
15
+ }
16
+ async send(resourceId, input) {
17
+ return this.runtime.sendLiveInput(asResourceId(resourceId), input);
18
+ }
19
+ }