Persist harness projects to database
Some checks failed
CI / lint-and-test (push) Successful in 35s
CI / build (push) Has been cancelled
Deploy Production / deploy (push) Has been cancelled

Projects were stored purely in useState — lost on every page refresh.
- Add harness_projects table (id, name, workspaces jsonb)
- Add /api/projects CRUD route (GET/POST/PUT/DELETE)
- Load projects from DB on dashboard mount
- All project mutations (create, delete, add/remove repo) now
  persist via API calls
This commit is contained in:
Julia McGhee
2026-03-22 11:28:24 +00:00
parent 2d359c2eb3
commit 3288d69f9b
6 changed files with 945 additions and 20 deletions

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { projects } from "@homelab/db";
export async function GET() {
const rows = await db.select().from(projects);
return NextResponse.json(rows);
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const project = {
id: body.id || `proj-${Date.now()}`,
name: body.name,
workspaces: body.workspaces || [],
};
await db.insert(projects).values(project).onConflictDoUpdate({
target: projects.id,
set: {
name: project.name,
workspaces: project.workspaces,
updatedAt: new Date(),
},
});
return NextResponse.json(project, { status: 201 });
}
export async function PUT(request: NextRequest) {
const body = await request.json();
if (!body.id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
await db.update(projects).set({
...(body.name !== undefined && { name: body.name }),
...(body.workspaces !== undefined && { workspaces: body.workspaces }),
updatedAt: new Date(),
}).where(eq(projects.id, body.id));
return NextResponse.json({ ok: true });
}
export async function DELETE(request: NextRequest) {
const id = request.nextUrl.searchParams.get("id");
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
await db.delete(projects).where(eq(projects.id, id));
return NextResponse.json({ ok: true });
}

View File

@@ -912,40 +912,62 @@ function ProjectsTab({ projects, setProjects, mobile }: {
const showDetail = mobile ? selectedId !== null || creating : true;
const showList = mobile ? selectedId === null && !creating : true;
const handleCreate = () => {
const handleCreate = async () => {
if (!newName.trim()) return;
const proj: Project = {
id: `proj-${Date.now()}`,
name: newName.trim(),
workspaces: [],
};
setProjects(prev => [...prev, proj]);
setSelectedId(proj.id);
setCreating(false);
setNewName("");
try {
await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(proj),
});
setProjects(prev => [...prev, proj]);
setSelectedId(proj.id);
setCreating(false);
setNewName("");
} catch { /* ignore */ }
};
const handleDelete = (id: string) => {
setProjects(prev => prev.filter(p => p.id !== id));
setSelectedId(null);
const handleDelete = async (id: string) => {
try {
await fetch(`/api/projects?id=${encodeURIComponent(id)}`, { method: "DELETE" });
setProjects(prev => prev.filter(p => p.id !== id));
setSelectedId(null);
} catch { /* ignore */ }
};
const handleAddRepo = (repo: RepoResult) => {
const handleAddRepo = async (repo: RepoResult) => {
if (!selectedId) return;
const name = repo.fullName.split("/").pop() || repo.fullName;
setProjects(prev => prev.map(p =>
p.id === selectedId
? { ...p, workspaces: [...p.workspaces, { name, repo: repo.url }] }
: p
));
const proj = projects.find(p => p.id === selectedId);
if (!proj) return;
const updated = { ...proj, workspaces: [...proj.workspaces, { name, repo: repo.url }] };
try {
await fetch("/api/projects", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedId, workspaces: updated.workspaces }),
});
setProjects(prev => prev.map(p => p.id === selectedId ? updated : p));
} catch { /* ignore */ }
};
const handleRemoveWorkspace = (projId: string, repoName: string) => {
setProjects(prev => prev.map(p =>
p.id === projId
? { ...p, workspaces: p.workspaces.filter(w => w.name !== repoName) }
: p
));
const handleRemoveWorkspace = async (projId: string, repoName: string) => {
const proj = projects.find(p => p.id === projId);
if (!proj) return;
const updated = proj.workspaces.filter(w => w.name !== repoName);
try {
await fetch("/api/projects", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: projId, workspaces: updated }),
});
setProjects(prev => prev.map(p => p.id === projId ? { ...p, workspaces: updated } : p));
} catch { /* ignore */ }
};
const ProjectRow = ({ project }: { project: Project }) => {

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "harness_projects" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"workspaces" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,821 @@
{
"id": "7e2cc1ba-033e-48cf-9c60-b0513293039e",
"prevId": "e84e33a6-8185-4839-ad18-37149f19eb32",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.harness_agent_configs": {
"name": "harness_agent_configs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"runtime": {
"name": "runtime",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_tokens": {
"name": "max_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"env": {
"name": "env",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_credentials": {
"name": "harness_credentials",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_curated_models": {
"name": "harness_curated_models",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"cost_per_1k_input": {
"name": "cost_per_1k_input",
"type": "real",
"primaryKey": false,
"notNull": false
},
"cost_per_1k_output": {
"name": "cost_per_1k_output",
"type": "real",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_event_log": {
"name": "harness_event_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"trigger_id": {
"name": "trigger_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delivery_id": {
"name": "delivery_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repo": {
"name": "repo",
"type": "text",
"primaryKey": false,
"notNull": true
},
"commit_sha": {
"name": "commit_sha",
"type": "text",
"primaryKey": false,
"notNull": false
},
"branch": {
"name": "branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'received'"
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"skip_reason": {
"name": "skip_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"harness_event_log_delivery_id_unique": {
"name": "harness_event_log_delivery_id_unique",
"nullsNotDistinct": false,
"columns": [
"delivery_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_event_triggers": {
"name": "harness_event_triggers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repo_filter": {
"name": "repo_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state_filter": {
"name": "state_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"context_filter": {
"name": "context_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"task_template": {
"name": "task_template",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"consecutive_failures": {
"name": "consecutive_failures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_consecutive_failures": {
"name": "max_consecutive_failures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 3
},
"disabled_reason": {
"name": "disabled_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"webhook_secret": {
"name": "webhook_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_iterations": {
"name": "harness_iterations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"n": {
"name": "n",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"diagnosis": {
"name": "diagnosis",
"type": "text",
"primaryKey": false,
"notNull": false
},
"agent_output": {
"name": "agent_output",
"type": "text",
"primaryKey": false,
"notNull": false
},
"evals": {
"name": "evals",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_stats": {
"name": "diff_stats",
"type": "text",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_model_usage": {
"name": "harness_model_usage",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"task_slug": {
"name": "task_slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"input_tokens": {
"name": "input_tokens",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"output_tokens": {
"name": "output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"timestamp": {
"name": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_orchestrator": {
"name": "harness_orchestrator",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "'singleton'"
},
"running": {
"name": "running",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"current_task_id": {
"name": "current_task_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"heartbeat": {
"name": "heartbeat",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_projects": {
"name": "harness_projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"workspaces": {
"name": "workspaces",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_tasks": {
"name": "harness_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"goal": {
"name": "goal",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_iterations": {
"name": "max_iterations",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 6
},
"started_at": {
"name": "started_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"project": {
"name": "project",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'—'"
},
"evals": {
"name": "evals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pr": {
"name": "pr",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"cancel_requested": {
"name": "cancel_requested",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"spec": {
"name": "spec",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1774127485727,
"tag": "0001_stale_pride",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1774178806032,
"tag": "0002_cold_annihilus",
"breakpoints": true
}
]
}

View File

@@ -75,6 +75,19 @@ export const agentConfigs = pgTable("harness_agent_configs", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// ─── HARNESS: PROJECTS ────────────────────────────────────
export const projects = pgTable("harness_projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
workspaces: jsonb("workspaces")
.$type<{ name: string; repo: string }[]>()
.default([])
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// ─── HARNESS: ORCHESTRATOR STATE ──────────────────────────
export const orchestratorState = pgTable("harness_orchestrator", {