the-android-mcp 3.3.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,10 +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 MAX_JOBS = 250;
24
- const WORKFLOW_DIR = path_1.default.join(os_1.default.homedir(), '.the-android-mcp');
25
- 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');
26
27
  const SNAPSHOT_KINDS = [
27
28
  'radio',
28
29
  'display',
@@ -39,16 +40,33 @@ const metrics = {};
39
40
  const snapshotCache = {};
40
41
  let workflows = loadWorkflows();
41
42
  const jobs = [];
42
- const jobQueue = [];
43
- let jobRunnerActive = false;
43
+ const lanes = {};
44
44
  function nowIso() {
45
45
  return new Date().toISOString();
46
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
+ }
47
61
  function isSnapshotKind(value) {
48
62
  return SNAPSHOT_KINDS.includes(value);
49
63
  }
50
64
  function isJobType(value) {
51
- return value === 'open_url' || value === 'snapshot_suite' || value === 'stress_run' || value === 'workflow_run';
65
+ return (value === 'open_url' ||
66
+ value === 'snapshot_suite' ||
67
+ value === 'stress_run' ||
68
+ value === 'workflow_run' ||
69
+ value === 'device_profile');
52
70
  }
53
71
  function clampInt(value, fallback, min, max) {
54
72
  if (typeof value !== 'number' || !Number.isFinite(value)) {
@@ -69,6 +87,7 @@ function pushEvent(type, message, data) {
69
87
  if (eventHistory.length > MAX_EVENTS) {
70
88
  eventHistory.splice(0, eventHistory.length - MAX_EVENTS);
71
89
  }
90
+ appendSessionEvent(event);
72
91
  broadcastEvent(event);
73
92
  return event;
74
93
  }
@@ -89,6 +108,16 @@ function broadcastEvent(event) {
89
108
  }
90
109
  }
91
110
  }
111
+ function sendJson(response, statusCode, body) {
112
+ response.statusCode = statusCode;
113
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
114
+ response.end(JSON.stringify(body));
115
+ }
116
+ function sendHtml(response, html) {
117
+ response.statusCode = 200;
118
+ response.setHeader('Content-Type', 'text/html; charset=utf-8');
119
+ response.end(html);
120
+ }
92
121
  function trackMetric(name, durationMs, ok, errorMessage) {
93
122
  const entry = metrics[name] ?? {
94
123
  name,
@@ -124,16 +153,6 @@ async function withMetric(name, fn) {
124
153
  throw error;
125
154
  }
126
155
  }
127
- function sendJson(response, statusCode, body) {
128
- response.statusCode = statusCode;
129
- response.setHeader('Content-Type', 'application/json; charset=utf-8');
130
- response.end(JSON.stringify(body));
131
- }
132
- function sendHtml(response, html) {
133
- response.statusCode = 200;
134
- response.setHeader('Content-Type', 'text/html; charset=utf-8');
135
- response.end(html);
136
- }
137
156
  function compactStringSummary(value) {
138
157
  if (typeof value !== 'string') {
139
158
  return { type: typeof value };
@@ -141,7 +160,7 @@ function compactStringSummary(value) {
141
160
  return {
142
161
  length: value.length,
143
162
  lines: value.split('\n').length,
144
- preview: value.slice(0, 200),
163
+ preview: value.slice(0, 220),
145
164
  };
146
165
  }
147
166
  function summarizeObjectShape(input) {
@@ -165,7 +184,7 @@ function summarizeObjectShape(input) {
165
184
  result[key] = { type: 'array', length: value.length };
166
185
  continue;
167
186
  }
168
- if (typeof value === 'object' && value !== null) {
187
+ if (typeof value === 'object') {
169
188
  result[key] = { type: 'object', keys: Object.keys(value).length };
170
189
  continue;
171
190
  }
@@ -173,19 +192,41 @@ function summarizeObjectShape(input) {
173
192
  }
174
193
  return result;
175
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
+ }
176
217
  function normalizeWorkflowStep(value) {
177
218
  if (!value || typeof value !== 'object') {
178
- throw new Error('Invalid workflow step: object expected');
219
+ throw new Error('Workflow step must be an object');
179
220
  }
180
221
  const step = value;
181
222
  const type = step.type;
182
223
  if (type !== 'open_url' && type !== 'snapshot' && type !== 'snapshot_suite' && type !== 'sleep_ms') {
183
- throw new Error('Invalid workflow step type');
224
+ throw new Error('Unsupported workflow step type');
184
225
  }
185
226
  const normalized = { type };
186
227
  if (type === 'open_url') {
187
228
  if (typeof step.url !== 'string' || !/^https?:\/\//i.test(step.url)) {
188
- throw new Error('Workflow open_url step requires valid url');
229
+ throw new Error('open_url step requires valid url');
189
230
  }
190
231
  normalized.url = step.url;
191
232
  normalized.waitForReadyMs = clampInt(step.waitForReadyMs, 1000, 200, 10000);
@@ -193,7 +234,7 @@ function normalizeWorkflowStep(value) {
193
234
  }
194
235
  if (type === 'snapshot') {
195
236
  if (typeof step.snapshot !== 'string' || !isSnapshotKind(step.snapshot)) {
196
- throw new Error('Workflow snapshot step requires valid snapshot kind');
237
+ throw new Error('snapshot step requires valid snapshot kind');
197
238
  }
198
239
  normalized.snapshot = step.snapshot;
199
240
  if (typeof step.packageName === 'string') {
@@ -208,40 +249,12 @@ function normalizeWorkflowStep(value) {
208
249
  }
209
250
  return normalized;
210
251
  }
211
- normalized.durationMs = clampInt(step.durationMs, 500, 50, 20000);
252
+ normalized.durationMs = clampInt(step.durationMs, 500, 50, 30000);
212
253
  return normalized;
213
254
  }
214
- function ensureWorkflowStore() {
215
- if (!fs_1.default.existsSync(WORKFLOW_DIR)) {
216
- fs_1.default.mkdirSync(WORKFLOW_DIR, { recursive: true });
217
- }
218
- }
219
- function defaultWorkflows() {
220
- const createdAt = nowIso();
221
- return {
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
- },
240
- };
241
- }
242
255
  function loadWorkflows() {
243
256
  try {
244
- ensureWorkflowStore();
257
+ ensureAppStateDir();
245
258
  if (!fs_1.default.existsSync(WORKFLOW_FILE)) {
246
259
  const defaults = defaultWorkflows();
247
260
  fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(defaults, null, 2), 'utf8');
@@ -261,11 +274,11 @@ function loadWorkflows() {
261
274
  if (typeof item.name !== 'string' || item.name.trim().length === 0) {
262
275
  continue;
263
276
  }
264
- const stepsValue = Array.isArray(item.steps) ? item.steps : [];
277
+ const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
265
278
  const steps = [];
266
- for (const stepValue of stepsValue) {
279
+ for (const stepRaw of stepsRaw) {
267
280
  try {
268
- steps.push(normalizeWorkflowStep(stepValue));
281
+ steps.push(normalizeWorkflowStep(stepRaw));
269
282
  }
270
283
  catch {
271
284
  // skip invalid step
@@ -281,29 +294,26 @@ function loadWorkflows() {
281
294
  steps,
282
295
  };
283
296
  }
284
- if (Object.keys(result).length === 0) {
285
- return defaultWorkflows();
286
- }
287
- return result;
297
+ return Object.keys(result).length > 0 ? result : defaultWorkflows();
288
298
  }
289
299
  catch {
290
300
  return defaultWorkflows();
291
301
  }
292
302
  }
293
303
  function saveWorkflows() {
294
- ensureWorkflowStore();
304
+ ensureAppStateDir();
295
305
  fs_1.default.writeFileSync(WORKFLOW_FILE, JSON.stringify(workflows, null, 2), 'utf8');
296
306
  }
297
307
  function workflowsList() {
298
308
  return Object.values(workflows).sort((a, b) => a.name.localeCompare(b.name));
299
309
  }
300
- async function sleepMs(durationMs) {
301
- await new Promise(resolve => setTimeout(resolve, durationMs));
302
- }
303
310
  function extractDeviceId(body) {
304
311
  const value = body.deviceId;
305
312
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
306
313
  }
314
+ async function sleepMs(durationMs) {
315
+ await new Promise(resolve => setTimeout(resolve, durationMs));
316
+ }
307
317
  function captureSnapshot(kind, options) {
308
318
  const includeRaw = options.includeRaw === true;
309
319
  if (kind === 'radio') {
@@ -387,27 +397,15 @@ function diffSnapshot(prev, next) {
387
397
  for (const key of keys) {
388
398
  const oldValue = a[key];
389
399
  const newValue = b[key];
390
- if (typeof oldValue === 'string' || typeof newValue === 'string') {
391
- const oldText = typeof oldValue === 'string' ? oldValue : JSON.stringify(oldValue);
392
- const newText = typeof newValue === 'string' ? newValue : JSON.stringify(newValue);
393
- if (oldText !== newText) {
394
- changed.push({
395
- key,
396
- type: 'text',
397
- beforeLength: oldText.length,
398
- afterLength: newText.length,
399
- });
400
- }
401
- continue;
402
- }
403
400
  const oldJson = JSON.stringify(oldValue);
404
401
  const newJson = JSON.stringify(newValue);
405
402
  if (oldJson !== newJson) {
406
403
  changed.push({
407
404
  key,
408
- type: 'value',
409
- before: oldValue,
410
- after: newValue,
405
+ beforeType: typeof oldValue,
406
+ afterType: typeof newValue,
407
+ beforeSize: oldJson ? oldJson.length : 0,
408
+ afterSize: newJson ? newJson.length : 0,
411
409
  });
412
410
  }
413
411
  }
@@ -435,6 +433,22 @@ function getSnapshotSuite(deviceId, packageName) {
435
433
  },
436
434
  };
437
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
+ }
438
452
  function buildDeviceProfile(deviceId, packageName) {
439
453
  const startedAt = Date.now();
440
454
  const radio = (0, adb_js_1.captureAndroidRadioSnapshot)({
@@ -473,7 +487,7 @@ function buildDeviceProfile(deviceId, packageName) {
473
487
  includeFeatures: false,
474
488
  packageListLines: 600,
475
489
  });
476
- return {
490
+ const profile = {
477
491
  capturedAt: nowIso(),
478
492
  durationMs: Date.now() - startedAt,
479
493
  deviceId: radio.deviceId,
@@ -504,6 +518,28 @@ function buildDeviceProfile(deviceId, packageName) {
504
518
  disabled: packages.disabledCount,
505
519
  },
506
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
+ };
507
543
  }
508
544
  function runStressScenario(body) {
509
545
  const deviceId = extractDeviceId(body);
@@ -514,8 +550,8 @@ function runStressScenario(body) {
514
550
  const normalizedUrls = urls.length > 0
515
551
  ? urls
516
552
  : ['https://www.wikipedia.org', 'https://news.ycombinator.com', 'https://developer.android.com'];
517
- const loops = clampInt(body.loops, 1, 1, 5);
518
- 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);
519
555
  const includeSnapshotAfterEach = body.includeSnapshotAfterEach !== false;
520
556
  const startedAt = Date.now();
521
557
  const steps = [];
@@ -591,7 +627,6 @@ async function runWorkflow(name, options) {
591
627
  step: 'open_url',
592
628
  url: step.url,
593
629
  strategy: result.strategy,
594
- deviceId: result.deviceId,
595
630
  durationMs: Date.now() - stepStarted,
596
631
  });
597
632
  continue;
@@ -620,12 +655,9 @@ async function runWorkflow(name, options) {
620
655
  });
621
656
  continue;
622
657
  }
623
- const duration = clampInt(step.durationMs, 500, 50, 20000);
658
+ const duration = clampInt(step.durationMs, 500, 50, 30000);
624
659
  await sleepMs(duration);
625
- outputs.push({
626
- step: 'sleep_ms',
627
- durationMs: duration,
628
- });
660
+ outputs.push({ step: 'sleep_ms', durationMs: duration });
629
661
  }
630
662
  return {
631
663
  ok: true,
@@ -640,10 +672,30 @@ async function runWorkflow(name, options) {
640
672
  updateHint: UPDATE_HINT,
641
673
  };
642
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
+ }
643
691
  function createJob(type, input) {
692
+ const rawDeviceId = typeof input.deviceId === 'string' ? input.deviceId : undefined;
693
+ const lane = resolveLaneForDevice(rawDeviceId);
644
694
  const job = {
645
695
  id: jobSeq++,
646
696
  type,
697
+ laneId: lane.id,
698
+ deviceId: lane.deviceId,
647
699
  status: 'queued',
648
700
  createdAt: nowIso(),
649
701
  input,
@@ -652,67 +704,112 @@ function createJob(type, input) {
652
704
  if (jobs.length > MAX_JOBS) {
653
705
  jobs.splice(MAX_JOBS);
654
706
  }
655
- jobQueue.push(job.id);
656
- pushEvent('job-queued', 'Job queued', { id: job.id, type: job.type });
657
- void runJobQueue();
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();
658
716
  return job;
659
717
  }
660
718
  function getJobById(id) {
661
719
  return jobs.find(job => job.id === id);
662
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
+ }
663
738
  function cancelQueuedJob(id) {
664
739
  const job = getJobById(id);
665
- if (!job) {
740
+ if (!job || job.status !== 'queued') {
666
741
  return false;
667
742
  }
668
- if (job.status !== 'queued') {
743
+ const lane = lanes[job.laneId];
744
+ if (!lane) {
669
745
  return false;
670
746
  }
747
+ const idx = lane.queue.indexOf(id);
748
+ if (idx >= 0) {
749
+ lane.queue.splice(idx, 1);
750
+ }
671
751
  job.status = 'cancelled';
672
752
  job.finishedAt = nowIso();
673
753
  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 });
754
+ lane.cancelled += 1;
755
+ lane.updatedAt = nowIso();
756
+ pushEvent('job-cancelled', 'Job cancelled', { id, laneId: lane.id });
679
757
  return true;
680
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
+ }
681
770
  async function executeJob(job) {
682
771
  if (job.type === 'open_url') {
683
772
  const urlValue = typeof job.input.url === 'string' ? job.input.url : '';
684
773
  if (!urlValue || !/^https?:\/\//i.test(urlValue)) {
685
- throw new Error('Job open_url requires valid url');
774
+ throw new Error('open_url job requires valid url');
686
775
  }
687
776
  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, {
777
+ return (0, adb_js_1.openUrlInChrome)(urlValue, job.deviceId, {
689
778
  waitForReadyMs,
690
779
  fallbackToDefault: true,
691
780
  });
692
781
  }
693
782
  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);
783
+ return getSnapshotSuite(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
695
784
  }
696
785
  if (job.type === 'stress_run') {
697
- return runStressScenario(job.input);
786
+ const input = { ...job.input };
787
+ if (!input.deviceId && job.deviceId) {
788
+ input.deviceId = job.deviceId;
789
+ }
790
+ return runStressScenario(input);
698
791
  }
699
- const workflowName = typeof job.input.name === 'string' ? job.input.name : '';
700
- if (!workflowName) {
701
- throw new Error('Job workflow_run requires workflow name');
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
+ });
702
802
  }
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
- });
803
+ return buildDeviceProfile(job.deviceId, typeof job.input.packageName === 'string' ? job.input.packageName : undefined);
708
804
  }
709
- async function runJobQueue() {
710
- if (jobRunnerActive) {
805
+ async function processLane(lane) {
806
+ if (lane.active) {
711
807
  return;
712
808
  }
713
- jobRunnerActive = true;
714
- while (jobQueue.length > 0) {
715
- const jobId = jobQueue.shift();
809
+ lane.active = true;
810
+ lane.updatedAt = nowIso();
811
+ while (lane.queue.length > 0) {
812
+ const jobId = lane.queue.shift();
716
813
  if (!jobId) {
717
814
  continue;
718
815
  }
@@ -722,7 +819,12 @@ async function runJobQueue() {
722
819
  }
723
820
  job.status = 'running';
724
821
  job.startedAt = nowIso();
725
- pushEvent('job-running', 'Job started', { id: job.id, type: job.type });
822
+ lane.updatedAt = nowIso();
823
+ pushEvent('job-running', 'Job started', {
824
+ id: job.id,
825
+ type: job.type,
826
+ laneId: lane.id,
827
+ });
726
828
  const startedAtMs = Date.now();
727
829
  try {
728
830
  const result = await executeJob(job);
@@ -730,9 +832,12 @@ async function runJobQueue() {
730
832
  job.status = 'completed';
731
833
  job.finishedAt = nowIso();
732
834
  job.durationMs = Date.now() - startedAtMs;
835
+ lane.completed += 1;
836
+ lane.updatedAt = nowIso();
733
837
  pushEvent('job-completed', 'Job completed', {
734
838
  id: job.id,
735
839
  type: job.type,
840
+ laneId: lane.id,
736
841
  durationMs: job.durationMs,
737
842
  });
738
843
  }
@@ -742,30 +847,70 @@ async function runJobQueue() {
742
847
  job.error = message;
743
848
  job.finishedAt = nowIso();
744
849
  job.durationMs = Date.now() - startedAtMs;
850
+ lane.failed += 1;
851
+ lane.updatedAt = nowIso();
745
852
  pushEvent('job-failed', 'Job failed', {
746
853
  id: job.id,
747
854
  type: job.type,
855
+ laneId: lane.id,
748
856
  error: message,
749
857
  });
750
858
  }
751
859
  }
752
- jobRunnerActive = false;
860
+ lane.active = false;
861
+ lane.updatedAt = nowIso();
753
862
  }
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,
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,
767
894
  }));
768
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
+ }
769
914
  async function readJsonBody(request) {
770
915
  return await new Promise((resolve, reject) => {
771
916
  const chunks = [];
@@ -857,7 +1002,8 @@ function buildStatePayload(host, port) {
857
1002
  connectedDeviceCount: devices.length,
858
1003
  eventCount: eventHistory.length,
859
1004
  workflowCount: Object.keys(workflows).length,
860
- queueDepth: jobQueue.length,
1005
+ laneCount: Object.keys(lanes).length,
1006
+ queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
861
1007
  jobCount: jobs.length,
862
1008
  snapshotKinds: SNAPSHOT_KINDS,
863
1009
  };
@@ -878,6 +1024,23 @@ function buildMetricsPayload() {
878
1024
  entries,
879
1025
  };
880
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,
1042
+ };
1043
+ }
881
1044
  async function handleApi(request, response, context) {
882
1045
  const method = request.method ?? 'GET';
883
1046
  const url = request.url ? new URL(request.url, 'http://localhost') : new URL('http://localhost/');
@@ -911,34 +1074,20 @@ async function handleApi(request, response, context) {
911
1074
  });
912
1075
  return;
913
1076
  }
914
- if (method === 'GET' && pathname === '/api/history') {
915
- await withMetric('history', () => {
916
- const limitRaw = Number(url.searchParams.get('limit') ?? '120');
917
- 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', () => {
918
1079
  sendJson(response, 200, {
919
- count: Math.min(limit, eventHistory.length),
920
- events: eventHistory.slice(-limit),
1080
+ lanes: lanesSummary(),
1081
+ count: Object.keys(lanes).length,
921
1082
  });
922
1083
  });
923
1084
  return;
924
1085
  }
925
- if (method === 'GET' && pathname === '/api/events') {
926
- await withMetric('events', () => {
927
- setupSse(request, response);
928
- });
929
- return;
930
- }
931
- if (method === 'GET' && pathname === '/api/metrics') {
932
- await withMetric('metrics', () => {
933
- sendJson(response, 200, buildMetricsPayload());
934
- });
935
- return;
936
- }
937
1086
  if (method === 'GET' && pathname === '/api/jobs') {
938
1087
  await withMetric('jobs-list', () => {
939
1088
  sendJson(response, 200, {
940
1089
  jobs: listJobSummaries(),
941
- queueDepth: jobQueue.length,
1090
+ queueDepth: Object.values(lanes).reduce((sum, lane) => sum + lane.queue.length, 0),
942
1091
  });
943
1092
  });
944
1093
  return;
@@ -948,7 +1097,7 @@ async function handleApi(request, response, context) {
948
1097
  const body = await readJsonBody(request);
949
1098
  const typeValue = typeof body.type === 'string' ? body.type : '';
950
1099
  if (!isJobType(typeValue)) {
951
- sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run' });
1100
+ sendJson(response, 400, { error: 'type must be one of open_url, snapshot_suite, stress_run, workflow_run, device_profile' });
952
1101
  return;
953
1102
  }
954
1103
  const input = body.input && typeof body.input === 'object' && !Array.isArray(body.input)
@@ -958,7 +1107,37 @@ async function handleApi(request, response, context) {
958
1107
  sendJson(response, 200, {
959
1108
  ok: true,
960
1109
  job,
961
- queueDepth: jobQueue.length,
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,
962
1141
  });
963
1142
  });
964
1143
  return;
@@ -987,6 +1166,22 @@ async function handleApi(request, response, context) {
987
1166
  });
988
1167
  return;
989
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
+ }
990
1185
  if (method === 'GET' && pathname === '/api/workflows') {
991
1186
  await withMetric('workflows-list', () => {
992
1187
  sendJson(response, 200, {
@@ -1011,7 +1206,7 @@ async function handleApi(request, response, context) {
1011
1206
  const replace = body.replace === true;
1012
1207
  const payload = body.workflows;
1013
1208
  const imported = replace ? {} : { ...workflows };
1014
- const consumeDefinition = (value) => {
1209
+ const consumeWorkflow = (value) => {
1015
1210
  if (!value || typeof value !== 'object') {
1016
1211
  return;
1017
1212
  }
@@ -1022,8 +1217,8 @@ async function handleApi(request, response, context) {
1022
1217
  }
1023
1218
  const stepsRaw = Array.isArray(item.steps) ? item.steps : [];
1024
1219
  const steps = [];
1025
- for (const stepValue of stepsRaw) {
1026
- steps.push(normalizeWorkflowStep(stepValue));
1220
+ for (const stepRaw of stepsRaw) {
1221
+ steps.push(normalizeWorkflowStep(stepRaw));
1027
1222
  }
1028
1223
  if (steps.length === 0) {
1029
1224
  return;
@@ -1037,12 +1232,12 @@ async function handleApi(request, response, context) {
1037
1232
  };
1038
1233
  if (Array.isArray(payload)) {
1039
1234
  for (const value of payload) {
1040
- consumeDefinition(value);
1235
+ consumeWorkflow(value);
1041
1236
  }
1042
1237
  }
1043
1238
  else if (payload && typeof payload === 'object') {
1044
1239
  for (const value of Object.values(payload)) {
1045
- consumeDefinition(value);
1240
+ consumeWorkflow(value);
1046
1241
  }
1047
1242
  }
1048
1243
  else {
@@ -1170,6 +1365,7 @@ async function handleApi(request, response, context) {
1170
1365
  pushEvent('device-profile', 'Device profile captured', {
1171
1366
  deviceId: profile.deviceId,
1172
1367
  durationMs: profile.durationMs,
1368
+ healthScore: profile.healthScore,
1173
1369
  });
1174
1370
  sendJson(response, 200, {
1175
1371
  ok: true,
@@ -1179,6 +1375,27 @@ async function handleApi(request, response, context) {
1179
1375
  });
1180
1376
  return;
1181
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
+ }
1182
1399
  if (method === 'POST' && pathname === '/api/snapshot-suite') {
1183
1400
  await withMetric('snapshot-suite', async () => {
1184
1401
  const body = await readJsonBody(request);
@@ -1202,15 +1419,15 @@ async function handleApi(request, response, context) {
1202
1419
  sendJson(response, 400, { error: 'kind must be one of snapshot kinds' });
1203
1420
  return;
1204
1421
  }
1205
- const prev = snapshotCache[kindValue];
1422
+ const previous = snapshotCache[kindValue];
1206
1423
  const fresh = captureSnapshot(kindValue, {
1207
1424
  deviceId: extractDeviceId(body),
1208
1425
  packageName: typeof body.packageName === 'string' ? body.packageName : undefined,
1209
1426
  includeRaw: true,
1210
1427
  });
1211
- const raw = fresh.raw;
1212
- snapshotCache[kindValue] = raw;
1213
- const diff = diffSnapshot(prev, raw);
1428
+ const currentRaw = fresh.raw;
1429
+ snapshotCache[kindValue] = currentRaw;
1430
+ const diff = diffSnapshot(previous, currentRaw);
1214
1431
  pushEvent('snapshot-diff', 'Snapshot diff captured', {
1215
1432
  kind: kindValue,
1216
1433
  changedCount: diff.changedCount,
@@ -1218,7 +1435,7 @@ async function handleApi(request, response, context) {
1218
1435
  sendJson(response, 200, {
1219
1436
  ok: true,
1220
1437
  kind: kindValue,
1221
- hadPrevious: Boolean(prev),
1438
+ hadPrevious: Boolean(previous),
1222
1439
  diff,
1223
1440
  currentSummary: fresh.summary,
1224
1441
  updateHint: UPDATE_HINT,
@@ -1263,6 +1480,47 @@ async function handleApi(request, response, context) {
1263
1480
  });
1264
1481
  return;
1265
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
+ }
1266
1524
  sendJson(response, 404, { error: 'Not found' });
1267
1525
  }
1268
1526
  const INDEX_HTML = String.raw `<!doctype html>
@@ -1270,7 +1528,7 @@ const INDEX_HTML = String.raw `<!doctype html>
1270
1528
  <head>
1271
1529
  <meta charset="utf-8" />
1272
1530
  <meta name="viewport" content="width=device-width,initial-scale=1" />
1273
- <title>the-android-mcp web ui v3.3</title>
1531
+ <title>the-android-mcp web ui v3.4</title>
1274
1532
  <link rel="preconnect" href="https://fonts.googleapis.com" />
1275
1533
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1276
1534
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
@@ -1301,7 +1559,7 @@ const INDEX_HTML = String.raw `<!doctype html>
1301
1559
  linear-gradient(155deg, var(--bg0), var(--bg1) 44%, var(--bg2));
1302
1560
  padding: 16px;
1303
1561
  }
1304
- .app { max-width: 1500px; margin: 0 auto; display: grid; gap: 12px; }
1562
+ .app { max-width: 1560px; margin: 0 auto; display: grid; gap: 12px; }
1305
1563
  .hero {
1306
1564
  border: 1px solid var(--line);
1307
1565
  border-radius: 18px;
@@ -1353,13 +1611,13 @@ const INDEX_HTML = String.raw `<!doctype html>
1353
1611
  padding: 9px;
1354
1612
  font: inherit;
1355
1613
  }
1356
- textarea { min-height: 90px; resize: vertical; }
1614
+ textarea { min-height: 86px; resize: vertical; }
1357
1615
  button { cursor: pointer; font-weight: 700; transition: transform 120ms ease, filter 120ms ease; }
1358
1616
  button:hover { transform: translateY(-1px); filter: brightness(1.08); }
1359
1617
  .p { background: linear-gradient(130deg, #0d7666, #155f75); }
1360
1618
  .s { background: linear-gradient(130deg, #1d3348, #192b3c); }
1361
1619
  .w { background: linear-gradient(130deg, #7a5917, #61401f); }
1362
- .events, .metrics, .jobs {
1620
+ .events, .metrics, .jobs, .lanes {
1363
1621
  max-height: 300px;
1364
1622
  overflow: auto;
1365
1623
  border: 1px solid #2f4a61;
@@ -1391,7 +1649,7 @@ const INDEX_HTML = String.raw `<!doctype html>
1391
1649
  pre {
1392
1650
  margin: 0;
1393
1651
  padding: 10px;
1394
- max-height: 450px;
1652
+ max-height: 460px;
1395
1653
  overflow: auto;
1396
1654
  color: #c6e4ff;
1397
1655
  font: 12px/1.45 'JetBrains Mono', monospace;
@@ -1411,11 +1669,12 @@ const INDEX_HTML = String.raw `<!doctype html>
1411
1669
  <span class="pill" id="version-pill">v3</span>
1412
1670
  <span class="pill" id="device-pill">device: n/a</span>
1413
1671
  <span class="pill" id="workflow-pill">workflows: 0</span>
1672
+ <span class="pill" id="lane-pill">lanes: 0</span>
1414
1673
  <span class="pill" id="queue-pill">queue: 0</span>
1415
1674
  <span class="pill">port: 50000</span>
1416
1675
  </div>
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>
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>
1419
1678
  </section>
1420
1679
 
1421
1680
  <section class="grid">
@@ -1435,20 +1694,13 @@ const INDEX_HTML = String.raw `<!doctype html>
1435
1694
  </div>
1436
1695
  <div class="split">
1437
1696
  <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" />
1697
+ <button class="s" id="profiles-btn">Profiles all</button>
1446
1698
  </div>
1447
1699
  <p class="muted">Update hint: <code>npm install -g the-android-mcp@latest</code></p>
1448
1700
  </article>
1449
1701
 
1450
1702
  <article class="card stack">
1451
- <h2>Snapshot + diff</h2>
1703
+ <h2>Snapshot + stress</h2>
1452
1704
  <select id="snapshot-kind">
1453
1705
  <option value="radio">radio</option>
1454
1706
  <option value="display">display</option>
@@ -1460,12 +1712,22 @@ https://developer.android.com</textarea>
1460
1712
  <button class="s" id="snapshot-btn">Capture</button>
1461
1713
  <button class="w" id="snapshot-diff-btn">Capture diff</button>
1462
1714
  </div>
1715
+ <textarea id="stress-urls">https://www.wikipedia.org
1716
+ https://news.ycombinator.com
1717
+ https://developer.android.com</textarea>
1718
+ <div class="split">
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" />
1721
+ </div>
1722
+ <button class="w" id="stress-btn">Run stress</button>
1723
+ </article>
1463
1724
 
1464
- <h2 style="margin-top:8px;">Workflow engine</h2>
1725
+ <article class="card stack">
1726
+ <h2>Workflow engine</h2>
1465
1727
  <select id="workflow-select"></select>
1466
- <input id="workflow-name" placeholder="workflow name" value="" />
1728
+ <input id="workflow-name" placeholder="workflow name" />
1467
1729
  <textarea id="workflow-steps">[
1468
- {"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":1000},
1730
+ {"type":"open_url","url":"https://www.wikipedia.org","waitForReadyMs":900},
1469
1731
  {"type":"snapshot","snapshot":"radio"},
1470
1732
  {"type":"snapshot_suite","packageName":"com.android.chrome"}
1471
1733
  ]</textarea>
@@ -1487,6 +1749,7 @@ https://developer.android.com</textarea>
1487
1749
  <option value="snapshot_suite">snapshot_suite</option>
1488
1750
  <option value="stress_run">stress_run</option>
1489
1751
  <option value="workflow_run">workflow_run</option>
1752
+ <option value="device_profile">device_profile</option>
1490
1753
  </select>
1491
1754
  <input id="job-url" type="url" value="https://developer.android.com" />
1492
1755
  <select id="job-workflow"></select>
@@ -1494,18 +1757,29 @@ https://developer.android.com</textarea>
1494
1757
  <button class="p" id="job-enqueue-btn">Enqueue job</button>
1495
1758
  <button class="s" id="job-refresh-btn">Refresh jobs</button>
1496
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>
1497
1766
  <div id="jobs" class="jobs"></div>
1498
1767
  </article>
1499
1768
 
1500
1769
  <article class="card stack">
1501
- <h2>Live events</h2>
1770
+ <h2>Lanes + events</h2>
1771
+ <div id="lanes" class="lanes"></div>
1502
1772
  <div id="events" class="events"></div>
1503
1773
  </article>
1504
1774
 
1505
1775
  <article class="card stack">
1506
- <h2>Backend metrics</h2>
1776
+ <h2>Metrics + session</h2>
1507
1777
  <div id="metrics" class="metrics"></div>
1508
- <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>
1509
1783
  </article>
1510
1784
 
1511
1785
  <article class="card" style="grid-column: 1 / -1;">
@@ -1524,13 +1798,13 @@ https://developer.android.com</textarea>
1524
1798
  const state = {
1525
1799
  deviceId: undefined,
1526
1800
  workflows: [],
1527
- jobs: [],
1528
1801
  };
1529
1802
 
1530
1803
  const $status = document.getElementById('status');
1531
1804
  const $versionPill = document.getElementById('version-pill');
1532
1805
  const $devicePill = document.getElementById('device-pill');
1533
1806
  const $workflowPill = document.getElementById('workflow-pill');
1807
+ const $lanePill = document.getElementById('lane-pill');
1534
1808
  const $queuePill = document.getElementById('queue-pill');
1535
1809
  const $deviceSelect = document.getElementById('device-select');
1536
1810
  const $urlInput = document.getElementById('url-input');
@@ -1539,6 +1813,7 @@ https://developer.android.com</textarea>
1539
1813
  const $events = document.getElementById('events');
1540
1814
  const $metrics = document.getElementById('metrics');
1541
1815
  const $jobs = document.getElementById('jobs');
1816
+ const $lanes = document.getElementById('lanes');
1542
1817
  const $snapshotKind = document.getElementById('snapshot-kind');
1543
1818
  const $stressUrls = document.getElementById('stress-urls');
1544
1819
  const $stressLoops = document.getElementById('stress-loops');
@@ -1549,6 +1824,7 @@ https://developer.android.com</textarea>
1549
1824
  const $jobType = document.getElementById('job-type');
1550
1825
  const $jobUrl = document.getElementById('job-url');
1551
1826
  const $jobWorkflow = document.getElementById('job-workflow');
1827
+ const $bulkJobs = document.getElementById('bulk-jobs');
1552
1828
 
1553
1829
  function setMessage(text, isError) {
1554
1830
  $message.textContent = text;
@@ -1566,20 +1842,10 @@ https://developer.android.com</textarea>
1566
1842
  }
1567
1843
  const item = document.createElement('div');
1568
1844
  item.className = 'item';
1569
- const meta = document.createElement('div');
1570
- meta.className = 'meta';
1571
- meta.textContent = '[' + (event.at || new Date().toISOString()) + '] ' + event.type;
1572
- const text = document.createElement('div');
1573
- text.textContent = event.message || '';
1574
- item.appendChild(meta);
1575
- item.appendChild(text);
1576
- if (event.data) {
1577
- const data = document.createElement('div');
1578
- data.style.color = '#a5c6db';
1579
- data.style.marginTop = '4px';
1580
- data.textContent = JSON.stringify(event.data);
1581
- item.appendChild(data);
1582
- }
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>' : '');
1583
1849
  $events.prepend(item);
1584
1850
  while ($events.children.length > 120) {
1585
1851
  $events.removeChild($events.lastChild);
@@ -1589,7 +1855,7 @@ https://developer.android.com</textarea>
1589
1855
  function renderMetrics(payload) {
1590
1856
  const entries = Array.isArray(payload.entries) ? payload.entries : [];
1591
1857
  $metrics.innerHTML = '';
1592
- for (const entry of entries.slice(0, 30)) {
1858
+ for (const entry of entries.slice(0, 25)) {
1593
1859
  const item = document.createElement('div');
1594
1860
  item.className = 'item';
1595
1861
  item.innerHTML =
@@ -1603,35 +1869,70 @@ https://developer.android.com</textarea>
1603
1869
  }
1604
1870
  }
1605
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
+
1606
1889
  function renderJobs(payload) {
1607
1890
  const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
1608
- state.jobs = jobs;
1609
1891
  $jobs.innerHTML = '';
1610
1892
  for (const job of jobs.slice(0, 40)) {
1611
1893
  const item = document.createElement('div');
1612
1894
  item.className = 'item';
1613
1895
  item.innerHTML =
1614
1896
  '<div class="meta">#' + job.id + ' ' + job.type + ' [' + job.status + ']</div>' +
1615
- '<div>created=' + (job.createdAt || '-') + '</div>' +
1616
- '<div>duration=' + (job.durationMs || 0) + 'ms</div>' +
1897
+ '<div>lane=' + (job.laneId || '-') + ' duration=' + (job.durationMs || 0) + 'ms</div>' +
1617
1898
  (job.error ? '<div style="color:#ff8f8f;">' + job.error + '</div>' : '');
1899
+
1618
1900
  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 () {
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 () {
1624
1906
  try {
1625
1907
  const result = await api('/api/jobs/' + job.id + '/cancel', 'POST', {});
1626
1908
  renderOutput(result);
1627
- await refreshJobs();
1909
+ await refreshJobsAndLanes();
1628
1910
  setMessage('Job cancelled', false);
1629
1911
  } catch (error) {
1630
1912
  setMessage(String(error), true);
1631
1913
  }
1632
1914
  });
1633
- item.appendChild(cancel);
1915
+ item.appendChild(cancelBtn);
1634
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
+
1635
1936
  $jobs.appendChild(item);
1636
1937
  }
1637
1938
  if (!jobs.length) {
@@ -1661,7 +1962,7 @@ https://developer.android.com</textarea>
1661
1962
  return value || undefined;
1662
1963
  }
1663
1964
 
1664
- function currentStressConfig() {
1965
+ function stressConfig() {
1665
1966
  return {
1666
1967
  urls: $stressUrls.value
1667
1968
  .split('\n')
@@ -1673,9 +1974,12 @@ https://developer.android.com</textarea>
1673
1974
  };
1674
1975
  }
1675
1976
 
1676
- async function refreshJobs() {
1677
- const payload = await api('/api/jobs');
1678
- renderJobs(payload);
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);
1679
1983
  }
1680
1984
 
1681
1985
  async function refreshCore() {
@@ -1683,6 +1987,7 @@ https://developer.android.com</textarea>
1683
1987
  $status.textContent = statePayload.connectedDeviceCount > 0 ? 'online' : 'no-device';
1684
1988
  $versionPill.textContent = 'v' + statePayload.version;
1685
1989
  $workflowPill.textContent = 'workflows: ' + statePayload.workflowCount;
1990
+ $lanePill.textContent = 'lanes: ' + statePayload.laneCount;
1686
1991
  $queuePill.textContent = 'queue: ' + statePayload.queueDepth;
1687
1992
 
1688
1993
  const devicesPayload = await api('/api/devices');
@@ -1697,11 +2002,10 @@ https://developer.android.com</textarea>
1697
2002
  $deviceSelect.appendChild(option);
1698
2003
  }
1699
2004
 
1700
- state.deviceId = undefined;
1701
2005
  if (previousDevice && devices.some(function (d) { return d.id === previousDevice; })) {
1702
2006
  state.deviceId = previousDevice;
1703
- } else if (devices[0]) {
1704
- state.deviceId = devices[0].id;
2007
+ } else {
2008
+ state.deviceId = devices[0] ? devices[0].id : undefined;
1705
2009
  }
1706
2010
 
1707
2011
  if (state.deviceId) {
@@ -1715,24 +2019,24 @@ https://developer.android.com</textarea>
1715
2019
  const workflows = Array.isArray(workflowsPayload.workflows) ? workflowsPayload.workflows : [];
1716
2020
  state.workflows = workflows;
1717
2021
 
1718
- const renderWorkflowSelect = function (select) {
1719
- const prev = (select.value || '').trim();
1720
- select.innerHTML = '';
1721
- for (const workflow of workflows) {
2022
+ const fillWorkflowSelect = function (selectElement) {
2023
+ const prev = (selectElement.value || '').trim();
2024
+ selectElement.innerHTML = '';
2025
+ for (const wf of workflows) {
1722
2026
  const option = document.createElement('option');
1723
- option.value = workflow.name;
1724
- option.textContent = workflow.name;
1725
- select.appendChild(option);
2027
+ option.value = wf.name;
2028
+ option.textContent = wf.name;
2029
+ selectElement.appendChild(option);
1726
2030
  }
1727
2031
  if (prev && workflows.some(function (w) { return w.name === prev; })) {
1728
- select.value = prev;
2032
+ selectElement.value = prev;
1729
2033
  } else if (workflows[0]) {
1730
- select.value = workflows[0].name;
2034
+ selectElement.value = workflows[0].name;
1731
2035
  }
1732
2036
  };
1733
2037
 
1734
- renderWorkflowSelect($workflowSelect);
1735
- renderWorkflowSelect($jobWorkflow);
2038
+ fillWorkflowSelect($workflowSelect);
2039
+ fillWorkflowSelect($jobWorkflow);
1736
2040
 
1737
2041
  if ($workflowSelect.value) {
1738
2042
  const selected = workflows.find(function (w) { return w.name === $workflowSelect.value; });
@@ -1745,7 +2049,7 @@ https://developer.android.com</textarea>
1745
2049
  const metricsPayload = await api('/api/metrics');
1746
2050
  renderMetrics(metricsPayload);
1747
2051
 
1748
- await refreshJobs();
2052
+ await refreshJobsAndLanes();
1749
2053
  }
1750
2054
 
1751
2055
  function connectEvents() {
@@ -1753,7 +2057,7 @@ https://developer.android.com</textarea>
1753
2057
  es.onmessage = function (event) {
1754
2058
  try {
1755
2059
  addEvent(JSON.parse(event.data));
1756
- } catch (err) {
2060
+ } catch (error) {
1757
2061
  // ignore
1758
2062
  }
1759
2063
  };
@@ -1763,8 +2067,8 @@ https://developer.android.com</textarea>
1763
2067
  setMessage('Opening URL: ' + url, false);
1764
2068
  const result = await api('/api/open-url', 'POST', {
1765
2069
  deviceId: selectedDeviceId(),
1766
- url: url,
1767
- waitForReadyMs: 1000,
2070
+ url,
2071
+ waitForReadyMs: 900,
1768
2072
  });
1769
2073
  renderOutput(result);
1770
2074
  setMessage('URL opened', false);
@@ -1794,11 +2098,11 @@ https://developer.android.com</textarea>
1794
2098
 
1795
2099
  async function captureSnapshotDiff() {
1796
2100
  const kind = $snapshotKind.value;
1797
- setMessage('Capturing diff: ' + kind, false);
2101
+ setMessage('Capturing snapshot diff: ' + kind, false);
1798
2102
  const result = await api('/api/snapshot/diff', 'POST', {
1799
2103
  deviceId: selectedDeviceId(),
1800
2104
  packageName: 'com.android.chrome',
1801
- kind: kind,
2105
+ kind,
1802
2106
  });
1803
2107
  renderOutput(result);
1804
2108
  setMessage('Snapshot diff completed', false);
@@ -1811,12 +2115,21 @@ https://developer.android.com</textarea>
1811
2115
  packageName: 'com.android.chrome',
1812
2116
  });
1813
2117
  renderOutput(result);
1814
- setMessage('Device profile ready', false);
2118
+ setMessage('Device profile completed', false);
2119
+ }
2120
+
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);
1815
2128
  }
1816
2129
 
1817
2130
  async function runStress() {
1818
- setMessage('Running stress', false);
1819
- const cfg = currentStressConfig();
2131
+ setMessage('Running stress run', false);
2132
+ const cfg = stressConfig();
1820
2133
  const result = await api('/api/stress-run', 'POST', {
1821
2134
  deviceId: selectedDeviceId(),
1822
2135
  urls: cfg.urls,
@@ -1825,7 +2138,7 @@ https://developer.android.com</textarea>
1825
2138
  includeSnapshotAfterEach: true,
1826
2139
  });
1827
2140
  renderOutput(result);
1828
- setMessage('Stress run complete', false);
2141
+ setMessage('Stress run completed', false);
1829
2142
  }
1830
2143
 
1831
2144
  async function saveWorkflow() {
@@ -1839,10 +2152,7 @@ https://developer.android.com</textarea>
1839
2152
  } catch (error) {
1840
2153
  throw new Error('workflow steps must be valid JSON');
1841
2154
  }
1842
- const result = await api('/api/workflows', 'POST', {
1843
- name,
1844
- steps,
1845
- });
2155
+ const result = await api('/api/workflows', 'POST', { name, steps });
1846
2156
  renderOutput(result);
1847
2157
  setMessage('Workflow saved', false);
1848
2158
  await refreshCore();
@@ -1878,7 +2188,7 @@ https://developer.android.com</textarea>
1878
2188
  const result = await api('/api/workflows/export');
1879
2189
  $workflowSteps.value = JSON.stringify(result.workflows || {}, null, 2);
1880
2190
  renderOutput(result);
1881
- setMessage('Workflows exported into editor', false);
2191
+ setMessage('Workflows exported to editor', false);
1882
2192
  }
1883
2193
 
1884
2194
  async function importWorkflows() {
@@ -1886,9 +2196,8 @@ https://developer.android.com</textarea>
1886
2196
  try {
1887
2197
  parsed = JSON.parse($workflowSteps.value || '{}');
1888
2198
  } catch (error) {
1889
- throw new Error('workflow editor content must be valid JSON');
2199
+ throw new Error('workflow editor must contain valid JSON');
1890
2200
  }
1891
-
1892
2201
  const result = await api('/api/workflows/import', 'POST', {
1893
2202
  workflows: parsed,
1894
2203
  replace: false,
@@ -1898,17 +2207,17 @@ https://developer.android.com</textarea>
1898
2207
  await refreshCore();
1899
2208
  }
1900
2209
 
1901
- async function enqueueJob() {
2210
+ async function enqueueSingleJob() {
1902
2211
  const type = ($jobType.value || '').trim();
1903
2212
  const input = { deviceId: selectedDeviceId() };
1904
2213
 
1905
2214
  if (type === 'open_url') {
1906
2215
  input.url = $jobUrl.value || 'https://developer.android.com';
1907
- input.waitForReadyMs = 1000;
2216
+ input.waitForReadyMs = 900;
1908
2217
  } else if (type === 'snapshot_suite') {
1909
2218
  input.packageName = 'com.android.chrome';
1910
2219
  } else if (type === 'stress_run') {
1911
- const cfg = currentStressConfig();
2220
+ const cfg = stressConfig();
1912
2221
  input.urls = cfg.urls;
1913
2222
  input.loops = cfg.loops;
1914
2223
  input.waitForReadyMs = cfg.waitForReadyMs;
@@ -1920,16 +2229,54 @@ https://developer.android.com</textarea>
1920
2229
  }
1921
2230
  input.name = workflowName;
1922
2231
  input.packageName = 'com.android.chrome';
1923
- input.includeRaw = false;
2232
+ } else if (type === 'device_profile') {
2233
+ input.packageName = 'com.android.chrome';
1924
2234
  }
1925
2235
 
1926
- const result = await api('/api/jobs', 'POST', {
1927
- type,
1928
- input,
1929
- });
2236
+ const result = await api('/api/jobs', 'POST', { type, input });
1930
2237
  renderOutput(result);
1931
2238
  setMessage('Job enqueued', false);
1932
- await refreshJobs();
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();
1933
2280
  }
1934
2281
 
1935
2282
  document.getElementById('open-url-btn').addEventListener('click', async function () {
@@ -1947,6 +2294,9 @@ https://developer.android.com</textarea>
1947
2294
  document.getElementById('profile-btn').addEventListener('click', async function () {
1948
2295
  try { await captureDeviceProfile(); } catch (error) { setMessage(String(error), true); }
1949
2296
  });
2297
+ document.getElementById('profiles-btn').addEventListener('click', async function () {
2298
+ try { await captureAllProfiles(); } catch (error) { setMessage(String(error), true); }
2299
+ });
1950
2300
  document.getElementById('stress-btn').addEventListener('click', async function () {
1951
2301
  try { await runStress(); } catch (error) { setMessage(String(error), true); }
1952
2302
  });
@@ -1966,14 +2316,23 @@ https://developer.android.com</textarea>
1966
2316
  try { await importWorkflows(); } catch (error) { setMessage(String(error), true); }
1967
2317
  });
1968
2318
  document.getElementById('job-enqueue-btn').addEventListener('click', async function () {
1969
- try { await enqueueJob(); } catch (error) { setMessage(String(error), true); }
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); }
1970
2323
  });
1971
2324
  document.getElementById('job-refresh-btn').addEventListener('click', async function () {
1972
- try { await refreshJobs(); setMessage('Jobs refreshed', false); } catch (error) { setMessage(String(error), true); }
2325
+ try { await refreshJobsAndLanes(); setMessage('Jobs and lanes refreshed', false); } catch (error) { setMessage(String(error), true); }
1973
2326
  });
1974
2327
  document.getElementById('refresh-state-btn').addEventListener('click', async function () {
1975
2328
  try { await refreshCore(); setMessage('State refreshed', false); } catch (error) { setMessage(String(error), true); }
1976
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); }
2335
+ });
1977
2336
  document.getElementById('clear-output-btn').addEventListener('click', function () {
1978
2337
  renderOutput({});
1979
2338
  });
@@ -2005,10 +2364,91 @@ https://developer.android.com</textarea>
2005
2364
  }
2006
2365
  });
2007
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
+
2008
2448
  async function init() {
2009
2449
  try {
2010
2450
  await refreshCore();
2011
- const history = await api('/api/history?limit=40');
2451
+ const history = await api('/api/history?limit=45');
2012
2452
  const events = Array.isArray(history.events) ? history.events : [];
2013
2453
  for (let i = events.length - 1; i >= 0; i -= 1) {
2014
2454
  addEvent(events[i]);
@@ -2021,7 +2461,7 @@ https://developer.android.com</textarea>
2021
2461
  try {
2022
2462
  const metricsPayload = await api('/api/metrics');
2023
2463
  renderMetrics(metricsPayload);
2024
- await refreshJobs();
2464
+ await refreshJobsAndLanes();
2025
2465
  } catch (error) {
2026
2466
  setMessage('Background refresh failed', true);
2027
2467
  }