the-android-mcp 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web-ui.js CHANGED
@@ -19,9 +19,11 @@ exports.DEFAULT_WEB_UI_HOST = '127.0.0.1';
19
19
  exports.DEFAULT_WEB_UI_PORT = 50000;
20
20
  const UPDATE_HINT = 'npm install -g the-android-mcp@latest';
21
21
  const MAX_BODY_BYTES = 1024 * 1024;
22
- const MAX_EVENTS = 500;
23
- const WORKFLOW_DIR = path_1.default.join(os_1.default.homedir(), '.the-android-mcp');
24
- const WORKFLOW_FILE = path_1.default.join(WORKFLOW_DIR, 'web-ui-workflows.json');
22
+ const MAX_EVENTS = 600;
23
+ const MAX_JOBS = 300;
24
+ const APP_STATE_DIR = path_1.default.join(os_1.default.homedir(), '.the-android-mcp');
25
+ const WORKFLOW_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-workflows.json');
26
+ const SESSION_EVENTS_FILE = path_1.default.join(APP_STATE_DIR, 'web-ui-session-events.ndjson');
25
27
  const SNAPSHOT_KINDS = [
26
28
  'radio',
27
29
  'display',
@@ -31,17 +33,41 @@ const SNAPSHOT_KINDS = [
31
33
  ];
32
34
  const serverStartedAt = Date.now();
33
35
  let eventSeq = 1;
36
+ let jobSeq = 1;
34
37
  const eventHistory = [];
35
38
  const sseClients = new Set();
36
39
  const metrics = {};
37
40
  const snapshotCache = {};
38
41
  let workflows = loadWorkflows();
42
+ const jobs = [];
43
+ const lanes = {};
39
44
  function nowIso() {
40
45
  return new Date().toISOString();
41
46
  }
47
+ function ensureAppStateDir() {
48
+ if (!fs_1.default.existsSync(APP_STATE_DIR)) {
49
+ fs_1.default.mkdirSync(APP_STATE_DIR, { recursive: true });
50
+ }
51
+ }
52
+ function appendSessionEvent(event) {
53
+ try {
54
+ ensureAppStateDir();
55
+ fs_1.default.appendFileSync(SESSION_EVENTS_FILE, `${JSON.stringify(event)}\n`, 'utf8');
56
+ }
57
+ catch {
58
+ // ignore event persistence failures
59
+ }
60
+ }
42
61
  function isSnapshotKind(value) {
43
62
  return SNAPSHOT_KINDS.includes(value);
44
63
  }
64
+ function isJobType(value) {
65
+ return (value === 'open_url' ||
66
+ value === 'snapshot_suite' ||
67
+ value === 'stress_run' ||
68
+ value === 'workflow_run' ||
69
+ value === 'device_profile');
70
+ }
45
71
  function clampInt(value, fallback, min, max) {
46
72
  if (typeof value !== 'number' || !Number.isFinite(value)) {
47
73
  return fallback;
@@ -49,41 +75,6 @@ function clampInt(value, fallback, min, max) {
49
75
  const parsed = Math.trunc(value);
50
76
  return Math.max(min, Math.min(max, parsed));
51
77
  }
52
- function trackMetric(name, durationMs, ok, errorMessage) {
53
- const entry = metrics[name] ?? {
54
- name,
55
- count: 0,
56
- success: 0,
57
- errors: 0,
58
- totalDurationMs: 0,
59
- lastDurationMs: 0,
60
- };
61
- entry.count += 1;
62
- entry.totalDurationMs += durationMs;
63
- entry.lastDurationMs = durationMs;
64
- entry.lastAt = nowIso();
65
- if (ok) {
66
- entry.success += 1;
67
- }
68
- else {
69
- entry.errors += 1;
70
- entry.lastError = errorMessage;
71
- }
72
- metrics[name] = entry;
73
- }
74
- async function withMetric(name, fn) {
75
- const startedAt = Date.now();
76
- try {
77
- const result = await fn();
78
- trackMetric(name, Date.now() - startedAt, true);
79
- return result;
80
- }
81
- catch (error) {
82
- const message = error instanceof Error ? error.message : String(error);
83
- trackMetric(name, Date.now() - startedAt, false, message);
84
- throw error;
85
- }
86
- }
87
78
  function pushEvent(type, message, data) {
88
79
  const event = {
89
80
  id: eventSeq++,
@@ -96,6 +87,7 @@ function pushEvent(type, message, data) {
96
87
  if (eventHistory.length > MAX_EVENTS) {
97
88
  eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
98
89
  }
90
+ appendSessionEvent(event);
99
91
  broadcastEvent(event);
100
92
  return event;
101
93
  }
@@ -126,6 +118,41 @@ function sendHtml(response, html) {
126
118
  response.setHeader('Content-Type', 'text/html; charset=utf-8');
127
119
  response.end(html);
128
120
  }
121
+ function trackMetric(name, durationMs, ok, errorMessage) {
122
+ const entry = metrics[name] ?? {
123
+ name,
124
+ count: 0,
125
+ success: 0,
126
+ errors: 0,
127
+ totalDurationMs: 0,
128
+ lastDurationMs: 0,
129
+ };
130
+ entry.count += 1;
131
+ entry.totalDurationMs += durationMs;
132
+ entry.lastDurationMs = durationMs;
133
+ entry.lastAt = nowIso();
134
+ if (ok) {
135
+ entry.success += 1;
136
+ }
137
+ else {
138
+ entry.errors += 1;
139
+ entry.lastError = errorMessage;
140
+ }
141
+ metrics[name] = entry;
142
+ }
143
+ async function withMetric(name, fn) {
144
+ const startedAt = Date.now();
145
+ try {
146
+ const result = await fn();
147
+ trackMetric(name, Date.now() - startedAt, true);
148
+ return result;
149
+ }
150
+ catch (error) {
151
+ const message = error instanceof Error ? error.message : String(error);
152
+ trackMetric(name, Date.now() - startedAt, false, message);
153
+ throw error;
154
+ }
155
+ }
129
156
  function compactStringSummary(value) {
130
157
  if (typeof value !== 'string') {
131
158
  return { type: typeof value };
@@ -133,7 +160,7 @@ function compactStringSummary(value) {
133
160
  return {
134
161
  length: value.length,
135
162
  lines: value.split('\n').length,
136
- preview: value.slice(0, 200),
163
+ preview: value.slice(0, 220),
137
164
  };
138
165
  }
139
166
  function summarizeObjectShape(input) {
@@ -157,7 +184,7 @@ function summarizeObjectShape(input) {
157
184
  result[key] = { type: 'array', length: value.length };
158
185
  continue;
159
186
  }
160
- if (typeof value === 'object' && value !== null) {
187
+ if (typeof value === 'object') {
161
188
  result[key] = { type: 'object', keys: Object.keys(value).length };
162
189
  continue;
163
190
  }
@@ -165,27 +192,49 @@ function summarizeObjectShape(input) {
165
192
  }
166
193
  return result;
167
194
  }
195
+ function defaultWorkflows() {
196
+ const createdAt = nowIso();
197
+ return {
198
+ 'smoke-web-flow': {
199
+ name: 'smoke-web-flow',
200
+ description: 'Open URLs and collect lightweight snapshots.',
201
+ updatedAt: createdAt,
202
+ steps: [
203
+ { type: 'open_url', url: 'https://www.wikipedia.org', waitForReadyMs: 900 },
204
+ { type: 'snapshot', snapshot: 'radio' },
205
+ { type: 'open_url', url: 'https://news.ycombinator.com', waitForReadyMs: 900 },
206
+ { type: 'snapshot', snapshot: 'display' },
207
+ ],
208
+ },
209
+ 'diagnostic-suite': {
210
+ name: 'diagnostic-suite',
211
+ description: 'Run complete v3 snapshot suite.',
212
+ updatedAt: createdAt,
213
+ steps: [{ type: 'snapshot_suite', packageName: 'com.android.chrome' }],
214
+ },
215
+ };
216
+ }
168
217
  function normalizeWorkflowStep(value) {
169
218
  if (!value || typeof value !== 'object') {
170
- throw new Error('Invalid workflow step (must be object)');
219
+ throw new Error('Workflow step must be an object');
171
220
  }
172
221
  const step = value;
173
222
  const type = step.type;
174
223
  if (type !== 'open_url' && type !== 'snapshot' && type !== 'snapshot_suite' && type !== 'sleep_ms') {
175
- throw new Error('Invalid workflow step type');
224
+ throw new Error('Unsupported workflow step type');
176
225
  }
177
226
  const normalized = { type };
178
227
  if (type === 'open_url') {
179
228
  if (typeof step.url !== 'string' || !/^https?:\/\//i.test(step.url)) {
180
- throw new Error('Workflow open_url step requires valid url');
229
+ throw new Error('open_url step requires valid url');
181
230
  }
182
231
  normalized.url = step.url;
183
- normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1200, 200, 10000);
232
+ normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1000, 200, 10000);
184
233
  return normalized;
185
234
  }
186
235
  if (type === 'snapshot') {
187
236
  if (typeof step.snapshot !== 'string' || !isSnapshotKind(step.snapshot)) {
188
- throw new Error('Workflow snapshot step requires valid snapshot kind');
237
+ throw new Error('snapshot step requires valid snapshot kind');
189
238
  }
190
239
  normalized.snapshot = step.snapshot;
191
240
  if (typeof step.packageName === 'string') {
@@ -200,42 +249,12 @@ function normalizeWorkflowStep(value) {
200
249
  }
201
250
  return normalized;
202
251
  }
203
- normalized.durationMs = clampInt(step.durationMs, 500, 50, 20000);
252
+ normalized.durationMs = clampInt(step.durationMs, 500, 50, 30000);
204
253
  return normalized;
205
254
  }
206
- function ensureWorkflowStore() {
207
- if (!fs_1.default.existsSync(WORKFLOW_DIR)) {
208
- fs_1.default.mkdirSync(WORKFLOW_DIR, { recursive: true });
209
- }
210
- }
211
- function defaultWorkflows() {
212
- const createdAt = nowIso();
213
- const smoke = {
214
- name: 'smoke-web-flow',
215
- description: 'Open three URLs and capture radio/display quickly.',
216
- updatedAt: createdAt,
217
- steps: [
218
- { type: 'open_url', url: 'https://www.wikipedia.org', waitForReadyMs: 1000 },
219
- { type: 'snapshot', snapshot: 'radio' },
220
- { type: 'open_url', url: 'https://news.ycombinator.com', waitForReadyMs: 1000 },
221
- { type: 'snapshot', snapshot: 'display' },
222
- { type: 'open_url', url: 'https://developer.android.com', waitForReadyMs: 1000 },
223
- ],
224
- };
225
- const diagnostic = {
226
- name: 'diagnostic-suite',
227
- description: 'Capture full v3 snapshot suite for current device.',
228
- updatedAt: createdAt,
229
- steps: [{ type: 'snapshot_suite', packageName: 'com.android.chrome' }],
230
- };
231
- return {
232
- [smoke.name]: smoke,
233
- [diagnostic.name]: diagnostic,
234
- };
235
- }
236
255
  function loadWorkflows() {
237
256
  try {
238
- ensureWorkflowStore();
257
+ ensureAppStateDir();
239
258
  if (!fs_1.default.existsSync(WORKFLOW_FILE)) {
240
259
  const defaults = defaultWorkflows();
241
260
  fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(defaults, null, 2), 'utf8');
@@ -243,7 +262,7 @@ function loadWorkflows() {
243
262
  }
244
263
  const raw = fs_1.default.readFileSync(WORKFLOW_FILE, 'utf8');
245
264
  const parsed = JSON.parse(raw);
246
- if (!parsed || typeof parsed !== 'object') {
265
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
247
266
  return defaultWorkflows();
248
267
  }
249
268
  const result = {};
@@ -255,8 +274,19 @@ function loadWorkflows() {
255
274
  if (typeof item.name !== 'string' || item.name.trim().length === 0) {
256
275
  continue;
257
276
  }
258
- const stepsValue = Array.isArray(item.steps) ? item.steps : [];
259
- const steps = stepsValue.map(step => normalizeWorkflowStep(step));
277
+ const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
278
+ const steps = [];
279
+ for (const stepRaw of stepsRaw) {
280
+ try {
281
+ steps.push(normalizeWorkflowStep(stepRaw));
282
+ }
283
+ catch {
284
+ // skip invalid step
285
+ }
286
+ }
287
+ if (steps.length === 0) {
288
+ continue;
289
+ }
260
290
  result[name] = {
261
291
  name,
262
292
  description: typeof item.description === 'string' ? item.description : undefined,
@@ -264,29 +294,26 @@ function loadWorkflows() {
264
294
  steps,
265
295
  };
266
296
  }
267
- if (Object.keys(result).length === 0) {
268
- return defaultWorkflows();
269
- }
270
- return result;
297
+ return Object.keys(result).length > 0 ? result : defaultWorkflows();
271
298
  }
272
299
  catch {
273
300
  return defaultWorkflows();
274
301
  }
275
302
  }
276
303
  function saveWorkflows() {
277
- ensureWorkflowStore();
304
+ ensureAppStateDir();
278
305
  fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(workflows, null, 2), 'utf8');
279
306
  }
280
307
  function workflowsList() {
281
308
  return Object.values(workflows).sort((a, b) => a.name.localeCompare(b.name));
282
309
  }
283
- async function sleepMs(durationMs) {
284
- await new Promise(resolve => setTimeout(resolve, durationMs));
285
- }
286
310
  function extractDeviceId(body) {
287
311
  const value = body.deviceId;
288
312
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
289
313
  }
314
+ async function sleepMs(durationMs) {
315
+ await new Promise(resolve => setTimeout(resolve, durationMs));
316
+ }
290
317
  function captureSnapshot(kind, options) {
291
318
  const includeRaw = options.includeRaw === true;
292
319
  if (kind === 'radio') {
@@ -361,7 +388,7 @@ function captureSnapshot(kind, options) {
361
388
  }
362
389
  function diffSnapshot(prev, next) {
363
390
  if (!prev || typeof prev !== 'object' || !next || typeof next !== 'object') {
364
- return { changedCount: 0, changed: [], note: 'No comparable snapshots' };
391
+ return { changedCount: 0, changed: [], note: 'No comparable snapshots available' };
365
392
  }
366
393
  const a = prev;
367
394
  const b = next;
@@ -370,23 +397,16 @@ function diffSnapshot(prev, next) {
370
397
  for (const key of keys) {
371
398
  const oldValue = a[key];
372
399
  const newValue = b[key];
373
- if (typeof oldValue === 'string' || typeof newValue === 'string') {
374
- const oldText = typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue);
375
- const newText = typeof newValue === 'string' ? newValue : JSON.stringify(newValue);
376
- if (oldText !== newText) {
377
- changed.push({
378
- key,
379
- type: 'text',
380
- beforeLength: oldText.length,
381
- afterLength: newText.length,
382
- });
383
- }
384
- continue;
385
- }
386
400
  const oldJson = JSON.stringify(oldValue);
387
401
  const newJson = JSON.stringify(newValue);
388
402
  if (oldJson !== newJson) {
389
- changed.push({ key, type: 'value', before: oldValue, after: newValue });
403
+ changed.push({
404
+ key,
405
+ beforeType: typeof oldValue,
406
+ afterType: typeof newValue,
407
+ beforeSize: oldJson ? oldJson.length : 0,
408
+ afterSize: newJson ? newJson.length : 0,
409
+ });
390
410
  }
391
411
  }
392
412
  return {
@@ -413,6 +433,114 @@ function getSnapshotSuite(deviceId, packageName) {
413
433
  },
414
434
  };
415
435
  }
436
+ function computeHealthScore(profile) {
437
+ let score = 100;
438
+ const radio = profile.radio;
439
+ const location = profile.location;
440
+ const packages = profile.packages;
441
+ if (radio && radio.airplaneMode === '1') {
442
+ score -= 25;
443
+ }
444
+ if (location && location.mode === '0') {
445
+ score -= 20;
446
+ }
447
+ if (packages && typeof packages.disabled === 'number' && packages.disabled > 20) {
448
+ score -= 10;
449
+ }
450
+ return Math.max(0, Math.min(100, score));
451
+ }
452
+ function buildDeviceProfile(deviceId, packageName) {
453
+ const startedAt = Date.now();
454
+ const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({
455
+ deviceId,
456
+ includeWifiDump: false,
457
+ includeTelephonyDump: false,
458
+ includeBluetoothDump: false,
459
+ includeIpState: true,
460
+ includeConnectivityDump: false,
461
+ });
462
+ const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
463
+ deviceId,
464
+ includeDisplayDump: false,
465
+ includeWindowDump: false,
466
+ includeSurfaceFlinger: false,
467
+ });
468
+ const location = (0, adb_js_1.captureAndroidLocationSnapshot)({
469
+ deviceId,
470
+ packageName,
471
+ includeLocationDump: false,
472
+ includeLocationAppOps: true,
473
+ });
474
+ const power = (0, adb_js_1.captureAndroidPowerIdleSnapshot)({
475
+ deviceId,
476
+ includePowerDump: false,
477
+ includeDeviceIdle: false,
478
+ includeBatteryStats: false,
479
+ includeThermal: false,
480
+ });
481
+ const packages = (0, adb_js_1.captureAndroidPackageInventorySnapshot)({
482
+ deviceId,
483
+ includeThirdParty: true,
484
+ includeSystem: true,
485
+ includeDisabled: true,
486
+ includePackagePaths: false,
487
+ includeFeatures: false,
488
+ packageListLines: 600,
489
+ });
490
+ const profile = {
491
+ capturedAt: nowIso(),
492
+ durationMs: Date.now() - startedAt,
493
+ deviceId: radio.deviceId,
494
+ radio: {
495
+ wifiEnabled: radio.wifiEnabled,
496
+ mobileDataEnabled: radio.mobileDataEnabled,
497
+ airplaneMode: radio.airplaneMode,
498
+ bluetoothEnabled: radio.bluetoothEnabled,
499
+ },
500
+ display: {
501
+ wmSize: display.wmSize,
502
+ wmDensity: display.wmDensity,
503
+ brightness: display.screenBrightness,
504
+ rotation: display.userRotation,
505
+ },
506
+ location: {
507
+ mode: location.locationMode,
508
+ mockLocation: location.mockLocation,
509
+ appOps: compactStringSummary(location.locationAppOps),
510
+ },
511
+ power: {
512
+ battery: compactStringSummary(power.battery),
513
+ },
514
+ packages: {
515
+ total: packages.packageCount,
516
+ thirdParty: packages.thirdPartyCount,
517
+ system: packages.systemCount,
518
+ disabled: packages.disabledCount,
519
+ },
520
+ };
521
+ profile.healthScore = computeHealthScore(profile);
522
+ return profile;
523
+ }
524
+ function buildProfilesForDevices(deviceIds, packageName) {
525
+ const startedAt = Date.now();
526
+ const profiles = [];
527
+ for (const deviceId of deviceIds) {
528
+ try {
529
+ const profile = buildDeviceProfile(deviceId, packageName);
530
+ profiles.push({ ok: true, deviceId, profile });
531
+ }
532
+ catch (error) {
533
+ const message = error instanceof Error ? error.message : String(error);
534
+ profiles.push({ ok: false, deviceId, error: message });
535
+ }
536
+ }
537
+ return {
538
+ capturedAt: nowIso(),
539
+ durationMs: Date.now() - startedAt,
540
+ count: profiles.length,
541
+ profiles,
542
+ };
543
+ }
416
544
  function runStressScenario(body) {
417
545
  const deviceId = extractDeviceId(body);
418
546
  const urlsValue = body.urls;
@@ -422,28 +550,28 @@ function runStressScenario(body) {
422
550
  const normalizedUrls = urls.length > 0
423
551
  ? urls
424
552
  : ['https://www.wikipedia.org', 'https://news.ycombinator.com', 'https://developer.android.com'];
425
- const loops = clampInt(body.loops, 1, 1, 5);
426
- const waitForReadyMs = clampInt(body.waitForReadyMs, 1000, 200, 6000);
553
+ const loops = clampInt(body.loops, 1, 1, 6);
554
+ const waitForReadyMs = clampInt(body.waitForReadyMs, 1000, 200, 7000);
427
555
  const includeSnapshotAfterEach = body.includeSnapshotAfterEach !== false;
428
556
  const startedAt = Date.now();
429
557
  const steps = [];
430
558
  for (let loop = 1; loop <= loops; loop += 1) {
431
- for (const url of normalizedUrls) {
432
- const actionStarted = Date.now();
433
- const open = (0, adb_js_1.openUrlInChrome)(url, deviceId, {
559
+ for (const targetUrl of normalizedUrls) {
560
+ const openStarted = Date.now();
561
+ const open = (0, adb_js_1.openUrlInChrome)(targetUrl, deviceId, {
434
562
  waitForReadyMs,
435
563
  fallbackToDefault: true,
436
564
  });
437
- const record = {
565
+ const step = {
438
566
  kind: 'open_url',
439
567
  loop,
440
- url,
568
+ url: targetUrl,
441
569
  strategy: open.strategy,
442
570
  deviceId: open.deviceId,
443
- durationMs: Date.now() - actionStarted,
571
+ durationMs: Date.now() - openStarted,
444
572
  };
445
573
  if (includeSnapshotAfterEach) {
446
- const snapshotStarted = Date.now();
574
+ const snapStarted = Date.now();
447
575
  const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({ deviceId: open.deviceId, includeWifiDump: false });
448
576
  const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
449
577
  deviceId: open.deviceId,
@@ -451,8 +579,8 @@ function runStressScenario(body) {
451
579
  includeWindowDump: false,
452
580
  includeSurfaceFlinger: false,
453
581
  });
454
- record.snapshot = {
455
- durationMs: Date.now() - snapshotStarted,
582
+ step.snapshot = {
583
+ durationMs: Date.now() - snapStarted,
456
584
  radio: {
457
585
  wifiEnabled: radio.wifiEnabled,
458
586
  mobileDataEnabled: radio.mobileDataEnabled,
@@ -461,11 +589,11 @@ function runStressScenario(body) {
461
589
  display: {
462
590
  wmSize: display.wmSize,
463
591
  wmDensity: display.wmDensity,
464
- screenBrightness: display.screenBrightness,
592
+ brightness: display.screenBrightness,
465
593
  },
466
594
  };
467
595
  }
468
- steps.push(record);
596
+ steps.push(step);
469
597
  }
470
598
  }
471
599
  return {
@@ -492,28 +620,27 @@ async function runWorkflow(name, options) {
492
620
  const stepStarted = Date.now();
493
621
  if (step.type === 'open_url') {
494
622
  const result = (0, adb_js_1.openUrlInChrome)(step.url || 'https://www.wikipedia.org', options.deviceId, {
495
- waitForReadyMs: clampInt(step.waitForReadyMs, 1200, 200, 10000),
623
+ waitForReadyMs: clampInt(step.waitForReadyMs, 1000, 200, 10000),
496
624
  fallbackToDefault: true,
497
625
  });
498
626
  outputs.push({
499
627
  step: 'open_url',
500
628
  url: step.url,
501
629
  strategy: result.strategy,
502
- deviceId: result.deviceId,
503
630
  durationMs: Date.now() - stepStarted,
504
631
  });
505
632
  continue;
506
633
  }
507
634
  if (step.type === 'snapshot') {
508
- const snapshotKind = step.snapshot || 'radio';
509
- const snapshot = captureSnapshot(snapshotKind, {
635
+ const kind = step.snapshot || 'radio';
636
+ const snapshot = captureSnapshot(kind, {
510
637
  deviceId: options.deviceId,
511
638
  packageName: step.packageName || options.packageName,
512
639
  includeRaw: step.includeRaw === true || options.includeRaw === true,
513
640
  });
514
641
  outputs.push({
515
642
  step: 'snapshot',
516
- snapshotKind,
643
+ snapshotKind: kind,
517
644
  durationMs: Date.now() - stepStarted,
518
645
  snapshot,
519
646
  });
@@ -528,12 +655,9 @@ async function runWorkflow(name, options) {
528
655
  });
529
656
  continue;
530
657
  }
531
- const duration = clampInt(step.durationMs, 500, 50, 20000);
658
+ const duration = clampInt(step.durationMs, 500, 50, 30000);
532
659
  await sleepMs(duration);
533
- outputs.push({
534
- step: 'sleep_ms',
535
- durationMs: duration,
536
- });
660
+ outputs.push({ step: 'sleep_ms', durationMs: duration });
537
661
  }
538
662
  return {
539
663
  ok: true,
@@ -548,6 +672,245 @@ async function runWorkflow(name, options) {
548
672
  updateHint: UPDATE_HINT,
549
673
  };
550
674
  }
675
+ function resolveLaneForDevice(deviceId) {
676
+ const laneId = deviceId ? `device:${deviceId}` : 'device:default';
677
+ if (!lanes[laneId]) {
678
+ lanes[laneId] = {
679
+ id: laneId,
680
+ deviceId,
681
+ active: false,
682
+ queue: [],
683
+ completed: 0,
684
+ failed: 0,
685
+ cancelled: 0,
686
+ updatedAt: nowIso(),
687
+ };
688
+ }
689
+ return lanes[laneId];
690
+ }
691
+ function createJob(type, input) {
692
+ const rawDeviceId = typeof input.deviceId === 'string' ? input.deviceId : undefined;
693
+ const lane = resolveLaneForDevice(rawDeviceId);
694
+ const job = {
695
+ id: jobSeq++,
696
+ type,
697
+ laneId: lane.id,
698
+ deviceId: lane.deviceId,
699
+ status: 'queued',
700
+ createdAt: nowIso(),
701
+ input,
702
+ };
703
+ jobs.unshift(job);
704
+ if (jobs.length > MAX_JOBS) {
705
+ jobs.splice(MAX_JOBS);
706
+ }
707
+ lane.queue.push(job.id);
708
+ lane.updatedAt = nowIso();
709
+ pushEvent('job-queued', 'Job queued', {
710
+ id: job.id,
711
+ type: job.type,
712
+ laneId: lane.id,
713
+ queueDepth: lane.queue.length,
714
+ });
715
+ void scheduleLanes();
716
+ return job;
717
+ }
718
+ function getJobById(id) {
719
+ return jobs.find(job => job.id === id);
720
+ }
721
+ function listJobSummaries() {
722
+ return jobs
723
+ .slice()
724
+ .sort((a, b) => b.id - a.id)
725
+ .map(job => ({
726
+ id: job.id,
727
+ type: job.type,
728
+ laneId: job.laneId,
729
+ deviceId: job.deviceId,
730
+ status: job.status,
731
+ createdAt: job.createdAt,
732
+ startedAt: job.startedAt,
733
+ finishedAt: job.finishedAt,
734
+ durationMs: job.durationMs,
735
+ error: job.error,
736
+ }));
737
+ }
738
+ function cancelQueuedJob(id) {
739
+ const job = getJobById(id);
740
+ if (!job || job.status !== 'queued') {
741
+ return false;
742
+ }
743
+ const lane = lanes[job.laneId];
744
+ if (!lane) {
745
+ return false;
746
+ }
747
+ const idx = lane.queue.indexOf(id);
748
+ if (idx >= 0) {
749
+ lane.queue.splice(idx, 1);
750
+ }
751
+ job.status = 'cancelled';
752
+ job.finishedAt = nowIso();
753
+ job.durationMs = 0;
754
+ lane.cancelled += 1;
755
+ lane.updatedAt = nowIso();
756
+ pushEvent('job-cancelled', 'Job cancelled', { id, laneId: lane.id });
757
+ return true;
758
+ }
759
+ function retryJob(id) {
760
+ const previous = getJobById(id);
761
+ if (!previous) {
762
+ return undefined;
763
+ }
764
+ if (previous.status !== 'failed' && previous.status !== 'completed' && previous.status !== 'cancelled') {
765
+ return undefined;
766
+ }
767
+ const cloneInput = JSON.parse(JSON.stringify(previous.input));
768
+ return createJob(previous.type, cloneInput);
769
+ }
770
+ async function executeJob(job) {
771
+ if (job.type === 'open_url') {
772
+ const urlValue = typeof job.input.url === 'string' ? job.input.url : '';
773
+ if (!urlValue || !/^https?:\/\//i.test(urlValue)) {
774
+ throw new Error('open_url job requires valid url');
775
+ }
776
+ const waitForReadyMs = clampInt(job.input.waitForReadyMs, 1000, 200, 10000);
777
+ return (0, adb_js_1.openUrlInChrome)(urlValue, job.deviceId, {
778
+ waitForReadyMs,
779
+ fallbackToDefault: true,
780
+ });
781
+ }
782
+ if (job.type === 'snapshot_suite') {
783
+ return getSnapshotSuite(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
784
+ }
785
+ if (job.type === 'stress_run') {
786
+ const input = { ...job.input };
787
+ if (!input.deviceId && job.deviceId) {
788
+ input.deviceId = job.deviceId;
789
+ }
790
+ return runStressScenario(input);
791
+ }
792
+ if (job.type === 'workflow_run') {
793
+ const workflowName = typeof job.input.name === 'string' ? job.input.name : '';
794
+ if (!workflowName) {
795
+ throw new Error('workflow_run job requires name');
796
+ }
797
+ return await runWorkflow(workflowName, {
798
+ deviceId: job.deviceId,
799
+ packageName: typeof job.input.packageName === 'string' ? job.input.packageName : undefined,
800
+ includeRaw: job.input.includeRaw === true,
801
+ });
802
+ }
803
+ return buildDeviceProfile(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
804
+ }
805
+ async function processLane(lane) {
806
+ if (lane.active) {
807
+ return;
808
+ }
809
+ lane.active = true;
810
+ lane.updatedAt = nowIso();
811
+ while (lane.queue.length > 0) {
812
+ const jobId = lane.queue.shift();
813
+ if (!jobId) {
814
+ continue;
815
+ }
816
+ const job = getJobById(jobId);
817
+ if (!job || job.status !== 'queued') {
818
+ continue;
819
+ }
820
+ job.status = 'running';
821
+ job.startedAt = nowIso();
822
+ lane.updatedAt = nowIso();
823
+ pushEvent('job-running', 'Job started', {
824
+ id: job.id,
825
+ type: job.type,
826
+ laneId: lane.id,
827
+ });
828
+ const startedAtMs = Date.now();
829
+ try {
830
+ const result = await executeJob(job);
831
+ job.result = result;
832
+ job.status = 'completed';
833
+ job.finishedAt = nowIso();
834
+ job.durationMs = Date.now() - startedAtMs;
835
+ lane.completed += 1;
836
+ lane.updatedAt = nowIso();
837
+ pushEvent('job-completed', 'Job completed', {
838
+ id: job.id,
839
+ type: job.type,
840
+ laneId: lane.id,
841
+ durationMs: job.durationMs,
842
+ });
843
+ }
844
+ catch (error) {
845
+ const message = error instanceof Error ? error.message : String(error);
846
+ job.status = 'failed';
847
+ job.error = message;
848
+ job.finishedAt = nowIso();
849
+ job.durationMs = Date.now() - startedAtMs;
850
+ lane.failed += 1;
851
+ lane.updatedAt = nowIso();
852
+ pushEvent('job-failed', 'Job failed', {
853
+ id: job.id,
854
+ type: job.type,
855
+ laneId: lane.id,
856
+ error: message,
857
+ });
858
+ }
859
+ }
860
+ lane.active = false;
861
+ lane.updatedAt = nowIso();
862
+ }
863
+ let laneSchedulerRunning = false;
864
+ async function scheduleLanes() {
865
+ if (laneSchedulerRunning) {
866
+ return;
867
+ }
868
+ laneSchedulerRunning = true;
869
+ const promises = [];
870
+ for (const lane of Object.values(lanes)) {
871
+ if (lane.queue.length > 0 && !lane.active) {
872
+ promises.push(processLane(lane));
873
+ }
874
+ }
875
+ await Promise.all(promises);
876
+ laneSchedulerRunning = false;
877
+ const hasPending = Object.values(lanes).some(lane => lane.queue.length > 0 && !lane.active);
878
+ if (hasPending) {
879
+ void scheduleLanes();
880
+ }
881
+ }
882
+ function lanesSummary() {
883
+ return Object.values(lanes)
884
+ .sort((a, b) => a.id.localeCompare(b.id))
885
+ .map(lane => ({
886
+ id: lane.id,
887
+ deviceId: lane.deviceId,
888
+ active: lane.active,
889
+ queueDepth: lane.queue.length,
890
+ completed: lane.completed,
891
+ failed: lane.failed,
892
+ cancelled: lane.cancelled,
893
+ updatedAt: lane.updatedAt,
894
+ }));
895
+ }
896
+ function resetSession(options) {
897
+ eventHistory.splice(0, eventHistory.length);
898
+ for (const key of Object.keys(metrics)) {
899
+ delete metrics[key];
900
+ }
901
+ for (const key of Object.keys(snapshotCache)) {
902
+ delete snapshotCache[key];
903
+ }
904
+ jobs.splice(0, jobs.length);
905
+ for (const key of Object.keys(lanes)) {
906
+ delete lanes[key];
907
+ }
908
+ if (!options.keepWorkflows) {
909
+ workflows = defaultWorkflows();
910
+ saveWorkflows();
911
+ }
912
+ pushEvent('session-reset', 'Session state reset', { keepWorkflows: options.keepWorkflows !== false });
913
+ }
551
914
  async function readJsonBody(request) {
552
915
  return await new Promise((resolve, reject) => {
553
916
  const chunks = [];
@@ -639,23 +1002,43 @@ function buildStatePayload(host, port) {
639
1002
  connectedDeviceCount: devices.length,
640
1003
  eventCount: eventHistory.length,
641
1004
  workflowCount: Object.keys(workflows).length,
1005
+ laneCount: Object.keys(lanes).length,
1006
+ queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
1007
+ jobCount: jobs.length,
642
1008
  snapshotKinds: SNAPSHOT_KINDS,
643
1009
  };
644
1010
  }
645
1011
  function buildMetricsPayload() {
646
- const items = Object.values(metrics)
1012
+ const entries = Object.values(metrics)
647
1013
  .sort((a, b) => b.count - a.count)
648
- .map(item => ({
649
- ...item,
650
- avgDurationMs: item.count > 0 ? Math.round((item.totalDurationMs / item.count) * 100) / 100 : 0,
651
- successRate: item.count > 0 ? Math.round((item.success / item.count) * 10000) / 100 : 0,
1014
+ .map(entry => ({
1015
+ ...entry,
1016
+ avgDurationMs: entry.count > 0 ? Math.round((entry.totalDurationMs / entry.count) * 100) / 100 : 0,
1017
+ successRate: entry.count > 0 ? Math.round((entry.success / entry.count) * 10000) / 100 : 0,
652
1018
  }));
653
1019
  return {
654
1020
  generatedAt: nowIso(),
655
1021
  uptimeMs: Date.now() - serverStartedAt,
656
- totalActions: items.reduce((sum, item) => sum + item.count, 0),
657
- errors: items.reduce((sum, item) => sum + item.errors, 0),
658
- entries: items,
1022
+ totalActions: entries.reduce((sum, item) => sum + item.count, 0),
1023
+ errors: entries.reduce((sum, item) => sum + item.errors, 0),
1024
+ entries,
1025
+ };
1026
+ }
1027
+ function buildSessionExport() {
1028
+ return {
1029
+ exportedAt: nowIso(),
1030
+ state: {
1031
+ uptimeMs: Date.now() - serverStartedAt,
1032
+ workflowCount: Object.keys(workflows).length,
1033
+ jobCount: jobs.length,
1034
+ laneCount: Object.keys(lanes).length,
1035
+ eventCount: eventHistory.length,
1036
+ },
1037
+ workflows,
1038
+ lanes: lanesSummary(),
1039
+ jobs,
1040
+ metrics: buildMetricsPayload(),
1041
+ events: eventHistory,
659
1042
  };
660
1043
  }
661
1044
  async function handleApi(request, response, context) {
@@ -691,34 +1074,186 @@ async function handleApi(request, response, context) {
691
1074
  });
692
1075
  return;
693
1076
  }
694
- if (method === 'GET' && pathname === '/api/history') {
695
- await withMetric('history', () => {
696
- const limitRaw = Number(url.searchParams.get('limit') ?? '120');
697
- const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(500, Math.trunc(limitRaw))) : 120;
1077
+ if (method === 'GET' && pathname === '/api/lanes') {
1078
+ await withMetric('lanes-list', () => {
698
1079
  sendJson(response, 200, {
699
- count: Math.min(limit, eventHistory.length),
700
- events: eventHistory.slice(-limit),
1080
+ lanes: lanesSummary(),
1081
+ count: Object.keys(lanes).length,
1082
+ });
1083
+ });
1084
+ return;
1085
+ }
1086
+ if (method === 'GET' && pathname === '/api/jobs') {
1087
+ await withMetric('jobs-list', () => {
1088
+ sendJson(response, 200, {
1089
+ jobs: listJobSummaries(),
1090
+ queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
1091
+ });
1092
+ });
1093
+ return;
1094
+ }
1095
+ if (method === 'POST' && pathname === '/api/jobs') {
1096
+ await withMetric('jobs-create', async () => {
1097
+ const body = await readJsonBody(request);
1098
+ const typeValue = typeof body.type === 'string' ? body.type : '';
1099
+ if (!isJobType(typeValue)) {
1100
+ sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run, device_profile' });
1101
+ return;
1102
+ }
1103
+ const input = body.input && typeof body.input === 'object' && !Array.isArray(body.input)
1104
+ ? body.input
1105
+ : {};
1106
+ const job = createJob(typeValue, input);
1107
+ sendJson(response, 200, {
1108
+ ok: true,
1109
+ job,
1110
+ });
1111
+ });
1112
+ return;
1113
+ }
1114
+ if (method === 'POST' && pathname === '/api/jobs/bulk') {
1115
+ await withMetric('jobs-bulk', async () => {
1116
+ const body = await readJsonBody(request);
1117
+ const itemsRaw = Array.isArray(body.jobs) ? body.jobs : [];
1118
+ if (itemsRaw.length === 0) {
1119
+ sendJson(response, 400, { error: 'jobs array is required' });
1120
+ return;
1121
+ }
1122
+ const created = [];
1123
+ for (const itemRaw of itemsRaw) {
1124
+ if (!itemRaw || typeof itemRaw !== 'object') {
1125
+ continue;
1126
+ }
1127
+ const item = itemRaw;
1128
+ const typeValue = typeof item.type === 'string' ? item.type : '';
1129
+ if (!isJobType(typeValue)) {
1130
+ continue;
1131
+ }
1132
+ const input = item.input && typeof item.input === 'object' && !Array.isArray(item.input)
1133
+ ? item.input
1134
+ : {};
1135
+ created.push(createJob(typeValue, input));
1136
+ }
1137
+ sendJson(response, 200, {
1138
+ ok: true,
1139
+ createdCount: created.length,
1140
+ jobs: created,
1141
+ });
1142
+ });
1143
+ return;
1144
+ }
1145
+ if (method === 'GET' && /^\/api\/jobs\/\d+$/.test(pathname)) {
1146
+ await withMetric('jobs-get', () => {
1147
+ const id = Number(pathname.slice('/api/jobs/'.length));
1148
+ const job = getJobById(id);
1149
+ if (!job) {
1150
+ sendJson(response, 404, { error: `job ${id} not found` });
1151
+ return;
1152
+ }
1153
+ sendJson(response, 200, { ok: true, job });
1154
+ });
1155
+ return;
1156
+ }
1157
+ if (method === 'POST' && /^\/api\/jobs\/\d+\/cancel$/.test(pathname)) {
1158
+ await withMetric('jobs-cancel', () => {
1159
+ const id = Number(pathname.split('/')[3]);
1160
+ const ok = cancelQueuedJob(id);
1161
+ if (!ok) {
1162
+ sendJson(response, 400, { error: `job ${id} cannot be cancelled` });
1163
+ return;
1164
+ }
1165
+ sendJson(response, 200, { ok: true, id });
1166
+ });
1167
+ return;
1168
+ }
1169
+ if (method === 'POST' && /^\/api\/jobs\/\d+\/retry$/.test(pathname)) {
1170
+ await withMetric('jobs-retry', () => {
1171
+ const id = Number(pathname.split('/')[3]);
1172
+ const retried = retryJob(id);
1173
+ if (!retried) {
1174
+ sendJson(response, 400, { error: `job ${id} cannot be retried` });
1175
+ return;
1176
+ }
1177
+ sendJson(response, 200, {
1178
+ ok: true,
1179
+ previousJobId: id,
1180
+ newJob: retried,
1181
+ });
1182
+ });
1183
+ return;
1184
+ }
1185
+ if (method === 'GET' && pathname === '/api/workflows') {
1186
+ await withMetric('workflows-list', () => {
1187
+ sendJson(response, 200, {
1188
+ workflows: workflowsList(),
1189
+ count: Object.keys(workflows).length,
1190
+ });
1191
+ });
1192
+ return;
1193
+ }
1194
+ if (method === 'GET' && pathname === '/api/workflows/export') {
1195
+ await withMetric('workflows-export', () => {
1196
+ sendJson(response, 200, {
1197
+ exportedAt: nowIso(),
1198
+ workflows,
1199
+ });
1200
+ });
1201
+ return;
1202
+ }
1203
+ if (method === 'POST' && pathname === '/api/workflows/import') {
1204
+ await withMetric('workflows-import', async () => {
1205
+ const body = await readJsonBody(request);
1206
+ const replace = body.replace === true;
1207
+ const payload = body.workflows;
1208
+ const imported = replace ? {} : { ...workflows };
1209
+ const consumeWorkflow = (value) => {
1210
+ if (!value || typeof value !== 'object') {
1211
+ return;
1212
+ }
1213
+ const item = value;
1214
+ const name = typeof item.name === 'string' ? item.name.trim() : '';
1215
+ if (!name) {
1216
+ return;
1217
+ }
1218
+ const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
1219
+ const steps = [];
1220
+ for (const stepRaw of stepsRaw) {
1221
+ steps.push(normalizeWorkflowStep(stepRaw));
1222
+ }
1223
+ if (steps.length === 0) {
1224
+ return;
1225
+ }
1226
+ imported[name] = {
1227
+ name,
1228
+ description: typeof item.description === 'string' ? item.description : undefined,
1229
+ updatedAt: nowIso(),
1230
+ steps,
1231
+ };
1232
+ };
1233
+ if (Array.isArray(payload)) {
1234
+ for (const value of payload) {
1235
+ consumeWorkflow(value);
1236
+ }
1237
+ }
1238
+ else if (payload && typeof payload === 'object') {
1239
+ for (const value of Object.values(payload)) {
1240
+ consumeWorkflow(value);
1241
+ }
1242
+ }
1243
+ else {
1244
+ sendJson(response, 400, { error: 'workflows must be array or object' });
1245
+ return;
1246
+ }
1247
+ workflows = imported;
1248
+ saveWorkflows();
1249
+ pushEvent('workflow-import', 'Workflows imported', {
1250
+ count: Object.keys(workflows).length,
1251
+ replace,
701
1252
  });
702
- });
703
- return;
704
- }
705
- if (method === 'GET' && pathname === '/api/events') {
706
- await withMetric('events', () => {
707
- setupSse(request, response);
708
- });
709
- return;
710
- }
711
- if (method === 'GET' && pathname === '/api/metrics') {
712
- await withMetric('metrics', () => {
713
- sendJson(response, 200, buildMetricsPayload());
714
- });
715
- return;
716
- }
717
- if (method === 'GET' && pathname === '/api/workflows') {
718
- await withMetric('workflows-list', () => {
719
1253
  sendJson(response, 200, {
720
- workflows: workflowsList(),
1254
+ ok: true,
721
1255
  count: Object.keys(workflows).length,
1256
+ workflows: workflowsList(),
722
1257
  });
723
1258
  });
724
1259
  return;
@@ -738,13 +1273,12 @@ async function handleApi(request, response, context) {
738
1273
  return;
739
1274
  }
740
1275
  const steps = stepsRaw.map(step => normalizeWorkflowStep(step));
741
- const workflow = {
1276
+ workflows[name] = {
742
1277
  name,
743
1278
  description,
744
1279
  updatedAt: nowIso(),
745
1280
  steps,
746
1281
  };
747
- workflows[name] = workflow;
748
1282
  saveWorkflows();
749
1283
  pushEvent('workflow-saved', 'Workflow saved', {
750
1284
  name,
@@ -752,7 +1286,7 @@ async function handleApi(request, response, context) {
752
1286
  });
753
1287
  sendJson(response, 200, {
754
1288
  ok: true,
755
- workflow,
1289
+ workflow: workflows[name],
756
1290
  });
757
1291
  });
758
1292
  return;
@@ -804,11 +1338,9 @@ async function handleApi(request, response, context) {
804
1338
  sendJson(response, 400, { error: 'url must be valid http/https URL' });
805
1339
  return;
806
1340
  }
807
- const deviceId = extractDeviceId(body);
808
- const waitForReadyMs = clampInt(body.waitForReadyMs, 1200, 200, 10000);
809
1341
  const startedAt = Date.now();
810
- const result = (0, adb_js_1.openUrlInChrome)(targetUrl, deviceId, {
811
- waitForReadyMs,
1342
+ const result = (0, adb_js_1.openUrlInChrome)(targetUrl, extractDeviceId(body), {
1343
+ waitForReadyMs: clampInt(body.waitForReadyMs, 1000, 200, 10000),
812
1344
  fallbackToDefault: true,
813
1345
  });
814
1346
  pushEvent('open-url', 'Opened URL on device', {
@@ -826,6 +1358,44 @@ async function handleApi(request, response, context) {
826
1358
  });
827
1359
  return;
828
1360
  }
1361
+ if (method === 'POST' && pathname === '/api/device/profile') {
1362
+ await withMetric('device-profile', async () => {
1363
+ const body = await readJsonBody(request);
1364
+ const profile = buildDeviceProfile(extractDeviceId(body), typeof body.packageName === 'string' ? body.packageName : undefined);
1365
+ pushEvent('device-profile', 'Device profile captured', {
1366
+ deviceId: profile.deviceId,
1367
+ durationMs: profile.durationMs,
1368
+ healthScore: profile.healthScore,
1369
+ });
1370
+ sendJson(response, 200, {
1371
+ ok: true,
1372
+ profile,
1373
+ updateHint: UPDATE_HINT,
1374
+ });
1375
+ });
1376
+ return;
1377
+ }
1378
+ if (method === 'POST' && pathname === '/api/device/profiles') {
1379
+ await withMetric('device-profiles', async () => {
1380
+ const body = await readJsonBody(request);
1381
+ const packageName = typeof body.packageName === 'string' ? body.packageName : undefined;
1382
+ const devicesRaw = Array.isArray(body.deviceIds) ? body.deviceIds : [];
1383
+ const requested = devicesRaw.filter((value) => typeof value === 'string' && value.trim().length > 0);
1384
+ const fallback = (0, adb_js_1.getConnectedDevices)().map(device => device.id);
1385
+ const targetDevices = requested.length > 0 ? requested : fallback;
1386
+ const profiles = buildProfilesForDevices(targetDevices, packageName);
1387
+ pushEvent('device-profiles', 'Multi-device profiles captured', {
1388
+ count: profiles.count,
1389
+ durationMs: profiles.durationMs,
1390
+ });
1391
+ sendJson(response, 200, {
1392
+ ok: true,
1393
+ ...profiles,
1394
+ updateHint: UPDATE_HINT,
1395
+ });
1396
+ });
1397
+ return;
1398
+ }
829
1399
  if (method === 'POST' && pathname === '/api/snapshot-suite') {
830
1400
  await withMetric('snapshot-suite', async () => {
831
1401
  const body = await readJsonBody(request);
@@ -849,23 +1419,23 @@ async function handleApi(request, response, context) {
849
1419
  sendJson(response, 400, { error: 'kind must be one of snapshot kinds' });
850
1420
  return;
851
1421
  }
852
- const prev = snapshotCache[kindValue];
1422
+ const previous = snapshotCache[kindValue];
853
1423
  const fresh = captureSnapshot(kindValue, {
854
1424
  deviceId: extractDeviceId(body),
855
1425
  packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
856
1426
  includeRaw: true,
857
1427
  });
858
- const currentRaw = fresh.raw ?? null;
859
- snapshotCache[kindValue] = currentRaw ?? snapshotCache[kindValue];
860
- const diff = diffSnapshot(prev, currentRaw);
861
- pushEvent('snapshot-diff', 'Captured snapshot diff', {
1428
+ const currentRaw = fresh.raw;
1429
+ snapshotCache[kindValue] = currentRaw;
1430
+ const diff = diffSnapshot(previous, currentRaw);
1431
+ pushEvent('snapshot-diff', 'Snapshot diff captured', {
862
1432
  kind: kindValue,
863
1433
  changedCount: diff.changedCount,
864
1434
  });
865
1435
  sendJson(response, 200, {
866
1436
  ok: true,
867
1437
  kind: kindValue,
868
- hadPrevious: Boolean(prev),
1438
+ hadPrevious: Boolean(previous),
869
1439
  diff,
870
1440
  currentSummary: fresh.summary,
871
1441
  updateHint: UPDATE_HINT,
@@ -886,7 +1456,7 @@ async function handleApi(request, response, context) {
886
1456
  packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
887
1457
  includeRaw: body.includeRaw === true,
888
1458
  });
889
- pushEvent('snapshot', 'Captured snapshot', {
1459
+ pushEvent('snapshot', 'Snapshot captured', {
890
1460
  kind: kindValue,
891
1461
  deviceId: result.deviceId,
892
1462
  });
@@ -902,7 +1472,7 @@ async function handleApi(request, response, context) {
902
1472
  await withMetric('stress-run', async () => {
903
1473
  const body = await readJsonBody(request);
904
1474
  const result = runStressScenario(body);
905
- pushEvent('stress-run', 'Completed stress run', {
1475
+ pushEvent('stress-run', 'Stress run completed', {
906
1476
  totalSteps: result.totalSteps,
907
1477
  durationMs: result.durationMs,
908
1478
  });
@@ -910,6 +1480,47 @@ async function handleApi(request, response, context) {
910
1480
  });
911
1481
  return;
912
1482
  }
1483
+ if (method === 'GET' && pathname === '/api/history') {
1484
+ await withMetric('history', () => {
1485
+ const limitRaw = Number(url.searchParams.get('limit') ?? '120');
1486
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(600, Math.trunc(limitRaw))) : 120;
1487
+ sendJson(response, 200, {
1488
+ count: Math.min(limit, eventHistory.length),
1489
+ events: eventHistory.slice(-limit),
1490
+ });
1491
+ });
1492
+ return;
1493
+ }
1494
+ if (method === 'GET' && pathname === '/api/events') {
1495
+ await withMetric('events', () => {
1496
+ setupSse(request, response);
1497
+ });
1498
+ return;
1499
+ }
1500
+ if (method === 'GET' && pathname === '/api/metrics') {
1501
+ await withMetric('metrics', () => {
1502
+ sendJson(response, 200, buildMetricsPayload());
1503
+ });
1504
+ return;
1505
+ }
1506
+ if (method === 'GET' && pathname === '/api/session/export') {
1507
+ await withMetric('session-export', () => {
1508
+ sendJson(response, 200, buildSessionExport());
1509
+ });
1510
+ return;
1511
+ }
1512
+ if (method === 'POST' && pathname === '/api/session/reset') {
1513
+ await withMetric('session-reset', async () => {
1514
+ const body = await readJsonBody(request);
1515
+ const keepWorkflows = body.keepWorkflows !== false;
1516
+ resetSession({ keepWorkflows });
1517
+ sendJson(response, 200, {
1518
+ ok: true,
1519
+ keepWorkflows,
1520
+ });
1521
+ });
1522
+ return;
1523
+ }
913
1524
  sendJson(response, 404, { error: 'Not found' });
914
1525
  }
915
1526
  const INDEX_HTML = String.raw `<!doctype html>
@@ -917,7 +1528,7 @@ const INDEX_HTML = String.raw `<!doctype html>
917
1528
  <head>
918
1529
  <meta charset="utf-8" />
919
1530
  <meta name="viewport" content="width=device-width,initial-scale=1" />
920
- <title>the-android-mcp web ui v3.2</title>
1531
+ <title>the-android-mcp web ui v3.4</title>
921
1532
  <link rel="preconnect" href="https://fonts.googleapis.com" />
922
1533
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
923
1534
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
@@ -948,7 +1559,7 @@ const INDEX_HTML = String.raw `<!doctype html>
948
1559
  linear-gradient(155deg, var(--bg0), var(--bg1) 44%, var(--bg2));
949
1560
  padding: 16px;
950
1561
  }
951
- .app { max-width: 1450px; margin: 0 auto; display: grid; gap: 12px; }
1562
+ .app { max-width: 1560px; margin: 0 auto; display: grid; gap: 12px; }
952
1563
  .hero {
953
1564
  border: 1px solid var(--line);
954
1565
  border-radius: 18px;
@@ -978,7 +1589,7 @@ const INDEX_HTML = String.raw `<!doctype html>
978
1589
  .grid {
979
1590
  display: grid;
980
1591
  gap: 10px;
981
- grid-template-columns: 1.1fr 1fr 1fr;
1592
+ grid-template-columns: 1fr 1fr 1fr;
982
1593
  }
983
1594
  .card {
984
1595
  border: 1px solid var(--line);
@@ -1000,13 +1611,13 @@ const INDEX_HTML = String.raw `<!doctype html>
1000
1611
  padding: 9px;
1001
1612
  font: inherit;
1002
1613
  }
1003
- textarea { min-height: 94px; resize: vertical; }
1614
+ textarea { min-height: 86px; resize: vertical; }
1004
1615
  button { cursor: pointer; font-weight: 700; transition: transform 120ms ease, filter 120ms ease; }
1005
1616
  button:hover { transform: translateY(-1px); filter: brightness(1.08); }
1006
1617
  .p { background: linear-gradient(130deg, #0d7666, #155f75); }
1007
1618
  .s { background: linear-gradient(130deg, #1d3348, #192b3c); }
1008
1619
  .w { background: linear-gradient(130deg, #7a5917, #61401f); }
1009
- .events, .metrics {
1620
+ .events, .metrics, .jobs, .lanes {
1010
1621
  max-height: 300px;
1011
1622
  overflow: auto;
1012
1623
  border: 1px solid #2f4a61;
@@ -1014,7 +1625,7 @@ const INDEX_HTML = String.raw `<!doctype html>
1014
1625
  padding: 8px;
1015
1626
  background: #0b141d;
1016
1627
  }
1017
- .event, .metric {
1628
+ .item {
1018
1629
  border: 1px solid #2e4a61;
1019
1630
  border-radius: 8px;
1020
1631
  padding: 6px;
@@ -1038,7 +1649,7 @@ const INDEX_HTML = String.raw `<!doctype html>
1038
1649
  pre {
1039
1650
  margin: 0;
1040
1651
  padding: 10px;
1041
- max-height: 450px;
1652
+ max-height: 460px;
1042
1653
  overflow: auto;
1043
1654
  color: #c6e4ff;
1044
1655
  font: 12px/1.45 'JetBrains Mono', monospace;
@@ -1058,15 +1669,17 @@ const INDEX_HTML = String.raw `<!doctype html>
1058
1669
  <span class="pill" id="version-pill">v3</span>
1059
1670
  <span class="pill" id="device-pill">device: n/a</span>
1060
1671
  <span class="pill" id="workflow-pill">workflows: 0</span>
1672
+ <span class="pill" id="lane-pill">lanes: 0</span>
1673
+ <span class="pill" id="queue-pill">queue: 0</span>
1061
1674
  <span class="pill">port: 50000</span>
1062
1675
  </div>
1063
- <h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.2 command center</h1>
1064
- <p class="muted">Live backend integration, workflow engine, snapshot diff, stress automation, and streaming events.</p>
1676
+ <h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.4 command center</h1>
1677
+ <p class="muted">Multi-lane backend orchestration, workflow import/export, profile matrix, snapshot diff, and live job controls.</p>
1065
1678
  </section>
1066
1679
 
1067
1680
  <section class="grid">
1068
1681
  <article class="card stack">
1069
- <h2>Device + URL actions</h2>
1682
+ <h2>Device operations</h2>
1070
1683
  <select id="device-select"></select>
1071
1684
  <input id="url-input" type="url" value="https://www.wikipedia.org" />
1072
1685
  <div class="split">
@@ -1079,11 +1692,15 @@ const INDEX_HTML = String.raw `<!doctype html>
1079
1692
  <button class="s quick-url" data-url="https://developer.android.com">Android Docs</button>
1080
1693
  <button class="s quick-url" data-url="https://www.youtube.com">YouTube</button>
1081
1694
  </div>
1695
+ <div class="split">
1696
+ <button class="s" id="profile-btn">Device profile</button>
1697
+ <button class="s" id="profiles-btn">Profiles all</button>
1698
+ </div>
1082
1699
  <p class="muted">Update hint: <code>npm install -g the-android-mcp@latest</code></p>
1083
1700
  </article>
1084
1701
 
1085
1702
  <article class="card stack">
1086
- <h2>Snapshot + diff</h2>
1703
+ <h2>Snapshot + stress</h2>
1087
1704
  <select id="snapshot-kind">
1088
1705
  <option value="radio">radio</option>
1089
1706
  <option value="display">display</option>
@@ -1095,48 +1712,74 @@ const INDEX_HTML = String.raw `<!doctype html>
1095
1712
  <button class="s" id="snapshot-btn">Capture</button>
1096
1713
  <button class="w" id="snapshot-diff-btn">Capture diff</button>
1097
1714
  </div>
1098
- <p class="muted">Diff compares latest capture with previous capture of same type.</p>
1099
- </article>
1100
-
1101
- <article class="card stack">
1102
- <h2>Stress run</h2>
1103
1715
  <textarea id="stress-urls">https://www.wikipedia.org
1104
1716
  https://news.ycombinator.com
1105
1717
  https://developer.android.com</textarea>
1106
1718
  <div class="split">
1107
- <input id="stress-loops" type="number" min="1" max="5" value="1" />
1108
- <input id="stress-wait" type="number" min="200" max="6000" value="1000" />
1719
+ <input id="stress-loops" type="number" min="1" max="6" value="1" />
1720
+ <input id="stress-wait" type="number" min="200" max="7000" value="1000" />
1109
1721
  </div>
1110
1722
  <button class="w" id="stress-btn">Run stress</button>
1111
- <p class="muted">Config: loops / wait-ms</p>
1112
1723
  </article>
1113
1724
 
1114
1725
  <article class="card stack">
1115
1726
  <h2>Workflow engine</h2>
1116
1727
  <select id="workflow-select"></select>
1117
- <input id="workflow-name" placeholder="workflow name" value="" />
1728
+ <input id="workflow-name" placeholder="workflow name" />
1118
1729
  <textarea id="workflow-steps">[
1119
- {"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":1000},
1730
+ {"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":900},
1120
1731
  {"type":"snapshot","snapshot":"radio"},
1121
- {"type":"open_url","url":"https://developer.android.com","waitForReadyMs":1000},
1122
1732
  {"type":"snapshot_suite","packageName":"com.android.chrome"}
1123
1733
  ]</textarea>
1124
1734
  <div class="split">
1125
1735
  <button class="p" id="workflow-save-btn">Save</button>
1126
1736
  <button class="p" id="workflow-run-btn">Run</button>
1127
1737
  </div>
1128
- <button class="s" id="workflow-delete-btn">Delete selected</button>
1738
+ <div class="split">
1739
+ <button class="s" id="workflow-delete-btn">Delete</button>
1740
+ <button class="s" id="workflow-export-btn">Export</button>
1741
+ </div>
1742
+ <button class="s" id="workflow-import-btn">Import from editor</button>
1129
1743
  </article>
1130
1744
 
1131
1745
  <article class="card stack">
1132
- <h2>Live events</h2>
1746
+ <h2>Job orchestrator</h2>
1747
+ <select id="job-type">
1748
+ <option value="open_url">open_url</option>
1749
+ <option value="snapshot_suite">snapshot_suite</option>
1750
+ <option value="stress_run">stress_run</option>
1751
+ <option value="workflow_run">workflow_run</option>
1752
+ <option value="device_profile">device_profile</option>
1753
+ </select>
1754
+ <input id="job-url" type="url" value="https://developer.android.com" />
1755
+ <select id="job-workflow"></select>
1756
+ <div class="split">
1757
+ <button class="p" id="job-enqueue-btn">Enqueue job</button>
1758
+ <button class="s" id="job-refresh-btn">Refresh jobs</button>
1759
+ </div>
1760
+ <textarea id="bulk-jobs">[
1761
+ {"type":"open_url","input":{"url":"https://www.wikipedia.org","waitForReadyMs":800}},
1762
+ {"type":"open_url","input":{"url":"https://news.ycombinator.com","waitForReadyMs":800}},
1763
+ {"type":"snapshot_suite","input":{"packageName":"com.android.chrome"}}
1764
+ ]</textarea>
1765
+ <button class="w" id="job-bulk-btn">Enqueue bulk JSON</button>
1766
+ <div id="jobs" class="jobs"></div>
1767
+ </article>
1768
+
1769
+ <article class="card stack">
1770
+ <h2>Lanes + events</h2>
1771
+ <div id="lanes" class="lanes"></div>
1133
1772
  <div id="events" class="events"></div>
1134
1773
  </article>
1135
1774
 
1136
1775
  <article class="card stack">
1137
- <h2>Backend metrics</h2>
1776
+ <h2>Metrics + session</h2>
1138
1777
  <div id="metrics" class="metrics"></div>
1139
- <button class="s" id="refresh-state-btn">Refresh state</button>
1778
+ <div class="split">
1779
+ <button class="s" id="refresh-state-btn">Refresh state</button>
1780
+ <button class="s" id="export-session-btn">Export session</button>
1781
+ </div>
1782
+ <button class="w" id="reset-session-btn">Reset session (keep workflows)</button>
1140
1783
  </article>
1141
1784
 
1142
1785
  <article class="card" style="grid-column: 1 / -1;">
@@ -1161,12 +1804,16 @@ https://developer.android.com</textarea>
1161
1804
  const $versionPill = document.getElementById('version-pill');
1162
1805
  const $devicePill = document.getElementById('device-pill');
1163
1806
  const $workflowPill = document.getElementById('workflow-pill');
1807
+ const $lanePill = document.getElementById('lane-pill');
1808
+ const $queuePill = document.getElementById('queue-pill');
1164
1809
  const $deviceSelect = document.getElementById('device-select');
1165
1810
  const $urlInput = document.getElementById('url-input');
1166
1811
  const $message = document.getElementById('message');
1167
1812
  const $output = document.getElementById('output');
1168
1813
  const $events = document.getElementById('events');
1169
1814
  const $metrics = document.getElementById('metrics');
1815
+ const $jobs = document.getElementById('jobs');
1816
+ const $lanes = document.getElementById('lanes');
1170
1817
  const $snapshotKind = document.getElementById('snapshot-kind');
1171
1818
  const $stressUrls = document.getElementById('stress-urls');
1172
1819
  const $stressLoops = document.getElementById('stress-loops');
@@ -1174,6 +1821,10 @@ https://developer.android.com</textarea>
1174
1821
  const $workflowSelect = document.getElementById('workflow-select');
1175
1822
  const $workflowName = document.getElementById('workflow-name');
1176
1823
  const $workflowSteps = document.getElementById('workflow-steps');
1824
+ const $jobType = document.getElementById('job-type');
1825
+ const $jobUrl = document.getElementById('job-url');
1826
+ const $jobWorkflow = document.getElementById('job-workflow');
1827
+ const $bulkJobs = document.getElementById('bulk-jobs');
1177
1828
 
1178
1829
  function setMessage(text, isError) {
1179
1830
  $message.textContent = text;
@@ -1190,23 +1841,13 @@ https://developer.android.com</textarea>
1190
1841
  return;
1191
1842
  }
1192
1843
  const item = document.createElement('div');
1193
- item.className = 'event';
1194
- const meta = document.createElement('div');
1195
- meta.className = 'meta';
1196
- meta.textContent = '[' + (event.at || new Date().toISOString()) + '] ' + event.type;
1197
- const message = document.createElement('div');
1198
- message.textContent = event.message || '';
1199
- item.appendChild(meta);
1200
- item.appendChild(message);
1201
- if (event.data) {
1202
- const data = document.createElement('div');
1203
- data.style.color = '#a5c6db';
1204
- data.style.marginTop = '4px';
1205
- data.textContent = JSON.stringify(event.data);
1206
- item.appendChild(data);
1207
- }
1844
+ item.className = 'item';
1845
+ item.innerHTML =
1846
+ '<div class="meta">[' + (event.at || new Date().toISOString()) + '] ' + event.type + '</div>' +
1847
+ '<div>' + (event.message || '') + '</div>' +
1848
+ (event.data ? '<div style="color:#a5c6db;margin-top:4px;">' + JSON.stringify(event.data) + '</div>' : '');
1208
1849
  $events.prepend(item);
1209
- while ($events.children.length > 100) {
1850
+ while ($events.children.length > 120) {
1210
1851
  $events.removeChild($events.lastChild);
1211
1852
  }
1212
1853
  }
@@ -1214,9 +1855,9 @@ https://developer.android.com</textarea>
1214
1855
  function renderMetrics(payload) {
1215
1856
  const entries = Array.isArray(payload.entries) ? payload.entries : [];
1216
1857
  $metrics.innerHTML = '';
1217
- for (const entry of entries.slice(0, 20)) {
1858
+ for (const entry of entries.slice(0, 25)) {
1218
1859
  const item = document.createElement('div');
1219
- item.className = 'metric';
1860
+ item.className = 'item';
1220
1861
  item.innerHTML =
1221
1862
  '<div class="meta">' + entry.name + '</div>' +
1222
1863
  '<div>count=' + entry.count + ' success=' + entry.success + ' errors=' + entry.errors + '</div>' +
@@ -1228,6 +1869,77 @@ https://developer.android.com</textarea>
1228
1869
  }
1229
1870
  }
1230
1871
 
1872
+ function renderLanes(payload) {
1873
+ const lanes = Array.isArray(payload.lanes) ? payload.lanes : [];
1874
+ $lanes.innerHTML = '';
1875
+ for (const lane of lanes.slice(0, 30)) {
1876
+ const item = document.createElement('div');
1877
+ item.className = 'item';
1878
+ item.innerHTML =
1879
+ '<div class="meta">' + lane.id + (lane.active ? ' [active]' : '') + '</div>' +
1880
+ '<div>queue=' + lane.queueDepth + ' completed=' + lane.completed + ' failed=' + lane.failed + ' cancelled=' + lane.cancelled + '</div>' +
1881
+ '<div>device=' + (lane.deviceId || 'default') + '</div>';
1882
+ $lanes.appendChild(item);
1883
+ }
1884
+ if (!lanes.length) {
1885
+ $lanes.textContent = 'No lanes yet.';
1886
+ }
1887
+ }
1888
+
1889
+ function renderJobs(payload) {
1890
+ const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
1891
+ $jobs.innerHTML = '';
1892
+ for (const job of jobs.slice(0, 40)) {
1893
+ const item = document.createElement('div');
1894
+ item.className = 'item';
1895
+ item.innerHTML =
1896
+ '<div class="meta">#' + job.id + ' ' + job.type + ' [' + job.status + ']</div>' +
1897
+ '<div>lane=' + (job.laneId || '-') + ' duration=' + (job.durationMs || 0) + 'ms</div>' +
1898
+ (job.error ? '<div style="color:#ff8f8f;">' + job.error + '</div>' : '');
1899
+
1900
+ if (job.status === 'queued') {
1901
+ const cancelBtn = document.createElement('button');
1902
+ cancelBtn.className = 'w';
1903
+ cancelBtn.textContent = 'Cancel';
1904
+ cancelBtn.style.marginTop = '6px';
1905
+ cancelBtn.addEventListener('click', async function () {
1906
+ try {
1907
+ const result = await api('/api/jobs/' + job.id + '/cancel', 'POST', {});
1908
+ renderOutput(result);
1909
+ await refreshJobsAndLanes();
1910
+ setMessage('Job cancelled', false);
1911
+ } catch (error) {
1912
+ setMessage(String(error), true);
1913
+ }
1914
+ });
1915
+ item.appendChild(cancelBtn);
1916
+ }
1917
+
1918
+ if (job.status === 'failed' || job.status === 'completed' || job.status === 'cancelled') {
1919
+ const retryBtn = document.createElement('button');
1920
+ retryBtn.className = 's';
1921
+ retryBtn.textContent = 'Retry';
1922
+ retryBtn.style.marginTop = '6px';
1923
+ retryBtn.addEventListener('click', async function () {
1924
+ try {
1925
+ const result = await api('/api/jobs/' + job.id + '/retry', 'POST', {});
1926
+ renderOutput(result);
1927
+ await refreshJobsAndLanes();
1928
+ setMessage('Job retried', false);
1929
+ } catch (error) {
1930
+ setMessage(String(error), true);
1931
+ }
1932
+ });
1933
+ item.appendChild(retryBtn);
1934
+ }
1935
+
1936
+ $jobs.appendChild(item);
1937
+ }
1938
+ if (!jobs.length) {
1939
+ $jobs.textContent = 'No jobs yet.';
1940
+ }
1941
+ }
1942
+
1231
1943
  async function api(path, method, body) {
1232
1944
  const response = await fetch(path, {
1233
1945
  method: method || 'GET',
@@ -1245,14 +1957,43 @@ https://developer.android.com</textarea>
1245
1957
  return state.deviceId || undefined;
1246
1958
  }
1247
1959
 
1248
- async function refreshState() {
1960
+ function selectedWorkflowName() {
1961
+ const value = ($workflowSelect.value || '').trim();
1962
+ return value || undefined;
1963
+ }
1964
+
1965
+ function stressConfig() {
1966
+ return {
1967
+ urls: $stressUrls.value
1968
+ .split('\n')
1969
+ .map(function (line) { return line.trim(); })
1970
+ .filter(function (line) { return line.length > 0; }),
1971
+ loops: Number($stressLoops.value || '1'),
1972
+ waitForReadyMs: Number($stressWait.value || '1000'),
1973
+ includeSnapshotAfterEach: true,
1974
+ };
1975
+ }
1976
+
1977
+ async function refreshJobsAndLanes() {
1978
+ const jobsPayload = await api('/api/jobs');
1979
+ renderJobs(jobsPayload);
1980
+
1981
+ const lanesPayload = await api('/api/lanes');
1982
+ renderLanes(lanesPayload);
1983
+ }
1984
+
1985
+ async function refreshCore() {
1249
1986
  const statePayload = await api('/api/state');
1250
1987
  $status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
1251
1988
  $versionPill.textContent = 'v' + statePayload.version;
1252
1989
  $workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
1990
+ $lanePill.textContent = 'lanes: ' + statePayload.laneCount;
1991
+ $queuePill.textContent = 'queue: ' + statePayload.queueDepth;
1253
1992
 
1254
1993
  const devicesPayload = await api('/api/devices');
1255
1994
  const devices = Array.isArray(devicesPayload.devices) ? devicesPayload.devices : [];
1995
+ const previousDevice = state.deviceId;
1996
+
1256
1997
  $deviceSelect.innerHTML = '';
1257
1998
  for (const device of devices) {
1258
1999
  const option = document.createElement('option');
@@ -1260,7 +2001,13 @@ https://developer.android.com</textarea>
1260
2001
  option.textContent = device.id + ' (' + (device.model || 'unknown') + ')';
1261
2002
  $deviceSelect.appendChild(option);
1262
2003
  }
1263
- state.deviceId = devices[0] ? devices[0].id : undefined;
2004
+
2005
+ if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
2006
+ state.deviceId = previousDevice;
2007
+ } else {
2008
+ state.deviceId = devices[0] ? devices[0].id : undefined;
2009
+ }
2010
+
1264
2011
  if (state.deviceId) {
1265
2012
  $deviceSelect.value = state.deviceId;
1266
2013
  $devicePill.textContent = 'device: ' + state.deviceId;
@@ -1269,23 +2016,40 @@ https://developer.android.com</textarea>
1269
2016
  }
1270
2017
 
1271
2018
  const workflowsPayload = await api('/api/workflows');
1272
- state.workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
1273
- $workflowSelect.innerHTML = '';
1274
- for (const workflow of state.workflows) {
1275
- const option = document.createElement('option');
1276
- option.value = workflow.name;
1277
- option.textContent = workflow.name;
1278
- $workflowSelect.appendChild(option);
1279
- }
1280
- if (state.workflows.length > 0) {
1281
- const selected = state.workflows[0];
1282
- $workflowSelect.value = selected.name;
1283
- $workflowName.value = selected.name;
1284
- $workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
2019
+ const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
2020
+ state.workflows = workflows;
2021
+
2022
+ const fillWorkflowSelect = function (selectElement) {
2023
+ const prev = (selectElement.value || '').trim();
2024
+ selectElement.innerHTML = '';
2025
+ for (const wf of workflows) {
2026
+ const option = document.createElement('option');
2027
+ option.value = wf.name;
2028
+ option.textContent = wf.name;
2029
+ selectElement.appendChild(option);
2030
+ }
2031
+ if (prev && workflows.some(function (w) { return w.name === prev; })) {
2032
+ selectElement.value = prev;
2033
+ } else if (workflows[0]) {
2034
+ selectElement.value = workflows[0].name;
2035
+ }
2036
+ };
2037
+
2038
+ fillWorkflowSelect($workflowSelect);
2039
+ fillWorkflowSelect($jobWorkflow);
2040
+
2041
+ if ($workflowSelect.value) {
2042
+ const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
2043
+ if (selected) {
2044
+ $workflowName.value = selected.name;
2045
+ $workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
2046
+ }
1285
2047
  }
1286
2048
 
1287
2049
  const metricsPayload = await api('/api/metrics');
1288
2050
  renderMetrics(metricsPayload);
2051
+
2052
+ await refreshJobsAndLanes();
1289
2053
  }
1290
2054
 
1291
2055
  function connectEvents() {
@@ -1293,7 +2057,7 @@ https://developer.android.com</textarea>
1293
2057
  es.onmessage = function (event) {
1294
2058
  try {
1295
2059
  addEvent(JSON.parse(event.data));
1296
- } catch (err) {
2060
+ } catch (error) {
1297
2061
  // ignore
1298
2062
  }
1299
2063
  };
@@ -1303,13 +2067,23 @@ https://developer.android.com</textarea>
1303
2067
  setMessage('Opening URL: ' + url, false);
1304
2068
  const result = await api('/api/open-url', 'POST', {
1305
2069
  deviceId: selectedDeviceId(),
1306
- url: url,
1307
- waitForReadyMs: 1000,
2070
+ url,
2071
+ waitForReadyMs: 900,
1308
2072
  });
1309
2073
  renderOutput(result);
1310
2074
  setMessage('URL opened', false);
1311
2075
  }
1312
2076
 
2077
+ async function runSuite() {
2078
+ setMessage('Running suite', false);
2079
+ const result = await api('/api/snapshot-suite', 'POST', {
2080
+ deviceId: selectedDeviceId(),
2081
+ packageName: 'com.android.chrome',
2082
+ });
2083
+ renderOutput(result);
2084
+ setMessage('Suite completed', false);
2085
+ }
2086
+
1313
2087
  async function captureSnapshot() {
1314
2088
  const kind = $snapshotKind.value;
1315
2089
  setMessage('Capturing snapshot: ' + kind, false);
@@ -1328,40 +2102,43 @@ https://developer.android.com</textarea>
1328
2102
  const result = await api('/api/snapshot/diff', 'POST', {
1329
2103
  deviceId: selectedDeviceId(),
1330
2104
  packageName: 'com.android.chrome',
1331
- kind: kind,
2105
+ kind,
1332
2106
  });
1333
2107
  renderOutput(result);
1334
- setMessage('Snapshot diff done', false);
2108
+ setMessage('Snapshot diff completed', false);
1335
2109
  }
1336
2110
 
1337
- async function runSuite() {
1338
- setMessage('Running full suite', false);
1339
- const result = await api('/api/snapshot-suite', 'POST', {
2111
+ async function captureDeviceProfile() {
2112
+ setMessage('Capturing device profile', false);
2113
+ const result = await api('/api/device/profile', 'POST', {
1340
2114
  deviceId: selectedDeviceId(),
1341
2115
  packageName: 'com.android.chrome',
1342
2116
  });
1343
2117
  renderOutput(result);
1344
- setMessage('Suite done', false);
2118
+ setMessage('Device profile completed', false);
1345
2119
  }
1346
2120
 
1347
- async function runStress() {
1348
- setMessage('Running stress', false);
1349
- const urls = $stressUrls.value
1350
- .split('\n')
1351
- .map(function (line) { return line.trim(); })
1352
- .filter(function (line) { return line.length > 0; });
1353
- const loops = Number($stressLoops.value || '1');
1354
- const waitForReadyMs = Number($stressWait.value || '1000');
2121
+ async function captureAllProfiles() {
2122
+ setMessage('Capturing profiles for all devices', false);
2123
+ const result = await api('/api/device/profiles', 'POST', {
2124
+ packageName: 'com.android.chrome',
2125
+ });
2126
+ renderOutput(result);
2127
+ setMessage('Profiles matrix completed', false);
2128
+ }
1355
2129
 
2130
+ async function runStress() {
2131
+ setMessage('Running stress run', false);
2132
+ const cfg = stressConfig();
1356
2133
  const result = await api('/api/stress-run', 'POST', {
1357
2134
  deviceId: selectedDeviceId(),
1358
- urls,
1359
- loops,
1360
- waitForReadyMs,
2135
+ urls: cfg.urls,
2136
+ loops: cfg.loops,
2137
+ waitForReadyMs: cfg.waitForReadyMs,
1361
2138
  includeSnapshotAfterEach: true,
1362
2139
  });
1363
2140
  renderOutput(result);
1364
- setMessage('Stress run done', false);
2141
+ setMessage('Stress run completed', false);
1365
2142
  }
1366
2143
 
1367
2144
  async function saveWorkflow() {
@@ -1373,19 +2150,16 @@ https://developer.android.com</textarea>
1373
2150
  try {
1374
2151
  steps = JSON.parse($workflowSteps.value || '[]');
1375
2152
  } catch (error) {
1376
- throw new Error('workflow steps must be valid JSON array');
2153
+ throw new Error('workflow steps must be valid JSON');
1377
2154
  }
1378
- const result = await api('/api/workflows', 'POST', {
1379
- name,
1380
- steps,
1381
- });
2155
+ const result = await api('/api/workflows', 'POST', { name, steps });
1382
2156
  renderOutput(result);
1383
2157
  setMessage('Workflow saved', false);
1384
- await refreshState();
2158
+ await refreshCore();
1385
2159
  }
1386
2160
 
1387
2161
  async function runWorkflow() {
1388
- const name = ($workflowSelect.value || '').trim();
2162
+ const name = selectedWorkflowName();
1389
2163
  if (!name) {
1390
2164
  throw new Error('select workflow first');
1391
2165
  }
@@ -1396,18 +2170,113 @@ https://developer.android.com</textarea>
1396
2170
  includeRaw: false,
1397
2171
  });
1398
2172
  renderOutput(result);
1399
- setMessage('Workflow done', false);
2173
+ setMessage('Workflow executed', false);
1400
2174
  }
1401
2175
 
1402
2176
  async function deleteWorkflow() {
1403
- const name = ($workflowSelect.value || '').trim();
2177
+ const name = selectedWorkflowName();
1404
2178
  if (!name) {
1405
2179
  throw new Error('select workflow first');
1406
2180
  }
1407
2181
  const result = await api('/api/workflows/' + encodeURIComponent(name), 'DELETE');
1408
2182
  renderOutput(result);
1409
2183
  setMessage('Workflow deleted', false);
1410
- await refreshState();
2184
+ await refreshCore();
2185
+ }
2186
+
2187
+ async function exportWorkflows() {
2188
+ const result = await api('/api/workflows/export');
2189
+ $workflowSteps.value = JSON.stringify(result.workflows || {}, null, 2);
2190
+ renderOutput(result);
2191
+ setMessage('Workflows exported to editor', false);
2192
+ }
2193
+
2194
+ async function importWorkflows() {
2195
+ let parsed;
2196
+ try {
2197
+ parsed = JSON.parse($workflowSteps.value || '{}');
2198
+ } catch (error) {
2199
+ throw new Error('workflow editor must contain valid JSON');
2200
+ }
2201
+ const result = await api('/api/workflows/import', 'POST', {
2202
+ workflows: parsed,
2203
+ replace: false,
2204
+ });
2205
+ renderOutput(result);
2206
+ setMessage('Workflows imported', false);
2207
+ await refreshCore();
2208
+ }
2209
+
2210
+ async function enqueueSingleJob() {
2211
+ const type = ($jobType.value || '').trim();
2212
+ const input = { deviceId: selectedDeviceId() };
2213
+
2214
+ if (type === 'open_url') {
2215
+ input.url = $jobUrl.value || 'https://developer.android.com';
2216
+ input.waitForReadyMs = 900;
2217
+ } else if (type === 'snapshot_suite') {
2218
+ input.packageName = 'com.android.chrome';
2219
+ } else if (type === 'stress_run') {
2220
+ const cfg = stressConfig();
2221
+ input.urls = cfg.urls;
2222
+ input.loops = cfg.loops;
2223
+ input.waitForReadyMs = cfg.waitForReadyMs;
2224
+ input.includeSnapshotAfterEach = true;
2225
+ } else if (type === 'workflow_run') {
2226
+ const workflowName = ($jobWorkflow.value || '').trim();
2227
+ if (!workflowName) {
2228
+ throw new Error('select workflow for workflow_run job');
2229
+ }
2230
+ input.name = workflowName;
2231
+ input.packageName = 'com.android.chrome';
2232
+ } else if (type === 'device_profile') {
2233
+ input.packageName = 'com.android.chrome';
2234
+ }
2235
+
2236
+ const result = await api('/api/jobs', 'POST', { type, input });
2237
+ renderOutput(result);
2238
+ setMessage('Job enqueued', false);
2239
+ await refreshJobsAndLanes();
2240
+ }
2241
+
2242
+ async function enqueueBulkJobs() {
2243
+ let parsed;
2244
+ try {
2245
+ parsed = JSON.parse($bulkJobs.value || '[]');
2246
+ } catch (error) {
2247
+ throw new Error('bulk jobs must be valid JSON array');
2248
+ }
2249
+
2250
+ if (!Array.isArray(parsed)) {
2251
+ throw new Error('bulk jobs must be array');
2252
+ }
2253
+
2254
+ for (const item of parsed) {
2255
+ if (!item.input || typeof item.input !== 'object' || Array.isArray(item.input)) {
2256
+ item.input = {};
2257
+ }
2258
+ if (!item.input.deviceId) {
2259
+ item.input.deviceId = selectedDeviceId();
2260
+ }
2261
+ }
2262
+
2263
+ const result = await api('/api/jobs/bulk', 'POST', { jobs: parsed });
2264
+ renderOutput(result);
2265
+ setMessage('Bulk jobs enqueued', false);
2266
+ await refreshJobsAndLanes();
2267
+ }
2268
+
2269
+ async function exportSession() {
2270
+ const result = await api('/api/session/export');
2271
+ renderOutput(result);
2272
+ setMessage('Session exported to output', false);
2273
+ }
2274
+
2275
+ async function resetSession() {
2276
+ const result = await api('/api/session/reset', 'POST', { keepWorkflows: true });
2277
+ renderOutput(result);
2278
+ setMessage('Session reset complete', false);
2279
+ await refreshCore();
1411
2280
  }
1412
2281
 
1413
2282
  document.getElementById('open-url-btn').addEventListener('click', async function () {
@@ -1422,6 +2291,12 @@ https://developer.android.com</textarea>
1422
2291
  document.getElementById('snapshot-diff-btn').addEventListener('click', async function () {
1423
2292
  try { await captureSnapshotDiff(); } catch (error) { setMessage(String(error), true); }
1424
2293
  });
2294
+ document.getElementById('profile-btn').addEventListener('click', async function () {
2295
+ try { await captureDeviceProfile(); } catch (error) { setMessage(String(error), true); }
2296
+ });
2297
+ document.getElementById('profiles-btn').addEventListener('click', async function () {
2298
+ try { await captureAllProfiles(); } catch (error) { setMessage(String(error), true); }
2299
+ });
1425
2300
  document.getElementById('stress-btn').addEventListener('click', async function () {
1426
2301
  try { await runStress(); } catch (error) { setMessage(String(error), true); }
1427
2302
  });
@@ -1434,20 +2309,36 @@ https://developer.android.com</textarea>
1434
2309
  document.getElementById('workflow-delete-btn').addEventListener('click', async function () {
1435
2310
  try { await deleteWorkflow(); } catch (error) { setMessage(String(error), true); }
1436
2311
  });
2312
+ document.getElementById('workflow-export-btn').addEventListener('click', async function () {
2313
+ try { await exportWorkflows(); } catch (error) { setMessage(String(error), true); }
2314
+ });
2315
+ document.getElementById('workflow-import-btn').addEventListener('click', async function () {
2316
+ try { await importWorkflows(); } catch (error) { setMessage(String(error), true); }
2317
+ });
2318
+ document.getElementById('job-enqueue-btn').addEventListener('click', async function () {
2319
+ try { await enqueueSingleJob(); } catch (error) { setMessage(String(error), true); }
2320
+ });
2321
+ document.getElementById('job-bulk-btn').addEventListener('click', async function () {
2322
+ try { await enqueueBulkJobs(); } catch (error) { setMessage(String(error), true); }
2323
+ });
2324
+ document.getElementById('job-refresh-btn').addEventListener('click', async function () {
2325
+ try { await refreshJobsAndLanes(); setMessage('Jobs and lanes refreshed', false); } catch (error) { setMessage(String(error), true); }
2326
+ });
1437
2327
  document.getElementById('refresh-state-btn').addEventListener('click', async function () {
1438
- try {
1439
- await refreshState();
1440
- setMessage('State refreshed', false);
1441
- } catch (error) {
1442
- setMessage(String(error), true);
1443
- }
2328
+ try { await refreshCore(); setMessage('State refreshed', false); } catch (error) { setMessage(String(error), true); }
2329
+ });
2330
+ document.getElementById('export-session-btn').addEventListener('click', async function () {
2331
+ try { await exportSession(); } catch (error) { setMessage(String(error), true); }
2332
+ });
2333
+ document.getElementById('reset-session-btn').addEventListener('click', async function () {
2334
+ try { await resetSession(); } catch (error) { setMessage(String(error), true); }
1444
2335
  });
1445
2336
  document.getElementById('clear-output-btn').addEventListener('click', function () {
1446
2337
  renderOutput({});
1447
2338
  });
1448
2339
 
1449
2340
  $deviceSelect.addEventListener('change', function () {
1450
- state.deviceId = $deviceSelect.value;
2341
+ state.deviceId = $deviceSelect.value || undefined;
1451
2342
  $devicePill.textContent = 'device: ' + (state.deviceId || 'none');
1452
2343
  });
1453
2344
 
@@ -1466,17 +2357,98 @@ https://developer.android.com</textarea>
1466
2357
  }
1467
2358
 
1468
2359
  $workflowSelect.addEventListener('change', function () {
1469
- const selected = state.workflows.find(function (item) { return item.name === $workflowSelect.value; });
2360
+ const selected = state.workflows.find(function (w) { return w.name === $workflowSelect.value; });
1470
2361
  if (selected) {
1471
2362
  $workflowName.value = selected.name;
1472
2363
  $workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
1473
2364
  }
1474
2365
  });
1475
2366
 
2367
+ function connectEvents() {
2368
+ const es = new EventSource('/api/events');
2369
+ es.onmessage = function (event) {
2370
+ try {
2371
+ addEvent(JSON.parse(event.data));
2372
+ } catch {
2373
+ // ignore
2374
+ }
2375
+ };
2376
+ }
2377
+
2378
+ async function refreshCore() {
2379
+ const statePayload = await api('/api/state');
2380
+ $status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
2381
+ $versionPill.textContent = 'v' + statePayload.version;
2382
+ $workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
2383
+ $lanePill.textContent = 'lanes: ' + statePayload.laneCount;
2384
+ $queuePill.textContent = 'queue: ' + statePayload.queueDepth;
2385
+
2386
+ const devicesPayload = await api('/api/devices');
2387
+ const devices = Array.isArray(devicesPayload.devices) ? devicesPayload.devices : [];
2388
+ const previousDevice = state.deviceId;
2389
+
2390
+ $deviceSelect.innerHTML = '';
2391
+ for (const device of devices) {
2392
+ const option = document.createElement('option');
2393
+ option.value = device.id;
2394
+ option.textContent = device.id + ' (' + (device.model || 'unknown') + ')';
2395
+ $deviceSelect.appendChild(option);
2396
+ }
2397
+
2398
+ if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
2399
+ state.deviceId = previousDevice;
2400
+ } else {
2401
+ state.deviceId = devices[0] ? devices[0].id : undefined;
2402
+ }
2403
+
2404
+ if (state.deviceId) {
2405
+ $deviceSelect.value = state.deviceId;
2406
+ $devicePill.textContent = 'device: ' + state.deviceId;
2407
+ } else {
2408
+ $devicePill.textContent = 'device: none';
2409
+ }
2410
+
2411
+ const workflowsPayload = await api('/api/workflows');
2412
+ const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
2413
+ state.workflows = workflows;
2414
+
2415
+ const fillSelect = function (selectElement) {
2416
+ const prev = (selectElement.value || '').trim();
2417
+ selectElement.innerHTML = '';
2418
+ for (const workflow of workflows) {
2419
+ const option = document.createElement('option');
2420
+ option.value = workflow.name;
2421
+ option.textContent = workflow.name;
2422
+ selectElement.appendChild(option);
2423
+ }
2424
+ if (prev && workflows.some(function (w) { return w.name === prev; })) {
2425
+ selectElement.value = prev;
2426
+ } else if (workflows[0]) {
2427
+ selectElement.value = workflows[0].name;
2428
+ }
2429
+ };
2430
+
2431
+ fillSelect($workflowSelect);
2432
+ fillSelect($jobWorkflow);
2433
+
2434
+ if ($workflowSelect.value) {
2435
+ const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
2436
+ if (selected) {
2437
+ $workflowName.value = selected.name;
2438
+ $workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
2439
+ }
2440
+ }
2441
+
2442
+ const metricsPayload = await api('/api/metrics');
2443
+ renderMetrics(metricsPayload);
2444
+
2445
+ await refreshJobsAndLanes();
2446
+ }
2447
+
1476
2448
  async function init() {
1477
2449
  try {
1478
- await refreshState();
1479
- const history = await api('/api/history?limit=30');
2450
+ await refreshCore();
2451
+ const history = await api('/api/history?limit=45');
1480
2452
  const events = Array.isArray(history.events) ? history.events : [];
1481
2453
  for (let i = events.length - 1; i >= 0; i -= 1) {
1482
2454
  addEvent(events[i]);
@@ -1484,12 +2456,14 @@ https://developer.android.com</textarea>
1484
2456
  connectEvents();
1485
2457
  renderOutput({ ok: true, message: 'UI ready', updateHint: '${UPDATE_HINT}' });
1486
2458
  setMessage('Ready.', false);
2459
+
1487
2460
  setInterval(async function () {
1488
2461
  try {
1489
2462
  const metricsPayload = await api('/api/metrics');
1490
2463
  renderMetrics(metricsPayload);
2464
+ await refreshJobsAndLanes();
1491
2465
  } catch (error) {
1492
- setMessage('Metrics refresh failed', true);
2466
+ setMessage('Background refresh failed', true);
1493
2467
  }
1494
2468
  }, 5000);
1495
2469
  } catch (error) {