Skip to content
126 changes: 126 additions & 0 deletions src/core/plan-approval.ts
Original file line number Diff line number Diff line change
@@ -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 <AkshatRaj00>
*/

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<string, ApprovalQueueEntry>();

const STATUS_MAP: Record<PlanApprovalAction, ApprovalStatus> = {
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);
133 changes: 133 additions & 0 deletions src/ui/plan-editor-routes.ts
Original file line number Diff line number Diff line change
@@ -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 <AkshatRaj00>
*/

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<PlanApprovalAction> = 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<string> =>
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 <T>(req: http.IncomingMessage): Promise<T> => {
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<Partial<PlanEditRequest>>(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<Partial<ApprovalDecision>>(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;
};
Loading
Loading