feat: surface task execution progress across Host Agent and Cloud
Host Agent now persists step-level execution detail locally (via a real TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded in-progress snapshot piggybacked on lease renewal. Cloud persists that snapshot per active assignment and exposes it through the existing task list/detail query path; Cloud Console renders it as a live badge. Host Agent's local console gains authenticated, read-only task list and detail/timeline pages (same-origin, server-rendered) with inlined screenshots. Also fixes a pre-existing gap in the shared Timeline: the actual per-step LLM prompt is now recorded instead of the task goal, benefiting both Runtime and Host Agent consoles. When a host uses the cloud planner transport, each decide call's prompt and resulting tool decision are durably logged in a new planner_decision_log table (with bounded retention) and browsable from Cloud Console; direct-transport hosts explicitly surface a "not reported" state. Includes Alembic migrations 0008 (progress columns on scheduled_tasks) and 0009 (planner_decision_log), bounded Host-Agent-local retention, dual-backend repository parity, and Vitest + pytest coverage. Task 6.5 (manual end-to-end device verification) remains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||
import {
|
||||
CloudApiError,
|
||||
getTaskAttempts,
|
||||
getTaskPlannerDecisions,
|
||||
listTasks,
|
||||
listDevices,
|
||||
listHosts,
|
||||
@@ -16,7 +17,10 @@ import type {
|
||||
TaskStatus,
|
||||
DeviceRecord,
|
||||
HostRecord,
|
||||
PlannerDecisionItem,
|
||||
} from "../types";
|
||||
import { formatTaskProgress } from "../taskProgress";
|
||||
import { computePlannerHistoryState } from "../plannerHistory";
|
||||
|
||||
const props = defineProps<{ canSubmit: boolean }>();
|
||||
|
||||
@@ -48,6 +52,9 @@ const submitCapabilityTags = ref("");
|
||||
const submitting = ref(false);
|
||||
const hosts = ref<HostRecord[]>([]);
|
||||
const devices = ref<DeviceRecord[]>([]);
|
||||
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
||||
const plannerLoading = ref(false);
|
||||
const plannerError = ref("");
|
||||
const availableDevices = computed(() =>
|
||||
devices.value.filter((device) => device.host_id === submitHostId.value),
|
||||
);
|
||||
@@ -75,6 +82,7 @@ async function refresh() {
|
||||
if (!stillPresent) {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -129,21 +137,51 @@ async function selectTask(task: TaskListItem) {
|
||||
selectedTask.value = task;
|
||||
attempts.value = [];
|
||||
attemptsError.value = "";
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
attemptsLoading.value = true;
|
||||
|
||||
// Fetch attempts first so we know which attempt numbers exist.
|
||||
let loadedAttempts: TaskAttempt[] = [];
|
||||
try {
|
||||
attempts.value = await getTaskAttempts(task.id);
|
||||
loadedAttempts = await getTaskAttempts(task.id);
|
||||
attempts.value = loadedAttempts;
|
||||
} catch (err) {
|
||||
if (err instanceof CloudApiError && err.status === 404) {
|
||||
// Task was deleted between list and detail load.
|
||||
selectedTask.value = null;
|
||||
await refresh();
|
||||
} else {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
attemptsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
attemptsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
attemptsLoading.value = false;
|
||||
|
||||
// Fetch planner decisions for the latest attempt (if any).
|
||||
const latestAttempt = loadedAttempts.length
|
||||
? Math.max(...loadedAttempts.map((a) => a.attempt))
|
||||
: task.attempt_count > 0
|
||||
? task.attempt_count - 1
|
||||
: null;
|
||||
|
||||
if (latestAttempt !== null) {
|
||||
plannerLoading.value = true;
|
||||
try {
|
||||
plannerDecisions.value = await getTaskPlannerDecisions(task.id, latestAttempt);
|
||||
} catch (err) {
|
||||
// 404 on the task is already handled above; other errors are non-fatal.
|
||||
if (!(err instanceof CloudApiError && err.status === 404)) {
|
||||
handleError(err, "failed to load planner decisions");
|
||||
plannerError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
}
|
||||
} finally {
|
||||
plannerLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +208,8 @@ function goNextPage() {
|
||||
function clearSelection() {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
@@ -212,6 +252,30 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
return String(attempt.terminal_result);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTaskProgress = computed(() =>
|
||||
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
|
||||
);
|
||||
|
||||
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
|
||||
if (!selectedTask.value?.assigned_host_id) return null;
|
||||
const host = hosts.value.find(
|
||||
(h) => h.host_id === selectedTask.value?.assigned_host_id,
|
||||
);
|
||||
return host?.planner_transport ?? null;
|
||||
});
|
||||
|
||||
const plannerHistoryState = computed(() =>
|
||||
computePlannerHistoryState(plannerDecisions.value, selectedHostTransport.value),
|
||||
);
|
||||
|
||||
function formatArguments(args: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(args, null, 2);
|
||||
} catch {
|
||||
return String(args);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -305,6 +369,13 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<div v-if="task.failure_reason" class="dim">
|
||||
{{ task.failure_reason }}
|
||||
</div>
|
||||
<div
|
||||
v-if="formatTaskProgress(task)"
|
||||
class="task-progress"
|
||||
:title="formatTaskProgress(task) ?? ''"
|
||||
>
|
||||
{{ formatTaskProgress(task) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="task.assigned_device_id">
|
||||
@@ -363,6 +434,10 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<strong class="text-danger">Failure reason:</strong>
|
||||
{{ selectedTask.failure_reason }}
|
||||
</p>
|
||||
<p v-if="selectedTaskProgress" class="task-progress-detail">
|
||||
<strong>Current step:</strong>
|
||||
<code>{{ selectedTaskProgress }}</code>
|
||||
</p>
|
||||
|
||||
<h3>Attempt history</h3>
|
||||
<div v-if="attemptsLoading" class="muted">loading attempts…</div>
|
||||
@@ -402,6 +477,93 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="muted">No attempts recorded for this task yet.</div>
|
||||
|
||||
<h3>LLM interaction history</h3>
|
||||
<div v-if="plannerLoading" class="muted">loading planner decisions…</div>
|
||||
<div v-else-if="plannerError" class="notice error">{{ plannerError }}</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'populated'" class="planner-history">
|
||||
<div
|
||||
v-for="decision in plannerHistoryState.decisions"
|
||||
:key="`${decision.attempt}-${decision.step_index}`"
|
||||
class="planner-decision"
|
||||
>
|
||||
<div class="planner-decision-header">
|
||||
<span class="status-badge queued">step {{ decision.step_index }}</span>
|
||||
<code>{{ decision.tool_name }}</code>
|
||||
<span class="dim">{{ formatTime(decision.created_at) }}</span>
|
||||
</div>
|
||||
<details>
|
||||
<summary>User prompt</summary>
|
||||
<pre class="planner-prompt">{{ decision.user_prompt }}</pre>
|
||||
</details>
|
||||
<details v-if="decision.system_prompt">
|
||||
<summary>System prompt</summary>
|
||||
<pre class="planner-prompt">{{ decision.system_prompt }}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Arguments</summary>
|
||||
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
|
||||
No planner decisions reported yet for this task.
|
||||
</div>
|
||||
<div v-else-if="plannerHistoryState.kind === 'direct_transport_hidden'" class="muted">
|
||||
This host uses the <code>direct</code> planner transport and does not report LLM interactions to Cloud.
|
||||
</div>
|
||||
<div v-else class="muted">
|
||||
Host transport unknown; no planner decisions available.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-progress {
|
||||
margin-top: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-progress-detail {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.task-progress-detail code {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.planner-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.planner-decision {
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.planner-decision-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.planner-prompt {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user