diff --git a/src/dashboard/views/tasks-view.ts b/src/dashboard/views/tasks-view.ts index a882dd973..6361944f8 100644 --- a/src/dashboard/views/tasks-view.ts +++ b/src/dashboard/views/tasks-view.ts @@ -12,14 +12,45 @@ import type { ScreenSize } from '../types.js'; import { Renderer } from '../renderer.js'; import { TaskStore, defaultTaskRootDir } from '../../core/task-ledger/index.js'; import type { TaskMeta, TaskStatus } from '../../core/task-ledger/index.js'; +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; export interface TasksViewData { tasks: TaskMeta[]; version: string; + recovery?: RecoveryViewData; /** Last error encountered while reading the ledger, if any. */ errorMessage?: string; } +export interface RecoveryViewData { + taskRuns: TaskRunRecovery[]; + handoffs: HandoffRecovery[]; + errorMessage?: string; +} + +export interface TaskRunRecovery { + run_id: string; + status: string; + reason?: string; + requested_at?: number; + resume_hint?: string; + current_cursor?: string; + last_checkpoint?: string; + evidence?: Array<{ kind: string; ref: string; summary?: string }>; +} + +export interface HandoffRecovery { + handoff_id: string; + status: string; + reason?: string; + run_id?: string; + expires_at?: number; + before_url?: string; + before_title?: string; +} + const STATUS_COLORS: Record = { PENDING: ANSI.dim, RUNNING: ANSI.cyan, @@ -28,6 +59,11 @@ const STATUS_COLORS: Record = { CANCELLED: ANSI.yellow, }; +const RECOVERY_STATUS_COLORS: Record = { + NEEDS_HELP: ANSI.brightYellow, + ACTIVE: ANSI.brightMagenta, +}; + function colorize(text: string, code: string | undefined): string { if (!code) return text; return code + text + ANSI.reset; @@ -58,13 +94,15 @@ export class TasksView { ), ); } else { - const visibleRows = Math.max(0, size.rows - 7); + const recoveryLines = this.renderRecovery(data.recovery, width); + const visibleRows = Math.max(0, size.rows - 7 - recoveryLines.length); for (const t of data.tasks.slice(0, visibleRows)) { lines.push(this.renderTaskRow(t, width)); } + lines.push(...recoveryLines); } - while (lines.length < size.rows - 2) { + while (lines.length < size.rows - 3) { lines.push(this.renderer.emptyLine(width)); } @@ -76,6 +114,51 @@ export class TasksView { return lines; } + private renderRecovery(recovery: RecoveryViewData | undefined, width: number): string[] { + if (!recovery) return []; + const lines: string[] = []; + if (recovery.errorMessage) { + lines.push(BOX.teeRight + horizontalLine(width - 2) + BOX.teeLeft); + lines.push(this.renderer.contentLine(colorize(`recovery metadata unavailable: ${recovery.errorMessage}`, ANSI.dim), width)); + return lines; + } + const needsHelp = recovery.taskRuns.filter(run => run.status === 'NEEDS_HELP'); + const activeHandoffs = recovery.handoffs.filter(handoff => handoff.status === 'ACTIVE'); + if (needsHelp.length === 0 && activeHandoffs.length === 0) return []; + + lines.push(BOX.teeRight + horizontalLine(width - 2) + BOX.teeLeft); + lines.push(this.renderer.contentLine(colorize('Recovery / Human Help', ANSI.bold), width)); + for (const run of needsHelp.slice(0, 3)) { + const status = colorize('NEEDS_HELP', RECOVERY_STATUS_COLORS.NEEDS_HELP); + lines.push(this.renderer.contentLine( + truncate(`${status} run=${run.run_id.slice(0, 8)} reason=${run.reason || '—'}`, width - 4), + width, + )); + lines.push(this.renderer.contentLine( + truncate(` cursor=${run.current_cursor || '—'} resume=${run.resume_hint || '—'}`, width - 4), + width, + )); + const evidence = (run.evidence || []).slice(0, 3).map(item => `${item.kind}:${item.ref}`).join(', ') || '—'; + lines.push(this.renderer.contentLine( + truncate(` checkpoint=${run.last_checkpoint || '—'} evidence=${evidence}`, width - 4), + width, + )); + } + for (const handoff of activeHandoffs.slice(0, 3)) { + const remaining = handoff.expires_at ? formatAge(Math.max(0, handoff.expires_at - Date.now())) : '—'; + const status = colorize('ACTIVE', RECOVERY_STATUS_COLORS.ACTIVE); + lines.push(this.renderer.contentLine( + truncate(`${status} handoff=${handoff.handoff_id.slice(0, 8)} run=${handoff.run_id?.slice(0, 8) || '—'} timeout=${remaining}`, width - 4), + width, + )); + lines.push(this.renderer.contentLine( + truncate(` reason=${handoff.reason || '—'} before=${handoff.before_url || handoff.before_title || '—'}`, width - 4), + width, + )); + } + return lines; + } + private renderColumnHeaders(width: number): string { const header = colorize( pad('task_id', 10) + @@ -136,9 +219,95 @@ export async function readTasksSnapshot(limit = 200): Promise { const store = new TaskStore({ rootDir: defaultTaskRootDir() }); const tasks = await store.list({ limit }); tasks.sort((a, b) => (b.started_at ?? 0) - (a.started_at ?? 0)); - return { tasks, version: '1' }; + const recovery = await readRecoverySnapshot(); + return { tasks, version: '1', recovery }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { tasks: [], version: '1', errorMessage: msg }; } } + +export async function readRecoverySnapshot(limit = 50): Promise { + try { + const home = process.env.OPENCHROME_HOME || join(homedir(), '.openchrome'); + const taskRuns = await readTaskRuns(join(home, 'task-runs'), limit); + const handoffs = await readHandoffs(join(home, 'handoffs'), limit); + return { taskRuns, handoffs }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { taskRuns: [], handoffs: [], errorMessage: msg }; + } +} + +async function readTaskRuns(root: string, limit: number): Promise { + const entries = await readdirSafe(root); + const rows: TaskRunRecovery[] = []; + for (const entry of entries.slice(0, limit)) { + const meta = await readJsonSafe>(join(root, entry, 'meta.json')); + if (!meta) continue; + const needsHelp = meta.needs_help as Record | undefined; + rows.push({ + run_id: String(meta.run_id || entry), + status: String(meta.status || 'UNKNOWN'), + reason: typeof needsHelp?.reason === 'string' ? needsHelp.reason : undefined, + requested_at: typeof needsHelp?.requested_at === 'number' ? needsHelp.requested_at : undefined, + resume_hint: typeof needsHelp?.resume_hint === 'string' ? needsHelp.resume_hint : undefined, + current_cursor: typeof meta.current_cursor === 'string' ? meta.current_cursor : undefined, + last_checkpoint: await readLatestCheckpointId(join(root, entry, 'checkpoints')), + evidence: Array.isArray(meta.last_evidence) ? meta.last_evidence as TaskRunRecovery['evidence'] : undefined, + }); + } + return rows.sort((a, b) => (b.requested_at || 0) - (a.requested_at || 0)); +} + +async function readHandoffs(root: string, limit: number): Promise { + const entries = await readdirSafe(root); + const rows: HandoffRecovery[] = []; + for (const entry of entries.slice(0, limit)) { + const meta = await readJsonSafe>(join(root, entry, 'meta.json')); + if (!meta) continue; + const before = meta.before as Record | undefined; + rows.push({ + handoff_id: String(meta.handoff_id || entry), + status: String(meta.status || 'UNKNOWN'), + reason: typeof meta.reason === 'string' ? meta.reason : undefined, + run_id: typeof meta.run_id === 'string' ? meta.run_id : undefined, + expires_at: typeof meta.expires_at === 'number' ? meta.expires_at : undefined, + before_url: typeof before?.url === 'string' ? before.url : undefined, + before_title: typeof before?.title === 'string' ? before.title : undefined, + }); + } + return rows.sort((a, b) => (b.expires_at || 0) - (a.expires_at || 0)); +} + +async function readLatestCheckpointId(root: string): Promise { + const entries = await readdirSafe(root); + const checkpoints: Array<{ id: string; created_at: number }> = []; + for (const entry of entries.filter(name => name.endsWith('.json'))) { + const json = await readJsonSafe>(join(root, entry)); + if (!json) continue; + checkpoints.push({ + id: String(json.checkpoint_id || entry.replace(/\.json$/, '')), + created_at: typeof json.created_at === 'number' ? json.created_at : 0, + }); + } + checkpoints.sort((a, b) => b.created_at - a.created_at); + return checkpoints[0]?.id; +} + +async function readdirSafe(root: string): Promise { + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries.filter(entry => entry.isDirectory() || entry.isFile()).map(entry => entry.name); + } catch { + return []; + } +} + +async function readJsonSafe(file: string): Promise { + try { + return JSON.parse(await fs.readFile(file, 'utf8')) as T; + } catch { + return undefined; + } +} diff --git a/tests/dashboard/views/ledger-views.test.ts b/tests/dashboard/views/ledger-views.test.ts index be07f6c8b..182be0c14 100644 --- a/tests/dashboard/views/ledger-views.test.ts +++ b/tests/dashboard/views/ledger-views.test.ts @@ -16,7 +16,7 @@ import { join } from 'node:path'; import { Renderer } from '../../../src/dashboard/renderer.js'; import { Dashboard } from '../../../src/dashboard/index.js'; -import { TasksView, type TasksViewData } from '../../../src/dashboard/views/tasks-view.js'; +import { TasksView, readRecoverySnapshot, type TasksViewData } from '../../../src/dashboard/views/tasks-view.js'; import { SkillsView, type SkillsViewData } from '../../../src/dashboard/views/skills-view.js'; import type { TaskMeta } from '../../../src/core/task-ledger/index.js'; import type { SkillRecord } from '../../../src/core/skill-memory/index.js'; @@ -95,6 +95,68 @@ describe('TasksView', () => { ); expect(lines.join('\n')).toMatch(/permission denied/); }); + + test('renders NEEDS_HELP and active handoff recovery details read-only', () => { + const renderer = new Renderer(); + const view = new TasksView(renderer); + const data: TasksViewData = { + version: '1', + tasks: [mkTaskMeta({ task_id: 'r'.repeat(16), status: 'RUNNING' })], + recovery: { + taskRuns: [{ + run_id: '1'.repeat(16), + status: 'NEEDS_HELP', + reason: 'Manual login required before continuing', + requested_at: Date.now(), + resume_hint: 'Continue after login', + current_cursor: 'https://the-internet.herokuapp.com/login', + last_checkpoint: 'c'.repeat(16), + evidence: [{ kind: 'url', ref: 'https://the-internet.herokuapp.com/login' }], + }], + handoffs: [{ + handoff_id: 'h'.repeat(16), + status: 'ACTIVE', + reason: 'Manual login required before continuing', + run_id: '1'.repeat(16), + expires_at: Date.now() + 10_000, + before_url: 'https://the-internet.herokuapp.com/login', + before_title: 'Login Page', + }], + }, + }; + const joined = view.render(data, SIZE).join('\n'); + expect(joined).toMatch(/Recovery \/ Human Help/); + expect(joined).toMatch(/NEEDS_HELP/); + expect(joined).toMatch(/Manual login required/); + expect(joined).toMatch(/Continue after login/); + expect(joined).toMatch(/ACTIVE/); + expect(joined).toMatch(/handoff=hhhhhhhh/); + }); + + test.each([ + { rows: 24, columns: 80 }, + { rows: 40, columns: 120 }, + { rows: 60, columns: 200 }, + ])('recovery pane renders within terminal size %o', (size) => { + const renderer = new Renderer(); + const view = new TasksView(renderer); + const lines = view.render({ + version: '1', + tasks: [mkTaskMeta({ task_id: 'r'.repeat(16), status: 'RUNNING' })], + recovery: { + taskRuns: [{ + run_id: '1'.repeat(16), + status: 'NEEDS_HELP', + reason: 'Manual login required before continuing', + current_cursor: 'https://example.com/login', + resume_hint: 'resume', + }], + handoffs: [], + }, + }, size); + expect(lines.length).toBe(size.rows); + expect(lines.join('\n')).toMatch(/NEEDS_HELP/); + }); }); describe('SkillsView', () => { @@ -152,8 +214,69 @@ describe('snapshot readers — read-only behavior', () => { await fs.rm(fakeHome, { recursive: true, force: true }); } }); + + test('readRecoverySnapshot reads TaskRun and handoff metadata without mutating files', async () => { + const fakeHome = await mkdtemp(join(tmpdir(), 'oc-recovery-home-')); + const prev = process.env.OPENCHROME_HOME; + process.env.OPENCHROME_HOME = fakeHome; + try { + const runDir = join(fakeHome, 'task-runs', '1'.repeat(16)); + const handoffDir = join(fakeHome, 'handoffs', 'h'.repeat(16)); + const checkpointDir = join(runDir, 'checkpoints'); + await fs.mkdir(checkpointDir, { recursive: true }); + await fs.mkdir(handoffDir, { recursive: true }); + await fs.writeFile(join(runDir, 'meta.json'), JSON.stringify({ + run_id: '1'.repeat(16), + status: 'NEEDS_HELP', + current_cursor: 'https://example.com/login', + needs_help: { + reason: 'Manual login required', + requested_at: 1_700_000_000_000, + resume_hint: 'Continue after login', + }, + last_evidence: [{ kind: 'url', ref: 'https://example.com/login' }], + })); + await fs.writeFile(join(checkpointDir, 'c.json'), JSON.stringify({ + checkpoint_id: 'c'.repeat(16), + created_at: 1_700_000_000_001, + })); + await fs.writeFile(join(handoffDir, 'meta.json'), JSON.stringify({ + handoff_id: 'h'.repeat(16), + status: 'ACTIVE', + reason: 'Manual login required', + run_id: '1'.repeat(16), + expires_at: 1_700_000_010_000, + before: { url: 'https://example.com/login', title: 'Login' }, + })); + const before = JSON.stringify(await tree(fakeHome)); + for (let i = 0; i < 3; i++) { + const snapshot = await readRecoverySnapshot(); + expect(snapshot.taskRuns[0].status).toBe('NEEDS_HELP'); + expect(snapshot.taskRuns[0].last_checkpoint).toBe('c'.repeat(16)); + expect(snapshot.handoffs[0].status).toBe('ACTIVE'); + } + expect(JSON.stringify(await tree(fakeHome))).toBe(before); + } finally { + process.env.OPENCHROME_HOME = prev; + await fs.rm(fakeHome, { recursive: true, force: true }); + } + }); }); +async function tree(root: string): Promise { + const out: string[] = []; + async function walk(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + out.push(full.replace(root, '')); + if (entry.isDirectory()) await walk(full); + } + } + await walk(root); + return out.sort(); +} + describe('Dashboard ledger refresh ticks', () => { test('timer path refreshes ledgers while staying on tasks or skills view', async () => { const dashboard = new Dashboard({ enabled: false }) as unknown as {