feat: add multi-device concurrency, server lifecycle CLI, and unified execution stream

- Implement DevicePool and DeviceExecutionLock concurrency modes (per-device FIFO vs global serialization)
- Extend ArtemisClient SDK and CLI with explicit device targeting and concurrency strategy configuration
- Add server lifecycle management (artemis restart, stop, status) with cross-platform PID discovery
- Update MCP server tools to support multi-device dispatch, trace tagging, and per-device execution
- Enhance Showcase UI with step-by-step visual replay, playback speed controls, and device serial badges
- Add comprehensive unit test coverage and edge-case verification scripts
This commit is contained in:
somew1nd committed 2026-08-26 15:53:14 -07:00
1 parent a36a66ce6d
commit c2c90a6378
84 files changed
+5612 -645

No files matched your search

@@ -77,6 +77,11 @@
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="task-card-header-actions">
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
@@ -126,6 +131,11 @@
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="task-card-header-actions">
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
@@ -296,7 +296,8 @@ $text-muted: #a1a1aa;
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
.task-card-header .task-card-header-actions {
&:has(.btn-delete-card) .task-time {
&:has(.btn-delete-card) .task-time,
&:has(.btn-delete-card) .task-time-divider {
opacity: 0;
}
@@ -327,8 +328,11 @@ $text-muted: #a1a1aa;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
min-width: 0;
.task-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 10.5px;
@@ -405,11 +409,67 @@ $text-muted: #a1a1aa;
.task-card-header-actions {
display: flex;
align-items: center;
gap: 4px;
position: relative;
min-width: 0;
flex-shrink: 1;
justify-content: flex-end;
.task-device {
display: inline-flex;
align-items: center;
gap: 2.5px;
min-width: 0;
flex-shrink: 1;
font-size: 11px;
color: #94a3b8;
line-height: 1;
cursor: default;
transition: color 0.15s ease, opacity 0.15s ease;
.device-icon {
font-size: 12.5px;
width: 12.5px;
height: 12.5px;
line-height: 12.5px;
color: #94a3b8;
opacity: 0.75;
flex-shrink: 0;
}
.device-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-family: inherit;
letter-spacing: -0.15px;
}
&:hover {
color: #64748b;
.device-icon {
color: #64748b;
opacity: 1;
}
}
}
.task-time-divider {
font-size: 10px;
color: #cbd5e1;
line-height: 1;
user-select: none;
flex-shrink: 0;
transition: opacity 0.15s ease;
}
.task-time {
font-size: 11.5px;
color: #94a3b8;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
transition: opacity 0.15s ease;
}
@@ -49,9 +49,11 @@ export class ChatInterfaceComponent {
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
if (statusA === 'running' || statusA === 'paused') return -1;
if (statusB === 'running' || statusB === 'paused') return 1;
return a.start_time - b.start_time; // FIFO order
const isRunA = statusA === 'running' || statusA === 'paused';
const isRunB = statusB === 'running' || statusB === 'paused';
if (isRunA && !isRunB) return -1;
if (!isRunA && isRunB) return 1;
return (a.start_time || 0) - (b.start_time || 0); // stable FIFO order
});
});
@@ -155,18 +157,43 @@ export class ChatInterfaceComponent {
* Determine the current task execution status
*/
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
return (s === 'success' ? 'completed' : s) as any;
}
if (s === 'running' || s === 'paused' || s === 'pending') {
return s as any;
}
}
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
return 'completed';
}
/**
* Determine the device serial number for the session
*/
public getDeviceSerial(session: Session): string | null {
const serial = session.device_serial || session.device_id;
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
return serial;
}
if (session.device_info) {
try {
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
const s = info?.device_id || info?.device_serial;
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
return s;
}
} catch {
// ignore
}
}
return null;
}
/**
* Select a session in the UI to monitor its steps
*/