- Route now returns 404 only for ENOENT, 500 for transient errors - store.getTask() retries once after 50ms on non-ENOENT failures - Added route tests for success, 404, and 500 cases - Updated vitest config to include src/**/*.test.* files
148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
import { Router } from "express";
|
|
import type { TaskStore, Column, MergeResult } from "@hai/core";
|
|
import { COLUMNS } from "@hai/core";
|
|
import type { ServerOptions } from "./server.js";
|
|
|
|
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
|
const router = Router();
|
|
|
|
// Scheduler config (includes persisted settings)
|
|
router.get("/config", async (_req, res) => {
|
|
try {
|
|
const settings = await store.getSettings();
|
|
res.json({
|
|
maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2,
|
|
maxWorktrees: settings.maxWorktrees ?? 4,
|
|
});
|
|
} catch {
|
|
res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4 });
|
|
}
|
|
});
|
|
|
|
// Settings CRUD
|
|
router.get("/settings", async (_req, res) => {
|
|
try {
|
|
const settings = await store.getSettings();
|
|
res.json(settings);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.put("/settings", async (req, res) => {
|
|
try {
|
|
const settings = await store.updateSettings(req.body);
|
|
res.json(settings);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// List all tasks
|
|
router.get("/tasks", async (_req, res) => {
|
|
try {
|
|
const tasks = await store.listTasks();
|
|
res.json(tasks);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Create task
|
|
router.post("/tasks", async (req, res) => {
|
|
try {
|
|
const { title, description, column, dependencies } = req.body;
|
|
if (!description || typeof description !== "string") {
|
|
res.status(400).json({ error: "description is required" });
|
|
return;
|
|
}
|
|
const task = await store.createTask({
|
|
title,
|
|
description,
|
|
column,
|
|
dependencies,
|
|
});
|
|
res.status(201).json(task);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Move task to column
|
|
router.post("/tasks/:id/move", async (req, res) => {
|
|
try {
|
|
const { column } = req.body;
|
|
if (!column || !COLUMNS.includes(column as Column)) {
|
|
res.status(400).json({
|
|
error: `Invalid column. Must be one of: ${COLUMNS.join(", ")}`,
|
|
});
|
|
return;
|
|
}
|
|
const task = await store.moveTask(req.params.id, column as Column);
|
|
res.json(task);
|
|
} catch (err: any) {
|
|
const status = err.message.includes("Invalid transition") ? 400 : 500;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Merge task (in-review → done, merges branch + cleans worktree)
|
|
// Uses AI merge handler if provided, falls back to store.mergeTask
|
|
router.post("/tasks/:id/merge", async (req, res) => {
|
|
try {
|
|
const merge = options?.onMerge ?? ((id: string) => store.mergeTask(id));
|
|
const result = await merge(req.params.id);
|
|
res.json(result);
|
|
} catch (err: any) {
|
|
const status = err.message.includes("Cannot merge") ? 400
|
|
: err.message.includes("conflict") ? 409
|
|
: 500;
|
|
res.status(status).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get single task with prompt content
|
|
router.get("/tasks/:id", async (req, res) => {
|
|
try {
|
|
const task = await store.getTask(req.params.id);
|
|
res.json(task);
|
|
} catch (err: any) {
|
|
// ENOENT means the task directory/file genuinely doesn't exist → 404.
|
|
// Any other error (e.g. JSON parse failure from a concurrent partial write,
|
|
// or a transient FS error) should surface as 500 so clients can retry.
|
|
if (err.code === "ENOENT") {
|
|
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
|
} else {
|
|
res.status(500).json({ error: err.message || "Internal server error" });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update task
|
|
router.patch("/tasks/:id", async (req, res) => {
|
|
try {
|
|
const { title, description, prompt } = req.body;
|
|
const task = await store.updateTask(req.params.id, {
|
|
title,
|
|
description,
|
|
prompt,
|
|
});
|
|
res.json(task);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Delete task
|
|
router.delete("/tasks/:id", async (req, res) => {
|
|
try {
|
|
const task = await store.deleteTask(req.params.id);
|
|
res.json(task);
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|