diff --git a/README.md b/README.md index f408f2d..2817e60 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,22 @@ Agent Computer Use Platform gives agents a real Linux desktop to work in, with s - Runs agent tasks inside disposable Linux sessions instead of collapsing everything into a browser tab. - Gives you one place to work with screenshots, shell commands, files, desktop input, and session state. - Keeps operators in the loop with a live desktop view, structured receipts, and clear fallback modes. +- Exposes a `review_recording` summary for qemu `product` sessions and lets you explicitly export a sparse review bundle for later human review instead of recording default video. + +## Default flow + +The default happy path is now: + +1. Start a session with default settings (`qemu` + `product`) +2. Wait for readiness +3. Submit a task +4. Watch `live_desktop_view` or the truthful screenshot fallback +5. Export the sparse review bundle if you want durable evidence for later review +6. Delete the session when done; only exported bundles survive session teardown + +QEMU review recording is intentionally storage-first in v1: the durable artifact is a sparse review bundle (`review.json`, `timeline.jsonl`, deduplicated screenshots), not a continuous video capture. + +That is the workflow agents should infer first. Advanced/debug controls still exist, but they are secondary. ## Quickstart diff --git a/apps/control-plane/src/server.desktop-app.test.ts b/apps/control-plane/src/server.desktop-app.test.ts index 09c61eb..058e9a2 100644 --- a/apps/control-plane/src/server.desktop-app.test.ts +++ b/apps/control-plane/src/server.desktop-app.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; import { createServer } from 'node:http'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -54,6 +54,27 @@ async function startGuestServer() { return; } + if (req.method === 'POST' && url.pathname === '/api/storage/reclaim') { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + mode: body.mode ?? 'report', + candidate_count: 1, + candidates: [ + { + path: '/tmp/inspectors/runtime/stale-session', + tier: 'runtime', + kind: 'legacy_runtime', + reason: 'legacy inspectors runtime directory without an active container reference', + }, + ], + reclaimed: body.mode === 'apply' ? ['/tmp/inspectors/runtime/stale-session'] : [], + })); + return; + } + res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'not found', path: url.pathname })); }); @@ -152,3 +173,71 @@ test('qemu product session creation preserves desktop user metadata and live vie await stopServers(controlPlane, guest.guestServer); } }); + +test('control-plane proxies storage reclaim requests', async () => { + const guest = await startGuestServer(); + const controlPlane = await startControlPlaneServer(0, guest.baseUrl); + const baseUrl = `http://127.0.0.1:${(controlPlane.server.address() as { port: number }).port}`; + + try { + const response = await fetch(`${baseUrl}/api/storage/reclaim`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'apply' }), + }); + assert.equal(response.status, 200); + const payload = await response.json() as { + mode: string; + candidate_count: number; + reclaimed: string[]; + }; + assert.equal(payload.mode, 'apply'); + assert.equal(payload.candidate_count, 1); + assert.deepEqual(payload.reclaimed, ['/tmp/inspectors/runtime/stale-session']); + } finally { + await stopServers(controlPlane, guest.guestServer); + } +}); + +test('qemu product session creation can activate the desktop app when configured', async () => { + const activationDir = tempDir('acu-desktop-activate'); + const activationScript = join(activationDir, 'activate.js'); + const activationOutput = join(activationDir, 'activation.json'); + writeFileSync( + activationScript, + `const fs = require('node:fs'); fs.writeFileSync(process.argv[2], JSON.stringify(process.argv.slice(3)));`, + ); + + const previousActivateBin = process.env.ACU_DESKTOP_ACTIVATE_BIN; + const previousActivateArgs = process.env.ACU_DESKTOP_ACTIVATE_ARGS_JSON; + process.env.ACU_DESKTOP_ACTIVATE_BIN = process.execPath; + process.env.ACU_DESKTOP_ACTIVATE_ARGS_JSON = JSON.stringify([activationScript, activationOutput]); + + const guest = await startGuestServer(); + const controlPlane = await startControlPlaneServer(0, guest.baseUrl); + const baseUrl = `http://127.0.0.1:${(controlPlane.server.address() as { port: number }).port}`; + + try { + const response = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ provider: 'qemu', qemu_profile: 'product' }), + }); + assert.equal(response.status, 201); + const activationArgs = JSON.parse(readFileSync(activationOutput, 'utf8')) as string[]; + assert.deepEqual(activationArgs, ['--activate-desktop', '--session', 'qemu-product']); + } finally { + if (previousActivateBin === undefined) { + delete process.env.ACU_DESKTOP_ACTIVATE_BIN; + } else { + process.env.ACU_DESKTOP_ACTIVATE_BIN = previousActivateBin; + } + if (previousActivateArgs === undefined) { + delete process.env.ACU_DESKTOP_ACTIVATE_ARGS_JSON; + } else { + process.env.ACU_DESKTOP_ACTIVATE_ARGS_JSON = previousActivateArgs; + } + await stopServers(controlPlane, guest.guestServer); + rmSync(activationDir, { recursive: true, force: true }); + } +}); diff --git a/apps/control-plane/src/server.live-view.test.ts b/apps/control-plane/src/server.live-view.test.ts index 8b18f3f..d80e92d 100644 --- a/apps/control-plane/src/server.live-view.test.ts +++ b/apps/control-plane/src/server.live-view.test.ts @@ -32,6 +32,19 @@ function sessionRecord(id: string, overrides: Record = {}) { runtime_base_url: 'http://127.0.0.1:4001', viewer_url: null, live_desktop_view: null, + review_recording: { + mode: 'sparse_timeline', + status: 'active', + retention: 'ephemeral_until_export', + event_count: 1, + screenshot_count: 0, + approx_bytes: 128, + last_captured_at: new Date().toISOString(), + exportable: true, + exported_bundle: null, + postmortem_retained_until: null, + reason: null, + }, bridge_status: 'runtime_ready', readiness_state: 'runtime_ready', bridge_error: null, @@ -135,6 +148,19 @@ async function startHarness(): Promise { provider: 'qemu', qemu_profile: 'regression', viewer_url: viewerUrl, + review_recording: { + mode: 'unavailable', + status: 'unavailable', + retention: 'ephemeral_until_export', + event_count: 0, + screenshot_count: 0, + approx_bytes: 0, + last_captured_at: null, + exportable: false, + exported_bundle: null, + postmortem_retained_until: null, + reason: 'review recording is available only for qemu product sessions in v1', + }, }), })); return; @@ -148,10 +174,51 @@ async function startHarness(): Promise { viewer_url: null, display: ':90', capabilities: ['screenshot'], + review_recording: { + mode: 'unavailable', + status: 'unavailable', + retention: 'ephemeral_until_export', + event_count: 0, + screenshot_count: 0, + approx_bytes: 0, + last_captured_at: null, + exportable: false, + exported_bundle: null, + postmortem_retained_until: null, + reason: 'review recording is available only for qemu product sessions in v1', + }, }), })); return; } + if (req.method === 'POST' && url.pathname === '/api/sessions/qemu-product/review/export') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + bundle: { + kind: 'review_bundle', + path: 'artifacts/exports/qemu-product-review', + mime_type: null, + }, + review_recording: { + mode: 'sparse_timeline', + status: 'exported', + retention: 'ephemeral_until_export', + event_count: 4, + screenshot_count: 2, + approx_bytes: 512, + last_captured_at: new Date().toISOString(), + exportable: true, + exported_bundle: { + kind: 'review_bundle', + path: 'artifacts/exports/qemu-product-review', + mime_type: null, + }, + postmortem_retained_until: null, + reason: null, + }, + })); + return; + } if (req.method === 'GET' && url.pathname === '/api/sessions/missing') { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'session not found' })); @@ -203,16 +270,20 @@ test('session metadata exposes truthful live_desktop_view modes', async () => { assert.equal(qemuProduct.session.live_desktop_view.canonical_url, '/api/sessions/qemu-product/live-view/'); assert.equal(qemuProduct.session.live_desktop_view.debug_url, harness.viewerUrl); assert.equal(qemuProduct.session.live_desktop_view.matches_action_plane, true); + assert.equal(qemuProduct.session.review_recording.mode, 'sparse_timeline'); + assert.equal(qemuProduct.session.review_recording.exportable, true); const qemuRegression = await fetch(`${harness.baseUrl}/api/sessions/qemu-regression`).then((res) => res.json()) as { session: any }; assert.equal(qemuRegression.session.live_desktop_view.mode, 'screenshot_poll'); assert.equal(qemuRegression.session.live_desktop_view.canonical_url, '/api/sessions/qemu-regression/screenshot'); assert.equal(qemuRegression.session.live_desktop_view.debug_url, harness.viewerUrl); + assert.equal(qemuRegression.session.review_recording.mode, 'unavailable'); const xvfb = await fetch(`${harness.baseUrl}/api/sessions/xvfb`).then((res) => res.json()) as { session: any }; assert.equal(xvfb.session.live_desktop_view.mode, 'screenshot_poll'); assert.equal(xvfb.session.live_desktop_view.canonical_url, '/api/sessions/xvfb/screenshot'); assert.match(String(xvfb.session.live_desktop_view.reason), /screenshot fallback/i); + assert.equal(xvfb.session.review_recording.mode, 'unavailable'); } finally { await stopHarness(harness); } @@ -305,3 +376,21 @@ test('live-view websocket upgrades proxy to the upstream viewer', async () => { await stopHarness(harness); } }); + +test('review export route proxies durable review bundle metadata', async () => { + const harness = await startHarness(); + try { + const response = await fetch(`${harness.baseUrl}/api/sessions/qemu-product/review/export`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.equal(response.status, 200); + const payload = await response.json() as { bundle: { kind: string; path: string }; review_recording: { status: string } }; + assert.equal(payload.bundle.kind, 'review_bundle'); + assert.equal(payload.bundle.path, 'artifacts/exports/qemu-product-review'); + assert.equal(payload.review_recording.status, 'exported'); + } finally { + await stopHarness(harness); + } +}); diff --git a/apps/control-plane/src/server.ts b/apps/control-plane/src/server.ts index 47a8497..29df1d0 100644 --- a/apps/control-plane/src/server.ts +++ b/apps/control-plane/src/server.ts @@ -10,7 +10,7 @@ import { Duplex } from 'node:stream'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import type { BrowserContext, Page } from 'playwright-core'; -import type { ActionReceipt, ActionRequest, JsonObject, LiveDesktopView, RuntimeCapabilities, SessionRecord, TaskRecord } from './types.js'; +import type { ActionReceipt, ActionRequest, JsonObject, LiveDesktopView, ReviewRecordingSummary, RuntimeCapabilities, SessionRecord, TaskRecord } from './types.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '../../..'); @@ -57,6 +57,10 @@ function resolveArtifactRoot(): string { return resolve(process.env.ACU_ARTIFACT_ROOT ?? join(repoRoot, 'artifacts')); } +function resolveExportRoot(artifactRoot: string): string { + return join(artifactRoot, 'exports'); +} + async function loadPlaywrightModule(): Promise { if (!playwrightEnabled) { throw new Error('playwright browser adapter is disabled in this environment'); @@ -139,8 +143,25 @@ function deriveLiveDesktopView(session: SessionRecord): LiveDesktopView { }; } +function deriveReviewRecording(session: SessionRecord): ReviewRecordingSummary { + return session.review_recording ?? { + mode: 'unavailable', + status: 'unavailable', + retention: 'ephemeral_until_export', + event_count: 0, + screenshot_count: 0, + approx_bytes: 0, + last_captured_at: null, + exportable: false, + exported_bundle: null, + postmortem_retained_until: null, + reason: 'review recording metadata unavailable', + }; +} + function withLiveDesktopView(session: SessionRecord): SessionRecord { const liveDesktopView = deriveLiveDesktopView(session); + const reviewRecording = deriveReviewRecording(session); if (liveDesktopView.mode === 'stream') { liveDesktopView.canonical_url = buildLiveViewPath(session.id); } else if (liveDesktopView.mode === 'screenshot_poll') { @@ -150,6 +171,7 @@ function withLiveDesktopView(session: SessionRecord): SessionRecord { return { ...session, live_desktop_view: liveDesktopView, + review_recording: reviewRecording, }; } @@ -294,6 +316,28 @@ async function fetchBrowserSnapshot(url: string): Promise typeof value === 'string') : []; + } catch { + return []; + } +} + +async function maybeActivateDesktop(session: SessionRecord): Promise { + const activateBin = process.env.ACU_DESKTOP_ACTIVATE_BIN; + if (!activateBin) return; + const liveDesktopView = withLiveDesktopView(session).live_desktop_view; + if (!liveDesktopView || liveDesktopView.mode !== 'stream') return; + if (session.provider !== 'qemu' || session.qemu_profile === 'regression') return; + + const args = [...getDesktopActivateArgs(), '--activate-desktop', '--session', session.id]; + await execFileAsync(activateBin, args); +} + async function guestRequest(state: ControlPlaneState, path: string, init?: RequestInit): Promise<{ status: number; payload: any }> { const response = await fetch(`${state.guestRuntimeUrl}${path}`, { headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, @@ -322,6 +366,55 @@ async function getGuestSession(state: ControlPlaneState, sessionId: string): Pro return payload.session; } +interface ReviewEventAppendPayload { + event_id: string; + source: string; + kind: string; + task_id?: string | null; + action_type?: string | null; + status?: string | null; + receipt?: ActionReceipt | null; + details?: JsonObject | null; +} + +async function appendReviewEvent(state: ControlPlaneState, sessionId: string, payload: ReviewEventAppendPayload): Promise { + try { + await guestJson<{ review_recording: ReviewRecordingSummary }>( + state, + `/api/sessions/${sessionId}/review-events`, + { method: 'POST', body: JSON.stringify(payload) }, + ); + } catch { + // Review capture should never break the operator path. + } +} + +async function recordControlPlaneAction(state: ControlPlaneState, sessionId: string, action: BrowserAction, receipt?: ActionReceipt): Promise { + const taskId = action.taskId ?? null; + const actionType = action.kind; + if (!receipt) { + await appendReviewEvent(state, sessionId, { + event_id: `control-plane-pre:${randomUUID()}`, + source: 'control-plane', + kind: 'pre_action', + task_id: taskId, + action_type: actionType, + details: { action: action as unknown as JsonObject }, + }); + return; + } + await appendReviewEvent(state, sessionId, { + event_id: `${receipt.receipt_id}:control-plane`, + source: 'control-plane', + kind: receipt.status === 'ok' ? 'action_completed' : 'action_failed', + task_id: taskId, + action_type: actionType, + status: receipt.status, + receipt, + details: { action: action as unknown as JsonObject }, + }); +} + async function commandExists(command: string): Promise { try { await execFileAsync('sh', ['-lc', `command -v ${command} >/dev/null 2>&1`]); @@ -482,6 +575,10 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, pushHistory(state, sessionId, { action: action as unknown as JsonObject, receipt: receipt as unknown as JsonObject, source: 'browser-open-fallback' }); attachReceiptToTask(state, action.taskId, receipt); + if (receipt.status !== 'ok') { + await recordControlPlaneAction(state, sessionId, action); + await recordControlPlaneAction(state, sessionId, action, receipt); + } return receipt; } if (action.kind === 'browser_click' && typeof action.x === 'number' && typeof action.y === 'number') { @@ -497,6 +594,7 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, return receipt; } if (action.kind === 'browser_screenshot') { + await recordControlPlaneAction(state, sessionId, action); const observation = await guestJson>(state, `/api/sessions/${sessionId}/observation`); const screenshot = (observation.screenshot ?? {}) as Record; const receipt: ActionReceipt = { @@ -511,11 +609,13 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, }; pushHistory(state, sessionId, { action: action as unknown as JsonObject, receipt: receipt as unknown as JsonObject, source: 'browser-screenshot-fallback' }); attachReceiptToTask(state, action.taskId, receipt); + await recordControlPlaneAction(state, sessionId, action, receipt); return receipt; } if (action.kind === 'browser_get_dom') { const snapshot = state.browserSnapshots.get(sessionId); if (snapshot?.lastHtml) { + await recordControlPlaneAction(state, sessionId, action); const receipt: ActionReceipt = { status: 'ok', receipt_id: randomUUID(), @@ -528,12 +628,15 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, }; pushHistory(state, sessionId, { action: action as unknown as JsonObject, receipt: receipt as unknown as JsonObject, source: 'browser-dom-fetch-fallback' }); attachReceiptToTask(state, action.taskId, receipt); + await recordControlPlaneAction(state, sessionId, action, receipt); return receipt; } } + await recordControlPlaneAction(state, sessionId, action); const receipt = unsupportedBrowserReceipt(action, 'DOM-aware browser automation is disabled in this environment; enable ACU_ENABLE_PLAYWRIGHT=1 to attempt the Playwright adapter.'); pushHistory(state, sessionId, { action: action as unknown as JsonObject, receipt: receipt as unknown as JsonObject, source: 'browser-disabled' }); attachReceiptToTask(state, action.taskId, receipt); + await recordControlPlaneAction(state, sessionId, action, receipt); return receipt; } @@ -545,11 +648,13 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, switch (action.kind) { case 'browser_open': + await recordControlPlaneAction(state, sessionId, action); await browser.page.goto(action.url, { waitUntil: 'domcontentloaded' }); result = { url: browser.page.url(), title: await browser.page.title() }; state.browserSnapshots.set(sessionId, { lastUrl: String(result.url), fetchedAt: new Date().toISOString() }); break; case 'browser_get_dom': + await recordControlPlaneAction(state, sessionId, action); result = { current_url: browser.page.url(), title: await browser.page.title(), @@ -561,6 +666,7 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, break; case 'browser_click': if (action.selector) { + await recordControlPlaneAction(state, sessionId, action); await browser.page.locator(action.selector).first().click(); result = { mode: 'selector', selector: action.selector, clicked: true }; } else if (typeof action.x === 'number' && typeof action.y === 'number') { @@ -574,6 +680,7 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, break; case 'browser_type': if (action.selector) { + await recordControlPlaneAction(state, sessionId, action); const locator = browser.page.locator(action.selector).first(); await locator.click(); await locator.fill(action.text); @@ -587,7 +694,8 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, } break; case 'browser_screenshot': { - const path = join(state.artifactRoot, `${sessionId}-${Date.now()}-browser.png`); + await recordControlPlaneAction(state, sessionId, action); + const path = join(resolveExportRoot(state.artifactRoot), `${sessionId}-${Date.now()}-browser.png`); await mkdir(dirname(path), { recursive: true }); await browser.page.screenshot({ path, fullPage: true }); result = { path }; @@ -608,6 +716,7 @@ async function handleBrowserAction(state: ControlPlaneState, sessionId: string, }; pushHistory(state, sessionId, { action: action as unknown as JsonObject, receipt: receipt as unknown as JsonObject, source: 'browser-adapter' }); attachReceiptToTask(state, action.taskId, receipt); + await recordControlPlaneAction(state, sessionId, action, receipt); return receipt; } @@ -659,6 +768,16 @@ export function createRequestHandler(state: ControlPlaneState) { return; } + if (req.method === 'POST' && url.pathname === '/api/storage/reclaim') { + const body = await readJson(req); + const upstream = await guestRequest(state, '/api/storage/reclaim', { + method: 'POST', + body: JSON.stringify(body), + }); + json(res, upstream.status, upstream.payload); + return; + } + if (req.method === 'GET' && url.pathname === '/api/sessions') { const upstream = await guestRequest(state, '/api/sessions'); if (upstream.status === 200 && Array.isArray(upstream.payload?.sessions)) { @@ -680,6 +799,7 @@ export function createRequestHandler(state: ControlPlaneState) { }); if (upstream.status === 201 && upstream.payload?.session) { upstream.payload.session = withLiveDesktopView(upstream.payload.session as SessionRecord); + await maybeActivateDesktop(upstream.payload.session as SessionRecord).catch(() => undefined); } json(res, upstream.status, upstream.payload); return; @@ -716,6 +836,16 @@ export function createRequestHandler(state: ControlPlaneState) { return; } + const reviewExportMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/review\/export$/); + if (reviewExportMatch && req.method === 'POST') { + const upstream = await guestRequest(state, `/api/sessions/${reviewExportMatch[1]}/review/export`, { + method: 'POST', + body: JSON.stringify({}), + }); + json(res, upstream.status, upstream.payload); + return; + } + const liveViewMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/live-view(?:\/.*)?$/); if (liveViewMatch && ['GET', 'HEAD'].includes(req.method ?? 'GET')) { const sessionId = liveViewMatch[1]; @@ -850,6 +980,18 @@ export function createRequestHandler(state: ControlPlaneState) { requireApproval: Boolean(body.require_approval), }; state.tasks.set(task.id, task); + if (task.sessionId) { + await appendReviewEvent(state, task.sessionId, { + event_id: `task-created:${task.id}`, + source: 'control-plane', + kind: 'task_created', + task_id: task.id, + details: { + description: task.description, + thought_summary: task.thoughtSummary ?? null, + }, + }); + } json(res, 201, { task }); return; } @@ -875,6 +1017,19 @@ export function createRequestHandler(state: ControlPlaneState) { task.status = taskActionMatch[2] === 'reset' ? 'pending' : mapTaskStatus(taskActionMatch[2]); task.updatedAt = new Date().toISOString(); state.tasks.set(task.id, task); + if (task.sessionId) { + await appendReviewEvent(state, task.sessionId, { + event_id: `task-state:${task.id}:${taskActionMatch[2]}:${task.updatedAt}`, + source: 'control-plane', + kind: 'task_state_changed', + task_id: task.id, + status: task.status, + details: { + next_status: task.status, + verb: taskActionMatch[2], + }, + }); + } json(res, 200, { task }); return; } @@ -959,6 +1114,7 @@ export async function startControlPlaneServer(port = Number(process.env.PORT ?? const uiRoot = resolveUiRoot(); const artifactRoot = resolveArtifactRoot(); await mkdir(artifactRoot, { recursive: true }); + await mkdir(resolveExportRoot(artifactRoot), { recursive: true }); const state: ControlPlaneState = { guestRuntimeUrl, uiRoot, diff --git a/apps/control-plane/src/types.ts b/apps/control-plane/src/types.ts index 9196642..c419052 100644 --- a/apps/control-plane/src/types.ts +++ b/apps/control-plane/src/types.ts @@ -50,6 +50,20 @@ export interface LiveDesktopView { refresh_interval_ms?: number | null; } +export interface ReviewRecordingSummary { + mode: 'sparse_timeline' | 'unavailable'; + status: 'active' | 'idle' | 'exported' | 'unavailable'; + retention: 'ephemeral_until_export' | 'temporary_postmortem_pin'; + event_count: number; + screenshot_count: number; + approx_bytes: number; + last_captured_at?: string | null; + exportable: boolean; + exported_bundle?: { kind: string; path: string; mime_type?: string | null } | null; + postmortem_retained_until?: string | null; + reason?: string | null; +} + export interface SessionRecord { id: string; provider: string; @@ -68,6 +82,7 @@ export interface SessionRecord { runtime_base_url?: string | null; viewer_url?: string | null; live_desktop_view?: LiveDesktopView | null; + review_recording?: ReviewRecordingSummary | null; bridge_status?: string | null; readiness_state?: string | null; bridge_error?: unknown; diff --git a/apps/web-ui/public/app.js b/apps/web-ui/public/app.js index ab5dff6..af84e55 100644 --- a/apps/web-ui/public/app.js +++ b/apps/web-ui/public/app.js @@ -1,4 +1,4 @@ -import { buildScreenshotUrl, describeLiveDesktopView, getLiveDesktopView } from './live-view.js'; +import { buildScreenshotUrl, describeLiveDesktopView, describeReviewRecording, getLiveDesktopView } from './live-view.js'; import { buildSessionUrl, getSessionIdFromLocation, parseSessionReference } from './session-link.js'; const sessionMeta = document.getElementById('session-meta'); @@ -12,6 +12,10 @@ const viewerLink = document.getElementById('viewer-link'); const observation = document.getElementById('observation'); const historyEl = document.getElementById('history'); const tasksEl = document.getElementById('tasks'); +const reviewRecordingMeta = document.getElementById('review-recording-meta'); +const reviewRecordingBadge = document.getElementById('review-recording-badge'); +const reviewRecordingSummary = document.getElementById('review-recording-summary'); +const exportReviewBundleButton = document.getElementById('export-review-bundle'); const taskDescription = document.getElementById('task-description'); const actionPayload = document.getElementById('action-payload'); const providerSelect = document.getElementById('session-provider'); @@ -41,6 +45,22 @@ function updateProviderOptions() { qemuOptions.hidden = !isQemu; } +function clearLocalSelection() { + sessionId = null; + taskId = null; + existingSessionInput.value = ''; + syncSessionLocation(); + sessionSummary.textContent = 'No session'; + sessionMeta.textContent = 'No session'; + observation.textContent = ''; + historyEl.textContent = ''; + tasksEl.textContent = ''; + if (reviewRecordingMeta) reviewRecordingMeta.textContent = 'No session'; + if (reviewRecordingBadge) reviewRecordingBadge.textContent = 'Unavailable'; + if (reviewRecordingSummary) reviewRecordingSummary.textContent = 'No review bundle available.'; + resetDesktopState(); +} + function syncSessionLocation() { const nextUrl = buildSessionUrl(sessionId); window.history.replaceState({}, '', nextUrl); @@ -62,6 +82,7 @@ function resetDesktopState(message = 'Live desktop unavailable') { desktopPanelTitle.textContent = 'Live desktop view'; liveViewBadge.textContent = 'Unavailable'; liveViewTrust.textContent = 'No session selected.'; + if (exportReviewBundleButton) exportReviewBundleButton.disabled = true; } function sessionOptionLabel(session) { @@ -107,17 +128,30 @@ async function refreshSessionPicker() { function summarizeSession(session) { const liveView = getLiveDesktopView(session); + const review = session.review_recording ?? { mode: 'unavailable', status: 'unavailable' }; const parts = [ `provider=${session.provider}`, `bridge=${session.bridge_status ?? 'n/a'}`, `ready=${session.readiness_state ?? 'n/a'}`, `view=${liveView.mode}/${liveView.status}`, + `review=${review.mode}/${review.status}`, ]; if (session.qemu_profile) parts.push(`profile=${session.qemu_profile}`); if (session.desktop_user) parts.push(`desktop=${session.desktop_user}`); sessionSummary.textContent = parts.join(' · '); } +function updateReviewRecording(session) { + if (!reviewRecordingMeta || !reviewRecordingBadge || !reviewRecordingSummary) return; + const description = describeReviewRecording(session); + reviewRecordingBadge.textContent = description.badge; + reviewRecordingSummary.textContent = description.summary; + reviewRecordingMeta.textContent = description.counts; + if (exportReviewBundleButton) { + exportReviewBundleButton.disabled = !description.exportable || !sessionId; + } +} + function updateLiveView(session, observation) { const liveView = getLiveDesktopView(session); const description = describeLiveDesktopView(session, observation); @@ -164,6 +198,7 @@ async function refresh() { summarizeSession(session); sessionMeta.textContent = JSON.stringify(sessionPayload, null, 2); updateLiveView(session, null); + updateReviewRecording(session); syncSessionLocation(); try { @@ -188,6 +223,9 @@ async function refresh() { observation.textContent = JSON.stringify({ error: 'observation unavailable' }, null, 2); historyEl.textContent = '[]'; tasksEl.textContent = '[]'; + if (reviewRecordingMeta) reviewRecordingMeta.textContent = 'Unavailable'; + if (reviewRecordingBadge) reviewRecordingBadge.textContent = 'Unavailable'; + if (reviewRecordingSummary) reviewRecordingSummary.textContent = 'Review recording unavailable'; resetDesktopState('Live desktop unavailable for the requested session'); } } @@ -211,6 +249,15 @@ document.getElementById('create-session').addEventListener('click', async () => }); document.getElementById('refresh-session').addEventListener('click', refresh); +document.getElementById('delete-session').addEventListener('click', async () => { + if (!sessionId) { + sessionSummary.textContent = 'Select a session before deleting it.'; + return; + } + await json(`/api/sessions/${sessionId}`, { method: 'DELETE' }); + clearLocalSelection(); + await refreshSessionPicker(); +}); document.getElementById('attach-session').addEventListener('click', async () => { const nextSessionId = parseSessionReference(existingSessionInput.value); if (!nextSessionId) { @@ -228,17 +275,24 @@ sessionPicker?.addEventListener('change', async () => { existingSessionInput.value = sessionId; await refresh(); }); -document.getElementById('clear-session').addEventListener('click', () => { - sessionId = null; - taskId = null; - existingSessionInput.value = ''; - syncSessionLocation(); - sessionSummary.textContent = 'No session'; - sessionMeta.textContent = 'No session'; - observation.textContent = ''; - historyEl.textContent = ''; - tasksEl.textContent = ''; - resetDesktopState(); +document.getElementById('clear-session').addEventListener('click', clearLocalSelection); +document.getElementById('reclaim-storage')?.addEventListener('click', async () => { + const payload = await json('/api/storage/reclaim', { + method: 'POST', + body: JSON.stringify({ mode: 'apply' }), + }); + tasksEl.textContent = JSON.stringify(payload, null, 2); + sessionSummary.textContent = `reclaim=${payload.reclaimed?.length ?? 0} · candidates=${payload.candidate_count ?? 0}`; + await refreshSessionPicker(); +}); +exportReviewBundleButton?.addEventListener('click', async () => { + if (!sessionId) return; + const payload = await json(`/api/sessions/${sessionId}/review/export`, { + method: 'POST', + body: JSON.stringify({}), + }); + tasksEl.textContent = JSON.stringify(payload, null, 2); + await refresh(); }); providerSelect.addEventListener('change', updateProviderOptions); updateProviderOptions(); diff --git a/apps/web-ui/public/index.html b/apps/web-ui/public/index.html index 3b67132..2e8f4a1 100644 --- a/apps/web-ui/public/index.html +++ b/apps/web-ui/public/index.html @@ -94,45 +94,55 @@

