From 9c6b8d2d4945a39dccff33447e8b484f9547bd66 Mon Sep 17 00:00:00 2001 From: Akshat Raj <154466152+AkshatRaj00@users.noreply.github.com> Date: Mon, 18 May 2026 04:59:08 +0530 Subject: [PATCH 1/6] feat(ui): add plan editor types, approval queue foundation, and REST endpoint stubs (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PlanEditRequest, PlanApprovalAction, ApprovalQueueEntry types to src/types/index.ts - Add src/core/plan-approval.ts: approval queue manager with enqueue/dequeue/update - Add REST endpoint stubs in src/ui/plan-editor-routes.ts for GET/PATCH/POST plan approval - Closes #8 (partial — MVP foundation for interactive plan editor dashboard flow)" --- src/core/plan-approval.ts | 161 +++++++++++++++++++++++++++++++++++ src/ui/plan-editor-routes.ts | 94 ++++++++++++++++++++ test/plan-approval.test.ts | 137 +++++++++++++++++++++++++++++ 3 files changed, 392 insertions(+) create mode 100644 src/core/plan-approval.ts create mode 100644 src/ui/plan-editor-routes.ts create mode 100644 test/plan-approval.test.ts diff --git a/src/core/plan-approval.ts b/src/core/plan-approval.ts new file mode 100644 index 0000000..83cbb0f --- /dev/null +++ b/src/core/plan-approval.ts @@ -0,0 +1,161 @@ +/** + * Approval Queue Manager — MVP foundation for Issue #8. + * + * Manages pending plan approval entries in-memory. Each entry represents + * a generated plan that is 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 — 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 } from '../types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'revision_requested'; + +export interface ApprovalQueueEntry { + /** Unique entry id — same as the associated task id */ + id: string; + /** The plan snapshot at the time it was enqueued */ + plan: Plan; + /** Current decision status */ + status: ApprovalStatus; + /** ISO timestamp when the entry was enqueued */ + enqueuedAt: string; + /** ISO timestamp of the last status update, if any */ + updatedAt?: string; + /** Optional free-text feedback from the reviewer (used for revision_requested / rejected) */ + reviewerFeedback?: string; +} + +export interface PlanEditRequest { + /** Target entry id */ + entryId: string; + /** Sparse patch — only the fields the user changed */ + stepUpdates?: Array<{ + stepId: string; + description?: string; + /** New 0-based position in the step array */ + newIndex?: number; + }>; +} + +export type PlanApprovalAction = 'approve' | 'reject' | 'request_revision'; + +export interface ApprovalDecision { + action: PlanApprovalAction; + /** Required when action is 'reject' or 'request_revision' */ + feedback?: string; +} + +// --------------------------------------------------------------------------- +// Queue manager +// --------------------------------------------------------------------------- + +const _queue = new Map(); + +/** + * Add a plan to the approval queue. + * Replaces any existing entry for the same id (idempotent re-enqueue). + */ +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; +}; + +/** + * Return a snapshot of all entries currently in the queue. + */ +export const listQueue = (): ApprovalQueueEntry[] => + Array.from(_queue.values()); + +/** + * Return a single entry by id, or undefined if not found. + */ +export const getEntry = (id: string): ApprovalQueueEntry | undefined => + _queue.get(id); + +/** + * Apply a sparse patch to the plan steps of a pending entry. + * Returns the updated entry, or null if the entry is not found or not pending. + */ +export const applyPlanEdit = ( + req: PlanEditRequest, +): ApprovalQueueEntry | null => { + const entry = _queue.get(req.entryId); + if (!entry || entry.status !== 'pending') return null; + + let steps = [...entry.plan.steps]; + + for (const update of req.stepUpdates ?? []) { + const idx = steps.findIndex((s) => s.id === update.stepId); + if (idx === -1) continue; + + // Apply description patch + if (update.description !== undefined) { + steps[idx] = { ...steps[idx], description: update.description }; + } + + // Apply reorder + if (update.newIndex !== undefined && update.newIndex !== idx) { + const [moved] = steps.splice(idx, 1); + const clampedTarget = Math.max(0, Math.min(update.newIndex, steps.length)); + steps.splice(clampedTarget, 0, moved); + } + } + + const updated: ApprovalQueueEntry = { + ...entry, + plan: { ...entry.plan, steps }, + updatedAt: new Date().toISOString(), + }; + _queue.set(req.entryId, updated); + return updated; +}; + +/** + * Record a reviewer decision (approve / reject / request_revision). + * Returns the updated entry, or null if the entry is not found. + */ +export const recordDecision = ( + id: string, + decision: ApprovalDecision, +): ApprovalQueueEntry | null => { + const entry = _queue.get(id); + if (!entry) return null; + + const statusMap: Record = { + approve: 'approved', + reject: 'rejected', + request_revision: 'revision_requested', + }; + + const updated: ApprovalQueueEntry = { + ...entry, + status: statusMap[decision.action], + reviewerFeedback: decision.feedback, + updatedAt: new Date().toISOString(), + }; + _queue.set(id, updated); + return updated; +}; + +/** + * Remove an entry from the queue (e.g. after the loop has consumed it). + */ +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..3e4dd9b --- /dev/null +++ b/src/ui/plan-editor-routes.ts @@ -0,0 +1,94 @@ +/** + * REST endpoint stubs for the Interactive Plan Editor — Issue #8. + * + * These handlers are designed to be mounted on the existing Express app in + * src/ui/server.ts with a single line: + * + * import { registerPlanEditorRoutes } from './plan-editor-routes'; + * registerPlanEditorRoutes(app); + * + * 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 type { Request, Response, Express } from 'express'; +import { + listQueue, + getEntry, + applyPlanEdit, + recordDecision, + PlanEditRequest, + ApprovalDecision, +} from '../core/plan-approval'; + +const ok = (res: Response, data: unknown) => + res.json({ ok: true, data }); + +const err = (res: Response, status: number, message: string) => + res.status(status).json({ ok: false, error: message }); + +/** + * Mount all plan-editor routes onto the given Express app. + */ +export const registerPlanEditorRoutes = (app: Express): void => { + + /** List all entries in the approval queue */ + app.get('/api/approval-queue', (_req: Request, res: Response) => { + ok(res, listQueue()); + }); + + /** Get a single approval queue entry */ + app.get('/api/approval-queue/:id', (req: Request, res: Response) => { + const entry = getEntry(req.params.id); + if (!entry) return err(res, 404, `No queue entry found for id: ${req.params.id}`); + ok(res, entry); + }); + + /** + * Apply a sparse patch to plan steps. + * Body: PlanEditRequest (stepUpdates array) + */ + app.patch('/api/approval-queue/:id', (req: Request, res: Response) => { + const editReq: PlanEditRequest = { + entryId: req.params.id, + stepUpdates: req.body?.stepUpdates ?? [], + }; + const updated = applyPlanEdit(editReq); + if (!updated) { + return err( + res, + 409, + `Cannot edit entry ${req.params.id}: not found or not in pending state.`, + ); + } + ok(res, updated); + }); + + /** + * Record an approval decision. + * Body: ApprovalDecision { action: 'approve' | 'reject' | 'request_revision', feedback? } + */ + app.post('/api/approval-queue/:id/decision', (req: Request, res: Response) => { + const decision: ApprovalDecision = req.body; + if (!decision?.action) { + return err(res, 400, 'Missing required field: action'); + } + const allowed: ApprovalDecision['action'][] = ['approve', 'reject', 'request_revision']; + if (!allowed.includes(decision.action)) { + return err(res, 400, `Invalid action. Must be one of: ${allowed.join(', ')}`); + } + const updated = recordDecision(req.params.id, decision); + if (!updated) { + return err(res, 404, `No queue entry found for id: ${req.params.id}`); + } + ok(res, updated); + }); +}; diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts new file mode 100644 index 0000000..952b0d5 --- /dev/null +++ b/test/plan-approval.test.ts @@ -0,0 +1,137 @@ +/** + * Unit tests for the plan approval queue manager. + * Covers: enqueue, list, edit, decision recording, dequeue. + * + * Run with: npm test + */ + +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', + steps: [ + { id: 'step-1', type: 'write_file', description: 'Create index.ts' }, + { id: 'step-2', type: 'run_tests', description: 'Run test suite' }, + ], +}); + +// Reset queue state between tests by dequeuing known ids +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()); + const second = enqueue('task-1', makePlan()); + expect(listQueue().filter((e) => e.id === 'task-1')).toHaveLength(1); + expect(second.status).toBe('pending'); + }); +}); + +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(); + }); +}); + +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); + }); +}); From 0a633433831cb6cf1275e3fa7b09561f40c729d7 Mon Sep 17 00:00:00 2001 From: Akshat Raj <154466152+AkshatRaj00@users.noreply.github.com> Date: Mon, 18 May 2026 05:07:49 +0530 Subject: [PATCH 2/6] fix: correct Plan/PlanStep types, add createdAt+mode+version, fix lint & format issues --- src/core/plan-approval.ts | 43 ++++++++++------- src/ui/plan-editor-routes.ts | 92 +++++++++++++++++++++++------------- test/plan-approval.test.ts | 23 ++++++--- 3 files changed, 100 insertions(+), 58 deletions(-) diff --git a/src/core/plan-approval.ts b/src/core/plan-approval.ts index 83cbb0f..23d7157 100644 --- a/src/core/plan-approval.ts +++ b/src/core/plan-approval.ts @@ -2,25 +2,29 @@ * Approval Queue Manager — MVP foundation for Issue #8. * * Manages pending plan approval entries in-memory. Each entry represents - * a generated plan that is waiting for a dashboard user to approve, reject, - * or request a revision before the agentic loop is allowed to execute. + * 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 — 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). + * - 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 } from '../types'; +import { Plan } from '../types/index.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'revision_requested'; +export type ApprovalStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'revision_requested'; export interface ApprovalQueueEntry { /** Unique entry id — same as the associated task id */ @@ -33,20 +37,22 @@ export interface ApprovalQueueEntry { enqueuedAt: string; /** ISO timestamp of the last status update, if any */ updatedAt?: string; - /** Optional free-text feedback from the reviewer (used for revision_requested / rejected) */ + /** Optional free-text feedback from the reviewer */ reviewerFeedback?: string; } +export interface StepUpdate { + stepId: string; + description?: string; + /** New 0-based position in the step array */ + newIndex?: number; +} + export interface PlanEditRequest { /** Target entry id */ entryId: string; /** Sparse patch — only the fields the user changed */ - stepUpdates?: Array<{ - stepId: string; - description?: string; - /** New 0-based position in the step array */ - newIndex?: number; - }>; + stepUpdates?: StepUpdate[]; } export type PlanApprovalAction = 'approve' | 'reject' | 'request_revision'; @@ -106,15 +112,16 @@ export const applyPlanEdit = ( const idx = steps.findIndex((s) => s.id === update.stepId); if (idx === -1) continue; - // Apply description patch if (update.description !== undefined) { steps[idx] = { ...steps[idx], description: update.description }; } - // Apply reorder if (update.newIndex !== undefined && update.newIndex !== idx) { const [moved] = steps.splice(idx, 1); - const clampedTarget = Math.max(0, Math.min(update.newIndex, steps.length)); + const clampedTarget = Math.max( + 0, + Math.min(update.newIndex, steps.length), + ); steps.splice(clampedTarget, 0, moved); } } diff --git a/src/ui/plan-editor-routes.ts b/src/ui/plan-editor-routes.ts index 3e4dd9b..b7ad819 100644 --- a/src/ui/plan-editor-routes.ts +++ b/src/ui/plan-editor-routes.ts @@ -1,17 +1,16 @@ /** * REST endpoint stubs for the Interactive Plan Editor — Issue #8. * - * These handlers are designed to be mounted on the existing Express app in - * src/ui/server.ts with a single line: + * Mount on the existing Express app in src/ui/server.ts: * - * import { registerPlanEditorRoutes } from './plan-editor-routes'; + * import { registerPlanEditorRoutes } from './plan-editor-routes.js'; * registerPlanEditorRoutes(app); * * 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 + * 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 }. @@ -25,70 +24,97 @@ import { getEntry, applyPlanEdit, recordDecision, +} from '../core/plan-approval.js'; +import type { PlanEditRequest, ApprovalDecision, -} from '../core/plan-approval'; + PlanApprovalAction, +} from '../core/plan-approval.js'; -const ok = (res: Response, data: unknown) => +const sendOk = (res: Response, data: unknown): void => { res.json({ ok: true, data }); +}; -const err = (res: Response, status: number, message: string) => +const sendErr = (res: Response, status: number, message: string): void => { res.status(status).json({ ok: false, error: message }); +}; + +const ALLOWED_ACTIONS: PlanApprovalAction[] = [ + 'approve', + 'reject', + 'request_revision', +]; /** * Mount all plan-editor routes onto the given Express app. */ export const registerPlanEditorRoutes = (app: Express): void => { - /** List all entries in the approval queue */ app.get('/api/approval-queue', (_req: Request, res: Response) => { - ok(res, listQueue()); + sendOk(res, listQueue()); }); /** Get a single approval queue entry */ app.get('/api/approval-queue/:id', (req: Request, res: Response) => { const entry = getEntry(req.params.id); - if (!entry) return err(res, 404, `No queue entry found for id: ${req.params.id}`); - ok(res, entry); + if (!entry) { + sendErr(res, 404, `No queue entry found for id: ${req.params.id}`); + return; + } + sendOk(res, entry); }); /** * Apply a sparse patch to plan steps. - * Body: PlanEditRequest (stepUpdates array) + * Body: { stepUpdates: StepUpdate[] } */ app.patch('/api/approval-queue/:id', (req: Request, res: Response) => { const editReq: PlanEditRequest = { entryId: req.params.id, - stepUpdates: req.body?.stepUpdates ?? [], + stepUpdates: (req.body as PlanEditRequest | undefined)?.stepUpdates ?? [], }; const updated = applyPlanEdit(editReq); if (!updated) { - return err( + sendErr( res, 409, `Cannot edit entry ${req.params.id}: not found or not in pending state.`, ); + return; } - ok(res, updated); + sendOk(res, updated); }); /** * Record an approval decision. - * Body: ApprovalDecision { action: 'approve' | 'reject' | 'request_revision', feedback? } + * Body: { action: 'approve' | 'reject' | 'request_revision', feedback?: string } */ - app.post('/api/approval-queue/:id/decision', (req: Request, res: Response) => { - const decision: ApprovalDecision = req.body; - if (!decision?.action) { - return err(res, 400, 'Missing required field: action'); - } - const allowed: ApprovalDecision['action'][] = ['approve', 'reject', 'request_revision']; - if (!allowed.includes(decision.action)) { - return err(res, 400, `Invalid action. Must be one of: ${allowed.join(', ')}`); - } - const updated = recordDecision(req.params.id, decision); - if (!updated) { - return err(res, 404, `No queue entry found for id: ${req.params.id}`); - } - ok(res, updated); - }); + app.post( + '/api/approval-queue/:id/decision', + (req: Request, res: Response) => { + const decision = req.body as ApprovalDecision | undefined; + if (!decision?.action) { + sendErr(res, 400, 'Missing required field: action'); + return; + } + if (!ALLOWED_ACTIONS.includes(decision.action)) { + sendErr( + res, + 400, + `Invalid action. Must be one of: ${ALLOWED_ACTIONS.join(', ')}`, + ); + return; + } + const updated = recordDecision(req.params.id, decision); + if (!updated) { + sendErr( + res, + 404, + `No queue entry found for id: ${req.params.id}`, + ); + return; + } + sendOk(res, updated); + }, + ); }; diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index 952b0d5..b90cdb6 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -13,19 +13,29 @@ import { applyPlanEdit, recordDecision, dequeue, -} from '../src/core/plan-approval'; -import type { Plan } from '../src/types'; +} from '../src/core/plan-approval.js'; +import type { Plan } from '../src/types/index.js'; const makePlan = (id = 'task-1'): Plan => ({ id, goal: 'test goal', + mode: 'balanced', + version: '1', + createdAt: new Date().toISOString(), steps: [ - { id: 'step-1', type: 'write_file', description: 'Create index.ts' }, - { id: 'step-2', type: 'run_tests', description: 'Run test suite' }, + { + id: 'step-1', + type: 'edit_file', + description: 'Create index.ts', + }, + { + id: 'step-2', + type: 'run_tests', + description: 'Run test suite', + }, ], }); -// Reset queue state between tests by dequeuing known ids beforeEach(() => { dequeue('task-1'); dequeue('task-2'); @@ -40,9 +50,8 @@ describe('enqueue', () => { it('replaces an existing entry (idempotent re-enqueue)', () => { enqueue('task-1', makePlan()); - const second = enqueue('task-1', makePlan()); + enqueue('task-1', makePlan()); expect(listQueue().filter((e) => e.id === 'task-1')).toHaveLength(1); - expect(second.status).toBe('pending'); }); }); From 3fd2d7a1e6756e943fe3f683a6522fab7143e52e Mon Sep 17 00:00:00 2001 From: Akshat Raj <154466152+AkshatRaj00@users.noreply.github.com> Date: Thu, 21 May 2026 23:33:49 +0530 Subject: [PATCH 3/6] fix: align import paths, move ALLOWED_ACTIONS outside fn, fix lint/format/typecheck --- src/core/plan-approval.ts | 38 +++++++++++-------------- src/ui/plan-editor-routes.ts | 55 ++++++++++++++++-------------------- test/plan-approval.test.ts | 6 ++++ 3 files changed, 48 insertions(+), 51 deletions(-) diff --git a/src/core/plan-approval.ts b/src/core/plan-approval.ts index 23d7157..eecc700 100644 --- a/src/core/plan-approval.ts +++ b/src/core/plan-approval.ts @@ -14,7 +14,7 @@ * @author Akshat Raj */ -import { Plan } from '../types/index.js'; +import { Plan } from '../types'; // --------------------------------------------------------------------------- // Types @@ -69,6 +69,14 @@ export interface ApprovalDecision { const _queue = new Map(); +// Static map hoisted outside function scope to avoid redundant allocations +// on every recordDecision call (Gemini suggestion). +const STATUS_MAP: Record = { + approve: 'approved', + reject: 'rejected', + request_revision: 'revision_requested', +}; + /** * Add a plan to the approval queue. * Replaces any existing entry for the same id (idempotent re-enqueue). @@ -87,22 +95,18 @@ export const enqueue = (id: string, plan: Plan): ApprovalQueueEntry => { /** * Return a snapshot of all entries currently in the queue. */ -export const listQueue = (): ApprovalQueueEntry[] => - Array.from(_queue.values()); +export const listQueue = (): ApprovalQueueEntry[] => Array.from(_queue.values()); /** * Return a single entry by id, or undefined if not found. */ -export const getEntry = (id: string): ApprovalQueueEntry | undefined => - _queue.get(id); +export const getEntry = (id: string): ApprovalQueueEntry | undefined => _queue.get(id); /** * Apply a sparse patch to the plan steps of a pending entry. * Returns the updated entry, or null if the entry is not found or not pending. */ -export const applyPlanEdit = ( - req: PlanEditRequest, -): ApprovalQueueEntry | null => { +export const applyPlanEdit = (req: PlanEditRequest): ApprovalQueueEntry | null => { const entry = _queue.get(req.entryId); if (!entry || entry.status !== 'pending') return null; @@ -118,10 +122,7 @@ export const applyPlanEdit = ( if (update.newIndex !== undefined && update.newIndex !== idx) { const [moved] = steps.splice(idx, 1); - const clampedTarget = Math.max( - 0, - Math.min(update.newIndex, steps.length), - ); + const clampedTarget = Math.max(0, Math.min(update.newIndex, steps.length)); steps.splice(clampedTarget, 0, moved); } } @@ -137,24 +138,19 @@ export const applyPlanEdit = ( /** * Record a reviewer decision (approve / reject / request_revision). - * Returns the updated entry, or null if the entry is not found. + * Only accepts entries currently in 'pending' state. + * Returns the updated entry, or null if the entry is not found or not pending. */ export const recordDecision = ( id: string, decision: ApprovalDecision, ): ApprovalQueueEntry | null => { const entry = _queue.get(id); - if (!entry) return null; - - const statusMap: Record = { - approve: 'approved', - reject: 'rejected', - request_revision: 'revision_requested', - }; + if (!entry || entry.status !== 'pending') return null; const updated: ApprovalQueueEntry = { ...entry, - status: statusMap[decision.action], + status: STATUS_MAP[decision.action], reviewerFeedback: decision.feedback, updatedAt: new Date().toISOString(), }; diff --git a/src/ui/plan-editor-routes.ts b/src/ui/plan-editor-routes.ts index b7ad819..2462922 100644 --- a/src/ui/plan-editor-routes.ts +++ b/src/ui/plan-editor-routes.ts @@ -39,11 +39,13 @@ const sendErr = (res: Response, status: number, message: string): void => { res.status(status).json({ ok: false, error: message }); }; -const ALLOWED_ACTIONS: PlanApprovalAction[] = [ +// Hoisted outside registerPlanEditorRoutes to avoid re-allocation on every +// request (Gemini suggestion). +const ALLOWED_ACTIONS: ReadonlySet = new Set([ 'approve', 'reject', 'request_revision', -]; +]); /** * Mount all plan-editor routes onto the given Express app. @@ -89,32 +91,25 @@ export const registerPlanEditorRoutes = (app: Express): void => { * Record an approval decision. * Body: { action: 'approve' | 'reject' | 'request_revision', feedback?: string } */ - app.post( - '/api/approval-queue/:id/decision', - (req: Request, res: Response) => { - const decision = req.body as ApprovalDecision | undefined; - if (!decision?.action) { - sendErr(res, 400, 'Missing required field: action'); - return; - } - if (!ALLOWED_ACTIONS.includes(decision.action)) { - sendErr( - res, - 400, - `Invalid action. Must be one of: ${ALLOWED_ACTIONS.join(', ')}`, - ); - return; - } - const updated = recordDecision(req.params.id, decision); - if (!updated) { - sendErr( - res, - 404, - `No queue entry found for id: ${req.params.id}`, - ); - return; - } - sendOk(res, updated); - }, - ); + app.post('/api/approval-queue/:id/decision', (req: Request, res: Response) => { + const decision = req.body as ApprovalDecision | undefined; + if (!decision?.action) { + sendErr(res, 400, 'Missing required field: action'); + return; + } + if (!ALLOWED_ACTIONS.has(decision.action)) { + sendErr( + res, + 400, + `Invalid action. Must be one of: ${[...ALLOWED_ACTIONS].join(', ')}`, + ); + return; + } + const updated = recordDecision(req.params.id, decision); + if (!updated) { + sendErr(res, 404, `No queue entry found for id: ${req.params.id}`); + return; + } + sendOk(res, updated); + }); }; diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index b90cdb6..bafce88 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -131,6 +131,12 @@ describe('recordDecision', () => { 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', () => { From db01cea8312b1b88c4139cf1c7fd7c234277110a Mon Sep 17 00:00:00 2001 From: Akshat Raj <154466152+AkshatRaj00@users.noreply.github.com> Date: Thu, 21 May 2026 23:43:55 +0530 Subject: [PATCH 4/6] fix(ci): remove .js extensions, fix noUnusedParameters, fix all lint/typecheck/format issues --- src/core/plan-approval.ts | 28 ++----------------- src/ui/plan-editor-routes.ts | 52 +++++++++++------------------------- test/plan-approval.test.ts | 18 +++---------- 3 files changed, 21 insertions(+), 77 deletions(-) diff --git a/src/core/plan-approval.ts b/src/core/plan-approval.ts index eecc700..58620ee 100644 --- a/src/core/plan-approval.ts +++ b/src/core/plan-approval.ts @@ -69,18 +69,12 @@ export interface ApprovalDecision { const _queue = new Map(); -// Static map hoisted outside function scope to avoid redundant allocations -// on every recordDecision call (Gemini suggestion). const STATUS_MAP: Record = { approve: 'approved', reject: 'rejected', request_revision: 'revision_requested', }; -/** - * Add a plan to the approval queue. - * Replaces any existing entry for the same id (idempotent re-enqueue). - */ export const enqueue = (id: string, plan: Plan): ApprovalQueueEntry => { const entry: ApprovalQueueEntry = { id, @@ -92,20 +86,10 @@ export const enqueue = (id: string, plan: Plan): ApprovalQueueEntry => { return entry; }; -/** - * Return a snapshot of all entries currently in the queue. - */ export const listQueue = (): ApprovalQueueEntry[] => Array.from(_queue.values()); -/** - * Return a single entry by id, or undefined if not found. - */ export const getEntry = (id: string): ApprovalQueueEntry | undefined => _queue.get(id); -/** - * Apply a sparse patch to the plan steps of a pending entry. - * Returns the updated entry, or null if the entry is not found or not pending. - */ export const applyPlanEdit = (req: PlanEditRequest): ApprovalQueueEntry | null => { const entry = _queue.get(req.entryId); if (!entry || entry.status !== 'pending') return null; @@ -122,8 +106,8 @@ export const applyPlanEdit = (req: PlanEditRequest): ApprovalQueueEntry | null = if (update.newIndex !== undefined && update.newIndex !== idx) { const [moved] = steps.splice(idx, 1); - const clampedTarget = Math.max(0, Math.min(update.newIndex, steps.length)); - steps.splice(clampedTarget, 0, moved); + const target = Math.max(0, Math.min(update.newIndex, steps.length)); + steps.splice(target, 0, moved); } } @@ -136,11 +120,6 @@ export const applyPlanEdit = (req: PlanEditRequest): ApprovalQueueEntry | null = return updated; }; -/** - * Record a reviewer decision (approve / reject / request_revision). - * Only accepts entries currently in 'pending' state. - * Returns the updated entry, or null if the entry is not found or not pending. - */ export const recordDecision = ( id: string, decision: ApprovalDecision, @@ -158,7 +137,4 @@ export const recordDecision = ( return updated; }; -/** - * Remove an entry from the queue (e.g. after the loop has consumed it). - */ 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 index 2462922..b5f8e92 100644 --- a/src/ui/plan-editor-routes.ts +++ b/src/ui/plan-editor-routes.ts @@ -3,7 +3,7 @@ * * Mount on the existing Express app in src/ui/server.ts: * - * import { registerPlanEditorRoutes } from './plan-editor-routes.js'; + * import { registerPlanEditorRoutes } from './plan-editor-routes'; * registerPlanEditorRoutes(app); * * Endpoints @@ -24,12 +24,18 @@ import { getEntry, applyPlanEdit, recordDecision, -} from '../core/plan-approval.js'; +} from '../core/plan-approval'; import type { PlanEditRequest, ApprovalDecision, PlanApprovalAction, -} from '../core/plan-approval.js'; +} from '../core/plan-approval'; + +const ALLOWED_ACTIONS: ReadonlySet = new Set([ + 'approve', + 'reject', + 'request_revision', +]); const sendOk = (res: Response, data: unknown): void => { res.json({ ok: true, data }); @@ -39,24 +45,11 @@ const sendErr = (res: Response, status: number, message: string): void => { res.status(status).json({ ok: false, error: message }); }; -// Hoisted outside registerPlanEditorRoutes to avoid re-allocation on every -// request (Gemini suggestion). -const ALLOWED_ACTIONS: ReadonlySet = new Set([ - 'approve', - 'reject', - 'request_revision', -]); - -/** - * Mount all plan-editor routes onto the given Express app. - */ export const registerPlanEditorRoutes = (app: Express): void => { - /** List all entries in the approval queue */ app.get('/api/approval-queue', (_req: Request, res: Response) => { sendOk(res, listQueue()); }); - /** Get a single approval queue entry */ app.get('/api/approval-queue/:id', (req: Request, res: Response) => { const entry = getEntry(req.params.id); if (!entry) { @@ -66,46 +59,31 @@ export const registerPlanEditorRoutes = (app: Express): void => { sendOk(res, entry); }); - /** - * Apply a sparse patch to plan steps. - * Body: { stepUpdates: StepUpdate[] } - */ app.patch('/api/approval-queue/:id', (req: Request, res: Response) => { + const body = req.body as Partial | undefined; const editReq: PlanEditRequest = { entryId: req.params.id, - stepUpdates: (req.body as PlanEditRequest | undefined)?.stepUpdates ?? [], + stepUpdates: body?.stepUpdates ?? [], }; const updated = applyPlanEdit(editReq); if (!updated) { - sendErr( - res, - 409, - `Cannot edit entry ${req.params.id}: not found or not in pending state.`, - ); + sendErr(res, 409, `Cannot edit entry ${req.params.id}: not found or not in pending state.`); return; } sendOk(res, updated); }); - /** - * Record an approval decision. - * Body: { action: 'approve' | 'reject' | 'request_revision', feedback?: string } - */ app.post('/api/approval-queue/:id/decision', (req: Request, res: Response) => { - const decision = req.body as ApprovalDecision | undefined; + const decision = req.body as Partial | undefined; if (!decision?.action) { sendErr(res, 400, 'Missing required field: action'); return; } if (!ALLOWED_ACTIONS.has(decision.action)) { - sendErr( - res, - 400, - `Invalid action. Must be one of: ${[...ALLOWED_ACTIONS].join(', ')}`, - ); + sendErr(res, 400, `Invalid action. Must be one of: ${[...ALLOWED_ACTIONS].join(', ')}`); return; } - const updated = recordDecision(req.params.id, decision); + const updated = recordDecision(req.params.id, decision as ApprovalDecision); if (!updated) { sendErr(res, 404, `No queue entry found for id: ${req.params.id}`); return; diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index bafce88..68798d0 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -1,8 +1,6 @@ /** * Unit tests for the plan approval queue manager. * Covers: enqueue, list, edit, decision recording, dequeue. - * - * Run with: npm test */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -13,8 +11,8 @@ import { applyPlanEdit, recordDecision, dequeue, -} from '../src/core/plan-approval.js'; -import type { Plan } from '../src/types/index.js'; +} from '../src/core/plan-approval'; +import type { Plan } from '../src/types'; const makePlan = (id = 'task-1'): Plan => ({ id, @@ -23,16 +21,8 @@ const makePlan = (id = 'task-1'): Plan => ({ 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', - }, + { id: 'step-1', type: 'edit_file', description: 'Create index.ts' }, + { id: 'step-2', type: 'run_tests', description: 'Run test suite' }, ], }); From bc7512964288d41ab42f9cc966f0520564bc8136 Mon Sep 17 00:00:00 2001 From: Akshat Raj <154466152+AkshatRaj00@users.noreply.github.com> Date: Thu, 21 May 2026 23:50:32 +0530 Subject: [PATCH 5/6] fix(ci): rewrite plan-editor-routes to use http.IncomingMessage (no express dep), fix let->const in plan-approval, fix all format/lint/typecheck errors --- src/core/plan-approval.ts | 20 +---- src/ui/plan-editor-routes.ts | 147 +++++++++++++++++++++++------------ 2 files changed, 101 insertions(+), 66 deletions(-) diff --git a/src/core/plan-approval.ts b/src/core/plan-approval.ts index 58620ee..c383cc7 100644 --- a/src/core/plan-approval.ts +++ b/src/core/plan-approval.ts @@ -14,44 +14,31 @@ * @author Akshat Raj */ -import { Plan } from '../types'; +import { Plan, PlanStep } from '../types'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export type ApprovalStatus = - | 'pending' - | 'approved' - | 'rejected' - | 'revision_requested'; +export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'revision_requested'; export interface ApprovalQueueEntry { - /** Unique entry id — same as the associated task id */ id: string; - /** The plan snapshot at the time it was enqueued */ plan: Plan; - /** Current decision status */ status: ApprovalStatus; - /** ISO timestamp when the entry was enqueued */ enqueuedAt: string; - /** ISO timestamp of the last status update, if any */ updatedAt?: string; - /** Optional free-text feedback from the reviewer */ reviewerFeedback?: string; } export interface StepUpdate { stepId: string; description?: string; - /** New 0-based position in the step array */ newIndex?: number; } export interface PlanEditRequest { - /** Target entry id */ entryId: string; - /** Sparse patch — only the fields the user changed */ stepUpdates?: StepUpdate[]; } @@ -59,7 +46,6 @@ export type PlanApprovalAction = 'approve' | 'reject' | 'request_revision'; export interface ApprovalDecision { action: PlanApprovalAction; - /** Required when action is 'reject' or 'request_revision' */ feedback?: string; } @@ -94,7 +80,7 @@ export const applyPlanEdit = (req: PlanEditRequest): ApprovalQueueEntry | null = const entry = _queue.get(req.entryId); if (!entry || entry.status !== 'pending') return null; - let steps = [...entry.plan.steps]; + const steps: PlanStep[] = entry.plan.steps.map((s) => ({ ...s })); for (const update of req.stepUpdates ?? []) { const idx = steps.findIndex((s) => s.id === update.stepId); diff --git a/src/ui/plan-editor-routes.ts b/src/ui/plan-editor-routes.ts index b5f8e92..cac821a 100644 --- a/src/ui/plan-editor-routes.ts +++ b/src/ui/plan-editor-routes.ts @@ -1,10 +1,11 @@ /** - * REST endpoint stubs for the Interactive Plan Editor — Issue #8. + * REST handlers for the Interactive Plan Editor — Issue #8. * - * Mount on the existing Express app in src/ui/server.ts: + * 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: * - * import { registerPlanEditorRoutes } from './plan-editor-routes'; - * registerPlanEditorRoutes(app); + * if (handlePlanEditorRoute(req, res, p)) return; * * Endpoints * --------- @@ -18,7 +19,7 @@ * @author Akshat Raj */ -import type { Request, Response, Express } from 'express'; +import * as http from 'http'; import { listQueue, getEntry, @@ -37,57 +38,105 @@ const ALLOWED_ACTIONS: ReadonlySet = new Set([ 'request_revision', ]); -const sendOk = (res: Response, data: unknown): void => { - res.json({ ok: true, data }); +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 sendErr = (res: Response, status: number, message: string): void => { - res.status(status).json({ ok: false, error: message }); +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; }; -export const registerPlanEditorRoutes = (app: Express): void => { - app.get('/api/approval-queue', (_req: Request, res: Response) => { - sendOk(res, listQueue()); - }); +/** + * 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; + } - app.get('/api/approval-queue/:id', (req: Request, res: Response) => { - const entry = getEntry(req.params.id); + // GET /api/approval-queue/:id + const idMatch = /^\/api\/approval-queue\/([^/]+)$/.exec(pathname); + if (idMatch && req.method === 'GET') { + const entry = getEntry(idMatch[1]); if (!entry) { - sendErr(res, 404, `No queue entry found for id: ${req.params.id}`); - return; + sendJson(res, 404, { ok: false, error: `No queue entry found for id: ${idMatch[1]}` }); + } else { + sendJson(res, 200, { ok: true, data: entry }); } - sendOk(res, entry); - }); + return true; + } - app.patch('/api/approval-queue/:id', (req: Request, res: Response) => { - const body = req.body as Partial | undefined; - const editReq: PlanEditRequest = { - entryId: req.params.id, - stepUpdates: body?.stepUpdates ?? [], - }; - const updated = applyPlanEdit(editReq); - if (!updated) { - sendErr(res, 409, `Cannot edit entry ${req.params.id}: not found or not in pending state.`); - return; - } - sendOk(res, updated); - }); + // 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; + } - app.post('/api/approval-queue/:id/decision', (req: Request, res: Response) => { - const decision = req.body as Partial | undefined; - if (!decision?.action) { - sendErr(res, 400, 'Missing required field: action'); - return; - } - if (!ALLOWED_ACTIONS.has(decision.action)) { - sendErr(res, 400, `Invalid action. Must be one of: ${[...ALLOWED_ACTIONS].join(', ')}`); - return; - } - const updated = recordDecision(req.params.id, decision as ApprovalDecision); - if (!updated) { - sendErr(res, 404, `No queue entry found for id: ${req.params.id}`); - return; - } - sendOk(res, updated); - }); + // 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; }; From 14e881177d3d4a97ff0c8572c0c3fc35eb9274e3 Mon Sep 17 00:00:00 2001 From: Akshat Raj <154466152+AkshatRaj00@users.noreply.github.com> Date: Thu, 21 May 2026 23:57:42 +0530 Subject: [PATCH 6/6] =?UTF-8?q?style:=20run=20prettier=20on=20plan-editor-?= =?UTF-8?q?routes.ts=20=E2=80=94=20collapse=20import=20lines=20to=20fix=20?= =?UTF-8?q?format=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/plan-editor-routes.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/ui/plan-editor-routes.ts b/src/ui/plan-editor-routes.ts index cac821a..7f363bc 100644 --- a/src/ui/plan-editor-routes.ts +++ b/src/ui/plan-editor-routes.ts @@ -20,17 +20,8 @@ */ import * as http from 'http'; -import { - listQueue, - getEntry, - applyPlanEdit, - recordDecision, -} from '../core/plan-approval'; -import type { - PlanEditRequest, - ApprovalDecision, - PlanApprovalAction, -} from '../core/plan-approval'; +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',