@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
JavaScript
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