Add harness app: agent orchestrator with cluster deployment
- Next.js app for orchestrating coding agent benchmarks (Claude Code, Codex, OpenCode) - Dockerfile installs git, gh CLI, and agent CLIs for headless execution - K8s deployment with workspace volume, sealed credentials for Claude + OpenCode - Traefik IngressRoute at harness.coreworlds.io with internal-only middleware + TLS - CI pipeline path filter for harness builds - Fix OpenCode runtime flags (subcommand-based headless mode)
This commit is contained in:
52
apps/harness/src/app/api/agents/route.ts
Normal file
52
apps/harness/src/app/api/agents/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getAllAgentConfigs,
|
||||
upsertAgentConfig,
|
||||
deleteAgentConfig,
|
||||
AGENT_RUNTIMES,
|
||||
AgentConfig,
|
||||
} from "@/lib/agents";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
configs: getAllAgentConfigs(),
|
||||
runtimes: Object.values(AGENT_RUNTIMES),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.runtime || !body.modelId || !body.provider) {
|
||||
return NextResponse.json(
|
||||
{ error: "runtime, modelId, and provider are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!AGENT_RUNTIMES[body.runtime as keyof typeof AGENT_RUNTIMES]) {
|
||||
return NextResponse.json(
|
||||
{ error: `runtime must be one of: ${Object.keys(AGENT_RUNTIMES).join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config: AgentConfig = {
|
||||
id: body.id || `agent-${Date.now()}`,
|
||||
name: body.name || `${body.runtime} · ${body.modelId}`,
|
||||
runtime: body.runtime,
|
||||
modelId: body.modelId,
|
||||
provider: body.provider,
|
||||
maxTokens: body.maxTokens,
|
||||
env: body.env,
|
||||
};
|
||||
|
||||
return NextResponse.json(upsertAgentConfig(config), { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const id = request.nextUrl.searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
deleteAgentConfig(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
9
apps/harness/src/app/api/health/route.ts
Normal file
9
apps/harness/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
service: "harness",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
54
apps/harness/src/app/api/models/curated/route.ts
Normal file
54
apps/harness/src/app/api/models/curated/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getCuratedModels,
|
||||
getEnabledModels,
|
||||
upsertCuratedModel,
|
||||
removeCuratedModel,
|
||||
toggleModelEnabled,
|
||||
updateModelCost,
|
||||
CuratedModel,
|
||||
} from "@/lib/model-store";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const enabledOnly = request.nextUrl.searchParams.get("enabled") === "true";
|
||||
return NextResponse.json(enabledOnly ? getEnabledModels() : getCuratedModels());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
if (body.action === "toggle" && body.id) {
|
||||
const result = toggleModelEnabled(body.id);
|
||||
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
if (body.action === "update-cost" && body.id) {
|
||||
const result = updateModelCost(body.id, body.costPer1kInput, body.costPer1kOutput);
|
||||
if (!result) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
if (!body.id || !body.provider) {
|
||||
return NextResponse.json({ error: "id and provider are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const model: CuratedModel = {
|
||||
id: body.id,
|
||||
name: body.name || body.id,
|
||||
provider: body.provider,
|
||||
enabled: body.enabled ?? true,
|
||||
contextWindow: body.contextWindow,
|
||||
costPer1kInput: body.costPer1kInput,
|
||||
costPer1kOutput: body.costPer1kOutput,
|
||||
};
|
||||
|
||||
return NextResponse.json(upsertCuratedModel(model), { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const id = request.nextUrl.searchParams.get("id");
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
removeCuratedModel(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
7
apps/harness/src/app/api/models/route.ts
Normal file
7
apps/harness/src/app/api/models/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchAllModels } from "@/lib/model-providers";
|
||||
|
||||
export async function GET() {
|
||||
const models = await fetchAllModels();
|
||||
return NextResponse.json(models);
|
||||
}
|
||||
9
apps/harness/src/app/api/models/usage/route.ts
Normal file
9
apps/harness/src/app/api/models/usage/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getUsageSummary, getUsageLog } from "@/lib/model-store";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
summary: getUsageSummary(),
|
||||
log: getUsageLog(),
|
||||
});
|
||||
}
|
||||
31
apps/harness/src/app/api/orchestrator/route.ts
Normal file
31
apps/harness/src/app/api/orchestrator/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
startOrchestrator,
|
||||
stopOrchestrator,
|
||||
isRunning,
|
||||
currentRunningTaskId,
|
||||
} from "@/lib/orchestrator";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
running: isRunning(),
|
||||
currentTaskId: currentRunningTaskId(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const action = body.action as string;
|
||||
|
||||
if (action === "start") {
|
||||
startOrchestrator();
|
||||
return NextResponse.json({ ok: true, running: true });
|
||||
}
|
||||
|
||||
if (action === "stop") {
|
||||
stopOrchestrator();
|
||||
return NextResponse.json({ ok: true, running: false });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action. Use 'start' or 'stop'" }, { status: 400 });
|
||||
}
|
||||
13
apps/harness/src/app/api/repos/search/route.ts
Normal file
13
apps/harness/src/app/api/repos/search/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { searchRepos } from "@/lib/repo-search";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const query = request.nextUrl.searchParams.get("q") || "";
|
||||
|
||||
if (query.length < 2) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const results = await searchRepos(query);
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
63
apps/harness/src/app/api/settings/credentials/route.ts
Normal file
63
apps/harness/src/app/api/settings/credentials/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getAllCredentials,
|
||||
getCredentialsByKind,
|
||||
upsertCredential,
|
||||
deleteCredential,
|
||||
Credential,
|
||||
GIT_PROVIDERS,
|
||||
AI_PROVIDERS,
|
||||
} from "@/lib/credentials";
|
||||
|
||||
const VALID_PROVIDERS = [...GIT_PROVIDERS, ...AI_PROVIDERS];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const kind = request.nextUrl.searchParams.get("kind");
|
||||
if (kind === "git" || kind === "ai") {
|
||||
return NextResponse.json(getCredentialsByKind(kind));
|
||||
}
|
||||
return NextResponse.json(getAllCredentials());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.provider || !body.token || !body.label) {
|
||||
return NextResponse.json(
|
||||
{ error: "provider, label, and token are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!VALID_PROVIDERS.includes(body.provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cred: Credential = {
|
||||
id: body.id || `cred-${Date.now()}`,
|
||||
provider: body.provider,
|
||||
label: body.label,
|
||||
token: body.token,
|
||||
baseUrl: body.baseUrl,
|
||||
};
|
||||
|
||||
const saved = upsertCredential(cred);
|
||||
return NextResponse.json(saved, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const id = request.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = deleteCredential(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
27
apps/harness/src/app/api/tasks/[id]/route.ts
Normal file
27
apps/harness/src/app/api/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTask, updateTask } from "@/lib/store";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const task = getTask(id);
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(task);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const updated = updateTask(id, body);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
27
apps/harness/src/app/api/tasks/[id]/start/route.ts
Normal file
27
apps/harness/src/app/api/tasks/[id]/start/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTask } from "@/lib/store";
|
||||
import { startOrchestrator } from "@/lib/orchestrator";
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const task = getTask(id);
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (task.status !== "pending") {
|
||||
return NextResponse.json(
|
||||
{ error: `Task is ${task.status}, not pending` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure orchestrator is running — it will pick up this task
|
||||
startOrchestrator();
|
||||
|
||||
return NextResponse.json({ ok: true, message: "Orchestrator started, task will be picked up" });
|
||||
}
|
||||
32
apps/harness/src/app/api/tasks/[id]/stop/route.ts
Normal file
32
apps/harness/src/app/api/tasks/[id]/stop/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTask } from "@/lib/store";
|
||||
import { cancelTask } from "@/lib/orchestrator";
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const task = getTask(id);
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (task.status !== "running") {
|
||||
return NextResponse.json(
|
||||
{ error: `Task is ${task.status}, not running` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const cancelled = cancelTask(id);
|
||||
if (!cancelled) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task is not the currently executing task" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, message: "Task cancellation requested" });
|
||||
}
|
||||
45
apps/harness/src/app/api/tasks/route.ts
Normal file
45
apps/harness/src/app/api/tasks/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAllTasks, createTask } from "@/lib/store";
|
||||
import { getAgentConfig } from "@/lib/agents";
|
||||
import { Task, TaskSpec } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getAllTasks());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const spec: TaskSpec = await request.json();
|
||||
|
||||
if (!spec.slug || !spec.goal) {
|
||||
return NextResponse.json({ error: "slug and goal are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!spec.agentId) {
|
||||
return NextResponse.json({ error: "agentId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const agentConfig = getAgentConfig(spec.agentId);
|
||||
if (!agentConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: `Agent config not found: ${spec.agentId}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const task: Task = {
|
||||
id: `task-${Date.now()}`,
|
||||
slug: spec.slug,
|
||||
goal: spec.goal,
|
||||
project: spec.project || "—",
|
||||
status: "pending",
|
||||
iteration: 0,
|
||||
maxIterations: spec.maxIterations || 6,
|
||||
startedAt: null,
|
||||
evals: {},
|
||||
iterations: [],
|
||||
spec,
|
||||
};
|
||||
|
||||
const created = createTask(task);
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
}
|
||||
18
apps/harness/src/app/layout.tsx
Normal file
18
apps/harness/src/app/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Harness — Agent Orchestrator",
|
||||
description: "Autonomous coding agent loop orchestrator and dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, padding: 0 }}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
apps/harness/src/app/page.tsx
Normal file
5
apps/harness/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import HarnessDashboard from "@/components/harness-dashboard";
|
||||
|
||||
export default function Page() {
|
||||
return <HarnessDashboard />;
|
||||
}
|
||||
Reference in New Issue
Block a user