diff --git a/src/core/plan-approval.ts b/src/core/plan-approval.ts new file mode 100644 index 0000000..c383cc7 --- /dev/null +++ b/src/core/plan-approval.ts @@ -0,0 +1,126 @@ +/** + * Approval Queue Manager — MVP foundation for Issue #8. + * + * Manages pending plan approval entries in-memory. Each entry represents + * a generated plan waiting for a dashboard user to approve, reject, or + * request a revision before the agentic loop is allowed to execute. + * + * Design notes: + * - Intentionally in-memory for the MVP (no persistence dependency). + * - Uses the existing Plan/PlanStep types verbatim — no schema changes needed. + * - Safe to call from the UI server and the core loop concurrently because + * all mutations are synchronous (Node.js single-threaded event loop). + * + * @author Akshat Raj + */ + +import { Plan, PlanStep } from '../types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'revision_requested'; + +export interface ApprovalQueueEntry { + id: string; + plan: Plan; + status: ApprovalStatus; + enqueuedAt: string; + updatedAt?: string; + reviewerFeedback?: string; +} + +export interface StepUpdate { + stepId: string; + description?: string; + newIndex?: number; +} + +export interface PlanEditRequest { + entryId: string; + stepUpdates?: StepUpdate[]; +} + +export type PlanApprovalAction = 'approve' | 'reject' | 'request_revision'; + +export interface ApprovalDecision { + action: PlanApprovalAction; + feedback?: string; +} + +// --------------------------------------------------------------------------- +// Queue manager +// --------------------------------------------------------------------------- + +const _queue = new Map(); + +const STATUS_MAP: Record = { + approve: 'approved', + reject: 'rejected', + request_revision: 'revision_requested', +}; + +export const enqueue = (id: string, plan: Plan): ApprovalQueueEntry => { + const entry: ApprovalQueueEntry = { + id, + plan, + status: 'pending', + enqueuedAt: new Date().toISOString(), + }; + _queue.set(id, entry); + return entry; +}; + +export const listQueue = (): ApprovalQueueEntry[] => Array.from(_queue.values()); + +export const getEntry = (id: string): ApprovalQueueEntry | undefined => _queue.get(id); + +export const applyPlanEdit = (req: PlanEditRequest): ApprovalQueueEntry | null => { + const entry = _queue.get(req.entryId); + if (!entry || entry.status !== 'pending') return null; + + const steps: PlanStep[] = entry.plan.steps.map((s) => ({ ...s })); + + for (const update of req.stepUpdates ?? []) { + const idx = steps.findIndex((s) => s.id === update.stepId); + if (idx === -1) continue; + + if (update.description !== undefined) { + steps[idx] = { ...steps[idx], description: update.description }; + } + + if (update.newIndex !== undefined && update.newIndex !== idx) { + const [moved] = steps.splice(idx, 1); + const target = Math.max(0, Math.min(update.newIndex, steps.length)); + steps.splice(target, 0, moved); + } + } + + const updated: ApprovalQueueEntry = { + ...entry, + plan: { ...entry.plan, steps }, + updatedAt: new Date().toISOString(), + }; + _queue.set(req.entryId, updated); + return updated; +}; + +export const recordDecision = ( + id: string, + decision: ApprovalDecision, +): ApprovalQueueEntry | null => { + const entry = _queue.get(id); + if (!entry || entry.status !== 'pending') return null; + + const updated: ApprovalQueueEntry = { + ...entry, + status: STATUS_MAP[decision.action], + reviewerFeedback: decision.feedback, + updatedAt: new Date().toISOString(), + }; + _queue.set(id, updated); + return updated; +}; + +export const dequeue = (id: string): boolean => _queue.delete(id); diff --git a/src/ui/plan-editor-routes.ts b/src/ui/plan-editor-routes.ts new file mode 100644 index 0000000..7f363bc --- /dev/null +++ b/src/ui/plan-editor-routes.ts @@ -0,0 +1,133 @@ +/** + * REST handlers for the Interactive Plan Editor — Issue #8. + * + * Plugs into the existing raw-http router in src/ui/server.ts via + * handlePlanEditorRoute(). Call it from the router function before the + * static-file fallback: + * + * if (handlePlanEditorRoute(req, res, p)) return; + * + * Endpoints + * --------- + * GET /api/approval-queue — list all pending plan entries + * GET /api/approval-queue/:id — get a single entry + * PATCH /api/approval-queue/:id — apply a sparse plan step edit + * POST /api/approval-queue/:id/decision — approve / reject / request revision + * + * All responses follow { ok: boolean, data?: unknown, error?: string }. + * + * @author Akshat Raj + */ + +import * as http from 'http'; +import { listQueue, getEntry, applyPlanEdit, recordDecision } from '../core/plan-approval'; +import type { PlanEditRequest, ApprovalDecision, PlanApprovalAction } from '../core/plan-approval'; + +const ALLOWED_ACTIONS: ReadonlySet = new Set([ + 'approve', + 'reject', + 'request_revision', +]); + +const sendJson = (res: http.ServerResponse, status: number, body: unknown): void => { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(payload), + }); + res.end(payload); +}; + +const readBody = (req: http.IncomingMessage): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); + +const parseBody = async (req: http.IncomingMessage): Promise => { + const raw = await readBody(req); + if (!raw.trim()) return {} as T; + return JSON.parse(raw) as T; +}; + +/** + * Handle plan-editor API routes. Returns true if the request was handled. + * Drop this into the server.ts router before the static-file fallback. + */ +export const handlePlanEditorRoute = ( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string, +): boolean => { + // GET /api/approval-queue + if (pathname === '/api/approval-queue' && req.method === 'GET') { + sendJson(res, 200, { ok: true, data: listQueue() }); + return true; + } + + // GET /api/approval-queue/:id + const idMatch = /^\/api\/approval-queue\/([^/]+)$/.exec(pathname); + if (idMatch && req.method === 'GET') { + const entry = getEntry(idMatch[1]); + if (!entry) { + sendJson(res, 404, { ok: false, error: `No queue entry found for id: ${idMatch[1]}` }); + } else { + sendJson(res, 200, { ok: true, data: entry }); + } + return true; + } + + // PATCH /api/approval-queue/:id + if (idMatch && req.method === 'PATCH') { + void (async () => { + const body = await parseBody>(req); + const editReq: PlanEditRequest = { + entryId: idMatch[1], + stepUpdates: body.stepUpdates ?? [], + }; + const updated = applyPlanEdit(editReq); + if (!updated) { + sendJson(res, 409, { + ok: false, + error: `Cannot edit entry ${idMatch[1]}: not found or not in pending state.`, + }); + } else { + sendJson(res, 200, { ok: true, data: updated }); + } + })(); + return true; + } + + // POST /api/approval-queue/:id/decision + const decisionMatch = /^\/api\/approval-queue\/([^/]+)\/decision$/.exec(pathname); + if (decisionMatch && req.method === 'POST') { + void (async () => { + const body = await parseBody>(req); + if (!body.action) { + sendJson(res, 400, { ok: false, error: 'Missing required field: action' }); + return; + } + if (!ALLOWED_ACTIONS.has(body.action)) { + sendJson(res, 400, { + ok: false, + error: `Invalid action. Must be one of: ${[...ALLOWED_ACTIONS].join(', ')}`, + }); + return; + } + const updated = recordDecision(decisionMatch[1], body as ApprovalDecision); + if (!updated) { + sendJson(res, 404, { + ok: false, + error: `No queue entry found for id: ${decisionMatch[1]}`, + }); + } else { + sendJson(res, 200, { ok: true, data: updated }); + } + })(); + return true; + } + + return false; +}; diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts new file mode 100644 index 0000000..68798d0 --- /dev/null +++ b/test/plan-approval.test.ts @@ -0,0 +1,142 @@ +/** + * Unit tests for the plan approval queue manager. + * Covers: enqueue, list, edit, decision recording, dequeue. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + enqueue, + listQueue, + getEntry, + applyPlanEdit, + recordDecision, + dequeue, +} from '../src/core/plan-approval'; +import type { Plan } from '../src/types'; + +const makePlan = (id = 'task-1'): Plan => ({ + id, + goal: 'test goal', + mode: 'balanced', + version: '1', + createdAt: new Date().toISOString(), + steps: [ + { id: 'step-1', type: 'edit_file', description: 'Create index.ts' }, + { id: 'step-2', type: 'run_tests', description: 'Run test suite' }, + ], +}); + +beforeEach(() => { + dequeue('task-1'); + dequeue('task-2'); +}); + +describe('enqueue', () => { + it('adds an entry with pending status', () => { + const entry = enqueue('task-1', makePlan()); + expect(entry.id).toBe('task-1'); + expect(entry.status).toBe('pending'); + }); + + it('replaces an existing entry (idempotent re-enqueue)', () => { + enqueue('task-1', makePlan()); + enqueue('task-1', makePlan()); + expect(listQueue().filter((e) => e.id === 'task-1')).toHaveLength(1); + }); +}); + +describe('getEntry', () => { + it('returns the entry when it exists', () => { + enqueue('task-1', makePlan()); + expect(getEntry('task-1')).toBeDefined(); + }); + + it('returns undefined for unknown id', () => { + expect(getEntry('does-not-exist')).toBeUndefined(); + }); +}); + +describe('applyPlanEdit', () => { + it('patches a step description', () => { + enqueue('task-1', makePlan()); + const updated = applyPlanEdit({ + entryId: 'task-1', + stepUpdates: [{ stepId: 'step-1', description: 'Updated description' }], + }); + expect(updated).not.toBeNull(); + expect(updated!.plan.steps[0].description).toBe('Updated description'); + }); + + it('reorders steps correctly', () => { + enqueue('task-1', makePlan()); + const updated = applyPlanEdit({ + entryId: 'task-1', + stepUpdates: [{ stepId: 'step-2', newIndex: 0 }], + }); + expect(updated!.plan.steps[0].id).toBe('step-2'); + expect(updated!.plan.steps[1].id).toBe('step-1'); + }); + + it('returns null for non-pending entry', () => { + enqueue('task-1', makePlan()); + recordDecision('task-1', { action: 'approve' }); + const result = applyPlanEdit({ + entryId: 'task-1', + stepUpdates: [{ stepId: 'step-1', description: 'Should not apply' }], + }); + expect(result).toBeNull(); + }); + + it('returns null for unknown entry', () => { + expect(applyPlanEdit({ entryId: 'ghost', stepUpdates: [] })).toBeNull(); + }); +}); + +describe('recordDecision', () => { + it('marks entry as approved', () => { + enqueue('task-1', makePlan()); + const result = recordDecision('task-1', { action: 'approve' }); + expect(result!.status).toBe('approved'); + }); + + it('marks entry as rejected with feedback', () => { + enqueue('task-1', makePlan()); + const result = recordDecision('task-1', { + action: 'reject', + feedback: 'Step order is wrong', + }); + expect(result!.status).toBe('rejected'); + expect(result!.reviewerFeedback).toBe('Step order is wrong'); + }); + + it('marks entry as revision_requested', () => { + enqueue('task-1', makePlan()); + const result = recordDecision('task-1', { + action: 'request_revision', + feedback: 'Please add a lint step', + }); + expect(result!.status).toBe('revision_requested'); + }); + + it('returns null for unknown id', () => { + expect(recordDecision('ghost', { action: 'approve' })).toBeNull(); + }); + + it('returns null for non-pending entry', () => { + enqueue('task-1', makePlan()); + recordDecision('task-1', { action: 'approve' }); + expect(recordDecision('task-1', { action: 'reject' })).toBeNull(); + }); +}); + +describe('dequeue', () => { + it('removes the entry', () => { + enqueue('task-1', makePlan()); + expect(dequeue('task-1')).toBe(true); + expect(getEntry('task-1')).toBeUndefined(); + }); + + it('returns false for unknown id', () => { + expect(dequeue('ghost')).toBe(false); + }); +});