Session

-
- - +

Default path: create a QEMU product session, watch the live desktop, and delete it when you are done.

+
+ + +
-
-
- + + +
+
+ +

Review recording

+
+

No review bundle available.

- + +
+
+ +
Unavailable
+
+
+ +
No session
diff --git a/apps/web-ui/public/live-view.js b/apps/web-ui/public/live-view.js index 1710831..a6dccb9 100644 --- a/apps/web-ui/public/live-view.js +++ b/apps/web-ui/public/live-view.js @@ -11,6 +11,22 @@ export function getLiveDesktopView(session) { }; } +export function getReviewRecording(session) { + return session?.review_recording ?? { + mode: 'unavailable', + status: 'unavailable', + retention: 'ephemeral_until_export', + event_count: 0, + screenshot_count: 0, + approx_bytes: 0, + last_captured_at: null, + exportable: false, + exported_bundle: null, + postmortem_retained_until: null, + reason: 'review recording metadata unavailable', + }; +} + function getObservationActiveWindow(observation) { if (!observation) return null; if (typeof observation.active_window === 'string' && observation.active_window) { @@ -101,3 +117,33 @@ export function buildScreenshotUrl(liveView) { if (!liveView?.canonical_url) return null; return `${liveView.canonical_url}?ts=${Date.now()}`; } + +export function describeReviewRecording(session) { + const review = getReviewRecording(session); + if (review.mode === 'unavailable') { + return { + badge: 'Unavailable', + summary: review.reason ?? 'No review bundle is available for this session.', + counts: '0 events · 0 screenshots', + exportable: false, + exportedPath: null, + }; + } + + const parts = [ + `${review.event_count} event${review.event_count === 1 ? '' : 's'}`, + `${review.screenshot_count} screenshot${review.screenshot_count === 1 ? '' : 's'}`, + `${review.approx_bytes} bytes`, + ]; + const retention = review.retention === 'temporary_postmortem_pin' + ? `Temporary postmortem pin${review.postmortem_retained_until ? ` until ${review.postmortem_retained_until}` : ''}.` + : 'Ephemeral until export.'; + + return { + badge: review.status === 'exported' ? 'Exported' : 'Sparse timeline', + summary: `${retention} ${review.exported_bundle?.path ? `Latest export: ${review.exported_bundle.path}` : 'Export to keep the bundle after session teardown.'}`, + counts: parts.join(' · '), + exportable: review.exportable, + exportedPath: review.exported_bundle?.path ?? null, + }; +} diff --git a/apps/web-ui/public/live-view.test.js b/apps/web-ui/public/live-view.test.js index 6a0e54a..d54fd5b 100644 --- a/apps/web-ui/public/live-view.test.js +++ b/apps/web-ui/public/live-view.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { buildScreenshotUrl, describeLiveDesktopView, getLiveDesktopView } from './live-view.js'; +import { buildScreenshotUrl, describeLiveDesktopView, describeReviewRecording, getLiveDesktopView } from './live-view.js'; test('describeLiveDesktopView prefers canonical qemu product stream', () => { const session = { @@ -125,3 +125,49 @@ test('buildScreenshotUrl appends a cache-busting timestamp', () => { }); assert.match(String(url), /^\/api\/sessions\/xvfb\/screenshot\?ts=\d+$/); }); + +test('describeReviewRecording reports exportable sparse review bundles', () => { + const description = describeReviewRecording({ + review_recording: { + mode: 'sparse_timeline', + status: 'active', + retention: 'ephemeral_until_export', + event_count: 4, + screenshot_count: 2, + approx_bytes: 512, + last_captured_at: '2026-04-15T11:00:00Z', + exportable: true, + exported_bundle: null, + postmortem_retained_until: null, + reason: null, + }, + }); + assert.equal(description.badge, 'Sparse timeline'); + assert.equal(description.exportable, true); + assert.match(description.counts, /4 events/); + assert.match(description.summary, /Ephemeral until export/i); +}); + +test('describeReviewRecording makes temporary postmortem retention explicit', () => { + const description = describeReviewRecording({ + review_recording: { + mode: 'sparse_timeline', + status: 'active', + retention: 'temporary_postmortem_pin', + event_count: 8, + screenshot_count: 3, + approx_bytes: 1024, + last_captured_at: '2026-04-15T11:00:00Z', + exportable: true, + exported_bundle: { + kind: 'review_bundle', + path: 'artifacts/exports/session-review', + }, + postmortem_retained_until: '2026-04-15T12:00:00Z', + reason: null, + }, + }); + assert.equal(description.badge, 'Sparse timeline'); + assert.match(description.summary, /Temporary postmortem pin/i); + assert.match(description.summary, /artifacts\/exports\/session-review/); +}); diff --git a/apps/web-ui/public/provider-default.test.js b/apps/web-ui/public/provider-default.test.js index e92647c..1fbf0d0 100644 --- a/apps/web-ui/public/provider-default.test.js +++ b/apps/web-ui/public/provider-default.test.js @@ -22,3 +22,16 @@ test('session picker is rendered for choosing running sessions', () => { assert.match(html, /