Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 172 additions & 3 deletions src/dashboard/views/tasks-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskStatus, string> = {
PENDING: ANSI.dim,
RUNNING: ANSI.cyan,
Expand All @@ -28,6 +59,11 @@ const STATUS_COLORS: Record<TaskStatus, string> = {
CANCELLED: ANSI.yellow,
};

const RECOVERY_STATUS_COLORS: Record<string, string> = {
NEEDS_HELP: ANSI.brightYellow,
ACTIVE: ANSI.brightMagenta,
};

function colorize(text: string, code: string | undefined): string {
if (!code) return text;
return code + text + ANSI.reset;
Expand Down Expand Up @@ -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)) {
Comment on lines +97 to 99
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render recovery pane even when no task rows are present

Callers can provide recovery metadata independently of the task ledger (for example, when TaskRun/handoff files exist but tasks is empty), but renderRecovery(...) is only reached inside the else branch for non-empty tasks. In that state the view shows only the empty-task hint and silently drops NEEDS_HELP/ACTIVE recovery signals, which defeats the operator visibility this feature adds.

Useful? React with 👍 / 👎.

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));
}

Expand All @@ -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(', ') || '—';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard evidence entries before dereferencing fields

The reader casts meta.last_evidence to typed objects without validating each element, and rendering unconditionally accesses item.kind/item.ref. If the on-disk JSON contains null/undefined/non-object entries (e.g., partial or malformed metadata), this line throws and can break dashboard rendering instead of degrading gracefully.

Useful? React with 👍 / 👎.

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) +
Expand Down Expand Up @@ -136,9 +219,95 @@ export async function readTasksSnapshot(limit = 200): Promise<TasksViewData> {
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<RecoveryViewData> {
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<TaskRunRecovery[]> {
const entries = await readdirSafe(root);
const rows: TaskRunRecovery[] = [];
for (const entry of entries.slice(0, limit)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply TaskRun limit after timestamp ordering

The loop truncates entries before any ordering, then sorts only the truncated subset by requested_at. Because fs.readdir does not provide a reliable recency order, dashboards with more than limit run folders can omit the most recent NEEDS_HELP runs entirely, so operators may not see current recovery state. Collect all readable rows first, sort by requested_at, and only then apply the limit.

Useful? React with 👍 / 👎.

const meta = await readJsonSafe<Record<string, unknown>>(join(root, entry, 'meta.json'));
if (!meta) continue;
const needsHelp = meta.needs_help as Record<string, unknown> | 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<HandoffRecovery[]> {
const entries = await readdirSafe(root);
const rows: HandoffRecovery[] = [];
for (const entry of entries.slice(0, limit)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply handoff limit after expiration ordering

This code also slices directory entries before sorting by expires_at, so only an arbitrary subset is ranked. In environments with many handoff records, active or soon-expiring handoffs can be dropped before sorting and never shown in the recovery panel, which defeats the new visibility feature. Read all candidate rows, sort them, then apply limit.

Useful? React with 👍 / 👎.

const meta = await readJsonSafe<Record<string, unknown>>(join(root, entry, 'meta.json'));
if (!meta) continue;
const before = meta.before as Record<string, unknown> | 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<string | undefined> {
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<Record<string, unknown>>(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<string[]> {
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<T>(file: string): Promise<T | undefined> {
try {
return JSON.parse(await fs.readFile(file, 'utf8')) as T;
} catch {
return undefined;
}
}
125 changes: 124 additions & 1 deletion tests/dashboard/views/ledger-views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
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 {
Expand Down