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,184 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createWriteStream } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ export class AgentRuntimeManager {
7
+ options;
8
+ child;
9
+ logStream;
10
+ startPromise;
11
+ logPath;
12
+ pidPath;
13
+ runtimeRoot;
14
+ preflightHome;
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.runtimeRoot = options.runtimeRoot ?? (process.env.AGENT_RUNTIME_ROOT?.trim() || undefined);
18
+ this.preflightHome = process.env.PREFLIGHT_HOME?.trim() || join(homedir(), ".preflight");
19
+ this.logPath = join(this.preflightHome, "runtime", "agent.log");
20
+ this.pidPath = join(this.preflightHome, "runtime", "agent.pid");
21
+ }
22
+ /** 运行模式 */
23
+ get mode() {
24
+ return this.runtimeRoot ? "runtime" : "dev";
25
+ }
26
+ async status() {
27
+ const health = await this.options.client.health();
28
+ if (health.ok) {
29
+ return {
30
+ ok: true,
31
+ state: "running",
32
+ message: "automation-agent HTTP is reachable.",
33
+ pid: this.child?.pid ?? (await this.readPid()),
34
+ health,
35
+ logPath: this.logPath,
36
+ pidPath: this.pidPath,
37
+ baseUrl: this.options.agentBaseUrl,
38
+ };
39
+ }
40
+ return {
41
+ ok: false,
42
+ state: "stopped",
43
+ message: `automation-agent HTTP is not reachable: ${health.error ?? "unknown error"}`,
44
+ pid: this.child?.pid ?? (await this.readPid()),
45
+ health,
46
+ logPath: this.logPath,
47
+ pidPath: this.pidPath,
48
+ baseUrl: this.options.agentBaseUrl,
49
+ };
50
+ }
51
+ async ensureStarted() {
52
+ const current = await this.status();
53
+ if (current.ok)
54
+ return current;
55
+ if (this.startPromise)
56
+ return this.startPromise;
57
+ this.startPromise = this.start();
58
+ try {
59
+ return await this.startPromise;
60
+ }
61
+ finally {
62
+ this.startPromise = undefined;
63
+ }
64
+ }
65
+ async stop() {
66
+ if (!this.child || this.child.killed) {
67
+ return this.status();
68
+ }
69
+ const pid = this.child.pid;
70
+ this.child.kill("SIGTERM");
71
+ await delay(500);
72
+ const health = await this.options.client.health();
73
+ return {
74
+ ok: !health.ok,
75
+ state: health.ok ? "running" : "stopped",
76
+ message: health.ok ? "automation-agent is still reachable after SIGTERM." : "automation-agent stopped.",
77
+ pid,
78
+ health,
79
+ logPath: this.logPath,
80
+ pidPath: this.pidPath,
81
+ baseUrl: this.options.agentBaseUrl,
82
+ };
83
+ }
84
+ async start() {
85
+ await mkdir(dirname(this.logPath), { recursive: true });
86
+ this.logStream = createWriteStream(this.logPath, { flags: "a" });
87
+ this.logStream.write(`\n[preflight-mcp] starting automation-agent (${this.mode} mode) at ${new Date().toISOString()}\n`);
88
+ const port = portFromBaseUrl(this.options.agentBaseUrl) ?? 18998;
89
+ const configEnv = (await this.options.loadConfigEnv?.()) ?? {};
90
+ const env = {
91
+ ...(this.options.env ?? process.env),
92
+ ...configEnv,
93
+ LOCAL_MCP_MODE: "1",
94
+ PREFLIGHT_HOME: this.preflightHome,
95
+ AGENT_HOME: this.preflightHome,
96
+ AGENT_HTTP_PORT: String(port),
97
+ AGENT_HTTP_TOKEN: "",
98
+ PLATFORM_WS_TOKEN: "",
99
+ PLATFORM_AGENT_CALLBACK_TOKEN: "",
100
+ PLATFORM_CALLBACK_ENDPOINT: "",
101
+ PLATFORM_COMMAND_POLL_BASE_URL: "",
102
+ MCP_AGENT_AUTOSTART: "1",
103
+ };
104
+ if (this.runtimeRoot) {
105
+ // Runtime 模式:使用打包后的 node + dist
106
+ const nodeBin = join(this.runtimeRoot, "node", "bin", "node");
107
+ const entry = join(this.runtimeRoot, "dist", "main.js");
108
+ this.logStream.write(`[preflight-mcp] runtimeRoot=${this.runtimeRoot} node=${nodeBin} entry=${entry}\n`);
109
+ this.child = spawn(nodeBin, [entry], {
110
+ cwd: this.runtimeRoot,
111
+ env,
112
+ stdio: ["ignore", "pipe", "pipe"],
113
+ });
114
+ }
115
+ else {
116
+ // 开发模式:使用 npm exec tsx
117
+ this.child = spawn("npm", ["--silent", "--prefix", this.options.projectRoot, "exec", "tsx", "src/main.ts"], {
118
+ cwd: this.options.projectRoot,
119
+ env,
120
+ stdio: ["ignore", "pipe", "pipe"],
121
+ });
122
+ }
123
+ this.child.stdout?.pipe(this.logStream, { end: false });
124
+ this.child.stderr?.pipe(this.logStream, { end: false });
125
+ this.child.on("exit", (code, signal) => {
126
+ this.logStream?.write(`[preflight-mcp] automation-agent exited code=${code ?? ""} signal=${signal ?? ""}\n`);
127
+ });
128
+ if (this.child.pid) {
129
+ await writeFile(this.pidPath, `${this.child.pid}\n`, "utf8");
130
+ }
131
+ const deadline = Date.now() + 30_000;
132
+ while (Date.now() < deadline) {
133
+ const health = await this.options.client.health();
134
+ if (health.ok) {
135
+ return {
136
+ ok: true,
137
+ state: "started",
138
+ message: "automation-agent started and HTTP is reachable.",
139
+ pid: this.child.pid,
140
+ health,
141
+ logPath: this.logPath,
142
+ pidPath: this.pidPath,
143
+ baseUrl: this.options.agentBaseUrl,
144
+ };
145
+ }
146
+ if (this.child.exitCode !== null)
147
+ break;
148
+ await delay(500);
149
+ }
150
+ const health = await this.options.client.health();
151
+ return {
152
+ ok: false,
153
+ state: "failed",
154
+ message: `automation-agent did not become ready in ${this.mode} mode. Check logPath: ${this.logPath}`,
155
+ pid: this.child.pid,
156
+ health,
157
+ logPath: this.logPath,
158
+ pidPath: this.pidPath,
159
+ baseUrl: this.options.agentBaseUrl,
160
+ };
161
+ }
162
+ async readPid() {
163
+ try {
164
+ const text = await readFile(this.pidPath, "utf8");
165
+ const pid = Number(text.trim());
166
+ return Number.isFinite(pid) && pid > 0 ? Math.floor(pid) : undefined;
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ }
172
+ }
173
+ function portFromBaseUrl(baseUrl) {
174
+ try {
175
+ const port = Number(new URL(baseUrl).port);
176
+ return Number.isFinite(port) && port > 0 ? Math.floor(port) : undefined;
177
+ }
178
+ catch {
179
+ return undefined;
180
+ }
181
+ }
182
+ function delay(ms) {
183
+ return new Promise((resolve) => setTimeout(resolve, ms));
184
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createPreflightMcpServer } from "./server.js";
4
+ import { setupLocalMcp } from "./setup.js";
5
+ // Prevent unhandled rejections / exceptions from crashing the server mid-session
6
+ process.on("unhandledRejection", (reason) => {
7
+ console.error("[Preflight-MCP] unhandledRejection:", reason);
8
+ });
9
+ process.on("uncaughtException", (err) => {
10
+ console.error("[Preflight-MCP] uncaughtException:", err);
11
+ });
12
+ const command = process.argv[2] ?? "serve";
13
+ if (command === "serve") {
14
+ const runtimeRoot = argValue("--runtime-root") ?? (process.env.AGENT_RUNTIME_ROOT?.trim() || undefined);
15
+ const server = createPreflightMcpServer({ runtimeRoot });
16
+ await server.connect(new StdioServerTransport());
17
+ }
18
+ else if (command === "setup") {
19
+ const projectRoot = argValue("--project-root") ?? process.cwd();
20
+ const agentBaseUrl = argValue("--agent-base-url") ?? process.env.AGENT_BASE_URL ?? "http://127.0.0.1:18998";
21
+ const livePort = Number(argValue("--live-port") ?? process.env.MCP_LIVE_PORT ?? "18999");
22
+ const runtimeRoot = argValue("--runtime-root") ?? process.env.AGENT_RUNTIME_ROOT?.trim() ?? undefined;
23
+ const installRuntime = !process.argv.includes("--no-install-runtime");
24
+ const result = await setupLocalMcp({ projectRoot, agentBaseUrl, livePort, runtimeRoot, installRuntime });
25
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
26
+ }
27
+ else {
28
+ console.error(`Unknown Preflight MCP command: ${command}`);
29
+ process.exitCode = 2;
30
+ }
31
+ function argValue(name) {
32
+ const idx = process.argv.indexOf(name);
33
+ if (idx < 0)
34
+ return undefined;
35
+ return process.argv[idx + 1];
36
+ }
@@ -0,0 +1,124 @@
1
+ import { spawn } from "node:child_process";
2
+ import http from "node:http";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ const MIDSCENE_KEY_NAMES = [
7
+ "MIDSCENE_MODEL_API_KEY",
8
+ "MIDSCENE_API_KEY",
9
+ "MIDSCENE_OPENAI_API_KEY",
10
+ "OPENAI_API_KEY",
11
+ "ANTHROPIC_API_KEY",
12
+ ];
13
+ // ---------------------------------------------------------------------------
14
+ // iOS WebDriverAgent health check
15
+ // ---------------------------------------------------------------------------
16
+ function checkWdaHealth(host, port, timeoutMs) {
17
+ return new Promise((resolve) => {
18
+ const req = http.get(`http://${host}:${port}/status`, { timeout: timeoutMs }, (res) => {
19
+ let body = "";
20
+ res.on("data", (chunk) => {
21
+ body += chunk;
22
+ });
23
+ res.on("end", () => resolve(body.trim().length > 0));
24
+ });
25
+ req.on("error", () => resolve(false));
26
+ req.on("timeout", () => {
27
+ req.destroy();
28
+ resolve(false);
29
+ });
30
+ });
31
+ }
32
+ async function iosWdaHealthCheck(env) {
33
+ const host = env.MIDSCENE_IOS_WDA_HOST || "127.0.0.1";
34
+ const defaultPort = Number(env.MIDSCENE_IOS_WDA_PORT) || 8200;
35
+ const candidates = new Set();
36
+ // Try to discover running WDA ports from the watchdog port map
37
+ const portMapPath = env.IOS_WDA_PORT_MAP_FILE_PATH
38
+ || join(homedir(), ".preflight", "runtime", ".wda-agent-state", "wda-port-map.json");
39
+ try {
40
+ const raw = await readFile(portMapPath, "utf8");
41
+ const parsed = JSON.parse(raw);
42
+ if (parsed?.portsByUdid) {
43
+ for (const p of Object.values(parsed.portsByUdid)) {
44
+ if (typeof p === "number" && Number.isFinite(p) && p > 0) {
45
+ candidates.add(Math.floor(p));
46
+ }
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ // port map not found — fall back to default port
52
+ }
53
+ candidates.add(defaultPort);
54
+ for (const port of candidates) {
55
+ if (await checkWdaHealth(host, port, 2000)) {
56
+ return {
57
+ id: "ios-wda",
58
+ title: "iOS WebDriverAgent",
59
+ status: "pass",
60
+ message: `WebDriverAgent is healthy on ${host}:${port}.`,
61
+ };
62
+ }
63
+ }
64
+ const portList = [...candidates].sort((a, b) => a - b).join(", ");
65
+ return {
66
+ id: "ios-wda",
67
+ title: "iOS WebDriverAgent",
68
+ status: "warn",
69
+ message: `WebDriverAgent is not running (checked host=${host}, ports=${portList}). iOS automation needs WDA. Call "start_ios_wda" tool to start it, or ensure the WDA watchdog is running.`,
70
+ };
71
+ }
72
+ export async function runDoctor(deps) {
73
+ const commandExists = deps.commandExists ?? defaultCommandExists;
74
+ const checks = [];
75
+ const agent = await deps.agentHealth();
76
+ checks.push({
77
+ id: "agent-http",
78
+ title: "Automation Agent HTTP",
79
+ status: agent.ok ? "pass" : "fail",
80
+ message: agent.ok ? "Agent HTTP is reachable." : `Agent HTTP is not reachable: ${agent.error ?? "unknown error"}`,
81
+ });
82
+ const keyName = MIDSCENE_KEY_NAMES.find((name) => deps.env[name]?.trim());
83
+ checks.push({
84
+ id: "midscene-api-key",
85
+ title: "Midscene API Key",
86
+ status: keyName ? "pass" : "fail",
87
+ message: keyName
88
+ ? `${keyName} is set.`
89
+ : `Missing Midscene model API key. Set one of: ${MIDSCENE_KEY_NAMES.join(", ")}.`,
90
+ });
91
+ checks.push(await commandCheck("android-adb", "Android adb", "adb", commandExists, "Android tests need adb in PATH."));
92
+ checks.push(await commandCheck("ffmpeg", "ffmpeg", "ffmpeg", commandExists, "Video recording needs ffmpeg in PATH."));
93
+ checks.push(await commandCheck("scrcpy", "scrcpy", "scrcpy", commandExists, "Android video recording needs scrcpy in PATH."));
94
+ checks.push(await commandCheck("harmony-hdc", "Harmony hdc", deps.env.MIDSCENE_HARMONY_HDC_PATH || deps.env.AGENT_HARMONY_HDC_PATH || "hdc", commandExists, "Harmony tests need hdc or MIDSCENE_HARMONY_HDC_PATH."));
95
+ checks.push(await commandCheck("ios-xcode", "iOS Xcode tools", "xcrun", commandExists, "iOS tests need Xcode command line tools."));
96
+ checks.push(await commandCheck("ios-iproxy", "iOS iproxy", "iproxy", commandExists, "iOS WDA live view needs iproxy."));
97
+ checks.push(await iosWdaHealthCheck(deps.env));
98
+ const blocking = checks.filter((check) => check.status === "fail");
99
+ const nonPass = checks.filter((check) => check.status !== "pass");
100
+ return {
101
+ ok: blocking.length === 0,
102
+ summary: blocking.length === 0 ? "All blocking checks passed." : `${blocking.length} blocking issue(s) found.`,
103
+ checks: nonPass,
104
+ };
105
+ }
106
+ async function commandCheck(id, title, command, commandExists, missingMessage) {
107
+ const exists = await commandExists(command);
108
+ return {
109
+ id,
110
+ title,
111
+ status: exists ? "pass" : "warn",
112
+ message: exists ? `${command} is available.` : missingMessage,
113
+ };
114
+ }
115
+ function defaultCommandExists(command) {
116
+ return new Promise((resolve) => {
117
+ const child = spawn("sh", ["-lc", `command -v ${shellQuote(command)} >/dev/null 2>&1`], { stdio: "ignore" });
118
+ child.on("close", (code) => resolve(code === 0));
119
+ child.on("error", () => resolve(false));
120
+ });
121
+ }
122
+ function shellQuote(value) {
123
+ return `'${value.replaceAll("'", "'\\''")}'`;
124
+ }
@@ -0,0 +1,57 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export async function writeEvidence(input) {
5
+ const runDir = join(input.outputRoot ?? join(homedir(), ".preflight"), "self-test-runs", input.run.runId);
6
+ await mkdir(runDir, { recursive: true });
7
+ const evidencePath = join(runDir, "evidence.md");
8
+ const metadataPath = join(runDir, "metadata.json");
9
+ await writeFile(evidencePath, renderEvidenceMarkdown(input.run), "utf8");
10
+ await writeFile(metadataPath, JSON.stringify(input.run, null, 2), "utf8");
11
+ return { runDir, evidencePath, metadataPath };
12
+ }
13
+ function renderEvidenceMarkdown(run) {
14
+ const artifactLines = run.artifacts.length
15
+ ? run.artifacts.map((artifact) => `- ${artifact.type}: ${artifact.uri}`).join("\n")
16
+ : "- 暂无产物";
17
+ return `# 自测留痕
18
+
19
+ - 结果:${run.status}
20
+ - 时间:${run.createdAt} -> ${run.updatedAt}
21
+ - 平台:${run.platform ?? "unknown"}
22
+ - 设备:${run.resourceId ?? "auto"}
23
+ - App 包:${run.appRef ?? "未指定"}
24
+ - Task ID:${run.taskId}
25
+ - Run ID:${run.runId}
26
+ - Live Viewer:${run.liveUrl}
27
+
28
+ ## 测试意图
29
+
30
+ ${run.testIntent ?? "未填写"}
31
+
32
+ ## 执行脚本
33
+
34
+ \`\`\`ts
35
+ ${run.script ?? ""}
36
+ \`\`\`
37
+
38
+ ## Visual Flow
39
+
40
+ \`\`\`json
41
+ ${run.visualFlow ? JSON.stringify(run.visualFlow, null, 2) : "{}"}
42
+ \`\`\`
43
+
44
+ ## 结果摘要
45
+
46
+ ${run.failureAnalysis.summary}
47
+
48
+ ## 失败原因
49
+
50
+ - 分类:${run.failureAnalysis.category}
51
+ - 建议:${run.failureAnalysis.recommendation}
52
+
53
+ ## 产物
54
+
55
+ ${artifactLines}
56
+ `;
57
+ }
@@ -0,0 +1,129 @@
1
+ import { z } from "zod";
2
+ import { getExplorationStartHandler, getExplorationEndHandler, } from "./tools-session.js";
3
+ import { getPageSummaryHandler, askAboutScreenHandler, aiActHandler, } from "./tools-intelligent.js";
4
+ import { getScreenshotHandler, getTypeHandler, getWaitHandler, } from "./tools-atomic.js";
5
+ function jsonResult(value) {
6
+ return {
7
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
8
+ };
9
+ }
10
+ /**
11
+ * Register all exploration MCP tools on the given server instance.
12
+ * Handlers are created once at the top and reused for each tool registration.
13
+ */
14
+ export function registerExplorationTools(server, ctx) {
15
+ // Instantiate all handler functions once
16
+ const startHandler = getExplorationStartHandler(ctx);
17
+ const endHandler = getExplorationEndHandler();
18
+ const pageSummaryHandler = getPageSummaryHandler(ctx);
19
+ const askHandler = askAboutScreenHandler(ctx);
20
+ const actHandler = aiActHandler(ctx);
21
+ const screenshotHandler = getScreenshotHandler(ctx);
22
+ const typeHandler = getTypeHandler(ctx);
23
+ const waitHandler = getWaitHandler(ctx);
24
+ // ---------------------------------------------------------------------------
25
+ // 1. exploration_start
26
+ // ---------------------------------------------------------------------------
27
+ server.registerTool("exploration_start", {
28
+ title: "Start Exploration",
29
+ description: "Start an interactive exploration session on a connected device. For iOS, automatically starts WebDriverAgent (WDA) if not already running. Creates a persistent device connection for the LLM to explore the app UI before generating test cases. Returns a sessionId that must be passed to subsequent exploration_* tools. Session auto-expires after 30 minutes of inactivity.",
30
+ inputSchema: {
31
+ resourceId: z
32
+ .string()
33
+ .optional()
34
+ .describe("Device resource ID from list_devices (e.g., android:emulator-5554). If omitted, the first available device is used."),
35
+ appRef: z
36
+ .string()
37
+ .optional()
38
+ .describe("App to launch. Can be a bundleId (e.g., com.example.app) or an installable URL/path (.apk/.ipa). If omitted, the current app state is used."),
39
+ },
40
+ }, async (input) => jsonResult(await startHandler(input)));
41
+ // ---------------------------------------------------------------------------
42
+ // 2. exploration_end
43
+ // ---------------------------------------------------------------------------
44
+ server.registerTool("exploration_end", {
45
+ title: "End Exploration",
46
+ description: "End an exploration session and release the device connection. Always call this when done.",
47
+ inputSchema: {
48
+ sessionId: z.string().describe("Session ID from exploration_start"),
49
+ },
50
+ }, async (input) => jsonResult(await endHandler(input)));
51
+ // ---------------------------------------------------------------------------
52
+ // 3. exploration_get_page_summary
53
+ // ---------------------------------------------------------------------------
54
+ server.registerTool("exploration_get_page_summary", {
55
+ title: "Get Page Summary",
56
+ description: "Get a natural language summary of the current screen, including layout type (fixed single-screen / scrollable long page / multi-tab / list). Call this first when entering a new page to understand its structure before acting. Use this INSTEAD of ai_act if you just want to observe — ai_act is for changing state, not for looking around.",
57
+ inputSchema: {
58
+ sessionId: z.string().describe("Session ID from exploration_start"),
59
+ },
60
+ }, async (input) => jsonResult(await pageSummaryHandler(input)));
61
+ // ---------------------------------------------------------------------------
62
+ // 4. exploration_ask_about_screen
63
+ // ---------------------------------------------------------------------------
64
+ server.registerTool("exploration_ask_about_screen", {
65
+ title: "Ask About Screen",
66
+ description: "Ask a specific question about the current screen. Examples: 'What color is the submit button?'",
67
+ inputSchema: {
68
+ sessionId: z.string().describe("Session ID from exploration_start"),
69
+ question: z
70
+ .string()
71
+ .describe("Your question about the current screen"),
72
+ },
73
+ }, async (input) => jsonResult(await askHandler(input)));
74
+ // ---------------------------------------------------------------------------
75
+ // 5. exploration_ai_act
76
+ // ---------------------------------------------------------------------------
77
+ server.registerTool("exploration_ai_act", {
78
+ title: "AI Act",
79
+ description: "Perform a high-level UI interaction described in natural language. Examples: 'Go back', 'Tap the settings icon', 'Type text in the search box'. After execution, returns a summary of the new page state including whether the action actually changed anything.\n\nIMPORTANT: Use get_page_summary FIRST to check if the page is a fixed single-screen layout. If it is, do NOT request scroll actions — there is nothing to scroll to. Use ai_act only for meaningful interactions (tap, type, swipe between tabs), not for 'look around' or 'scroll to see more'. If the post-action summary reports that the page did not change, stop acting on this page and move on.",
80
+ inputSchema: {
81
+ sessionId: z.string().describe("Session ID from exploration_start"),
82
+ intent: z.string().describe("Description of what to do"),
83
+ },
84
+ }, async (input) => jsonResult(await actHandler(input)));
85
+ // ---------------------------------------------------------------------------
86
+ // 6. exploration_screenshot
87
+ // ---------------------------------------------------------------------------
88
+ server.registerTool("exploration_screenshot", {
89
+ title: "Take Screenshot",
90
+ description: "Take a screenshot of the current device screen and return it as base64.",
91
+ inputSchema: {
92
+ sessionId: z.string().describe("Session ID from exploration_start"),
93
+ },
94
+ }, async (input) => {
95
+ const { screenshot, mimeType } = await screenshotHandler(input);
96
+ return {
97
+ content: [
98
+ { type: "image", data: screenshot, mimeType },
99
+ ],
100
+ };
101
+ });
102
+ // ---------------------------------------------------------------------------
103
+ // 7. exploration_type
104
+ // ---------------------------------------------------------------------------
105
+ server.registerTool("exploration_type", {
106
+ title: "Type Text",
107
+ description: "Type text into the currently focused input field.",
108
+ inputSchema: {
109
+ sessionId: z.string().describe("Session ID from exploration_start"),
110
+ text: z.string().describe("The text to type"),
111
+ },
112
+ }, async (input) => jsonResult(await typeHandler(input)));
113
+ // ---------------------------------------------------------------------------
114
+ // 8. exploration_wait
115
+ // ---------------------------------------------------------------------------
116
+ server.registerTool("exploration_wait", {
117
+ title: "Wait",
118
+ description: "Wait for a specified duration in milliseconds (max 10000). Use after actions that trigger animations or page transitions.",
119
+ inputSchema: {
120
+ sessionId: z.string().describe("Session ID from exploration_start"),
121
+ ms: z
122
+ .number()
123
+ .int()
124
+ .min(0)
125
+ .max(10000)
126
+ .describe("Duration to wait in ms"),
127
+ },
128
+ }, async (input) => jsonResult(await waitHandler(input)));
129
+ }
@@ -0,0 +1,122 @@
1
+ import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
5
+ /**
6
+ * In-memory session store. If the MCP server process is restarted between
7
+ * tool calls, sessions are recovered from a backing JSON file on first access.
8
+ */
9
+ const SESSIONS = new Map();
10
+ // ---------------------------------------------------------------------------
11
+ // File-based session metadata (survives MCP server restart)
12
+ // ---------------------------------------------------------------------------
13
+ function sessionsDir() {
14
+ return join(homedir(), ".preflight", "exploration-sessions");
15
+ }
16
+ function sessionFilePath(id) {
17
+ return join(sessionsDir(), `${id}.json`);
18
+ }
19
+ async function persistMeta(state, env) {
20
+ const dir = sessionsDir();
21
+ await mkdir(dir, { recursive: true });
22
+ const meta = {
23
+ id: state.id,
24
+ resourceId: state.resourceId,
25
+ platform: state.platform,
26
+ env,
27
+ createdAt: state.createdAt,
28
+ lastActivityAt: state.lastActivityAt,
29
+ };
30
+ await writeFile(sessionFilePath(state.id), JSON.stringify(meta, null, 2), "utf8");
31
+ }
32
+ export async function loadMeta(id) {
33
+ try {
34
+ const text = await readFile(sessionFilePath(id), "utf8");
35
+ return JSON.parse(text);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ async function removeMeta(id) {
42
+ try {
43
+ await unlink(sessionFilePath(id));
44
+ }
45
+ catch {
46
+ // ignore
47
+ }
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Session lifecycle
51
+ // ---------------------------------------------------------------------------
52
+ /** Create a new exploration session and persist metadata to disk. */
53
+ export function createSession(id, resourceId, platform, session, env) {
54
+ const state = {
55
+ id,
56
+ resourceId,
57
+ platform,
58
+ session,
59
+ createdAt: Date.now(),
60
+ lastActivityAt: Date.now(),
61
+ };
62
+ SESSIONS.set(id, state);
63
+ persistMeta(state, env).catch(() => { });
64
+ return state;
65
+ }
66
+ /**
67
+ * Get a session from memory. Throws if not found — caller should fall back
68
+ * to `restoreSession` for recovery across server restarts.
69
+ */
70
+ export function getSession(id) {
71
+ const state = SESSIONS.get(id);
72
+ if (!state) {
73
+ throw new Error(`Exploration session not found: ${id}. Call exploration_start first.`);
74
+ }
75
+ if (Date.now() - state.lastActivityAt > SESSION_TIMEOUT_MS) {
76
+ void destroySession(state);
77
+ throw new Error(`Exploration session ${id} has expired (30 min timeout). Call exploration_start again.`);
78
+ }
79
+ state.lastActivityAt = Date.now();
80
+ return state;
81
+ }
82
+ /**
83
+ * Store a recreated session in memory (used by tools that recover from disk metadata).
84
+ */
85
+ export function storeSession(id, session, meta) {
86
+ const state = {
87
+ id,
88
+ resourceId: meta.resourceId,
89
+ platform: meta.platform,
90
+ session,
91
+ createdAt: meta.createdAt,
92
+ lastActivityAt: Date.now(),
93
+ };
94
+ SESSIONS.set(id, state);
95
+ return state;
96
+ }
97
+ /** Destroy a session: remove from map + backing file, release device connection. */
98
+ export async function destroySession(state) {
99
+ SESSIONS.delete(state.id);
100
+ await removeMeta(state.id);
101
+ try {
102
+ await state.session.device.destroy();
103
+ }
104
+ catch {
105
+ // ignore cleanup errors
106
+ }
107
+ }
108
+ /** Look up a session by ID and destroy it. No-op if session does not exist. */
109
+ export async function destroySessionById(id) {
110
+ const state = SESSIONS.get(id);
111
+ if (!state) {
112
+ await removeMeta(id); // clean up orphaned meta
113
+ return;
114
+ }
115
+ await destroySession(state);
116
+ }
117
+ export function hasSession(id) {
118
+ return SESSIONS.has(id);
119
+ }
120
+ export function activeSessionCount() {
121
+ return SESSIONS.size;
122
+ }