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,168 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { mkdtemp, mkdir, readFile, writeFile, stat } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { ArtifactType, PlatformType } from "../../shared-kernel/enums/index.js";
7
+ import { getMidsceneReportRootDir, resolveTaskReportFilePaths } from "../transport/midscenePaths.js";
8
+ import { zipDirectoryToFile } from "../midscene/zipReportDir.js";
9
+ const execFileAsync = promisify(execFile);
10
+ function extractDeviceToken(resourceId) {
11
+ const idx = resourceId.indexOf(":");
12
+ return idx >= 0 ? resourceId.slice(idx + 1) : resourceId;
13
+ }
14
+ function shellQuote(value) {
15
+ return `'${value.replaceAll("'", "'\\''")}'`;
16
+ }
17
+ function requirePositivePort(value, name) {
18
+ const n = Number(value);
19
+ if (!Number.isFinite(n) || n <= 0)
20
+ throw new Error(`${name} is required`);
21
+ return Math.floor(n);
22
+ }
23
+ export function buildAirtestDeviceUri(platform, resourceId, options) {
24
+ const token = extractDeviceToken(String(resourceId));
25
+ if (platform === PlatformType.ANDROID) {
26
+ return `Android://${options.androidAdbHost ?? "127.0.0.1"}:${options.androidAdbPort ?? 5037}/${token}`;
27
+ }
28
+ if (platform === PlatformType.IOS) {
29
+ const port = requirePositivePort(options.iosWdaPort, "iosWdaPort");
30
+ const mjpeg = requirePositivePort(options.iosMjpegPort ?? port + 1000, "iosMjpegPort");
31
+ const host = options.iosWdaHost ?? "127.0.0.1";
32
+ return `iOS:///http://${host}:${port}/?mjpeg_port=${mjpeg}&udid=${encodeURIComponent(token)}`;
33
+ }
34
+ if (platform === PlatformType.HARMONY) {
35
+ const params = new URLSearchParams();
36
+ if (options.harmonyHdcPath)
37
+ params.set("hdc_path", options.harmonyHdcPath);
38
+ const q = params.toString();
39
+ return `Harmony:///${encodeURIComponent(token)}${q ? `?${q}` : ""}`;
40
+ }
41
+ throw new Error(`unsupported Airtest platform: ${platform}`);
42
+ }
43
+ function assertSafeZipEntries(entries) {
44
+ for (const entry of entries) {
45
+ const clean = entry.replaceAll("\\", "/");
46
+ if (!clean || clean.startsWith("/") || clean.includes("\0") || clean.split("/").includes("..")) {
47
+ throw new Error(`unsafe airtest zip entry: ${entry}`);
48
+ }
49
+ }
50
+ }
51
+ async function unzipSafe(zipPath, targetDir) {
52
+ const listing = await execFileAsync("unzip", ["-Z1", zipPath], { maxBuffer: 2 * 1024 * 1024 });
53
+ assertSafeZipEntries(String(listing.stdout).split(/\r?\n/).filter(Boolean));
54
+ await execFileAsync("unzip", ["-qq", "-o", zipPath, "-d", targetDir], { maxBuffer: 2 * 1024 * 1024 });
55
+ }
56
+ export class AirtestRuntime {
57
+ commandRunner;
58
+ options;
59
+ constructor(commandRunner, options) {
60
+ this.commandRunner = commandRunner;
61
+ this.options = options;
62
+ }
63
+ async execute(task, resourceId, signal, context) {
64
+ if (!task.airtest?.bundleBase64?.trim() || !task.airtest.entryDir?.trim()) {
65
+ throw new Error("airtest bundle and entryDir are required");
66
+ }
67
+ const baseDir = await mkdtemp(path.join(tmpdir(), "airtest-run-"));
68
+ const zipPath = path.join(baseDir, task.airtest.archiveName || "airtest.zip");
69
+ const scriptRoot = path.join(baseDir, "script");
70
+ await mkdir(scriptRoot, { recursive: true });
71
+ await writeFile(zipPath, Buffer.from(task.airtest.bundleBase64, "base64"));
72
+ await unzipSafe(zipPath, scriptRoot);
73
+ const entryDir = path.resolve(scriptRoot, task.airtest.entryDir);
74
+ if (!entryDir.startsWith(path.resolve(scriptRoot) + path.sep))
75
+ throw new Error("invalid airtest entryDir");
76
+ const entryBase = path.basename(entryDir, ".air");
77
+ await stat(path.join(entryDir, `${entryBase}.py`));
78
+ const env = { ...process.env, ...(context?.runtimeEnv ?? {}) };
79
+ const reportStem = String(env.MIDSCENE_FLOW_REPORT_STEM ?? `airtest-${Date.now()}`).trim();
80
+ const outputFormat = env.MIDSCENE_OUTPUT_FORMAT === "html-and-external-assets" ? "html-and-external-assets" : "single-html";
81
+ const reportRoot = getMidsceneReportRootDir(process.cwd(), { ...process.env, ...env });
82
+ const reportPaths = resolveTaskReportFilePaths(reportRoot, reportStem, outputFormat);
83
+ const reportDir = reportPaths.bundleDir ?? path.dirname(reportPaths.reportHtmlPath);
84
+ const logDir = path.join(baseDir, "log");
85
+ await mkdir(logDir, { recursive: true });
86
+ await mkdir(reportDir, { recursive: true });
87
+ const python = env.AIRTEST_PYTHON?.trim() || "python3";
88
+ const pythonPath = env.AIRTEST_REPO_PATH?.trim();
89
+ const childEnv = pythonPath
90
+ ? { ...env, PYTHONPATH: [pythonPath, env.PYTHONPATH].filter(Boolean).join(path.delimiter) }
91
+ : env;
92
+ const deviceUri = buildAirtestDeviceUri(task.requiredPlatform, resourceId, {
93
+ androidAdbHost: env.MIDSCENE_ANDROID_ADB_HOST ?? this.options.androidAdbHost,
94
+ androidAdbPort: Number(env.MIDSCENE_ANDROID_ADB_PORT ?? this.options.androidAdbPort ?? 5037),
95
+ iosWdaHost: env.MIDSCENE_IOS_WDA_HOST ?? this.options.iosWdaHost,
96
+ iosWdaPort: Number(env.MIDSCENE_IOS_WDA_PORT ?? env.IOS_WDA_PORT),
97
+ iosMjpegPort: Number(env.MIDSCENE_IOS_WDA_MJPEG_PORT ?? env.IOS_WDA_MJPEG_PORT),
98
+ harmonyHdcPath: env.MIDSCENE_HARMONY_HDC_PATH ?? this.options.harmonyHdcPath,
99
+ });
100
+ const runCommand = [
101
+ shellQuote(python),
102
+ "-m airtest run",
103
+ shellQuote(entryDir),
104
+ "--device",
105
+ shellQuote(deviceUri),
106
+ "--log",
107
+ shellQuote(logDir),
108
+ ].join(" ");
109
+ const startedAt = Date.now();
110
+ const run = await this.commandRunner.run(runCommand, this.options.runCommandTimeoutMs, signal, {
111
+ env: childEnv,
112
+ onStdoutChunk: context?.onLogChunk ? (t) => context.onLogChunk(t, "stdout") : undefined,
113
+ onStderrChunk: context?.onLogChunk ? (t) => context.onLogChunk(t, "stderr") : undefined,
114
+ });
115
+ const reportCommand = [
116
+ shellQuote(python),
117
+ "-m airtest report",
118
+ shellQuote(entryDir),
119
+ "--log_root",
120
+ shellQuote(logDir),
121
+ "--outfile",
122
+ shellQuote(reportPaths.reportHtmlPath),
123
+ ...(reportPaths.bundleDir ? ["--export", shellQuote(reportPaths.bundleDir)] : []),
124
+ ].join(" ");
125
+ const report = await this.commandRunner.run(reportCommand, this.options.runCommandTimeoutMs, signal, {
126
+ env: childEnv,
127
+ onStdoutChunk: context?.onLogChunk ? (t) => context.onLogChunk(t, "stdout") : undefined,
128
+ onStderrChunk: context?.onLogChunk ? (t) => context.onLogChunk(t, "stderr") : undefined,
129
+ });
130
+ const endedAt = Date.now();
131
+ const runtimeLog = path.join(baseDir, "runtime.log");
132
+ const traceFile = path.join(baseDir, "runtime-trace.json");
133
+ await writeFile(runtimeLog, [`command: ${runCommand}`, run.stdout, run.stderr, `report: ${reportCommand}`, report.stdout, report.stderr].join("\n"));
134
+ await writeFile(traceFile, JSON.stringify({ startedAt, endedAt, durationMs: endedAt - startedAt, exitCode: run.exitCode, ok: run.ok && report.ok }, null, 2));
135
+ const artifacts = [
136
+ { type: ArtifactType.LOG, uri: `file://${runtimeLog}` },
137
+ { type: ArtifactType.TRACE, uri: `file://${traceFile}` },
138
+ { type: ArtifactType.REPORT, uri: `file://${reportPaths.reportHtmlPath}` },
139
+ ];
140
+ let reportInfo;
141
+ if (run.ok && report.ok) {
142
+ const html = await readFile(reportPaths.reportHtmlPath, "utf8");
143
+ const reportBundleZipPath = reportPaths.bundleDir ? await zipDirectoryToFile(reportPaths.bundleDir, reportStem) : undefined;
144
+ reportInfo = {
145
+ reportHtmlPath: reportPaths.reportHtmlPath,
146
+ reportName: reportPaths.reportName,
147
+ reportFormat: outputFormat,
148
+ reportBundleDir: reportPaths.bundleDir,
149
+ reportBundleZipPath,
150
+ };
151
+ context?.onReportProgress?.({
152
+ reportHtml: html,
153
+ reportFormat: outputFormat,
154
+ partial: false,
155
+ reportName: reportPaths.reportName,
156
+ reportBundleZipUri: reportBundleZipPath ? `file://${reportBundleZipPath}` : undefined,
157
+ });
158
+ if (reportBundleZipPath)
159
+ artifacts.push({ type: ArtifactType.REPORT, uri: `file://${reportBundleZipPath}` });
160
+ }
161
+ return {
162
+ ok: run.ok && report.ok,
163
+ message: run.ok && report.ok ? "airtest execution success" : run.stderr || report.stderr || "airtest execution failed",
164
+ artifacts,
165
+ reportInfo,
166
+ };
167
+ }
168
+ }
@@ -0,0 +1,191 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { constants as FsConstants } from "node:fs";
6
+ import { PlatformType } from "../../shared-kernel/enums/index.js";
7
+ const INDEX_NAME = "url-cache-index.json";
8
+ const CACHE_SUBDIR = "url-cache";
9
+ function isProbablyHttpUrl(ref) {
10
+ return /^https?:\/\//i.test(ref.trim());
11
+ }
12
+ function normalizePackageUrl(raw) {
13
+ const u = new URL(raw.trim());
14
+ u.hash = "";
15
+ return u.toString();
16
+ }
17
+ function urlCacheKey(normalizedUrl, platform) {
18
+ return createHash("sha256").update(`${normalizedUrl}\n${platform}`, "utf8").digest("hex");
19
+ }
20
+ function extensionForPlatform(platform) {
21
+ switch (platform) {
22
+ case PlatformType.ANDROID:
23
+ return ".apk";
24
+ case PlatformType.IOS:
25
+ return ".ipa";
26
+ case PlatformType.HARMONY:
27
+ return ".hap";
28
+ default:
29
+ return ".pkg";
30
+ }
31
+ }
32
+ function guessExtensionFromUrl(sourceUrl, platform) {
33
+ try {
34
+ const u = new URL(sourceUrl);
35
+ const base = u.pathname.split("/").pop() ?? "";
36
+ const m = /\.(apk|ipa|hap)$/i.exec(base);
37
+ if (m)
38
+ return m[0].toLowerCase();
39
+ }
40
+ catch {
41
+ /* ignore */
42
+ }
43
+ return extensionForPlatform(platform);
44
+ }
45
+ /**
46
+ * 按 **规范化后的 http(s) 安装链接 + 目标平台** 主键复用本地包:命中则不再下载,直接用于安装;索引持久化在缓存目录。
47
+ */
48
+ export class AppPackageUrlCache {
49
+ cacheDir;
50
+ indexPath;
51
+ agentId;
52
+ onChanged;
53
+ fetchImpl;
54
+ index = { version: 1, entries: {} };
55
+ tails = new Map();
56
+ constructor(opts) {
57
+ this.agentId = opts.agentId;
58
+ this.onChanged = opts.onChanged;
59
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
60
+ const base = (opts.downloadDir?.trim() || path.join(tmpdir(), "agent-app-packages")).replace(/\/+$/, "");
61
+ this.cacheDir = path.join(base, CACHE_SUBDIR);
62
+ this.indexPath = path.join(this.cacheDir, INDEX_NAME);
63
+ }
64
+ getCacheDir() {
65
+ return this.cacheDir;
66
+ }
67
+ listItems() {
68
+ return Object.values(this.index.entries).map((e) => ({
69
+ url: e.url,
70
+ platform: e.platform,
71
+ localPath: path.join(this.cacheDir, e.fileName),
72
+ byteSize: e.byteSize,
73
+ downloadedAt: e.downloadedAt,
74
+ }));
75
+ }
76
+ async bootstrap() {
77
+ await mkdir(this.cacheDir, { recursive: true });
78
+ await this.loadIndex();
79
+ const prunedEmitted = await this.pruneMissingFiles();
80
+ if (this.listItems().length > 0 && !prunedEmitted) {
81
+ await this.fireChanged();
82
+ }
83
+ }
84
+ async resolveHttpToLocalPackage(rawUrl, platform) {
85
+ const normalized = normalizePackageUrl(rawUrl.trim());
86
+ const key = urlCacheKey(normalized, platform);
87
+ const prev = this.tails.get(key) ?? Promise.resolve();
88
+ const mine = prev.then(() => this.downloadOrUseCached(normalized, platform, key));
89
+ this.tails.set(key, mine);
90
+ try {
91
+ return await mine;
92
+ }
93
+ finally {
94
+ if (this.tails.get(key) === mine) {
95
+ this.tails.delete(key);
96
+ }
97
+ }
98
+ }
99
+ async downloadOrUseCached(normalizedUrl, platform, indexKey) {
100
+ await mkdir(this.cacheDir, { recursive: true });
101
+ await this.loadIndex();
102
+ const existing = this.index.entries[indexKey];
103
+ if (existing) {
104
+ const abs = path.join(this.cacheDir, existing.fileName);
105
+ try {
106
+ await access(abs, FsConstants.R_OK);
107
+ const noop = async () => { };
108
+ return { localPath: abs, cleanupTemp: noop };
109
+ }
110
+ catch {
111
+ delete this.index.entries[indexKey];
112
+ await this.saveIndex();
113
+ }
114
+ }
115
+ const ext = guessExtensionFromUrl(normalizedUrl, platform);
116
+ const fileName = `${indexKey}${ext}`;
117
+ const destPart = path.join(this.cacheDir, `${fileName}.part`);
118
+ const destFinal = path.join(this.cacheDir, fileName);
119
+ const res = await this.fetchImpl(normalizedUrl, { redirect: "follow" });
120
+ if (!res.ok) {
121
+ throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
122
+ }
123
+ const buf = Buffer.from(await res.arrayBuffer());
124
+ await writeFile(destPart, buf);
125
+ await rename(destPart, destFinal);
126
+ const st = await stat(destFinal);
127
+ const downloadedAt = new Date().toISOString();
128
+ this.index.entries[indexKey] = {
129
+ url: normalizedUrl,
130
+ platform,
131
+ fileName,
132
+ byteSize: st.size,
133
+ downloadedAt,
134
+ };
135
+ await this.saveIndex();
136
+ await this.fireChanged();
137
+ const noop = async () => { };
138
+ return { localPath: destFinal, cleanupTemp: noop };
139
+ }
140
+ async loadIndex() {
141
+ try {
142
+ const raw = await readFile(this.indexPath, "utf8");
143
+ const parsed = JSON.parse(raw);
144
+ if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") {
145
+ this.index = parsed;
146
+ }
147
+ }
148
+ catch {
149
+ this.index = { version: 1, entries: {} };
150
+ }
151
+ }
152
+ async saveIndex() {
153
+ const tmp = `${this.indexPath}.${process.pid}.tmp`;
154
+ await writeFile(tmp, JSON.stringify(this.index, null, 2), "utf8");
155
+ await rename(tmp, this.indexPath);
156
+ }
157
+ async pruneMissingFiles() {
158
+ let changed = false;
159
+ for (const [k, e] of Object.entries(this.index.entries)) {
160
+ const abs = path.join(this.cacheDir, e.fileName);
161
+ try {
162
+ await access(abs, FsConstants.R_OK);
163
+ }
164
+ catch {
165
+ delete this.index.entries[k];
166
+ changed = true;
167
+ }
168
+ }
169
+ if (changed) {
170
+ await this.saveIndex();
171
+ await this.fireChanged();
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+ /** 供 HTTP 查询:从磁盘刷新索引并清理幽灵项 */
177
+ async snapshotForHttp() {
178
+ await this.loadIndex();
179
+ await this.pruneMissingFiles();
180
+ return { agentId: this.agentId, items: this.listItems() };
181
+ }
182
+ async fireChanged() {
183
+ if (!this.onChanged)
184
+ return;
185
+ await this.onChanged({ agentId: this.agentId, items: this.listItems() });
186
+ }
187
+ /** 安装入口:http(s) 走缓存,其它仍由调用方用原有 resolve 逻辑 */
188
+ static isHttpRef(appRef) {
189
+ return isProbablyHttpUrl(appRef);
190
+ }
191
+ }
@@ -0,0 +1,13 @@
1
+ import path from "node:path";
2
+ export function resolveAppPackageDownloadDir(rawDownloadDir, env = process.env, cwd = process.cwd()) {
3
+ const trimmed = rawDownloadDir?.trim();
4
+ if (!trimmed)
5
+ return undefined;
6
+ if (path.isAbsolute(trimmed))
7
+ return trimmed;
8
+ const agentHome = env.AGENT_HOME?.trim();
9
+ if (agentHome) {
10
+ return path.resolve(agentHome, trimmed);
11
+ }
12
+ return path.resolve(cwd, trimmed);
13
+ }
@@ -0,0 +1,88 @@
1
+ import { AdapterRegistry } from "../adapters/AdapterRegistry.js";
2
+ import { AndroidResourceAdapter } from "../adapters/android/AndroidResourceAdapter.js";
3
+ import { buildAndroidAdbCliPrefix, buildHarmonyHdcShellPrefix } from "../adapters/deviceDetailsProbe.js";
4
+ import { HarmonyResourceAdapter } from "../adapters/harmony/HarmonyResourceAdapter.js";
5
+ import { IOSResourceAdapter } from "../adapters/ios/IOSResourceAdapter.js";
6
+ import { ShellCommandExecutor, ShellSnapshotProvider } from "../adapters/real/ShellCommandAndSnapshot.js";
7
+ import { MidsceneRuntimeReal } from "../midscene/MidsceneRuntimeReal.js";
8
+ import { NodeCommandRunner } from "../system/CommandRunner.js";
9
+ import path from "node:path";
10
+ export function buildRuntimeContext(env) {
11
+ const repoRoot = process.cwd();
12
+ const commandRunner = new NodeCommandRunner();
13
+ const harmonyDiscoveryCommand = env.HARMONY_DISCOVERY_COMMAND ?? "hdc list targets";
14
+ const iosDiscoveryCommand = env.IOS_DISCOVERY_COMMAND ?? "xcrun xctrace list devices";
15
+ const debugCommandTemplate = env.DEBUG_COMMAND_TEMPLATE ?? "echo '{command}'";
16
+ const snapshotCommandTemplate = env.SNAPSHOT_COMMAND_TEMPLATE ?? "echo 'file:///tmp/automation-agent-snapshot-{resourceId}.png'";
17
+ const defaultMidsceneRunner = path.join(repoRoot, "scripts", "run-midscene-task.sh");
18
+ const midsceneRunCommand = env.MIDSCENE_RUN_COMMAND ?? `bash "${defaultMidsceneRunner}" "{scriptFile}"`;
19
+ const midsceneRunTimeoutMs = Number(env.MIDSCENE_RUN_TIMEOUT_MS ?? "300000");
20
+ const iosWdaPortRangeStart = Number(env.IOS_WDA_PORT_RANGE_START ?? "8200");
21
+ const iosWdaPortRangeEnd = Number(env.IOS_WDA_PORT_RANGE_END ?? "8399");
22
+ const iosWdaHealthUrlTemplate = env.IOS_WDA_HEALTH_URL_TEMPLATE ?? "http://127.0.0.1:{wdaPort}/status";
23
+ const iosWdaHost = env.MIDSCENE_IOS_WDA_HOST?.trim() || env.IOS_WDA_HOST?.trim() || "127.0.0.1";
24
+ const defaultWdaStartScript = path.join(repoRoot, "scripts", "start-ios-wda.sh");
25
+ const iosWdaStartCommandTemplate = env.IOS_WDA_START_COMMAND_TEMPLATE?.trim() || `bash "${defaultWdaStartScript}" "{deviceId}" "{wdaPort}"`;
26
+ const iosWdaPortMapFilePath = env.IOS_WDA_PORT_MAP_FILE_PATH?.trim() || path.join(repoRoot, ".wda-agent-state", "wda-port-map.json");
27
+ const iosWdaStartupTimeoutMs = Number(env.IOS_WDA_STARTUP_TIMEOUT_MS ?? "120000");
28
+ const iosWdaStartupRetry = Number(env.IOS_WDA_STARTUP_RETRY ?? "2");
29
+ const androidAdbHost = env.MIDSCENE_ANDROID_ADB_HOST?.trim() || env.AGENT_ANDROID_ADB_HOST?.trim() || "127.0.0.1";
30
+ const androidAdbPort = Number(env.MIDSCENE_ANDROID_ADB_PORT ?? env.AGENT_ANDROID_ADB_PORT ?? "5037");
31
+ const androidDiscoveryCommand = env.ANDROID_DISCOVERY_COMMAND?.trim() ||
32
+ `${buildAndroidAdbCliPrefix(androidAdbHost, androidAdbPort)} devices`;
33
+ const harmonyHdcPath = env.MIDSCENE_HARMONY_HDC_PATH?.trim() || env.AGENT_HARMONY_HDC_PATH?.trim() || undefined;
34
+ const harmonyHdcHost = env.MIDSCENE_HARMONY_HDC_HOST?.trim() || env.AGENT_HARMONY_HDC_HOST?.trim() || undefined;
35
+ const harmonyHdcPortRaw = Number(env.MIDSCENE_HARMONY_HDC_PORT ?? env.AGENT_HARMONY_HDC_PORT ?? "");
36
+ const harmonyHdcPortResolved = Number.isFinite(harmonyHdcPortRaw) && harmonyHdcPortRaw > 0 ? Math.floor(harmonyHdcPortRaw) : undefined;
37
+ const iosWdaExplicitRaw = Number(env.MIDSCENE_IOS_WDA_PORT ?? env.IOS_WDA_PORT ?? "");
38
+ const iosWdaExplicitResolved = Number.isFinite(iosWdaExplicitRaw) && iosWdaExplicitRaw > 0 ? Math.floor(iosWdaExplicitRaw) : undefined;
39
+ const iosWdaMjpegPortRaw = env.MIDSCENE_IOS_WDA_MJPEG_PORT?.trim() || env.IOS_WDA_MJPEG_PORT?.trim() || "";
40
+ const iosWdaMjpegPortParsed = Number(iosWdaMjpegPortRaw);
41
+ const iosWdaMjpegPortResolved = iosWdaMjpegPortRaw !== "" && Number.isFinite(iosWdaMjpegPortParsed) && iosWdaMjpegPortParsed > 0
42
+ ? Math.floor(iosWdaMjpegPortParsed)
43
+ : undefined;
44
+ const harmonyHdcShellPrefix = buildHarmonyHdcShellPrefix(harmonyHdcPath, harmonyHdcHost, harmonyHdcPortResolved);
45
+ const resourceAdapters = [
46
+ new HarmonyResourceAdapter(commandRunner, harmonyDiscoveryCommand, harmonyHdcShellPrefix),
47
+ new AndroidResourceAdapter(commandRunner, androidDiscoveryCommand, androidAdbHost, androidAdbPort),
48
+ new IOSResourceAdapter(commandRunner, iosDiscoveryCommand),
49
+ ];
50
+ const adapterRegistry = new AdapterRegistry(resourceAdapters, new ShellCommandExecutor(commandRunner, debugCommandTemplate), new ShellSnapshotProvider(commandRunner, snapshotCommandTemplate));
51
+ const midsceneRuntime = new MidsceneRuntimeReal(commandRunner, midsceneRunCommand, {
52
+ runCommandTimeoutMs: Number.isFinite(midsceneRunTimeoutMs) && midsceneRunTimeoutMs > 0 ? Math.floor(midsceneRunTimeoutMs) : 120_000,
53
+ iosWdaPortRangeStart: Number.isFinite(iosWdaPortRangeStart) && iosWdaPortRangeStart > 0 ? Math.floor(iosWdaPortRangeStart) : 8200,
54
+ iosWdaPortRangeEnd: Number.isFinite(iosWdaPortRangeEnd) && iosWdaPortRangeEnd > 0 ? Math.floor(iosWdaPortRangeEnd) : 8399,
55
+ iosWdaHealthUrlTemplate,
56
+ iosWdaHost,
57
+ iosWdaStartCommandTemplate,
58
+ iosWdaPortMapFilePath,
59
+ iosWdaStartupTimeoutMs: Number.isFinite(iosWdaStartupTimeoutMs) && iosWdaStartupTimeoutMs > 0
60
+ ? Math.floor(iosWdaStartupTimeoutMs)
61
+ : 120_000,
62
+ iosWdaStartupRetry: Number.isFinite(iosWdaStartupRetry) && iosWdaStartupRetry > 0 ? Math.floor(iosWdaStartupRetry) : 2,
63
+ androidAdbHost,
64
+ androidAdbPort: Number.isFinite(androidAdbPort) && androidAdbPort > 0 ? Math.floor(androidAdbPort) : 5037,
65
+ harmonyHdcPath,
66
+ harmonyHdcHost,
67
+ harmonyHdcPort: harmonyHdcPortResolved,
68
+ });
69
+ return {
70
+ adapterRegistry,
71
+ midsceneRuntime,
72
+ commandRunner,
73
+ selectedMode: "REAL",
74
+ harmonyDebugHdc: {
75
+ path: harmonyHdcPath,
76
+ host: harmonyHdcHost,
77
+ port: harmonyHdcPortResolved,
78
+ },
79
+ iosDebugWda: {
80
+ host: iosWdaHost,
81
+ portMapFilePath: iosWdaPortMapFilePath,
82
+ portRangeStart: Number.isFinite(iosWdaPortRangeStart) && iosWdaPortRangeStart > 0 ? Math.floor(iosWdaPortRangeStart) : 8200,
83
+ portRangeEnd: Number.isFinite(iosWdaPortRangeEnd) && iosWdaPortRangeEnd > 0 ? Math.floor(iosWdaPortRangeEnd) : 8399,
84
+ explicitWdaPort: iosWdaExplicitResolved,
85
+ ...(iosWdaMjpegPortResolved != null ? { mjpegPort: iosWdaMjpegPortResolved } : {}),
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,150 @@
1
+ import { readdir, rm, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ async function entryRecursiveBytes(absPath) {
4
+ const st = await stat(absPath);
5
+ if (st.isFile() || st.isSymbolicLink()) {
6
+ return st.isFile() ? st.size : 0;
7
+ }
8
+ if (!st.isDirectory()) {
9
+ return 0;
10
+ }
11
+ let sum = 0;
12
+ const dirents = await readdir(absPath, { withFileTypes: true });
13
+ for (const d of dirents) {
14
+ const child = path.join(absPath, d.name);
15
+ if (d.isDirectory()) {
16
+ sum += await entryRecursiveBytes(child);
17
+ }
18
+ else if (d.isFile()) {
19
+ try {
20
+ sum += (await stat(child)).size;
21
+ }
22
+ catch {
23
+ /* 并发删除等 */
24
+ }
25
+ }
26
+ }
27
+ return sum;
28
+ }
29
+ async function listChildStats(rootResolved) {
30
+ let names;
31
+ try {
32
+ names = await readdir(rootResolved);
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ const out = [];
38
+ for (const name of names) {
39
+ const absPath = path.join(rootResolved, name);
40
+ let st;
41
+ try {
42
+ st = await stat(absPath);
43
+ }
44
+ catch {
45
+ continue;
46
+ }
47
+ const bytes = st.isDirectory() || st.isFile() ? await entryRecursiveBytes(absPath) : 0;
48
+ const birth = st.birthtimeMs > 0 ? st.birthtimeMs : st.mtimeMs;
49
+ const sortKey = Math.min(st.mtimeMs, birth);
50
+ out.push({ absPath, bytes, sortKey });
51
+ }
52
+ return out;
53
+ }
54
+ function totalBytes(children) {
55
+ return children.reduce((a, c) => a + c.bytes, 0);
56
+ }
57
+ /**
58
+ * 在单个根目录下,将其**直接子项**视为可删除单元;总占用超过 `maxBytes` 时按 `sortKey`(更偏创建/修改时间中较早者)从小到大删除,直到不超限或无可删项。
59
+ * 不递归挑选「子树内部的单个文件」,避免拆坏 Midscene 报告目录结构。
60
+ */
61
+ export async function trimDirectoryToMaxBytes(rootDir, maxBytes) {
62
+ const deletedPaths = [];
63
+ let freedBytes = 0;
64
+ const rootResolved = path.resolve(rootDir);
65
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
66
+ const children = await listChildStats(rootResolved);
67
+ return { deletedPaths, freedBytes, finalTotalBytes: totalBytes(children) };
68
+ }
69
+ let guard = 0;
70
+ while (guard < 50_000) {
71
+ guard += 1;
72
+ const children = await listChildStats(rootResolved);
73
+ const sum = totalBytes(children);
74
+ if (sum <= maxBytes) {
75
+ return { deletedPaths, freedBytes, finalTotalBytes: sum };
76
+ }
77
+ if (children.length === 0) {
78
+ return { deletedPaths, freedBytes, finalTotalBytes: sum };
79
+ }
80
+ children.sort((a, b) => a.sortKey - b.sortKey || a.absPath.localeCompare(b.absPath));
81
+ const victim = children[0];
82
+ if (!victim) {
83
+ return { deletedPaths, freedBytes, finalTotalBytes: sum };
84
+ }
85
+ try {
86
+ await rm(victim.absPath, { recursive: true, force: true });
87
+ deletedPaths.push(victim.absPath);
88
+ freedBytes += victim.bytes;
89
+ }
90
+ catch {
91
+ /* 可能正在被写入;下一轮再试 */
92
+ }
93
+ }
94
+ const children = await listChildStats(rootResolved);
95
+ return { deletedPaths, freedBytes, finalTotalBytes: totalBytes(children) };
96
+ }
97
+ /**
98
+ * 定时按容量裁剪多个根目录(各自独立上限均为 `maxBytes`)。
99
+ * @returns `stop` 清除定时器;不会等待进行中的 tick。
100
+ */
101
+ export function startDirCapacityWatchdog(options) {
102
+ const roots = [...new Set(options.roots.map((r) => path.resolve(r.trim())).filter(Boolean))];
103
+ const maxBytes = Math.floor(options.maxBytes);
104
+ const intervalMs = Math.floor(options.intervalMs);
105
+ const log = options.log;
106
+ const tick = async () => {
107
+ for (const root of roots) {
108
+ try {
109
+ const r = await trimDirectoryToMaxBytes(root, maxBytes);
110
+ if (r.deletedPaths.length > 0) {
111
+ log?.(`[CacheWatchdog] root=${root} 删除 ${r.deletedPaths.length} 项,释放约 ${r.freedBytes} 字节,剩余约 ${r.finalTotalBytes} / ${maxBytes}`);
112
+ }
113
+ }
114
+ catch (err) {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ log?.(`[CacheWatchdog] root=${root} 清理失败: ${msg}`);
117
+ }
118
+ }
119
+ };
120
+ const id = setInterval(() => {
121
+ void tick();
122
+ }, intervalMs);
123
+ if (typeof id.unref === "function") {
124
+ id.unref();
125
+ }
126
+ return {
127
+ stop: () => {
128
+ clearInterval(id);
129
+ },
130
+ };
131
+ }
132
+ /** 解析如 `1073741824`、`512m`、`2G`(二进制前缀)为正整数字节;无法解析时返回 0 */
133
+ export function parsePositiveByteCount(raw) {
134
+ if (raw == null)
135
+ return 0;
136
+ const compact = raw.trim().replace(/\s+/g, "");
137
+ if (!compact)
138
+ return 0;
139
+ const m = /^(\d+(?:\.\d+)?)([kmgt])?b?$/i.exec(compact);
140
+ if (!m) {
141
+ const n = Number(compact);
142
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
143
+ }
144
+ const base = Number(m[1]);
145
+ if (!Number.isFinite(base) || base <= 0)
146
+ return 0;
147
+ const u = (m[2] ?? "").toLowerCase();
148
+ const mul = u === "k" ? 1024 : u === "m" ? 1024 ** 2 : u === "g" ? 1024 ** 3 : u === "t" ? 1024 ** 4 : 1;
149
+ return Math.floor(base * mul);
150
+ }