diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index c22952a..e116470 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -63,10 +63,30 @@ type DashboardWorkerRun = { template_id?: string; status?: string; task?: string; + created_at?: string; updated_at?: string; summary?: string; error?: string; + tools_used?: string[]; + parent_worker_id?: string | null; + lineage_id?: string | null; + spawn_depth?: number; result_preview?: string; + output?: Record | null; + template_config?: { + model?: string | null; + max_thinking_steps?: number | null; + default_timeout_seconds?: number | null; + available_tools?: string[]; + can_spawn_children?: boolean; + } | null; + audit_timeline?: Array<{ + id?: string; + ts?: string; + level?: string; + event_type?: string; + data_preview?: string; + }>; }; type DesktopDashboardSnapshot = { diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index 55e66c8..d51ec19 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -169,10 +169,30 @@ type DesktopDashboardSnapshot = { template_id?: string; status?: string; task?: string; + created_at?: string; updated_at?: string; summary?: string; error?: string; + tools_used?: string[]; + parent_worker_id?: string | null; + lineage_id?: string | null; + spawn_depth?: number; result_preview?: string; + output?: Record | null; + template_config?: { + model?: string | null; + max_thinking_steps?: number | null; + default_timeout_seconds?: number | null; + available_tools?: string[]; + can_spawn_children?: boolean; + } | null; + audit_timeline?: Array<{ + id?: string; + ts?: string; + level?: string; + event_type?: string; + data_preview?: string; + }>; }>; }; system?: { diff --git a/desktop/src/renderer/src/components/DashboardScreen.tsx b/desktop/src/renderer/src/components/DashboardScreen.tsx index a0e9bc7..d413d61 100644 --- a/desktop/src/renderer/src/components/DashboardScreen.tsx +++ b/desktop/src/renderer/src/components/DashboardScreen.tsx @@ -1,4 +1,4 @@ -import { Download, ExternalLink, Pencil, Play, Plus, RotateCw, Square, Trash2, X } from "lucide-react"; +import { Clock, Download, ExternalLink, Eye, FileJson, GitBranch, ListChecks, Pencil, Play, Plus, RotateCw, Square, Trash2, Wrench, X } from "lucide-react"; import { motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -120,6 +120,94 @@ function formatTime(value?: string | number): string { return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(date); } +function formatDateTime(value?: string | number): string { + if (!value) { + return "-"; + } + const date = typeof value === "number" ? new Date(value) : new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function formatDuration(start?: string, end?: string): string { + if (!start || !end) { + return "-"; + } + const startDate = new Date(start); + const endDate = new Date(end); + const ms = endDate.getTime() - startDate.getTime(); + if (!Number.isFinite(ms) || ms < 0) { + return "-"; + } + const seconds = Math.round(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + if (minutes < 60) { + return rest > 0 ? `${minutes}m ${rest}s` : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +function formatEventName(value?: string): string { + const labels: Record = { + worker_spawned: "Worker spawned", + worker_started: "Process started", + worker_recovery_attempt: "Recovery attempt", + worker_waiting_for_children: "Waiting for children", + worker_resumed_after_children: "Children finished", + worker_resumed_for_child_instruction: "Child needs instruction", + worker_awaiting_instruction: "Awaiting instruction", + worker_instruction_answered: "Instruction answered", + worker_instruction_timeout: "Instruction timed out", + worker_result_repaired: "Result repaired", + worker_result: "Result returned", + worker_failed: "Worker failed", + worker_stopped: "Worker stopped", + intent_approval_requested: "Approval requested", + intent_approval_granted: "Approval granted", + intent_approval_denied: "Approval denied", + intent_executed_reported: "Intent executed", + }; + if (!value) { + return "Worker event"; + } + return labels[value] ?? value.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function countItems(values?: string[]): Array<{ name: string; count: number }> { + const counts = new Map(); + for (const value of values ?? []) { + const key = String(value || "").trim(); + if (!key) { + continue; + } + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts.entries()].map(([name, count]) => ({ name, count })); +} + +function jsonPreview(value: unknown): string { + if (!value) { + return ""; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + function limitDisplayText(value: string, maxLength: number): string { const normalized = value.replace(/\s+/g, " ").trim(); return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3).trim()}...` : normalized; @@ -196,6 +284,7 @@ export function DashboardScreen({ const [templateError, setTemplateError] = useState(""); const [templateNotice, setTemplateNotice] = useState(""); const [editingTemplateId, setEditingTemplateId] = useState(null); + const [selectedWorkerId, setSelectedWorkerId] = useState(null); const [templateForm, setTemplateForm] = useState(emptyTemplateForm); const [templateSaving, setTemplateSaving] = useState(false); @@ -265,6 +354,9 @@ export function DashboardScreen({ }, [history, snapshot?.load]); const recentWorkers = snapshot?.workers?.recent ?? []; + const selectedWorker = selectedWorkerId + ? recentWorkers.find((worker) => worker.id === selectedWorkerId) ?? null + : null; const octoState = snapshot?.octo?.state || runtimeView.state || "idle"; const octoHeadlineRaw = snapshot?.octo?.headline || runtimeView.title; const octoDetailRaw = snapshot?.octo?.detail || runtimeView.detail || copy("octopalStarted"); @@ -278,6 +370,9 @@ export function DashboardScreen({ ? templates.find((template) => template.id === editingTemplateId) ?? null : null; const isCreatingTemplate = editingTemplateId === ""; + const selectedWorkerTemplate = selectedWorker?.template_id + ? templates.find((template) => template.id === selectedWorker.template_id) ?? null + : null; function startCreateTemplate(): void { setEditingTemplateId(""); @@ -340,6 +435,15 @@ export function DashboardScreen({ } } + function openSelectedWorkerTemplate(): void { + if (!selectedWorkerTemplate) { + return; + } + setSelectedWorkerId(null); + setView("workers"); + startEditTemplate(selectedWorkerTemplate); + } + function renderControl() { return (
@@ -399,18 +503,28 @@ export function DashboardScreen({ {copy("template")} {copy("task")} {copy("updated")} + Details {recentWorkers.length === 0 ? (
{copy("noRecentWorkers")}
) : ( recentWorkers.slice(0, 8).map((worker, index) => ( -
+
+ + + Open + + )) )} @@ -560,6 +674,232 @@ export function DashboardScreen({ ); } + function renderWorkerDetailModal() { + if (!selectedWorker) { + return null; + } + const timeline = selectedWorker.audit_timeline?.length + ? selectedWorker.audit_timeline + : [ + { + id: `${selectedWorker.id}-created`, + ts: selectedWorker.created_at, + level: "info", + event_type: "worker_spawned", + data_preview: selectedWorker.task ?? "", + }, + { + id: `${selectedWorker.id}-updated`, + ts: selectedWorker.updated_at, + level: selectedWorker.error ? "error" : "info", + event_type: selectedWorker.error ? "worker_failed" : "worker_result", + data_preview: selectedWorker.result_preview ?? selectedWorker.summary ?? selectedWorker.error ?? "", + }, + ].filter((event) => event.ts || event.data_preview); + const outputText = jsonPreview(selectedWorker.output); + const usedTools = countItems(selectedWorker.tools_used); + const allowedTools = selectedWorker.template_config?.available_tools ?? []; + const preview = selectedWorker.result_preview || selectedWorker.summary || selectedWorker.error || "No result yet."; + + return ( +
+
+
+
+

Worker run

+

{selectedWorker.template_name ?? selectedWorker.template_id ?? shortId(selectedWorker.id)}

+

{selectedWorker.task ?? preview}

+
+
+ {selectedWorkerTemplate ? ( + + ) : null} + +
+
+ +
+
+ + Started + {formatDateTime(selectedWorker.created_at)} +
+
+ + Updated + {formatDateTime(selectedWorker.updated_at)} +
+
+ + Duration + {formatDuration(selectedWorker.created_at, selectedWorker.updated_at)} +
+
+ + Lineage + {shortId(selectedWorker.lineage_id) || "-"} +
+
+ + Tools + {selectedWorker.tools_used?.length ?? 0} +
+
+ + Status + {selectedWorker.status ?? "unknown"} +
+
+ +
+
+
+
+

Result

+ {selectedWorker.error ? "Needs attention" : selectedWorker.summary ? "Completed output" : "Waiting for output"} +
+ {selectedWorker.summary ?

{selectedWorker.summary}

: null} + {selectedWorker.error ?

{selectedWorker.error}

: null} + {!selectedWorker.summary && !selectedWorker.error ?

{preview}

: null} +
+ +
+
+

Action timeline

+ {timeline.length} event{timeline.length === 1 ? "" : "s"} +
+
    + {timeline.map((event, index) => ( +
  1. + +
    + {formatEventName(event.event_type)} + {event.data_preview ?

    {event.data_preview}

    : null} +
    +
  2. + ))} +
+
+ + {outputText ? ( +
+
+

Structured output

+ JSON +
+
{outputText}
+
+ ) : null} +
+ + +
+
+
+ ); + } + return ( + {renderWorkerDetailModal()} + {editingTemplateId !== null ? (
diff --git a/desktop/src/renderer/src/styles.css b/desktop/src/renderer/src/styles.css index 124a39d..16db9cf 100644 --- a/desktop/src/renderer/src/styles.css +++ b/desktop/src/renderer/src/styles.css @@ -2236,7 +2236,7 @@ p { .dashboard-worker-row { display: grid; - grid-template-columns: 96px 140px 160px minmax(220px, 1fr) 110px; + grid-template-columns: 96px 140px 160px minmax(220px, 1fr) 110px 92px; gap: 14px; align-items: center; min-height: 54px; @@ -2246,6 +2246,26 @@ p { font-size: 13px; } +.dashboard-worker-row-button { + width: 100%; + border-top: 0; + border-right: 0; + border-left: 0; + background: transparent; + font: inherit; + text-align: left; + cursor: pointer; +} + +.dashboard-worker-row-button:hover { + background: color-mix(in srgb, var(--primary) 7%, transparent); +} + +.dashboard-worker-row-button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 72%, white); + outline-offset: -3px; +} + .dashboard-worker-row:last-child { border-bottom: 0; } @@ -2265,6 +2285,20 @@ p { font-size: 12px; } +.worker-row-open { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent); + font-size: 12px; + font-weight: 800; +} + +.worker-row-open svg { + width: 16px; + height: 16px; +} + .dashboard-empty-row { margin: 0; padding: 16px; @@ -2272,6 +2306,354 @@ p { font-size: 13px; } +.worker-detail-backdrop { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + padding: 18px; + background: color-mix(in srgb, var(--background) 84%, transparent); + backdrop-filter: blur(18px); +} + +.worker-detail-modal { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 26px; + background: color-mix(in srgb, var(--surface-strong) 94%, var(--background)); + box-shadow: var(--shadow-strong); +} + +.worker-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 22px 24px 18px; + border-bottom: 1px solid var(--border); +} + +.worker-detail-kicker { + margin: 0 0 8px; + color: var(--accent); + font-size: 11px; + font-weight: 850; + text-transform: uppercase; +} + +.worker-detail-header h2 { + max-width: 820px; + margin: 0; + overflow-wrap: anywhere; + font-size: 25px; + line-height: 1.15; +} + +.worker-detail-header p:last-child { + max-width: 980px; + margin: 8px 0 0; + color: var(--muted-strong); + font-size: 14px; + line-height: 1.45; +} + +.worker-detail-header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.worker-detail-summary { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} + +.worker-detail-summary > div { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + grid-template-areas: + "icon label" + "icon value"; + column-gap: 8px; + align-items: center; + min-width: 0; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 16px; + background: color-mix(in srgb, var(--surface) 78%, transparent); +} + +.worker-detail-summary svg { + grid-area: icon; + width: 18px; + height: 18px; + color: var(--accent); +} + +.worker-detail-summary span { + grid-area: label; + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.worker-detail-summary strong { + grid-area: value; + min-width: 0; + overflow: hidden; + color: var(--foreground); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.worker-detail-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 16px; + min-height: 0; + padding: 18px; + overflow: hidden; +} + +.worker-detail-main, +.worker-detail-side { + display: grid; + align-content: start; + gap: 14px; + min-height: 0; + overflow: auto; + padding-right: 4px; +} + +.worker-detail-section { + min-width: 0; + padding: 15px; + border: 1px solid var(--border); + border-radius: 18px; + background: color-mix(in srgb, var(--surface) 82%, transparent); +} + +.worker-detail-section-result { + background: color-mix(in srgb, var(--primary) 7%, var(--surface)); +} + +.worker-detail-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.worker-detail-section-head h3 { + margin: 0; + font-size: 13px; + font-weight: 850; + text-transform: uppercase; +} + +.worker-detail-section-head span, +.worker-detail-section-head svg { + color: var(--muted); + font-size: 12px; +} + +.worker-detail-section-head svg { + width: 18px; + height: 18px; +} + +.worker-detail-result, +.worker-detail-error, +.worker-detail-muted, +.worker-detail-task { + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.55; +} + +.worker-detail-result { + color: var(--foreground); +} + +.worker-detail-error { + padding: 12px; + border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border)); + border-radius: 14px; + background: color-mix(in srgb, var(--danger) 9%, transparent); + color: color-mix(in srgb, var(--danger) 70%, var(--foreground)); +} + +.worker-detail-muted, +.worker-detail-task { + color: var(--muted-strong); +} + +.worker-timeline { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.worker-timeline-item { + position: relative; + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 14px; + min-height: 56px; + padding: 0 0 16px 22px; +} + +.worker-timeline-item::before { + content: ""; + position: absolute; + left: 5px; + top: 4px; + width: 10px; + height: 10px; + border: 2px solid var(--accent); + border-radius: 999px; + background: var(--surface-strong); +} + +.worker-timeline-item::after { + content: ""; + position: absolute; + left: 9px; + top: 18px; + bottom: 2px; + width: 1px; + background: var(--border); +} + +.worker-timeline-item:last-child { + padding-bottom: 0; +} + +.worker-timeline-item:last-child::after { + display: none; +} + +.worker-timeline-warning::before { + border-color: #f4b84f; +} + +.worker-timeline-error::before, +.worker-timeline-critical::before { + border-color: var(--danger); +} + +.worker-timeline time { + color: var(--muted); + font-size: 12px; + line-height: 1.35; +} + +.worker-timeline strong { + display: block; + margin-bottom: 4px; + color: var(--foreground); + font-size: 13px; +} + +.worker-timeline p { + margin: 0; + overflow-wrap: anywhere; + color: var(--muted-strong); + font-size: 12px; + line-height: 1.45; +} + +.worker-output-json { + max-height: 290px; + margin: 0; + overflow: auto; + padding: 13px; + border: 1px solid var(--border); + border-radius: 14px; + background: color-mix(in srgb, var(--background) 42%, var(--surface-strong)); + color: var(--muted-strong); + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.worker-detail-facts { + display: grid; + gap: 9px; + margin: 0; +} + +.worker-detail-facts div { + display: grid; + grid-template-columns: 94px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.worker-detail-facts dt { + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.worker-detail-facts dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--foreground); + font-size: 12px; + line-height: 1.45; +} + +.worker-tool-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.worker-tool-cloud span { + max-width: 100%; + overflow-wrap: anywhere; + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: color-mix(in srgb, var(--accent) 72%, var(--foreground)); + font-size: 11px; + font-weight: 750; +} + +.worker-tool-cloud-muted span { + border-color: var(--border); + background: color-mix(in srgb, var(--surface-strong) 76%, transparent); + color: var(--muted-strong); +} + +.worker-detail-link { + border: 0; + background: transparent; + color: var(--accent); + font-size: 12px; + font-weight: 850; + cursor: pointer; +} + .worker-studio-grid { display: block; min-height: 0; @@ -2771,8 +3153,45 @@ p { } .dashboard-worker-row { - grid-template-columns: 84px 132px 150px minmax(220px, 1fr) 92px; - min-width: 760px; + grid-template-columns: 84px 132px 150px minmax(220px, 1fr) 92px 86px; + min-width: 850px; + } + + .worker-detail-backdrop { + padding: 10px; + } + + .worker-detail-modal { + border-radius: 20px; + } + + .worker-detail-header, + .worker-detail-body { + padding: 14px; + } + + .worker-detail-header { + display: grid; + } + + .worker-detail-header-actions { + justify-content: flex-start; + } + + .worker-detail-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 12px; + } + + .worker-detail-body { + grid-template-columns: 1fr; + overflow: auto; + } + + .worker-detail-main, + .worker-detail-side { + overflow: visible; + padding-right: 0; } .worker-studio-grid, diff --git a/desktop/src/renderer/src/vite-env.d.ts b/desktop/src/renderer/src/vite-env.d.ts index 37e5a97..3a62d9a 100644 --- a/desktop/src/renderer/src/vite-env.d.ts +++ b/desktop/src/renderer/src/vite-env.d.ts @@ -146,10 +146,30 @@ type DesktopDashboardWorkerRun = { template_id?: string; status?: string; task?: string; + created_at?: string; updated_at?: string; summary?: string; error?: string; + tools_used?: string[]; + parent_worker_id?: string | null; + lineage_id?: string | null; + spawn_depth?: number; result_preview?: string; + output?: Record | null; + template_config?: { + model?: string | null; + max_thinking_steps?: number | null; + default_timeout_seconds?: number | null; + available_tools?: string[]; + can_spawn_children?: boolean; + } | null; + audit_timeline?: Array<{ + id?: string; + ts?: string; + level?: string; + event_type?: string; + data_preview?: string; + }>; }; type DesktopDashboardSnapshot = { diff --git a/src/octopal/gateway/dashboard.py b/src/octopal/gateway/dashboard.py index dd23d50..9978d79 100644 --- a/src/octopal/gateway/dashboard.py +++ b/src/octopal/gateway/dashboard.py @@ -1980,11 +1980,11 @@ def _estimate_mttr_minutes(recent_workers: list[WorkerRecord]) -> float | None: return round(sum(durations) / len(durations), 1) -def _serialize_recent_worker( - worker: WorkerRecord, - *, - store: SQLiteStore | None = None, - template_cache: dict[str, WorkerTemplateRecord | None] | None = None, +def _serialize_recent_worker( + worker: WorkerRecord, + *, + store: SQLiteStore | None = None, + template_cache: dict[str, WorkerTemplateRecord | None] | None = None, ) -> dict[str, Any]: output = worker.output if isinstance(worker.output, dict) else None template_config: dict[str, Any] | None = None @@ -2008,24 +2008,48 @@ def _serialize_recent_worker( return { "id": worker.id, "template_name": worker.template_name or worker.template_id or "", - "template_id": worker.template_id, - "status": worker.status, - "task": worker.task, - "updated_at": worker.updated_at.isoformat(), - "summary": worker.summary or "", - "error": worker.error or "", - "tools_used": worker.tools_used or [], - "parent_worker_id": worker.parent_worker_id, + "template_id": worker.template_id, + "status": worker.status, + "task": worker.task, + "created_at": worker.created_at.isoformat(), + "updated_at": worker.updated_at.isoformat(), + "summary": worker.summary or "", + "error": worker.error or "", + "tools_used": worker.tools_used or [], + "parent_worker_id": worker.parent_worker_id, "lineage_id": worker.lineage_id, "spawn_depth": worker.spawn_depth, - "result_preview": _worker_result_preview(worker), - "output": output, - "template_config": template_config, - } - - -def _worker_result_preview(worker: WorkerRecord) -> str: - summary = str(worker.summary or "").strip() + "result_preview": _worker_result_preview(worker), + "output": output, + "template_config": template_config, + "audit_timeline": _worker_audit_timeline(worker.id, store), + } + + +def _worker_audit_timeline(worker_id: str, store: SQLiteStore | None) -> list[dict[str, Any]]: + if store is None: + return [] + try: + events = store.list_audit_for_correlation(worker_id, limit=40) + except Exception: + logger.debug("Failed to load worker audit timeline", exc_info=True, extra={"worker_id": worker_id}) + return [] + return [_serialize_worker_audit_event(event) for event in events] + + +def _serialize_worker_audit_event(event: AuditEvent) -> dict[str, Any]: + data = event.data if isinstance(event.data, dict) else {} + return { + "id": event.id, + "ts": event.ts.isoformat(), + "level": event.level, + "event_type": event.event_type, + "data_preview": _truncate_preview(_safe_preview_json(data), 420) if data else "", + } + + +def _worker_result_preview(worker: WorkerRecord) -> str: + summary = str(worker.summary or "").strip() if summary: return _truncate_preview(summary, 280) diff --git a/src/octopal/infrastructure/store/base.py b/src/octopal/infrastructure/store/base.py index 85554c7..f8f4e2c 100644 --- a/src/octopal/infrastructure/store/base.py +++ b/src/octopal/infrastructure/store/base.py @@ -60,6 +60,8 @@ def append_audit(self, event: AuditEvent) -> None: ... def list_audit(self, limit: int = 100) -> list[AuditEvent]: ... + def list_audit_for_correlation(self, correlation_id: str, limit: int = 100) -> list[AuditEvent]: ... + def get_audit(self, event_id: str) -> AuditEvent | None: ... def add_memory_entry(self, entry: MemoryEntry) -> None: ... diff --git a/src/octopal/infrastructure/store/sqlite.py b/src/octopal/infrastructure/store/sqlite.py index 84442f6..300438b 100644 --- a/src/octopal/infrastructure/store/sqlite.py +++ b/src/octopal/infrastructure/store/sqlite.py @@ -255,6 +255,8 @@ def _init_schema(self) -> None: ); CREATE INDEX IF NOT EXISTS ix_workers_status_updated_at ON workers (status, updated_at); + CREATE INDEX IF NOT EXISTS ix_audit_events_correlation_ts + ON audit_events (correlation_id, ts DESC); CREATE INDEX IF NOT EXISTS ix_memory_entries_id ON memory_entries (id); """ ) @@ -732,6 +734,13 @@ def list_audit(self, limit: int = 100) -> list[AuditEvent]: ) return [self._row_to_audit(row) for row in cursor.fetchall()] + def list_audit_for_correlation(self, correlation_id: str, limit: int = 100) -> list[AuditEvent]: + cursor = self._conn.execute( + "SELECT * FROM audit_events WHERE correlation_id = ? ORDER BY ts ASC, rowid ASC LIMIT ?", + (correlation_id, limit), + ) + return [self._row_to_audit(row) for row in cursor.fetchall()] + def get_audit(self, event_id: str) -> AuditEvent | None: cursor = self._conn.execute("SELECT * FROM audit_events WHERE id = ?", (event_id,)) row = cursor.fetchone() diff --git a/tests/test_dashboard_v2_api.py b/tests/test_dashboard_v2_api.py index 6ae56dd..3f0979c 100644 --- a/tests/test_dashboard_v2_api.py +++ b/tests/test_dashboard_v2_api.py @@ -1,14 +1,15 @@ from __future__ import annotations -import json - -import pytest -from fastapi.testclient import TestClient - -from octopal.gateway.app import build_app -from octopal.infrastructure.config.models import OctopalConfig -from octopal.infrastructure.config.settings import Settings -from octopal.infrastructure.store.models import WorkerRecord +import json +import uuid + +import pytest +from fastapi.testclient import TestClient + +from octopal.gateway.app import build_app +from octopal.infrastructure.config.models import OctopalConfig +from octopal.infrastructure.config.settings import Settings +from octopal.infrastructure.store.models import AuditEvent, WorkerRecord from octopal.infrastructure.store.sqlite import SQLiteStore from octopal.runtime.state import ( update_last_internal_heartbeat, @@ -81,22 +82,42 @@ def test_dashboard_v2_workers_exposes_worker_result_details(tmp_path) -> None: app = build_app(settings) store = SQLiteStore(settings) now = utc_now() - store.create_worker( - WorkerRecord( - id="worker-12345678", - status="completed", - task="Summarize latest sync", + store.create_worker( + WorkerRecord( + id="worker-12345678", + status="completed", + task="Summarize latest sync", granted_caps=[], created_at=now, updated_at=now, summary="Sync finished successfully", output={"report": {"status": "ok", "items": 3}}, tools_used=["web_search", "web_fetch"], - template_name="Research Worker", - ) - ) - app.state.dashboard_store = store - client = TestClient(app) + template_name="Research Worker", + ) + ) + store.append_audit( + AuditEvent( + id=str(uuid.uuid4()), + ts=now, + correlation_id="worker-12345678", + level="info", + event_type="worker_spawned", + data={"template_id": "researcher", "task": "Summarize latest sync"}, + ) + ) + store.append_audit( + AuditEvent( + id=str(uuid.uuid4()), + ts=now, + correlation_id="worker-12345678", + level="info", + event_type="worker_result", + data={"summary": "Sync finished successfully"}, + ) + ) + app.state.dashboard_store = store + client = TestClient(app) headers = {"x-octopal-token": settings.dashboard_token} if settings.dashboard_token else {} response = client.get("/api/dashboard/v2/workers", headers=headers) @@ -105,9 +126,15 @@ def test_dashboard_v2_workers_exposes_worker_result_details(tmp_path) -> None: payload = response.json() recent = payload["workers"]["recent"] assert len(recent) == 1 - assert recent[0]["summary"] == "Sync finished successfully" + assert recent[0]["summary"] == "Sync finished successfully" assert recent[0]["result_preview"] == "Sync finished successfully" assert recent[0]["output"] == {"report": {"status": "ok", "items": 3}} + assert recent[0]["created_at"] == now.isoformat() + assert [event["event_type"] for event in recent[0]["audit_timeline"]] == [ + "worker_spawned", + "worker_result", + ] + assert "Sync finished successfully" in recent[0]["audit_timeline"][1]["data_preview"] def test_dashboard_v2_workers_returns_16_recent_workers_by_default(tmp_path) -> None: