UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

193 lines 8.24 kB
import { dirname } from "node:path"; import { classifyProjectState } from "../classify-state.js"; import { createFS } from "../facts/fs.js"; import { hydrateDashboardState, loadDashboardState, resolveProjectIdentity, } from "./dashboard-project-state.js"; import { buildDashboardTaskState, readActiveTaskPlanBody, writeActiveTaskPlan, } from "./dashboard-task-state.js"; import { validateLocalPath } from "./local-paths.js"; /** * Load the persisted recent-projects state, preferring the current state file and falling back to the * legacy projects-only file. Delegates to loadDashboardState, which swallows missing or malformed * files and returns empty state, so callers always receive a usable object. */ function readDashboardState(ctx) { return loadDashboardState(ctx.dashboardStateFile, ctx.legacyProjectsListFile); } /** * Map an active-plan write error message to an HTTP status: a missing target is a * 404, anything else is treated as a 400 bad request. * * @param message - The error message thrown while writing the active plan. * @returns `404` when the message indicates a missing target, otherwise `400`. */ function planWriteErrorStatus(message) { return message.includes("does not exist") || message.includes("not found") ? 404 : 400; } async function writeDashboardActivePlan(ctx, req, url, res) { try { const projectPath = ctx.validatedPath(url.searchParams.get("path"), "write-local-state"); const planName = readActiveTaskPlanBody(await ctx.readBody(req)); writeActiveTaskPlan(projectPath, planName); ctx.jsonResponse(res, 200, buildDashboardTaskState(projectPath, planName)); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.jsonResponse(res, planWriteErrorStatus(message), { error: message }); } } function readDashboardPlans(ctx, url, res) { try { const projectPath = ctx.validatedPath(url.searchParams.get("path"), "project-read"); ctx.jsonResponse(res, 200, buildDashboardTaskState(projectPath, url.searchParams.get("plan"))); } catch (err) { ctx.jsonResponse(res, ctx.responseStatusForError(err, 500), { error: err instanceof Error ? err.message : String(err), }); } } /** Return or update milestone/plan state for the selected project. */ async function handleTasksRequest(ctx, req, url, res) { if (url.pathname !== "/api/plans" && url.pathname !== "/api/tasks") { return false; } if (req.method === "POST") { await writeDashboardActivePlan(ctx, req, url, res); return true; } if (req.method !== "GET") { ctx.jsonResponse(res, 405, { error: "Method not allowed" }); return true; } readDashboardPlans(ctx, url, res); return true; } /** Save/load the dashboard state to/from disk so it survives server restarts. */ async function handleProjectsListRequest(ctx, req, url, res) { if (url.pathname !== "/api/projects/list") return false; if (req.method === "GET") { ctx.jsonResponse(res, 200, await readDashboardState(ctx)); return true; } if (req.method === "POST") { const body = await ctx.readBody(req); try { const { decodeProjectsListBody } = await import("./decoders.js"); const decoded = decodeProjectsListBody(body); if (!decoded.ok) { ctx.jsonResponse(res, 400, { error: decoded.error, path: decoded.path, }); return true; } const { mkdir, rm: remove, writeFile } = await import("node:fs/promises"); const previousState = await readDashboardState(ctx); const validatedProjectPaths = decoded.value.paths.map((path) => validateLocalPath(path, "write-local-state").path); const nextState = hydrateDashboardState({ ...decoded.value, paths: validatedProjectPaths, projects: {}, }, { allowMarkerWrite: true }); const previousPaths = new Set(previousState.paths); const nextPaths = new Set(nextState.paths); const removedCount = previousState.paths.filter((path) => !nextPaths.has(path)).length; const addedCount = nextState.paths.filter((path) => !previousPaths.has(path)).length; await mkdir(dirname(ctx.dashboardStateFile), { recursive: true }); await writeFile(ctx.dashboardStateFile, JSON.stringify(nextState, null, 2)); await remove(ctx.legacyProjectsListFile, { force: true }); ctx.recordDashboardEvent(ctx.absDefault, "project.save", { project_count: nextState.paths.length, favorite_count: nextState.favorites.length, added_count: addedCount, removed_count: removedCount, }); if (removedCount > 0) { ctx.recordDashboardEvent(ctx.absDefault, "project.remove", { removed_count: removedCount, }); } ctx.jsonResponse(res, 200, { ok: true }); } catch (err) { ctx.jsonResponse(res, 400, { error: err instanceof Error ? err.message : String(err), }); } return true; } ctx.jsonResponse(res, 405, { error: "Method not allowed" }); return true; } /** * Classify project adoption for one or more paths because the dashboard sends * both the current project and stored recent projects through the same route. * * Reports malformed path lists and validation failures as JSON. */ function handleProjectsStatusRequest(ctx, url, res) { if (url.pathname !== "/api/projects/status") return false; const pathsParam = url.searchParams.get("paths"); if (!pathsParam) { ctx.jsonResponse(res, 400, { error: "Missing paths parameter" }); return true; } const paths = pathsParam .split(",") .map((p) => p.trim()) .filter(Boolean); const results = paths.map((p) => { try { const projectPath = validateLocalPath(p, "write-local-state").path; const identity = resolveProjectIdentity(projectPath, { allowMarkerWrite: true, }); const fs = createFS(identity.currentPath); return { path: identity.currentPath, paths: [identity.currentPath], ...identity, ...classifyProjectState(fs), }; } catch (err) { return { path: p, state: "error", action: "none", details: String(err), }; } }); if (paths.length === 1) { const result = results[0]; if (result?.state !== "error" && typeof result?.path === "string") { ctx.recordDashboardEvent(result.path, "project.switch", { state: result.state, identity: "identity" in result ? result.identity : "", identity_source: "identitySource" in result ? result.identitySource : "", }); } } ctx.jsonResponse(res, 200, { projects: results }); return true; } /** * Bind the project-management handlers to one server's request context so each closure carries the * shared path validator, state-file locations, and evidence recorder. * * @param ctx - per-server dashboard route context with path validation, state-file paths, and IO hooks * @returns the plans, projects-list, and projects-status handlers; each resolves true once it has * answered a matching request, or false to let another handler claim the URL */ export function createProjectRouteHandlers(ctx) { return { handleTasksRequest: (req, url, res) => handleTasksRequest(ctx, req, url, res), handleProjectsListRequest: (req, url, res) => handleProjectsListRequest(ctx, req, url, res), handleProjectsStatusRequest: (url, res) => handleProjectsStatusRequest(ctx, url, res), }; } //# sourceMappingURL=dashboard-project-routes.js.map