android-midscene-automation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +160 -0
  2. package/bin/android-midscene-automation.js +27 -0
  3. package/index.html +12 -0
  4. package/package.json +49 -0
  5. package/remote-agent/index.ts +206 -0
  6. package/server/appium-recorder/appium-runner.ts +427 -0
  7. package/server/appium-recorder/repository.ts +228 -0
  8. package/server/appium-recorder/routes.ts +219 -0
  9. package/server/config-store.ts +167 -0
  10. package/server/config.ts +130 -0
  11. package/server/device-locks/repository.ts +147 -0
  12. package/server/device-locks/service.ts +72 -0
  13. package/server/device-locks/types.ts +22 -0
  14. package/server/device-sessions/repository.ts +169 -0
  15. package/server/device-sessions/service.ts +59 -0
  16. package/server/device-sessions/types.ts +24 -0
  17. package/server/http-api.ts +1389 -0
  18. package/server/model-call-usage-importer.ts +108 -0
  19. package/server/model-tester.ts +104 -0
  20. package/server/model-usage-repository.ts +131 -0
  21. package/server/operations/repository.ts +218 -0
  22. package/server/operations/service.ts +84 -0
  23. package/server/operations/types.ts +27 -0
  24. package/server/paths.ts +27 -0
  25. package/server/remote-agents/protocol.ts +38 -0
  26. package/server/remote-agents/registry.ts +136 -0
  27. package/server/remote-agents/routes.ts +89 -0
  28. package/server/script-agent.ts +284 -0
  29. package/server/script-db.ts +281 -0
  30. package/server/script-runner.ts +551 -0
  31. package/server/storage/sqlite.ts +49 -0
  32. package/server/test-case-import/formatter.ts +28 -0
  33. package/server/test-case-import/parsers/excel.ts +69 -0
  34. package/server/test-case-import/parsers/txt.ts +11 -0
  35. package/server/test-case-import/parsers/word.ts +9 -0
  36. package/server/test-case-import/service.ts +58 -0
  37. package/server/test-case-import/text-normalizer.ts +98 -0
  38. package/server/test-case-import/types.ts +24 -0
  39. package/server/test-case-import/validator.ts +34 -0
  40. package/src/App.vue +1450 -0
  41. package/src/api.ts +290 -0
  42. package/src/appium-recorder/AppiumPage.vue +894 -0
  43. package/src/appium-recorder/api.ts +64 -0
  44. package/src/appium-recorder/components/ComponentTree.vue +44 -0
  45. package/src/appium-recorder/components/NodeDetail.vue +152 -0
  46. package/src/appium-recorder/components/RecordedSteps.vue +79 -0
  47. package/src/appium-recorder/tree.ts +129 -0
  48. package/src/appium-recorder/types.ts +88 -0
  49. package/src/assets/device-actions/back.svg +5 -0
  50. package/src/assets/device-actions/home.svg +3 -0
  51. package/src/assets/device-actions/power.svg +5 -0
  52. package/src/assets/device-actions/tasks.svg +3 -0
  53. package/src/assets/device-actions/volume-down.svg +3 -0
  54. package/src/assets/device-actions/volume-up.svg +3 -0
  55. package/src/components/config/ModelUsageChart.vue +188 -0
  56. package/src/components/device/DevicePreviewPanel.vue +266 -0
  57. package/src/components/generator/GeneratedCodePanel.vue +70 -0
  58. package/src/components/generator/TestCaseFileUpload.vue +97 -0
  59. package/src/config/midscene-model-presets.ts +75 -0
  60. package/src/config/prompt-example.ts +6 -0
  61. package/src/main.ts +7 -0
  62. package/src/pages/AiGeneratorPage.vue +90 -0
  63. package/src/pages/AutomationPage.vue +161 -0
  64. package/src/pages/ConfigPage.vue +273 -0
  65. package/src/pages/GeneratorPage.vue +97 -0
  66. package/src/pages/ManualStepsPage.vue +179 -0
  67. package/src/script-generator/codegen.ts +126 -0
  68. package/src/script-generator/index.ts +4 -0
  69. package/src/script-generator/presets.ts +37 -0
  70. package/src/script-generator/step-options.ts +52 -0
  71. package/src/script-generator/types.ts +27 -0
  72. package/src/style.css +1983 -0
  73. package/src/types.ts +157 -0
  74. package/src/vite-env.d.ts +1 -0
  75. package/tsconfig.app.json +8 -0
  76. package/tsconfig.json +11 -0
  77. package/tsconfig.node.json +16 -0
  78. package/vite.config.ts +28 -0
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { appPath } from './paths';
4
+ import { saveModelUsageRecord } from './model-usage-repository';
5
+
6
+ type MidsceneModelConfig = {
7
+ provider?: string;
8
+ name?: string;
9
+ family?: string;
10
+ };
11
+
12
+ type ModelCallUsage = {
13
+ prompt_tokens?: number;
14
+ completion_tokens?: number;
15
+ total_tokens?: number;
16
+ time_cost?: number;
17
+ model_name?: string;
18
+ response_model_name?: string;
19
+ model_description?: string;
20
+ };
21
+
22
+ type ModelCallRecord = {
23
+ timestamp?: string;
24
+ type?: string;
25
+ modelFamily?: string;
26
+ provider?: string;
27
+ final?: {
28
+ usage?: ModelCallUsage;
29
+ timeCost?: number;
30
+ };
31
+ };
32
+
33
+ function modelRequestDir() {
34
+ return appPath('midscene_run', 'model-requests');
35
+ }
36
+
37
+ function numberValue(value: unknown) {
38
+ return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : undefined;
39
+ }
40
+
41
+ function listModelRequestFilesSince(sinceMs: number) {
42
+ const dir = modelRequestDir();
43
+ if (!fs.existsSync(dir)) return [];
44
+
45
+ return fs
46
+ .readdirSync(dir)
47
+ .filter((name) => name.endsWith('.jsonl'))
48
+ .map((name) => path.join(dir, name))
49
+ .filter((filePath) => {
50
+ const stat = fs.statSync(filePath);
51
+ return stat.mtimeMs >= sinceMs - 1_000;
52
+ });
53
+ }
54
+
55
+ function recordsFromFile(filePath: string) {
56
+ return fs
57
+ .readFileSync(filePath, 'utf8')
58
+ .split(/\r?\n/)
59
+ .map((line) => line.trim())
60
+ .filter(Boolean)
61
+ .flatMap((line) => {
62
+ try {
63
+ return [JSON.parse(line) as ModelCallRecord];
64
+ } catch {
65
+ return [];
66
+ }
67
+ });
68
+ }
69
+
70
+ export function importMidsceneModelUsage(input: {
71
+ sinceMs: number;
72
+ model: MidsceneModelConfig;
73
+ success: boolean;
74
+ }) {
75
+ let imported = 0;
76
+ const files = listModelRequestFilesSince(input.sinceMs);
77
+
78
+ for (const filePath of files) {
79
+ for (const record of recordsFromFile(filePath)) {
80
+ if (record.type !== 'response') continue;
81
+ const usage = record.final?.usage;
82
+ if (!usage) continue;
83
+
84
+ const promptTokens = numberValue(usage.prompt_tokens);
85
+ const completionTokens = numberValue(usage.completion_tokens);
86
+ const totalTokens = numberValue(usage.total_tokens) ?? numberValue((promptTokens || 0) + (completionTokens || 0));
87
+
88
+ saveModelUsageRecord({
89
+ modelKey: 'midscene',
90
+ provider: input.model.provider || (record.provider === 'codex-app-server' ? 'codex' : 'custom'),
91
+ modelName: usage.model_name || usage.response_model_name || input.model.name || '',
92
+ family: input.model.family || record.modelFamily || '',
93
+ success: input.success,
94
+ durationMs: numberValue(usage.time_cost) ?? numberValue(record.final?.timeCost) ?? 0,
95
+ promptTokens,
96
+ completionTokens,
97
+ totalTokens,
98
+ createdAt: record.timestamp,
99
+ });
100
+ imported += 1;
101
+ }
102
+ }
103
+
104
+ return {
105
+ imported,
106
+ files,
107
+ };
108
+ }
@@ -0,0 +1,104 @@
1
+ import { execFile } from 'node:child_process';
2
+ import OpenAI from 'openai';
3
+
4
+ type ModelInput = {
5
+ provider?: 'custom' | 'codex';
6
+ baseUrl: string;
7
+ apiKey: string;
8
+ name: string;
9
+ family?: string;
10
+ };
11
+
12
+ const CODEX_MODEL_NAMES = new Set([
13
+ 'gpt-5.4',
14
+ 'gpt-5.5',
15
+ 'gpt-5.6-sol',
16
+ 'gpt-5.6-terra',
17
+ 'gpt-5.6-luna',
18
+ ]);
19
+
20
+ type ModelUsage = {
21
+ promptTokens?: number;
22
+ completionTokens?: number;
23
+ totalTokens?: number;
24
+ };
25
+
26
+ type ModelTestResult = {
27
+ ok: boolean;
28
+ content: string;
29
+ durationMs: number;
30
+ usage?: ModelUsage;
31
+ };
32
+
33
+ function execCodex(args: string[]) {
34
+ return new Promise<string>((resolve, reject) => {
35
+ execFile('codex', args, { timeout: 10_000 }, (error, stdout, stderr) => {
36
+ if (error) {
37
+ reject(new Error((stderr || error.message).trim()));
38
+ return;
39
+ }
40
+ resolve((stdout || stderr).trim());
41
+ });
42
+ });
43
+ }
44
+
45
+ async function testCodexConnection(input: ModelInput): Promise<ModelTestResult> {
46
+ const startedAt = Date.now();
47
+ if (input.baseUrl !== 'codex://app-server' || !input.name || input.family !== 'gpt-5') {
48
+ throw new Error('Codex 配置不完整,请使用 codex://app-server、gpt-5 family 和支持的 GPT-5 模型');
49
+ }
50
+
51
+ if (!CODEX_MODEL_NAMES.has(input.name)) {
52
+ throw new Error(`Codex 模型不在当前支持列表:${Array.from(CODEX_MODEL_NAMES).join('、')}`);
53
+ }
54
+
55
+ const loginStatus = await execCodex(['login', 'status']);
56
+ await execCodex(['app-server', '--help']);
57
+
58
+ return {
59
+ ok: true,
60
+ content: `Codex 可用:${loginStatus || '已登录'};app-server 可用;模型 ${input.name}/${input.family}`,
61
+ durationMs: Date.now() - startedAt,
62
+ };
63
+ }
64
+
65
+ export async function testModelConnection(input: ModelInput): Promise<ModelTestResult> {
66
+ if (input.provider === 'codex' || input.baseUrl === 'codex://app-server') {
67
+ return testCodexConnection(input);
68
+ }
69
+
70
+ if (!input.baseUrl || !input.apiKey || !input.name) {
71
+ throw new Error('模型配置不完整');
72
+ }
73
+
74
+ const startedAt = Date.now();
75
+ const client = new OpenAI({
76
+ apiKey: input.apiKey,
77
+ baseURL: input.baseUrl,
78
+ });
79
+
80
+ const result = await client.chat.completions.create({
81
+ model: input.name,
82
+ temperature: 0,
83
+ messages: [
84
+ {
85
+ role: 'user',
86
+ content: 'Reply with OK only.',
87
+ },
88
+ ],
89
+ });
90
+
91
+ const content = result.choices[0]?.message?.content?.trim() || '';
92
+ const usage: ModelUsage = {
93
+ promptTokens: result.usage?.prompt_tokens,
94
+ completionTokens: result.usage?.completion_tokens,
95
+ totalTokens: result.usage?.total_tokens,
96
+ };
97
+
98
+ return {
99
+ ok: true,
100
+ content,
101
+ durationMs: Date.now() - startedAt,
102
+ usage,
103
+ };
104
+ }
@@ -0,0 +1,131 @@
1
+ import {
2
+ createId,
3
+ querySql,
4
+ runSql,
5
+ sqlNullableString,
6
+ sqlString,
7
+ } from './storage/sqlite';
8
+
9
+ export type ModelUsageRecord = {
10
+ id: string;
11
+ modelKey: 'midscene' | 'scriptOptimizer';
12
+ provider: string;
13
+ modelName: string;
14
+ family: string;
15
+ success: boolean;
16
+ durationMs: number;
17
+ promptTokens?: number;
18
+ completionTokens?: number;
19
+ totalTokens?: number;
20
+ errorMessage?: string;
21
+ createdAt: string;
22
+ };
23
+
24
+ type ModelUsageRow = {
25
+ id: string;
26
+ model_key: 'midscene' | 'scriptOptimizer';
27
+ provider: string;
28
+ model_name: string;
29
+ family: string | null;
30
+ success: 0 | 1;
31
+ duration_ms: number;
32
+ prompt_tokens: number | null;
33
+ completion_tokens: number | null;
34
+ total_tokens: number | null;
35
+ error_message: string | null;
36
+ created_at: string;
37
+ };
38
+
39
+ let initialized = false;
40
+
41
+ function sqlNullableNumber(value: number | null | undefined) {
42
+ return typeof value === 'number' && Number.isFinite(value) ? String(Math.max(0, Math.floor(value))) : 'NULL';
43
+ }
44
+
45
+ function rowToRecord(row: ModelUsageRow): ModelUsageRecord {
46
+ return {
47
+ id: row.id,
48
+ modelKey: row.model_key,
49
+ provider: row.provider,
50
+ modelName: row.model_name,
51
+ family: row.family || '',
52
+ success: row.success === 1,
53
+ durationMs: row.duration_ms,
54
+ promptTokens: row.prompt_tokens ?? undefined,
55
+ completionTokens: row.completion_tokens ?? undefined,
56
+ totalTokens: row.total_tokens ?? undefined,
57
+ errorMessage: row.error_message || undefined,
58
+ createdAt: row.created_at,
59
+ };
60
+ }
61
+
62
+ export function initModelUsageRepository() {
63
+ if (initialized) return;
64
+ initialized = true;
65
+ runSql(`
66
+ PRAGMA journal_mode = WAL;
67
+ CREATE TABLE IF NOT EXISTS model_usage_records (
68
+ id TEXT PRIMARY KEY,
69
+ model_key TEXT NOT NULL,
70
+ provider TEXT NOT NULL,
71
+ model_name TEXT NOT NULL,
72
+ family TEXT NULL,
73
+ success INTEGER NOT NULL,
74
+ duration_ms INTEGER NOT NULL,
75
+ prompt_tokens INTEGER NULL,
76
+ completion_tokens INTEGER NULL,
77
+ total_tokens INTEGER NULL,
78
+ error_message TEXT NULL,
79
+ created_at TEXT NOT NULL
80
+ );
81
+ CREATE INDEX IF NOT EXISTS idx_model_usage_records_created_at ON model_usage_records(created_at DESC);
82
+ `);
83
+ }
84
+
85
+ export function saveModelUsageRecord(input: Omit<ModelUsageRecord, 'id' | 'createdAt'> & { createdAt?: string }) {
86
+ initModelUsageRepository();
87
+ const id = createId('usage');
88
+ const createdAt = input.createdAt || new Date().toISOString();
89
+ runSql(`
90
+ INSERT INTO model_usage_records (
91
+ id,
92
+ model_key,
93
+ provider,
94
+ model_name,
95
+ family,
96
+ success,
97
+ duration_ms,
98
+ prompt_tokens,
99
+ completion_tokens,
100
+ total_tokens,
101
+ error_message,
102
+ created_at
103
+ )
104
+ VALUES (
105
+ ${sqlString(id)},
106
+ ${sqlString(input.modelKey)},
107
+ ${sqlString(input.provider || 'custom')},
108
+ ${sqlString(input.modelName || '')},
109
+ ${sqlNullableString(input.family)},
110
+ ${input.success ? 1 : 0},
111
+ ${sqlNullableNumber(input.durationMs)},
112
+ ${sqlNullableNumber(input.promptTokens)},
113
+ ${sqlNullableNumber(input.completionTokens)},
114
+ ${sqlNullableNumber(input.totalTokens)},
115
+ ${sqlNullableString(input.errorMessage)},
116
+ ${sqlString(createdAt)}
117
+ );
118
+ `);
119
+ return listModelUsageRecords(1)[0];
120
+ }
121
+
122
+ export function listModelUsageRecords(limit = 50) {
123
+ initModelUsageRepository();
124
+ const boundedLimit = Math.max(1, Math.min(200, Math.floor(limit)));
125
+ return querySql<ModelUsageRow>(`
126
+ SELECT *
127
+ FROM model_usage_records
128
+ ORDER BY datetime(created_at) DESC
129
+ LIMIT ${boundedLimit};
130
+ `).map(rowToRecord).reverse();
131
+ }
@@ -0,0 +1,218 @@
1
+ import {
2
+ createId,
3
+ querySql,
4
+ runSql,
5
+ sqlJson,
6
+ sqlNullableString,
7
+ sqlString,
8
+ } from '../storage/sqlite';
9
+ import type { OperationEventRecord, OperationRecord, OperationStatus } from './types';
10
+
11
+ type OperationRow = {
12
+ id: string;
13
+ kind: 'script_run';
14
+ status: OperationStatus;
15
+ script_name: string;
16
+ device_id: string;
17
+ session_id: string;
18
+ request_json: string;
19
+ result_json: string | null;
20
+ output: string | null;
21
+ file_path: string | null;
22
+ error_message: string | null;
23
+ created_at: string;
24
+ started_at: string;
25
+ finished_at: string | null;
26
+ };
27
+
28
+ type OperationEventRow = {
29
+ seq: number;
30
+ operation_id: string;
31
+ timestamp: string;
32
+ event_type: string;
33
+ message: string | null;
34
+ data_json: string;
35
+ };
36
+
37
+ let initialized = false;
38
+
39
+ function rowToOperation(row: OperationRow): OperationRecord {
40
+ return {
41
+ id: row.id,
42
+ kind: row.kind,
43
+ status: row.status,
44
+ scriptName: row.script_name,
45
+ deviceId: row.device_id,
46
+ sessionId: row.session_id,
47
+ request: JSON.parse(row.request_json || '{}') as Record<string, unknown>,
48
+ result: row.result_json ? JSON.parse(row.result_json) as Record<string, unknown> : null,
49
+ output: row.output || '',
50
+ filePath: row.file_path || '',
51
+ errorMessage: row.error_message || '',
52
+ createdAt: row.created_at,
53
+ startedAt: row.started_at,
54
+ finishedAt: row.finished_at || '',
55
+ };
56
+ }
57
+
58
+ function rowToEvent(row: OperationEventRow): OperationEventRecord {
59
+ return {
60
+ seq: row.seq,
61
+ operationId: row.operation_id,
62
+ timestamp: row.timestamp,
63
+ eventType: row.event_type,
64
+ message: row.message || '',
65
+ data: JSON.parse(row.data_json || '{}') as Record<string, unknown>,
66
+ };
67
+ }
68
+
69
+ export function initOperationRepository() {
70
+ if (initialized) return;
71
+ initialized = true;
72
+ runSql(`
73
+ PRAGMA journal_mode = WAL;
74
+ CREATE TABLE IF NOT EXISTS operations (
75
+ id TEXT PRIMARY KEY,
76
+ kind TEXT NOT NULL,
77
+ status TEXT NOT NULL,
78
+ script_name TEXT NOT NULL,
79
+ device_id TEXT NOT NULL,
80
+ session_id TEXT NOT NULL,
81
+ request_json TEXT NOT NULL DEFAULT '{}',
82
+ result_json TEXT NULL,
83
+ output TEXT NULL,
84
+ file_path TEXT NULL,
85
+ error_message TEXT NULL,
86
+ created_at TEXT NOT NULL,
87
+ started_at TEXT NOT NULL,
88
+ finished_at TEXT NULL
89
+ );
90
+ CREATE TABLE IF NOT EXISTS operation_events (
91
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ operation_id TEXT NOT NULL,
93
+ timestamp TEXT NOT NULL,
94
+ event_type TEXT NOT NULL,
95
+ message TEXT NULL,
96
+ data_json TEXT NOT NULL DEFAULT '{}'
97
+ );
98
+ CREATE INDEX IF NOT EXISTS idx_operations_created_at ON operations(created_at DESC);
99
+ CREATE INDEX IF NOT EXISTS idx_operations_device_status ON operations(device_id, status);
100
+ CREATE INDEX IF NOT EXISTS idx_operation_events_operation_id_seq ON operation_events(operation_id, seq);
101
+ `);
102
+ }
103
+
104
+ export function createOperationRecord(input: {
105
+ scriptName: string;
106
+ deviceId: string;
107
+ sessionId: string;
108
+ request: Record<string, unknown>;
109
+ }) {
110
+ initOperationRepository();
111
+ const id = createId('op');
112
+ const now = new Date().toISOString();
113
+ runSql(`
114
+ INSERT INTO operations (
115
+ id,
116
+ kind,
117
+ status,
118
+ script_name,
119
+ device_id,
120
+ session_id,
121
+ request_json,
122
+ created_at,
123
+ started_at
124
+ )
125
+ VALUES (
126
+ ${sqlString(id)},
127
+ 'script_run',
128
+ 'running',
129
+ ${sqlString(input.scriptName)},
130
+ ${sqlString(input.deviceId)},
131
+ ${sqlString(input.sessionId)},
132
+ ${sqlJson(input.request)},
133
+ ${sqlString(now)},
134
+ ${sqlString(now)}
135
+ );
136
+ `);
137
+ return getOperationRecord(id);
138
+ }
139
+
140
+ export function getOperationRecord(id: string) {
141
+ initOperationRepository();
142
+ const row = querySql<OperationRow>(`
143
+ SELECT *
144
+ FROM operations
145
+ WHERE id = ${sqlString(id)}
146
+ LIMIT 1;
147
+ `)[0];
148
+ return row ? rowToOperation(row) : null;
149
+ }
150
+
151
+ export function listOperationRecords(limit = 30) {
152
+ initOperationRepository();
153
+ return querySql<OperationRow>(`
154
+ SELECT *
155
+ FROM operations
156
+ ORDER BY datetime(created_at) DESC
157
+ LIMIT ${Math.max(1, Math.min(100, Math.floor(limit)))};
158
+ `).map(rowToOperation);
159
+ }
160
+
161
+ export function finishOperationRecord(input: {
162
+ id: string;
163
+ status: OperationStatus;
164
+ result?: Record<string, unknown> | null;
165
+ output?: string;
166
+ filePath?: string;
167
+ errorMessage?: string;
168
+ }) {
169
+ initOperationRepository();
170
+ const now = new Date().toISOString();
171
+ runSql(`
172
+ UPDATE operations
173
+ SET
174
+ status = ${sqlString(input.status)},
175
+ result_json = ${input.result === undefined ? 'result_json' : input.result === null ? 'NULL' : sqlJson(input.result)},
176
+ output = COALESCE(${input.output !== undefined ? sqlString(input.output) : 'NULL'}, output),
177
+ file_path = COALESCE(${sqlNullableString(input.filePath)}, file_path),
178
+ error_message = COALESCE(${sqlNullableString(input.errorMessage)}, error_message),
179
+ finished_at = ${sqlString(now)}
180
+ WHERE id = ${sqlString(input.id)};
181
+ `);
182
+ return getOperationRecord(input.id);
183
+ }
184
+
185
+ export function appendOperationEventRecord(input: {
186
+ operationId: string;
187
+ eventType: string;
188
+ message?: string;
189
+ data?: Record<string, unknown>;
190
+ }) {
191
+ initOperationRepository();
192
+ runSql(`
193
+ INSERT INTO operation_events (
194
+ operation_id,
195
+ timestamp,
196
+ event_type,
197
+ message,
198
+ data_json
199
+ )
200
+ VALUES (
201
+ ${sqlString(input.operationId)},
202
+ ${sqlString(new Date().toISOString())},
203
+ ${sqlString(input.eventType)},
204
+ ${sqlNullableString(input.message)},
205
+ ${sqlJson(input.data || {})}
206
+ );
207
+ `);
208
+ }
209
+
210
+ export function listOperationEventRecords(operationId: string) {
211
+ initOperationRepository();
212
+ return querySql<OperationEventRow>(`
213
+ SELECT *
214
+ FROM operation_events
215
+ WHERE operation_id = ${sqlString(operationId)}
216
+ ORDER BY seq ASC;
217
+ `).map(rowToEvent);
218
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ appendOperationEventRecord,
3
+ createOperationRecord,
4
+ finishOperationRecord,
5
+ getOperationRecord,
6
+ listOperationEventRecords,
7
+ listOperationRecords,
8
+ } from './repository';
9
+ import type { OperationStatus } from './types';
10
+
11
+ // 创建脚本执行记录;后续日志、步骤事件、最终结果都挂在这个 operation 下。
12
+ export function startScriptRunOperation(input: {
13
+ scriptName: string;
14
+ deviceId: string;
15
+ sessionId: string;
16
+ request: Record<string, unknown>;
17
+ }) {
18
+ const operation = createOperationRecord(input);
19
+ if (!operation) {
20
+ throw new Error('创建执行记录失败');
21
+ }
22
+ appendOperationEventRecord({
23
+ operationId: operation.id,
24
+ eventType: 'operation_start',
25
+ message: `开始执行脚本:${input.scriptName}`,
26
+ data: {
27
+ scriptName: input.scriptName,
28
+ deviceId: input.deviceId,
29
+ sessionId: input.sessionId,
30
+ },
31
+ });
32
+ return operation;
33
+ }
34
+
35
+ export function recordOperationEvent(input: {
36
+ operationId: string;
37
+ eventType: string;
38
+ message?: string;
39
+ data?: Record<string, unknown>;
40
+ }) {
41
+ appendOperationEventRecord(input);
42
+ }
43
+
44
+ export function finishScriptRunOperation(input: {
45
+ operationId: string;
46
+ status: OperationStatus;
47
+ result?: Record<string, unknown>;
48
+ output?: string;
49
+ filePath?: string;
50
+ errorMessage?: string;
51
+ }) {
52
+ recordOperationEvent({
53
+ operationId: input.operationId,
54
+ eventType: 'operation_finish',
55
+ message: input.errorMessage || `执行结束:${input.status}`,
56
+ data: {
57
+ status: input.status,
58
+ filePath: input.filePath,
59
+ },
60
+ });
61
+ return finishOperationRecord({
62
+ id: input.operationId,
63
+ status: input.status,
64
+ result: input.result || null,
65
+ output: input.output || '',
66
+ filePath: input.filePath || '',
67
+ errorMessage: input.errorMessage || '',
68
+ });
69
+ }
70
+
71
+ export function listOperations(limit?: number) {
72
+ return listOperationRecords(limit);
73
+ }
74
+
75
+ export function getOperationWithEvents(operationId: string) {
76
+ const operation = getOperationRecord(operationId);
77
+ if (!operation) {
78
+ return null;
79
+ }
80
+ return {
81
+ operation,
82
+ events: listOperationEventRecords(operationId),
83
+ };
84
+ }
@@ -0,0 +1,27 @@
1
+ export type OperationStatus = 'running' | 'succeeded' | 'failed' | 'cancelled';
2
+
3
+ export type OperationRecord = {
4
+ id: string;
5
+ kind: 'script_run';
6
+ status: OperationStatus;
7
+ scriptName: string;
8
+ deviceId: string;
9
+ sessionId: string;
10
+ request: Record<string, unknown>;
11
+ result: Record<string, unknown> | null;
12
+ output: string;
13
+ filePath: string;
14
+ errorMessage: string;
15
+ createdAt: string;
16
+ startedAt: string;
17
+ finishedAt: string;
18
+ };
19
+
20
+ export type OperationEventRecord = {
21
+ seq: number;
22
+ operationId: string;
23
+ timestamp: string;
24
+ eventType: string;
25
+ message: string;
26
+ data: Record<string, unknown>;
27
+ };
@@ -0,0 +1,27 @@
1
+ // App-root based path resolution.
2
+ //
3
+ // The standalone dev server runs with cwd = project root, so historically all
4
+ // runtime files (config.json, .midscene-app, scripts-output, midscene_run,
5
+ // .midscene-generated) were resolved against process.cwd(). When the app runs
6
+ // as a DSH plugin the harness process cwd is NOT the project root and must not
7
+ // be changed (DSH core packages rely on process.cwd()), so every runtime path
8
+ // now resolves through this module. The default root stays process.cwd() so
9
+ // the standalone flow behaves exactly as before.
10
+ import path from 'node:path';
11
+
12
+ let appRoot = process.cwd();
13
+
14
+ /** Point all app runtime paths at an explicit root (used by the DSH plugin). */
15
+ export function setAppRoot(root: string) {
16
+ appRoot = root;
17
+ }
18
+
19
+ /** The current app root (defaults to process.cwd()). */
20
+ export function getAppRoot() {
21
+ return appRoot;
22
+ }
23
+
24
+ /** Resolve one or more segments under the app root. */
25
+ export function appPath(...segments: string[]) {
26
+ return path.resolve(appRoot, ...segments);
27
+ }