the-android-mcp 3.2.0 → 3.3.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
@@ -20,6 +20,7 @@ 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
22
  const MAX_EVENTS = 500;
23
+ const MAX_JOBS = 250;
23
24
  const WORKFLOW_DIR = path_1.default.join(os_1.default.homedir(), '.the-android-mcp');
24
25
  const WORKFLOW_FILE = path_1.default.join(WORKFLOW_DIR, 'web-ui-workflows.json');
25
26
  const SNAPSHOT_KINDS = [
@@ -31,17 +32,24 @@ const SNAPSHOT_KINDS = [
31
32
  ];
32
33
  const serverStartedAt = Date.now();
33
34
  let eventSeq = 1;
35
+ let jobSeq = 1;
34
36
  const eventHistory = [];
35
37
  const sseClients = new Set();
36
38
  const metrics = {};
37
39
  const snapshotCache = {};
38
40
  let workflows = loadWorkflows();
41
+ const jobs = [];
42
+ const jobQueue = [];
43
+ let jobRunnerActive = false;
39
44
  function nowIso() {
40
45
  return new Date().toISOString();
41
46
  }
42
47
  function isSnapshotKind(value) {
43
48
  return SNAPSHOT_KINDS.includes(value);
44
49
  }
50
+ function isJobType(value) {
51
+ return value === 'open_url' || value === 'snapshot_suite' || value === 'stress_run' || value === 'workflow_run';
52
+ }
45
53
  function clampInt(value, fallback, min, max) {
46
54
  if (typeof value !== 'number' || !Number.isFinite(value)) {
47
55
  return fallback;
@@ -49,6 +57,38 @@ function clampInt(value, fallback, min, max) {
49
57
  const parsed = Math.trunc(value);
50
58
  return Math.max(min, Math.min(max, parsed));
51
59
  }
60
+ function pushEvent(type, message, data) {
61
+ const event = {
62
+ id: eventSeq++,
63
+ at: nowIso(),
64
+ type,
65
+ message,
66
+ data,
67
+ };
68
+ eventHistory.push(event);
69
+ if (eventHistory.length > MAX_EVENTS) {
70
+ eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
71
+ }
72
+ broadcastEvent(event);
73
+ return event;
74
+ }
75
+ function broadcastEvent(event) {
76
+ const payload = `data: ${JSON.stringify(event)}\n\n`;
77
+ for (const client of sseClients) {
78
+ try {
79
+ client.write(payload);
80
+ }
81
+ catch {
82
+ try {
83
+ client.end();
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ sseClients.delete(client);
89
+ }
90
+ }
91
+ }
52
92
  function trackMetric(name, durationMs, ok, errorMessage) {
53
93
  const entry = metrics[name] ?? {
54
94
  name,
@@ -84,38 +124,6 @@ async function withMetric(name, fn) {
84
124
  throw error;
85
125
  }
86
126
  }
87
- function pushEvent(type, message, data) {
88
- const event = {
89
- id: eventSeq++,
90
- at: nowIso(),
91
- type,
92
- message,
93
- data,
94
- };
95
- eventHistory.push(event);
96
- if (eventHistory.length > MAX_EVENTS) {
97
- eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
98
- }
99
- broadcastEvent(event);
100
- return event;
101
- }
102
- function broadcastEvent(event) {
103
- const payload = `data: ${JSON.stringify(event)}\n\n`;
104
- for (const client of sseClients) {
105
- try {
106
- client.write(payload);
107
- }
108
- catch {
109
- try {
110
- client.end();
111
- }
112
- catch {
113
- // ignore
114
- }
115
- sseClients.delete(client);
116
- }
117
- }
118
- }
119
127
  function sendJson(response, statusCode, body) {
120
128
  response.statusCode = statusCode;
121
129
  response.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -167,7 +175,7 @@ function summarizeObjectShape(input) {
167
175
  }
168
176
  function normalizeWorkflowStep(value) {
169
177
  if (!value || typeof value !== 'object') {
170
- throw new Error('Invalid workflow step (must be object)');
178
+ throw new Error('Invalid workflow step: object expected');
171
179
  }
172
180
  const step = value;
173
181
  const type = step.type;
@@ -180,7 +188,7 @@ function normalizeWorkflowStep(value) {
180
188
  throw new Error('Workflow open_url step requires valid url');
181
189
  }
182
190
  normalized.url = step.url;
183
- normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1200, 200, 10000);
191
+ normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1000, 200, 10000);
184
192
  return normalized;
185
193
  }
186
194
  if (type === 'snapshot') {
@@ -210,27 +218,25 @@ function ensureWorkflowStore() {
210
218
  }
211
219
  function defaultWorkflows() {
212
220
  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
221
  return {
232
- [smoke.name]: smoke,
233
- [diagnostic.name]: diagnostic,
222
+ 'smoke-web-flow': {
223
+ name: 'smoke-web-flow',
224
+ description: 'Open URLs and collect quick snapshots.',
225
+ updatedAt: createdAt,
226
+ steps: [
227
+ { type: 'open_url', url: 'https://www.wikipedia.org', waitForReadyMs: 1000 },
228
+ { type: 'snapshot', snapshot: 'radio' },
229
+ { type: 'open_url', url: 'https://news.ycombinator.com', waitForReadyMs: 1000 },
230
+ { type: 'snapshot', snapshot: 'display' },
231
+ { type: 'open_url', url: 'https://developer.android.com', waitForReadyMs: 1000 },
232
+ ],
233
+ },
234
+ 'diagnostic-suite': {
235
+ name: 'diagnostic-suite',
236
+ description: 'Run complete v3 suite.',
237
+ updatedAt: createdAt,
238
+ steps: [{ type: 'snapshot_suite', packageName: 'com.android.chrome' }],
239
+ },
234
240
  };
235
241
  }
236
242
  function loadWorkflows() {
@@ -243,7 +249,7 @@ function loadWorkflows() {
243
249
  }
244
250
  const raw = fs_1.default.readFileSync(WORKFLOW_FILE, 'utf8');
245
251
  const parsed = JSON.parse(raw);
246
- if (!parsed || typeof parsed !== 'object') {
252
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
247
253
  return defaultWorkflows();
248
254
  }
249
255
  const result = {};
@@ -256,7 +262,18 @@ function loadWorkflows() {
256
262
  continue;
257
263
  }
258
264
  const stepsValue = Array.isArray(item.steps) ? item.steps : [];
259
- const steps = stepsValue.map(step => normalizeWorkflowStep(step));
265
+ const steps = [];
266
+ for (const stepValue of stepsValue) {
267
+ try {
268
+ steps.push(normalizeWorkflowStep(stepValue));
269
+ }
270
+ catch {
271
+ // skip invalid step
272
+ }
273
+ }
274
+ if (steps.length === 0) {
275
+ continue;
276
+ }
260
277
  result[name] = {
261
278
  name,
262
279
  description: typeof item.description === 'string' ? item.description : undefined,
@@ -361,7 +378,7 @@ function captureSnapshot(kind, options) {
361
378
  }
362
379
  function diffSnapshot(prev, next) {
363
380
  if (!prev || typeof prev !== 'object' || !next || typeof next !== 'object') {
364
- return { changedCount: 0, changed: [], note: 'No comparable snapshots' };
381
+ return { changedCount: 0, changed: [], note: 'No comparable snapshots available' };
365
382
  }
366
383
  const a = prev;
367
384
  const b = next;
@@ -386,7 +403,12 @@ function diffSnapshot(prev, next) {
386
403
  const oldJson = JSON.stringify(oldValue);
387
404
  const newJson = JSON.stringify(newValue);
388
405
  if (oldJson !== newJson) {
389
- changed.push({ key, type: 'value', before: oldValue, after: newValue });
406
+ changed.push({
407
+ key,
408
+ type: 'value',
409
+ before: oldValue,
410
+ after: newValue,
411
+ });
390
412
  }
391
413
  }
392
414
  return {
@@ -413,6 +435,76 @@ function getSnapshotSuite(deviceId, packageName) {
413
435
  },
414
436
  };
415
437
  }
438
+ function buildDeviceProfile(deviceId, packageName) {
439
+ const startedAt = Date.now();
440
+ const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({
441
+ deviceId,
442
+ includeWifiDump: false,
443
+ includeTelephonyDump: false,
444
+ includeBluetoothDump: false,
445
+ includeIpState: true,
446
+ includeConnectivityDump: false,
447
+ });
448
+ const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
449
+ deviceId,
450
+ includeDisplayDump: false,
451
+ includeWindowDump: false,
452
+ includeSurfaceFlinger: false,
453
+ });
454
+ const location = (0, adb_js_1.captureAndroidLocationSnapshot)({
455
+ deviceId,
456
+ packageName,
457
+ includeLocationDump: false,
458
+ includeLocationAppOps: true,
459
+ });
460
+ const power = (0, adb_js_1.captureAndroidPowerIdleSnapshot)({
461
+ deviceId,
462
+ includePowerDump: false,
463
+ includeDeviceIdle: false,
464
+ includeBatteryStats: false,
465
+ includeThermal: false,
466
+ });
467
+ const packages = (0, adb_js_1.captureAndroidPackageInventorySnapshot)({
468
+ deviceId,
469
+ includeThirdParty: true,
470
+ includeSystem: true,
471
+ includeDisabled: true,
472
+ includePackagePaths: false,
473
+ includeFeatures: false,
474
+ packageListLines: 600,
475
+ });
476
+ return {
477
+ capturedAt: nowIso(),
478
+ durationMs: Date.now() - startedAt,
479
+ deviceId: radio.deviceId,
480
+ radio: {
481
+ wifiEnabled: radio.wifiEnabled,
482
+ mobileDataEnabled: radio.mobileDataEnabled,
483
+ airplaneMode: radio.airplaneMode,
484
+ bluetoothEnabled: radio.bluetoothEnabled,
485
+ },
486
+ display: {
487
+ wmSize: display.wmSize,
488
+ wmDensity: display.wmDensity,
489
+ brightness: display.screenBrightness,
490
+ rotation: display.userRotation,
491
+ },
492
+ location: {
493
+ mode: location.locationMode,
494
+ mockLocation: location.mockLocation,
495
+ appOps: compactStringSummary(location.locationAppOps),
496
+ },
497
+ power: {
498
+ battery: compactStringSummary(power.battery),
499
+ },
500
+ packages: {
501
+ total: packages.packageCount,
502
+ thirdParty: packages.thirdPartyCount,
503
+ system: packages.systemCount,
504
+ disabled: packages.disabledCount,
505
+ },
506
+ };
507
+ }
416
508
  function runStressScenario(body) {
417
509
  const deviceId = extractDeviceId(body);
418
510
  const urlsValue = body.urls;
@@ -428,22 +520,22 @@ function runStressScenario(body) {
428
520
  const startedAt = Date.now();
429
521
  const steps = [];
430
522
  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, {
523
+ for (const targetUrl of normalizedUrls) {
524
+ const openStarted = Date.now();
525
+ const open = (0, adb_js_1.openUrlInChrome)(targetUrl, deviceId, {
434
526
  waitForReadyMs,
435
527
  fallbackToDefault: true,
436
528
  });
437
- const record = {
529
+ const step = {
438
530
  kind: 'open_url',
439
531
  loop,
440
- url,
532
+ url: targetUrl,
441
533
  strategy: open.strategy,
442
534
  deviceId: open.deviceId,
443
- durationMs: Date.now() - actionStarted,
535
+ durationMs: Date.now() - openStarted,
444
536
  };
445
537
  if (includeSnapshotAfterEach) {
446
- const snapshotStarted = Date.now();
538
+ const snapStarted = Date.now();
447
539
  const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({ deviceId: open.deviceId, includeWifiDump: false });
448
540
  const display = (0, adb_js_1.captureAndroidDisplaySnapshot)({
449
541
  deviceId: open.deviceId,
@@ -451,8 +543,8 @@ function runStressScenario(body) {
451
543
  includeWindowDump: false,
452
544
  includeSurfaceFlinger: false,
453
545
  });
454
- record.snapshot = {
455
- durationMs: Date.now() - snapshotStarted,
546
+ step.snapshot = {
547
+ durationMs: Date.now() - snapStarted,
456
548
  radio: {
457
549
  wifiEnabled: radio.wifiEnabled,
458
550
  mobileDataEnabled: radio.mobileDataEnabled,
@@ -461,11 +553,11 @@ function runStressScenario(body) {
461
553
  display: {
462
554
  wmSize: display.wmSize,
463
555
  wmDensity: display.wmDensity,
464
- screenBrightness: display.screenBrightness,
556
+ brightness: display.screenBrightness,
465
557
  },
466
558
  };
467
559
  }
468
- steps.push(record);
560
+ steps.push(step);
469
561
  }
470
562
  }
471
563
  return {
@@ -492,7 +584,7 @@ async function runWorkflow(name, options) {
492
584
  const stepStarted = Date.now();
493
585
  if (step.type === 'open_url') {
494
586
  const result = (0, adb_js_1.openUrlInChrome)(step.url || 'https://www.wikipedia.org', options.deviceId, {
495
- waitForReadyMs: clampInt(step.waitForReadyMs, 1200, 200, 10000),
587
+ waitForReadyMs: clampInt(step.waitForReadyMs, 1000, 200, 10000),
496
588
  fallbackToDefault: true,
497
589
  });
498
590
  outputs.push({
@@ -505,15 +597,15 @@ async function runWorkflow(name, options) {
505
597
  continue;
506
598
  }
507
599
  if (step.type === 'snapshot') {
508
- const snapshotKind = step.snapshot || 'radio';
509
- const snapshot = captureSnapshot(snapshotKind, {
600
+ const kind = step.snapshot || 'radio';
601
+ const snapshot = captureSnapshot(kind, {
510
602
  deviceId: options.deviceId,
511
603
  packageName: step.packageName || options.packageName,
512
604
  includeRaw: step.includeRaw === true || options.includeRaw === true,
513
605
  });
514
606
  outputs.push({
515
607
  step: 'snapshot',
516
- snapshotKind,
608
+ snapshotKind: kind,
517
609
  durationMs: Date.now() - stepStarted,
518
610
  snapshot,
519
611
  });
@@ -548,6 +640,132 @@ async function runWorkflow(name, options) {
548
640
  updateHint: UPDATE_HINT,
549
641
  };
550
642
  }
643
+ function createJob(type, input) {
644
+ const job = {
645
+ id: jobSeq++,
646
+ type,
647
+ status: 'queued',
648
+ createdAt: nowIso(),
649
+ input,
650
+ };
651
+ jobs.unshift(job);
652
+ if (jobs.length > MAX_JOBS) {
653
+ jobs.splice(MAX_JOBS);
654
+ }
655
+ jobQueue.push(job.id);
656
+ pushEvent('job-queued', 'Job queued', { id: job.id, type: job.type });
657
+ void runJobQueue();
658
+ return job;
659
+ }
660
+ function getJobById(id) {
661
+ return jobs.find(job => job.id === id);
662
+ }
663
+ function cancelQueuedJob(id) {
664
+ const job = getJobById(id);
665
+ if (!job) {
666
+ return false;
667
+ }
668
+ if (job.status !== 'queued') {
669
+ return false;
670
+ }
671
+ job.status = 'cancelled';
672
+ job.finishedAt = nowIso();
673
+ job.durationMs = 0;
674
+ const queueIndex = jobQueue.indexOf(id);
675
+ if (queueIndex >= 0) {
676
+ jobQueue.splice(queueIndex, 1);
677
+ }
678
+ pushEvent('job-cancelled', 'Job cancelled', { id });
679
+ return true;
680
+ }
681
+ async function executeJob(job) {
682
+ if (job.type === 'open_url') {
683
+ const urlValue = typeof job.input.url === 'string' ? job.input.url : '';
684
+ if (!urlValue || !/^https?:\/\//i.test(urlValue)) {
685
+ throw new Error('Job open_url requires valid url');
686
+ }
687
+ const waitForReadyMs = clampInt(job.input.waitForReadyMs, 1000, 200, 10000);
688
+ return (0, adb_js_1.openUrlInChrome)(urlValue, typeof job.input.deviceId === 'string' ? job.input.deviceId : undefined, {
689
+ waitForReadyMs,
690
+ fallbackToDefault: true,
691
+ });
692
+ }
693
+ if (job.type === 'snapshot_suite') {
694
+ return getSnapshotSuite(typeof job.input.deviceId === 'string' ? job.input.deviceId : undefined, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
695
+ }
696
+ if (job.type === 'stress_run') {
697
+ return runStressScenario(job.input);
698
+ }
699
+ const workflowName = typeof job.input.name === 'string' ? job.input.name : '';
700
+ if (!workflowName) {
701
+ throw new Error('Job workflow_run requires workflow name');
702
+ }
703
+ return await runWorkflow(workflowName, {
704
+ deviceId: typeof job.input.deviceId === 'string' ? job.input.deviceId : undefined,
705
+ packageName: typeof job.input.packageName === 'string' ? job.input.packageName : undefined,
706
+ includeRaw: job.input.includeRaw === true,
707
+ });
708
+ }
709
+ async function runJobQueue() {
710
+ if (jobRunnerActive) {
711
+ return;
712
+ }
713
+ jobRunnerActive = true;
714
+ while (jobQueue.length > 0) {
715
+ const jobId = jobQueue.shift();
716
+ if (!jobId) {
717
+ continue;
718
+ }
719
+ const job = getJobById(jobId);
720
+ if (!job || job.status !== 'queued') {
721
+ continue;
722
+ }
723
+ job.status = 'running';
724
+ job.startedAt = nowIso();
725
+ pushEvent('job-running', 'Job started', { id: job.id, type: job.type });
726
+ const startedAtMs = Date.now();
727
+ try {
728
+ const result = await executeJob(job);
729
+ job.result = result;
730
+ job.status = 'completed';
731
+ job.finishedAt = nowIso();
732
+ job.durationMs = Date.now() - startedAtMs;
733
+ pushEvent('job-completed', 'Job completed', {
734
+ id: job.id,
735
+ type: job.type,
736
+ durationMs: job.durationMs,
737
+ });
738
+ }
739
+ catch (error) {
740
+ const message = error instanceof Error ? error.message : String(error);
741
+ job.status = 'failed';
742
+ job.error = message;
743
+ job.finishedAt = nowIso();
744
+ job.durationMs = Date.now() - startedAtMs;
745
+ pushEvent('job-failed', 'Job failed', {
746
+ id: job.id,
747
+ type: job.type,
748
+ error: message,
749
+ });
750
+ }
751
+ }
752
+ jobRunnerActive = false;
753
+ }
754
+ function listJobSummaries() {
755
+ return jobs
756
+ .slice()
757
+ .sort((a, b) => b.id - a.id)
758
+ .map(job => ({
759
+ id: job.id,
760
+ type: job.type,
761
+ status: job.status,
762
+ createdAt: job.createdAt,
763
+ startedAt: job.startedAt,
764
+ finishedAt: job.finishedAt,
765
+ durationMs: job.durationMs,
766
+ error: job.error,
767
+ }));
768
+ }
551
769
  async function readJsonBody(request) {
552
770
  return await new Promise((resolve, reject) => {
553
771
  const chunks = [];
@@ -639,23 +857,25 @@ function buildStatePayload(host, port) {
639
857
  connectedDeviceCount: devices.length,
640
858
  eventCount: eventHistory.length,
641
859
  workflowCount: Object.keys(workflows).length,
860
+ queueDepth: jobQueue.length,
861
+ jobCount: jobs.length,
642
862
  snapshotKinds: SNAPSHOT_KINDS,
643
863
  };
644
864
  }
645
865
  function buildMetricsPayload() {
646
- const items = Object.values(metrics)
866
+ const entries = Object.values(metrics)
647
867
  .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,
868
+ .map(entry => ({
869
+ ...entry,
870
+ avgDurationMs: entry.count > 0 ? Math.round((entry.totalDurationMs / entry.count) * 100) / 100 : 0,
871
+ successRate: entry.count > 0 ? Math.round((entry.success / entry.count) * 10000) / 100 : 0,
652
872
  }));
653
873
  return {
654
874
  generatedAt: nowIso(),
655
875
  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,
876
+ totalActions: entries.reduce((sum, item) => sum + item.count, 0),
877
+ errors: entries.reduce((sum, item) => sum + item.errors, 0),
878
+ entries,
659
879
  };
660
880
  }
661
881
  async function handleApi(request, response, context) {
@@ -714,6 +934,59 @@ async function handleApi(request, response, context) {
714
934
  });
715
935
  return;
716
936
  }
937
+ if (method === 'GET' && pathname === '/api/jobs') {
938
+ await withMetric('jobs-list', () => {
939
+ sendJson(response, 200, {
940
+ jobs: listJobSummaries(),
941
+ queueDepth: jobQueue.length,
942
+ });
943
+ });
944
+ return;
945
+ }
946
+ if (method === 'POST' && pathname === '/api/jobs') {
947
+ await withMetric('jobs-create', async () => {
948
+ const body = await readJsonBody(request);
949
+ const typeValue = typeof body.type === 'string' ? body.type : '';
950
+ if (!isJobType(typeValue)) {
951
+ sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run' });
952
+ return;
953
+ }
954
+ const input = body.input && typeof body.input === 'object' && !Array.isArray(body.input)
955
+ ? body.input
956
+ : {};
957
+ const job = createJob(typeValue, input);
958
+ sendJson(response, 200, {
959
+ ok: true,
960
+ job,
961
+ queueDepth: jobQueue.length,
962
+ });
963
+ });
964
+ return;
965
+ }
966
+ if (method === 'GET' && /^\/api\/jobs\/\d+$/.test(pathname)) {
967
+ await withMetric('jobs-get', () => {
968
+ const id = Number(pathname.slice('/api/jobs/'.length));
969
+ const job = getJobById(id);
970
+ if (!job) {
971
+ sendJson(response, 404, { error: `job ${id} not found` });
972
+ return;
973
+ }
974
+ sendJson(response, 200, { ok: true, job });
975
+ });
976
+ return;
977
+ }
978
+ if (method === 'POST' && /^\/api\/jobs\/\d+\/cancel$/.test(pathname)) {
979
+ await withMetric('jobs-cancel', () => {
980
+ const id = Number(pathname.split('/')[3]);
981
+ const ok = cancelQueuedJob(id);
982
+ if (!ok) {
983
+ sendJson(response, 400, { error: `job ${id} cannot be cancelled` });
984
+ return;
985
+ }
986
+ sendJson(response, 200, { ok: true, id });
987
+ });
988
+ return;
989
+ }
717
990
  if (method === 'GET' && pathname === '/api/workflows') {
718
991
  await withMetric('workflows-list', () => {
719
992
  sendJson(response, 200, {
@@ -723,6 +996,73 @@ async function handleApi(request, response, context) {
723
996
  });
724
997
  return;
725
998
  }
999
+ if (method === 'GET' && pathname === '/api/workflows/export') {
1000
+ await withMetric('workflows-export', () => {
1001
+ sendJson(response, 200, {
1002
+ exportedAt: nowIso(),
1003
+ workflows,
1004
+ });
1005
+ });
1006
+ return;
1007
+ }
1008
+ if (method === 'POST' && pathname === '/api/workflows/import') {
1009
+ await withMetric('workflows-import', async () => {
1010
+ const body = await readJsonBody(request);
1011
+ const replace = body.replace === true;
1012
+ const payload = body.workflows;
1013
+ const imported = replace ? {} : { ...workflows };
1014
+ const consumeDefinition = (value) => {
1015
+ if (!value || typeof value !== 'object') {
1016
+ return;
1017
+ }
1018
+ const item = value;
1019
+ const name = typeof item.name === 'string' ? item.name.trim() : '';
1020
+ if (!name) {
1021
+ return;
1022
+ }
1023
+ const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
1024
+ const steps = [];
1025
+ for (const stepValue of stepsRaw) {
1026
+ steps.push(normalizeWorkflowStep(stepValue));
1027
+ }
1028
+ if (steps.length === 0) {
1029
+ return;
1030
+ }
1031
+ imported[name] = {
1032
+ name,
1033
+ description: typeof item.description === 'string' ? item.description : undefined,
1034
+ updatedAt: nowIso(),
1035
+ steps,
1036
+ };
1037
+ };
1038
+ if (Array.isArray(payload)) {
1039
+ for (const value of payload) {
1040
+ consumeDefinition(value);
1041
+ }
1042
+ }
1043
+ else if (payload && typeof payload === 'object') {
1044
+ for (const value of Object.values(payload)) {
1045
+ consumeDefinition(value);
1046
+ }
1047
+ }
1048
+ else {
1049
+ sendJson(response, 400, { error: 'workflows must be array or object' });
1050
+ return;
1051
+ }
1052
+ workflows = imported;
1053
+ saveWorkflows();
1054
+ pushEvent('workflow-import', 'Workflows imported', {
1055
+ count: Object.keys(workflows).length,
1056
+ replace,
1057
+ });
1058
+ sendJson(response, 200, {
1059
+ ok: true,
1060
+ count: Object.keys(workflows).length,
1061
+ workflows: workflowsList(),
1062
+ });
1063
+ });
1064
+ return;
1065
+ }
726
1066
  if (method === 'POST' && pathname === '/api/workflows') {
727
1067
  await withMetric('workflows-save', async () => {
728
1068
  const body = await readJsonBody(request);
@@ -738,13 +1078,12 @@ async function handleApi(request, response, context) {
738
1078
  return;
739
1079
  }
740
1080
  const steps = stepsRaw.map(step => normalizeWorkflowStep(step));
741
- const workflow = {
1081
+ workflows[name] = {
742
1082
  name,
743
1083
  description,
744
1084
  updatedAt: nowIso(),
745
1085
  steps,
746
1086
  };
747
- workflows[name] = workflow;
748
1087
  saveWorkflows();
749
1088
  pushEvent('workflow-saved', 'Workflow saved', {
750
1089
  name,
@@ -752,7 +1091,7 @@ async function handleApi(request, response, context) {
752
1091
  });
753
1092
  sendJson(response, 200, {
754
1093
  ok: true,
755
- workflow,
1094
+ workflow: workflows[name],
756
1095
  });
757
1096
  });
758
1097
  return;
@@ -804,11 +1143,9 @@ async function handleApi(request, response, context) {
804
1143
  sendJson(response, 400, { error: 'url must be valid http/https URL' });
805
1144
  return;
806
1145
  }
807
- const deviceId = extractDeviceId(body);
808
- const waitForReadyMs = clampInt(body.waitForReadyMs, 1200, 200, 10000);
809
1146
  const startedAt = Date.now();
810
- const result = (0, adb_js_1.openUrlInChrome)(targetUrl, deviceId, {
811
- waitForReadyMs,
1147
+ const result = (0, adb_js_1.openUrlInChrome)(targetUrl, extractDeviceId(body), {
1148
+ waitForReadyMs: clampInt(body.waitForReadyMs, 1000, 200, 10000),
812
1149
  fallbackToDefault: true,
813
1150
  });
814
1151
  pushEvent('open-url', 'Opened URL on device', {
@@ -826,6 +1163,22 @@ async function handleApi(request, response, context) {
826
1163
  });
827
1164
  return;
828
1165
  }
1166
+ if (method === 'POST' && pathname === '/api/device/profile') {
1167
+ await withMetric('device-profile', async () => {
1168
+ const body = await readJsonBody(request);
1169
+ const profile = buildDeviceProfile(extractDeviceId(body), typeof body.packageName === 'string' ? body.packageName : undefined);
1170
+ pushEvent('device-profile', 'Device profile captured', {
1171
+ deviceId: profile.deviceId,
1172
+ durationMs: profile.durationMs,
1173
+ });
1174
+ sendJson(response, 200, {
1175
+ ok: true,
1176
+ profile,
1177
+ updateHint: UPDATE_HINT,
1178
+ });
1179
+ });
1180
+ return;
1181
+ }
829
1182
  if (method === 'POST' && pathname === '/api/snapshot-suite') {
830
1183
  await withMetric('snapshot-suite', async () => {
831
1184
  const body = await readJsonBody(request);
@@ -855,10 +1208,10 @@ async function handleApi(request, response, context) {
855
1208
  packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
856
1209
  includeRaw: true,
857
1210
  });
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', {
1211
+ const raw = fresh.raw;
1212
+ snapshotCache[kindValue] = raw;
1213
+ const diff = diffSnapshot(prev, raw);
1214
+ pushEvent('snapshot-diff', 'Snapshot diff captured', {
862
1215
  kind: kindValue,
863
1216
  changedCount: diff.changedCount,
864
1217
  });
@@ -886,7 +1239,7 @@ async function handleApi(request, response, context) {
886
1239
  packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
887
1240
  includeRaw: body.includeRaw === true,
888
1241
  });
889
- pushEvent('snapshot', 'Captured snapshot', {
1242
+ pushEvent('snapshot', 'Snapshot captured', {
890
1243
  kind: kindValue,
891
1244
  deviceId: result.deviceId,
892
1245
  });
@@ -902,7 +1255,7 @@ async function handleApi(request, response, context) {
902
1255
  await withMetric('stress-run', async () => {
903
1256
  const body = await readJsonBody(request);
904
1257
  const result = runStressScenario(body);
905
- pushEvent('stress-run', 'Completed stress run', {
1258
+ pushEvent('stress-run', 'Stress run completed', {
906
1259
  totalSteps: result.totalSteps,
907
1260
  durationMs: result.durationMs,
908
1261
  });
@@ -917,7 +1270,7 @@ const INDEX_HTML = String.raw `<!doctype html>
917
1270
  <head>
918
1271
  <meta charset="utf-8" />
919
1272
  <meta name="viewport" content="width=device-width,initial-scale=1" />
920
- <title>the-android-mcp web ui v3.2</title>
1273
+ <title>the-android-mcp web ui v3.3</title>
921
1274
  <link rel="preconnect" href="https://fonts.googleapis.com" />
922
1275
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
923
1276
  <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 +1301,7 @@ const INDEX_HTML = String.raw `<!doctype html>
948
1301
  linear-gradient(155deg, var(--bg0), var(--bg1) 44%, var(--bg2));
949
1302
  padding: 16px;
950
1303
  }
951
- .app { max-width: 1450px; margin: 0 auto; display: grid; gap: 12px; }
1304
+ .app { max-width: 1500px; margin: 0 auto; display: grid; gap: 12px; }
952
1305
  .hero {
953
1306
  border: 1px solid var(--line);
954
1307
  border-radius: 18px;
@@ -978,7 +1331,7 @@ const INDEX_HTML = String.raw `<!doctype html>
978
1331
  .grid {
979
1332
  display: grid;
980
1333
  gap: 10px;
981
- grid-template-columns: 1.1fr 1fr 1fr;
1334
+ grid-template-columns: 1fr 1fr 1fr;
982
1335
  }
983
1336
  .card {
984
1337
  border: 1px solid var(--line);
@@ -1000,13 +1353,13 @@ const INDEX_HTML = String.raw `<!doctype html>
1000
1353
  padding: 9px;
1001
1354
  font: inherit;
1002
1355
  }
1003
- textarea { min-height: 94px; resize: vertical; }
1356
+ textarea { min-height: 90px; resize: vertical; }
1004
1357
  button { cursor: pointer; font-weight: 700; transition: transform 120ms ease, filter 120ms ease; }
1005
1358
  button:hover { transform: translateY(-1px); filter: brightness(1.08); }
1006
1359
  .p { background: linear-gradient(130deg, #0d7666, #155f75); }
1007
1360
  .s { background: linear-gradient(130deg, #1d3348, #192b3c); }
1008
1361
  .w { background: linear-gradient(130deg, #7a5917, #61401f); }
1009
- .events, .metrics {
1362
+ .events, .metrics, .jobs {
1010
1363
  max-height: 300px;
1011
1364
  overflow: auto;
1012
1365
  border: 1px solid #2f4a61;
@@ -1014,7 +1367,7 @@ const INDEX_HTML = String.raw `<!doctype html>
1014
1367
  padding: 8px;
1015
1368
  background: #0b141d;
1016
1369
  }
1017
- .event, .metric {
1370
+ .item {
1018
1371
  border: 1px solid #2e4a61;
1019
1372
  border-radius: 8px;
1020
1373
  padding: 6px;
@@ -1058,15 +1411,16 @@ const INDEX_HTML = String.raw `<!doctype html>
1058
1411
  <span class="pill" id="version-pill">v3</span>
1059
1412
  <span class="pill" id="device-pill">device: n/a</span>
1060
1413
  <span class="pill" id="workflow-pill">workflows: 0</span>
1414
+ <span class="pill" id="queue-pill">queue: 0</span>
1061
1415
  <span class="pill">port: 50000</span>
1062
1416
  </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>
1417
+ <h1 style="margin:0;font-size:1.45rem;">the-android-mcp v3.3 command center</h1>
1418
+ <p class="muted">Upgraded backend integration with job queue, workflow engine, snapshot diff, metrics, and live events.</p>
1065
1419
  </section>
1066
1420
 
1067
1421
  <section class="grid">
1068
1422
  <article class="card stack">
1069
- <h2>Device + URL actions</h2>
1423
+ <h2>Device operations</h2>
1070
1424
  <select id="device-select"></select>
1071
1425
  <input id="url-input" type="url" value="https://www.wikipedia.org" />
1072
1426
  <div class="split">
@@ -1079,6 +1433,17 @@ const INDEX_HTML = String.raw `<!doctype html>
1079
1433
  <button class="s quick-url" data-url="https://developer.android.com">Android Docs</button>
1080
1434
  <button class="s quick-url" data-url="https://www.youtube.com">YouTube</button>
1081
1435
  </div>
1436
+ <div class="split">
1437
+ <button class="s" id="profile-btn">Device profile</button>
1438
+ <button class="w" id="stress-btn">Run stress</button>
1439
+ </div>
1440
+ <textarea id="stress-urls">https://www.wikipedia.org
1441
+ https://news.ycombinator.com
1442
+ https://developer.android.com</textarea>
1443
+ <div class="split">
1444
+ <input id="stress-loops" type="number" min="1" max="5" value="1" />
1445
+ <input id="stress-wait" type="number" min="200" max="6000" value="1000" />
1446
+ </div>
1082
1447
  <p class="muted">Update hint: <code>npm install -g the-android-mcp@latest</code></p>
1083
1448
  </article>
1084
1449
 
@@ -1095,37 +1460,41 @@ const INDEX_HTML = String.raw `<!doctype html>
1095
1460
  <button class="s" id="snapshot-btn">Capture</button>
1096
1461
  <button class="w" id="snapshot-diff-btn">Capture diff</button>
1097
1462
  </div>
1098
- <p class="muted">Diff compares latest capture with previous capture of same type.</p>
1099
- </article>
1100
1463
 
1101
- <article class="card stack">
1102
- <h2>Stress run</h2>
1103
- <textarea id="stress-urls">https://www.wikipedia.org
1104
- https://news.ycombinator.com
1105
- https://developer.android.com</textarea>
1106
- <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" />
1109
- </div>
1110
- <button class="w" id="stress-btn">Run stress</button>
1111
- <p class="muted">Config: loops / wait-ms</p>
1112
- </article>
1113
-
1114
- <article class="card stack">
1115
- <h2>Workflow engine</h2>
1464
+ <h2 style="margin-top:8px;">Workflow engine</h2>
1116
1465
  <select id="workflow-select"></select>
1117
1466
  <input id="workflow-name" placeholder="workflow name" value="" />
1118
1467
  <textarea id="workflow-steps">[
1119
1468
  {"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":1000},
1120
1469
  {"type":"snapshot","snapshot":"radio"},
1121
- {"type":"open_url","url":"https://developer.android.com","waitForReadyMs":1000},
1122
1470
  {"type":"snapshot_suite","packageName":"com.android.chrome"}
1123
1471
  ]</textarea>
1124
1472
  <div class="split">
1125
1473
  <button class="p" id="workflow-save-btn">Save</button>
1126
1474
  <button class="p" id="workflow-run-btn">Run</button>
1127
1475
  </div>
1128
- <button class="s" id="workflow-delete-btn">Delete selected</button>
1476
+ <div class="split">
1477
+ <button class="s" id="workflow-delete-btn">Delete</button>
1478
+ <button class="s" id="workflow-export-btn">Export</button>
1479
+ </div>
1480
+ <button class="s" id="workflow-import-btn">Import from editor</button>
1481
+ </article>
1482
+
1483
+ <article class="card stack">
1484
+ <h2>Job orchestrator</h2>
1485
+ <select id="job-type">
1486
+ <option value="open_url">open_url</option>
1487
+ <option value="snapshot_suite">snapshot_suite</option>
1488
+ <option value="stress_run">stress_run</option>
1489
+ <option value="workflow_run">workflow_run</option>
1490
+ </select>
1491
+ <input id="job-url" type="url" value="https://developer.android.com" />
1492
+ <select id="job-workflow"></select>
1493
+ <div class="split">
1494
+ <button class="p" id="job-enqueue-btn">Enqueue job</button>
1495
+ <button class="s" id="job-refresh-btn">Refresh jobs</button>
1496
+ </div>
1497
+ <div id="jobs" class="jobs"></div>
1129
1498
  </article>
1130
1499
 
1131
1500
  <article class="card stack">
@@ -1155,18 +1524,21 @@ https://developer.android.com</textarea>
1155
1524
  const state = {
1156
1525
  deviceId: undefined,
1157
1526
  workflows: [],
1527
+ jobs: [],
1158
1528
  };
1159
1529
 
1160
1530
  const $status = document.getElementById('status');
1161
1531
  const $versionPill = document.getElementById('version-pill');
1162
1532
  const $devicePill = document.getElementById('device-pill');
1163
1533
  const $workflowPill = document.getElementById('workflow-pill');
1534
+ const $queuePill = document.getElementById('queue-pill');
1164
1535
  const $deviceSelect = document.getElementById('device-select');
1165
1536
  const $urlInput = document.getElementById('url-input');
1166
1537
  const $message = document.getElementById('message');
1167
1538
  const $output = document.getElementById('output');
1168
1539
  const $events = document.getElementById('events');
1169
1540
  const $metrics = document.getElementById('metrics');
1541
+ const $jobs = document.getElementById('jobs');
1170
1542
  const $snapshotKind = document.getElementById('snapshot-kind');
1171
1543
  const $stressUrls = document.getElementById('stress-urls');
1172
1544
  const $stressLoops = document.getElementById('stress-loops');
@@ -1174,6 +1546,9 @@ https://developer.android.com</textarea>
1174
1546
  const $workflowSelect = document.getElementById('workflow-select');
1175
1547
  const $workflowName = document.getElementById('workflow-name');
1176
1548
  const $workflowSteps = document.getElementById('workflow-steps');
1549
+ const $jobType = document.getElementById('job-type');
1550
+ const $jobUrl = document.getElementById('job-url');
1551
+ const $jobWorkflow = document.getElementById('job-workflow');
1177
1552
 
1178
1553
  function setMessage(text, isError) {
1179
1554
  $message.textContent = text;
@@ -1190,14 +1565,14 @@ https://developer.android.com</textarea>
1190
1565
  return;
1191
1566
  }
1192
1567
  const item = document.createElement('div');
1193
- item.className = 'event';
1568
+ item.className = 'item';
1194
1569
  const meta = document.createElement('div');
1195
1570
  meta.className = 'meta';
1196
1571
  meta.textContent = '[' + (event.at || new Date().toISOString()) + '] ' + event.type;
1197
- const message = document.createElement('div');
1198
- message.textContent = event.message || '';
1572
+ const text = document.createElement('div');
1573
+ text.textContent = event.message || '';
1199
1574
  item.appendChild(meta);
1200
- item.appendChild(message);
1575
+ item.appendChild(text);
1201
1576
  if (event.data) {
1202
1577
  const data = document.createElement('div');
1203
1578
  data.style.color = '#a5c6db';
@@ -1206,7 +1581,7 @@ https://developer.android.com</textarea>
1206
1581
  item.appendChild(data);
1207
1582
  }
1208
1583
  $events.prepend(item);
1209
- while ($events.children.length > 100) {
1584
+ while ($events.children.length > 120) {
1210
1585
  $events.removeChild($events.lastChild);
1211
1586
  }
1212
1587
  }
@@ -1214,9 +1589,9 @@ https://developer.android.com</textarea>
1214
1589
  function renderMetrics(payload) {
1215
1590
  const entries = Array.isArray(payload.entries) ? payload.entries : [];
1216
1591
  $metrics.innerHTML = '';
1217
- for (const entry of entries.slice(0, 20)) {
1592
+ for (const entry of entries.slice(0, 30)) {
1218
1593
  const item = document.createElement('div');
1219
- item.className = 'metric';
1594
+ item.className = 'item';
1220
1595
  item.innerHTML =
1221
1596
  '<div class="meta">' + entry.name + '</div>' +
1222
1597
  '<div>count=' + entry.count + ' success=' + entry.success + ' errors=' + entry.errors + '</div>' +
@@ -1228,6 +1603,42 @@ https://developer.android.com</textarea>
1228
1603
  }
1229
1604
  }
1230
1605
 
1606
+ function renderJobs(payload) {
1607
+ const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
1608
+ state.jobs = jobs;
1609
+ $jobs.innerHTML = '';
1610
+ for (const job of jobs.slice(0, 40)) {
1611
+ const item = document.createElement('div');
1612
+ item.className = 'item';
1613
+ item.innerHTML =
1614
+ '<div class="meta">#' + job.id + ' ' + job.type + ' [' + job.status + ']</div>' +
1615
+ '<div>created=' + (job.createdAt || '-') + '</div>' +
1616
+ '<div>duration=' + (job.durationMs || 0) + 'ms</div>' +
1617
+ (job.error ? '<div style="color:#ff8f8f;">' + job.error + '</div>' : '');
1618
+ if (job.status === 'queued') {
1619
+ const cancel = document.createElement('button');
1620
+ cancel.className = 'w';
1621
+ cancel.textContent = 'Cancel';
1622
+ cancel.style.marginTop = '6px';
1623
+ cancel.addEventListener('click', async function () {
1624
+ try {
1625
+ const result = await api('/api/jobs/' + job.id + '/cancel', 'POST', {});
1626
+ renderOutput(result);
1627
+ await refreshJobs();
1628
+ setMessage('Job cancelled', false);
1629
+ } catch (error) {
1630
+ setMessage(String(error), true);
1631
+ }
1632
+ });
1633
+ item.appendChild(cancel);
1634
+ }
1635
+ $jobs.appendChild(item);
1636
+ }
1637
+ if (!jobs.length) {
1638
+ $jobs.textContent = 'No jobs yet.';
1639
+ }
1640
+ }
1641
+
1231
1642
  async function api(path, method, body) {
1232
1643
  const response = await fetch(path, {
1233
1644
  method: method || 'GET',
@@ -1245,14 +1656,39 @@ https://developer.android.com</textarea>
1245
1656
  return state.deviceId || undefined;
1246
1657
  }
1247
1658
 
1248
- async function refreshState() {
1659
+ function selectedWorkflowName() {
1660
+ const value = ($workflowSelect.value || '').trim();
1661
+ return value || undefined;
1662
+ }
1663
+
1664
+ function currentStressConfig() {
1665
+ return {
1666
+ urls: $stressUrls.value
1667
+ .split('\n')
1668
+ .map(function (line) { return line.trim(); })
1669
+ .filter(function (line) { return line.length > 0; }),
1670
+ loops: Number($stressLoops.value || '1'),
1671
+ waitForReadyMs: Number($stressWait.value || '1000'),
1672
+ includeSnapshotAfterEach: true,
1673
+ };
1674
+ }
1675
+
1676
+ async function refreshJobs() {
1677
+ const payload = await api('/api/jobs');
1678
+ renderJobs(payload);
1679
+ }
1680
+
1681
+ async function refreshCore() {
1249
1682
  const statePayload = await api('/api/state');
1250
1683
  $status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
1251
1684
  $versionPill.textContent = 'v' + statePayload.version;
1252
1685
  $workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
1686
+ $queuePill.textContent = 'queue: ' + statePayload.queueDepth;
1253
1687
 
1254
1688
  const devicesPayload = await api('/api/devices');
1255
1689
  const devices = Array.isArray(devicesPayload.devices) ? devicesPayload.devices : [];
1690
+ const previousDevice = state.deviceId;
1691
+
1256
1692
  $deviceSelect.innerHTML = '';
1257
1693
  for (const device of devices) {
1258
1694
  const option = document.createElement('option');
@@ -1260,7 +1696,14 @@ https://developer.android.com</textarea>
1260
1696
  option.textContent = device.id + ' (' + (device.model || 'unknown') + ')';
1261
1697
  $deviceSelect.appendChild(option);
1262
1698
  }
1263
- state.deviceId = devices[0] ? devices[0].id : undefined;
1699
+
1700
+ state.deviceId = undefined;
1701
+ if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
1702
+ state.deviceId = previousDevice;
1703
+ } else if (devices[0]) {
1704
+ state.deviceId = devices[0].id;
1705
+ }
1706
+
1264
1707
  if (state.deviceId) {
1265
1708
  $deviceSelect.value = state.deviceId;
1266
1709
  $devicePill.textContent = 'device: ' + state.deviceId;
@@ -1269,23 +1712,40 @@ https://developer.android.com</textarea>
1269
1712
  }
1270
1713
 
1271
1714
  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);
1715
+ const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
1716
+ state.workflows = workflows;
1717
+
1718
+ const renderWorkflowSelect = function (select) {
1719
+ const prev = (select.value || '').trim();
1720
+ select.innerHTML = '';
1721
+ for (const workflow of workflows) {
1722
+ const option = document.createElement('option');
1723
+ option.value = workflow.name;
1724
+ option.textContent = workflow.name;
1725
+ select.appendChild(option);
1726
+ }
1727
+ if (prev && workflows.some(function (w) { return w.name === prev; })) {
1728
+ select.value = prev;
1729
+ } else if (workflows[0]) {
1730
+ select.value = workflows[0].name;
1731
+ }
1732
+ };
1733
+
1734
+ renderWorkflowSelect($workflowSelect);
1735
+ renderWorkflowSelect($jobWorkflow);
1736
+
1737
+ if ($workflowSelect.value) {
1738
+ const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
1739
+ if (selected) {
1740
+ $workflowName.value = selected.name;
1741
+ $workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
1742
+ }
1285
1743
  }
1286
1744
 
1287
1745
  const metricsPayload = await api('/api/metrics');
1288
1746
  renderMetrics(metricsPayload);
1747
+
1748
+ await refreshJobs();
1289
1749
  }
1290
1750
 
1291
1751
  function connectEvents() {
@@ -1310,6 +1770,16 @@ https://developer.android.com</textarea>
1310
1770
  setMessage('URL opened', false);
1311
1771
  }
1312
1772
 
1773
+ async function runSuite() {
1774
+ setMessage('Running suite', false);
1775
+ const result = await api('/api/snapshot-suite', 'POST', {
1776
+ deviceId: selectedDeviceId(),
1777
+ packageName: 'com.android.chrome',
1778
+ });
1779
+ renderOutput(result);
1780
+ setMessage('Suite completed', false);
1781
+ }
1782
+
1313
1783
  async function captureSnapshot() {
1314
1784
  const kind = $snapshotKind.value;
1315
1785
  setMessage('Capturing snapshot: ' + kind, false);
@@ -1324,44 +1794,38 @@ https://developer.android.com</textarea>
1324
1794
 
1325
1795
  async function captureSnapshotDiff() {
1326
1796
  const kind = $snapshotKind.value;
1327
- setMessage('Capturing snapshot diff: ' + kind, false);
1797
+ setMessage('Capturing diff: ' + kind, false);
1328
1798
  const result = await api('/api/snapshot/diff', 'POST', {
1329
1799
  deviceId: selectedDeviceId(),
1330
1800
  packageName: 'com.android.chrome',
1331
1801
  kind: kind,
1332
1802
  });
1333
1803
  renderOutput(result);
1334
- setMessage('Snapshot diff done', false);
1804
+ setMessage('Snapshot diff completed', false);
1335
1805
  }
1336
1806
 
1337
- async function runSuite() {
1338
- setMessage('Running full suite', false);
1339
- const result = await api('/api/snapshot-suite', 'POST', {
1807
+ async function captureDeviceProfile() {
1808
+ setMessage('Capturing device profile', false);
1809
+ const result = await api('/api/device/profile', 'POST', {
1340
1810
  deviceId: selectedDeviceId(),
1341
1811
  packageName: 'com.android.chrome',
1342
1812
  });
1343
1813
  renderOutput(result);
1344
- setMessage('Suite done', false);
1814
+ setMessage('Device profile ready', false);
1345
1815
  }
1346
1816
 
1347
1817
  async function runStress() {
1348
1818
  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');
1355
-
1819
+ const cfg = currentStressConfig();
1356
1820
  const result = await api('/api/stress-run', 'POST', {
1357
1821
  deviceId: selectedDeviceId(),
1358
- urls,
1359
- loops,
1360
- waitForReadyMs,
1822
+ urls: cfg.urls,
1823
+ loops: cfg.loops,
1824
+ waitForReadyMs: cfg.waitForReadyMs,
1361
1825
  includeSnapshotAfterEach: true,
1362
1826
  });
1363
1827
  renderOutput(result);
1364
- setMessage('Stress run done', false);
1828
+ setMessage('Stress run complete', false);
1365
1829
  }
1366
1830
 
1367
1831
  async function saveWorkflow() {
@@ -1373,7 +1837,7 @@ https://developer.android.com</textarea>
1373
1837
  try {
1374
1838
  steps = JSON.parse($workflowSteps.value || '[]');
1375
1839
  } catch (error) {
1376
- throw new Error('workflow steps must be valid JSON array');
1840
+ throw new Error('workflow steps must be valid JSON');
1377
1841
  }
1378
1842
  const result = await api('/api/workflows', 'POST', {
1379
1843
  name,
@@ -1381,11 +1845,11 @@ https://developer.android.com</textarea>
1381
1845
  });
1382
1846
  renderOutput(result);
1383
1847
  setMessage('Workflow saved', false);
1384
- await refreshState();
1848
+ await refreshCore();
1385
1849
  }
1386
1850
 
1387
1851
  async function runWorkflow() {
1388
- const name = ($workflowSelect.value || '').trim();
1852
+ const name = selectedWorkflowName();
1389
1853
  if (!name) {
1390
1854
  throw new Error('select workflow first');
1391
1855
  }
@@ -1396,18 +1860,76 @@ https://developer.android.com</textarea>
1396
1860
  includeRaw: false,
1397
1861
  });
1398
1862
  renderOutput(result);
1399
- setMessage('Workflow done', false);
1863
+ setMessage('Workflow executed', false);
1400
1864
  }
1401
1865
 
1402
1866
  async function deleteWorkflow() {
1403
- const name = ($workflowSelect.value || '').trim();
1867
+ const name = selectedWorkflowName();
1404
1868
  if (!name) {
1405
1869
  throw new Error('select workflow first');
1406
1870
  }
1407
1871
  const result = await api('/api/workflows/' + encodeURIComponent(name), 'DELETE');
1408
1872
  renderOutput(result);
1409
1873
  setMessage('Workflow deleted', false);
1410
- await refreshState();
1874
+ await refreshCore();
1875
+ }
1876
+
1877
+ async function exportWorkflows() {
1878
+ const result = await api('/api/workflows/export');
1879
+ $workflowSteps.value = JSON.stringify(result.workflows || {}, null, 2);
1880
+ renderOutput(result);
1881
+ setMessage('Workflows exported into editor', false);
1882
+ }
1883
+
1884
+ async function importWorkflows() {
1885
+ let parsed;
1886
+ try {
1887
+ parsed = JSON.parse($workflowSteps.value || '{}');
1888
+ } catch (error) {
1889
+ throw new Error('workflow editor content must be valid JSON');
1890
+ }
1891
+
1892
+ const result = await api('/api/workflows/import', 'POST', {
1893
+ workflows: parsed,
1894
+ replace: false,
1895
+ });
1896
+ renderOutput(result);
1897
+ setMessage('Workflows imported', false);
1898
+ await refreshCore();
1899
+ }
1900
+
1901
+ async function enqueueJob() {
1902
+ const type = ($jobType.value || '').trim();
1903
+ const input = { deviceId: selectedDeviceId() };
1904
+
1905
+ if (type === 'open_url') {
1906
+ input.url = $jobUrl.value || 'https://developer.android.com';
1907
+ input.waitForReadyMs = 1000;
1908
+ } else if (type === 'snapshot_suite') {
1909
+ input.packageName = 'com.android.chrome';
1910
+ } else if (type === 'stress_run') {
1911
+ const cfg = currentStressConfig();
1912
+ input.urls = cfg.urls;
1913
+ input.loops = cfg.loops;
1914
+ input.waitForReadyMs = cfg.waitForReadyMs;
1915
+ input.includeSnapshotAfterEach = true;
1916
+ } else if (type === 'workflow_run') {
1917
+ const workflowName = ($jobWorkflow.value || '').trim();
1918
+ if (!workflowName) {
1919
+ throw new Error('select workflow for workflow_run job');
1920
+ }
1921
+ input.name = workflowName;
1922
+ input.packageName = 'com.android.chrome';
1923
+ input.includeRaw = false;
1924
+ }
1925
+
1926
+ const result = await api('/api/jobs', 'POST', {
1927
+ type,
1928
+ input,
1929
+ });
1930
+ renderOutput(result);
1931
+ setMessage('Job enqueued', false);
1932
+ await refreshJobs();
1411
1933
  }
1412
1934
 
1413
1935
  document.getElementById('open-url-btn').addEventListener('click', async function () {
@@ -1422,6 +1944,9 @@ https://developer.android.com</textarea>
1422
1944
  document.getElementById('snapshot-diff-btn').addEventListener('click', async function () {
1423
1945
  try { await captureSnapshotDiff(); } catch (error) { setMessage(String(error), true); }
1424
1946
  });
1947
+ document.getElementById('profile-btn').addEventListener('click', async function () {
1948
+ try { await captureDeviceProfile(); } catch (error) { setMessage(String(error), true); }
1949
+ });
1425
1950
  document.getElementById('stress-btn').addEventListener('click', async function () {
1426
1951
  try { await runStress(); } catch (error) { setMessage(String(error), true); }
1427
1952
  });
@@ -1434,20 +1959,27 @@ https://developer.android.com</textarea>
1434
1959
  document.getElementById('workflow-delete-btn').addEventListener('click', async function () {
1435
1960
  try { await deleteWorkflow(); } catch (error) { setMessage(String(error), true); }
1436
1961
  });
1962
+ document.getElementById('workflow-export-btn').addEventListener('click', async function () {
1963
+ try { await exportWorkflows(); } catch (error) { setMessage(String(error), true); }
1964
+ });
1965
+ document.getElementById('workflow-import-btn').addEventListener('click', async function () {
1966
+ try { await importWorkflows(); } catch (error) { setMessage(String(error), true); }
1967
+ });
1968
+ document.getElementById('job-enqueue-btn').addEventListener('click', async function () {
1969
+ try { await enqueueJob(); } catch (error) { setMessage(String(error), true); }
1970
+ });
1971
+ document.getElementById('job-refresh-btn').addEventListener('click', async function () {
1972
+ try { await refreshJobs(); setMessage('Jobs refreshed', false); } catch (error) { setMessage(String(error), true); }
1973
+ });
1437
1974
  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
- }
1975
+ try { await refreshCore(); setMessage('State refreshed', false); } catch (error) { setMessage(String(error), true); }
1444
1976
  });
1445
1977
  document.getElementById('clear-output-btn').addEventListener('click', function () {
1446
1978
  renderOutput({});
1447
1979
  });
1448
1980
 
1449
1981
  $deviceSelect.addEventListener('change', function () {
1450
- state.deviceId = $deviceSelect.value;
1982
+ state.deviceId = $deviceSelect.value || undefined;
1451
1983
  $devicePill.textContent = 'device: ' + (state.deviceId || 'none');
1452
1984
  });
1453
1985
 
@@ -1466,7 +1998,7 @@ https://developer.android.com</textarea>
1466
1998
  }
1467
1999
 
1468
2000
  $workflowSelect.addEventListener('change', function () {
1469
- const selected = state.workflows.find(function (item) { return item.name === $workflowSelect.value; });
2001
+ const selected = state.workflows.find(function (w) { return w.name === $workflowSelect.value; });
1470
2002
  if (selected) {
1471
2003
  $workflowName.value = selected.name;
1472
2004
  $workflowSteps.value = JSON.stringify(selected.steps || [], null, 2);
@@ -1475,8 +2007,8 @@ https://developer.android.com</textarea>
1475
2007
 
1476
2008
  async function init() {
1477
2009
  try {
1478
- await refreshState();
1479
- const history = await api('/api/history?limit=30');
2010
+ await refreshCore();
2011
+ const history = await api('/api/history?limit=40');
1480
2012
  const events = Array.isArray(history.events) ? history.events : [];
1481
2013
  for (let i = events.length - 1; i >= 0; i -= 1) {
1482
2014
  addEvent(events[i]);
@@ -1484,12 +2016,14 @@ https://developer.android.com</textarea>
1484
2016
  connectEvents();
1485
2017
  renderOutput({ ok: true, message: 'UI ready', updateHint: '${UPDATE_HINT}' });
1486
2018
  setMessage('Ready.', false);
2019
+
1487
2020
  setInterval(async function () {
1488
2021
  try {
1489
2022
  const metricsPayload = await api('/api/metrics');
1490
2023
  renderMetrics(metricsPayload);
2024
+ await refreshJobs();
1491
2025
  } catch (error) {
1492
- setMessage('Metrics refresh failed', true);
2026
+ setMessage('Background refresh failed', true);
1493
2027
  }
1494
2028
  }, 5000);
1495
2029
  } catch (error) {