diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 39cd97e..4dd74d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,6 +27,9 @@ jobs: api: - 'apps/api/**' - 'packages/**' + harness: + - 'apps/harness/**' + - 'packages/**' lint-and-test: runs-on: ubuntu-latest diff --git a/apps/harness/Dockerfile b/apps/harness/Dockerfile new file mode 100644 index 0000000..b470d6b --- /dev/null +++ b/apps/harness/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json ./ +RUN npm install + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production + +# System tools needed by agent executors +RUN apk add --no-cache git github-cli + +# Agent CLIs (installed globally before dropping to non-root) +RUN npm install -g @anthropic-ai/claude-code @openai/codex opencode + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Workspace directory for git worktrees (ephemeral) +RUN mkdir -p /data/harness && chown nextjs:nodejs /data/harness + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3100 +ENV PORT=3100 +CMD ["node", "server.js"] diff --git a/apps/harness/k8s/base/deployment.yaml b/apps/harness/k8s/base/deployment.yaml new file mode 100644 index 0000000..a4d0af0 --- /dev/null +++ b/apps/harness/k8s/base/deployment.yaml @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: harness + labels: + app: harness +spec: + replicas: 1 + selector: + matchLabels: + app: harness + template: + metadata: + labels: + app: harness + spec: + imagePullSecrets: + - name: ghcr-pull-secret + containers: + - name: harness + image: ghcr.io/lazorgurl/homelab-harness:latest + ports: + - containerPort: 3100 + env: + - name: HARNESS_WORK_DIR + value: /data/harness + - name: CLAUDE_CONFIG_DIR + value: /secrets/claude + - name: OPENCODE_CONFIG_DIR + value: /secrets/opencode + volumeMounts: + - name: workspace + mountPath: /data/harness + - name: claude-credentials + mountPath: /secrets/claude + readOnly: true + - name: opencode-credentials + mountPath: /secrets/opencode + readOnly: true + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 1Gi + readinessProbe: + httpGet: + path: /api/health + port: 3100 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 3100 + initialDelaySeconds: 15 + periodSeconds: 20 + volumes: + - name: workspace + emptyDir: + sizeLimit: 2Gi + - name: claude-credentials + secret: + secretName: harness-claude-credentials + - name: opencode-credentials + secret: + secretName: harness-opencode-credentials diff --git a/apps/harness/k8s/base/harness-claude-credentials-sealed.yaml b/apps/harness/k8s/base/harness-claude-credentials-sealed.yaml new file mode 100644 index 0000000..d131fe5 --- /dev/null +++ b/apps/harness/k8s/base/harness-claude-credentials-sealed.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: harness-claude-credentials + namespace: apps +spec: + encryptedData: + .credentials.json: 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 + template: + metadata: + name: harness-claude-credentials + namespace: apps diff --git a/apps/harness/k8s/base/harness-opencode-credentials-sealed.yaml b/apps/harness/k8s/base/harness-opencode-credentials-sealed.yaml new file mode 100644 index 0000000..34a3178 --- /dev/null +++ b/apps/harness/k8s/base/harness-opencode-credentials-sealed.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: harness-opencode-credentials + namespace: apps +spec: + encryptedData: + auth.json: AgBNDx8eNC6AiMRrZn0JZIJACWL7Wg/JhbeuPiNdsLOpnc3db7vrI+25AjIwk7f+EMh1XKDf9QMbPJAyC3/ZiO3hJ45JJjuAmb/QYH9c+Zgnsms/VhurMz5pYvaN04B5J6lzJusILjU2sqQjaHL5ARPh1jrqrXnk+pRY/WG4vZVGrVZ/J9rvswfQuXwPdpD2KBCia3rR44WgpBxRT+bIQso2FFWYCLTdRPz7HFH+jSuFTEA0MujWZj4vCyf8w+5kZ1fwWBze2pAuj3iTLl3+TX0TMJhS7G/wARbxEYxrSntBCK6LsAByn7Ul400rcbOLugPbe9QFJrnxyjvjVeoQrjP2x1yIYWo8UHy5iExCVR+wkTD8EDQceqkqZ4KGoIa5GZpqdRMl20PjhPXfvX2XgTJjyOL2uhszRD/8z/WPVEM2gDSdmI7KUGdxmnPcqEyS6cVwp0DuSoaCWmN3GxS8EvrQVnlLQNK6RWsibGmmYwt1O7PxE4T+8CEcRwfUkdXRtVqMURnr9aIvAhl+judMkxPAdh68s6L8WehHAbPyYBeA29FVKO3JsXhMoFfQGugCxxBvPH50GOHh0Ncxdvz2wzH/of+QP4vmkddV7JbQMMruLSEzF90pIk7pLDR0Vhd9OxehKeAeAHot7DqH21VG8UnqUn+NZstCCDtB57IY5JakSrcE1+pPSMR13a5PQ/lNYWjgFT5HVF/cGMYoUG74zo2BzgJK4k3S1yDvTANxqeQnO+ybxITVh4Azo1WE151t2Fsh2SmpKsADBwuNFQprJRz1OxZYphNMNnI7KlSua1+KlljkotFItLwEEmsLgO3/zm0HIRNPbZHzX4/d5+jSIb72QdVLiiVPSM4KtkbFVbuoHPdnsqwf9pzTIwSIGkTj6EBqECIBACaytwAQUf3ZmvAKXCTa34CxyXlfzQHgc7Hyzv+1u9csO49H+P7I8iMqr43NpUliDz9mcu+0964209DpBsaVNQBp9GUB3dnrraDG/bpVOzwljgZnwaV0WKfJSvk2uoMkKOZXEIqvX1A= + template: + metadata: + name: harness-opencode-credentials + namespace: apps diff --git a/apps/harness/k8s/base/kustomization.yaml b/apps/harness/k8s/base/kustomization.yaml new file mode 100644 index 0000000..ce0b673 --- /dev/null +++ b/apps/harness/k8s/base/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + - harness-claude-credentials-sealed.yaml + - harness-opencode-credentials-sealed.yaml diff --git a/apps/harness/k8s/base/service.yaml b/apps/harness/k8s/base/service.yaml new file mode 100644 index 0000000..626e160 --- /dev/null +++ b/apps/harness/k8s/base/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: harness +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 3100 + protocol: TCP + selector: + app: harness diff --git a/apps/harness/k8s/overlays/preview/kustomization.yaml b/apps/harness/k8s/overlays/preview/kustomization.yaml new file mode 100644 index 0000000..4b2a126 --- /dev/null +++ b/apps/harness/k8s/overlays/preview/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../base +patches: + - target: + kind: Deployment + name: harness + patch: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: harness + spec: + replicas: 1 diff --git a/apps/harness/k8s/overlays/production/kustomization.yaml b/apps/harness/k8s/overlays/production/kustomization.yaml new file mode 100644 index 0000000..4a80628 --- /dev/null +++ b/apps/harness/k8s/overlays/production/kustomization.yaml @@ -0,0 +1,19 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../../base +patches: +- patch: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: harness + spec: + replicas: 1 + target: + kind: Deployment + name: harness +images: +- name: ghcr.io/lazorgurl/homelab-harness + newName: ghcr.io/lazorgurl/homelab-harness + newTag: latest diff --git a/apps/harness/next-env.d.ts b/apps/harness/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/harness/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/harness/next.config.js b/apps/harness/next.config.js new file mode 100644 index 0000000..c10e07d --- /dev/null +++ b/apps/harness/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", +}; + +module.exports = nextConfig; diff --git a/apps/harness/package.json b/apps/harness/package.json new file mode 100644 index 0000000..09f0fd9 --- /dev/null +++ b/apps/harness/package.json @@ -0,0 +1,24 @@ +{ + "name": "@homelab/harness", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3100", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "echo \"no tests yet\"" + }, + "dependencies": { + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/apps/harness/src/app/api/agents/route.ts b/apps/harness/src/app/api/agents/route.ts new file mode 100644 index 0000000..4e8e720 --- /dev/null +++ b/apps/harness/src/app/api/agents/route.ts @@ -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 }); +} diff --git a/apps/harness/src/app/api/health/route.ts b/apps/harness/src/app/api/health/route.ts new file mode 100644 index 0000000..bedd1e3 --- /dev/null +++ b/apps/harness/src/app/api/health/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + status: "ok", + service: "harness", + timestamp: new Date().toISOString(), + }); +} diff --git a/apps/harness/src/app/api/models/curated/route.ts b/apps/harness/src/app/api/models/curated/route.ts new file mode 100644 index 0000000..e730773 --- /dev/null +++ b/apps/harness/src/app/api/models/curated/route.ts @@ -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 }); +} diff --git a/apps/harness/src/app/api/models/route.ts b/apps/harness/src/app/api/models/route.ts new file mode 100644 index 0000000..984bd2a --- /dev/null +++ b/apps/harness/src/app/api/models/route.ts @@ -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); +} diff --git a/apps/harness/src/app/api/models/usage/route.ts b/apps/harness/src/app/api/models/usage/route.ts new file mode 100644 index 0000000..f05c5ea --- /dev/null +++ b/apps/harness/src/app/api/models/usage/route.ts @@ -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(), + }); +} diff --git a/apps/harness/src/app/api/orchestrator/route.ts b/apps/harness/src/app/api/orchestrator/route.ts new file mode 100644 index 0000000..a4779b4 --- /dev/null +++ b/apps/harness/src/app/api/orchestrator/route.ts @@ -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 }); +} diff --git a/apps/harness/src/app/api/repos/search/route.ts b/apps/harness/src/app/api/repos/search/route.ts new file mode 100644 index 0000000..ba1e821 --- /dev/null +++ b/apps/harness/src/app/api/repos/search/route.ts @@ -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); +} diff --git a/apps/harness/src/app/api/settings/credentials/route.ts b/apps/harness/src/app/api/settings/credentials/route.ts new file mode 100644 index 0000000..2f588c8 --- /dev/null +++ b/apps/harness/src/app/api/settings/credentials/route.ts @@ -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 }); +} diff --git a/apps/harness/src/app/api/tasks/[id]/route.ts b/apps/harness/src/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..bce0ec0 --- /dev/null +++ b/apps/harness/src/app/api/tasks/[id]/route.ts @@ -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); +} diff --git a/apps/harness/src/app/api/tasks/[id]/start/route.ts b/apps/harness/src/app/api/tasks/[id]/start/route.ts new file mode 100644 index 0000000..bccff74 --- /dev/null +++ b/apps/harness/src/app/api/tasks/[id]/start/route.ts @@ -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" }); +} diff --git a/apps/harness/src/app/api/tasks/[id]/stop/route.ts b/apps/harness/src/app/api/tasks/[id]/stop/route.ts new file mode 100644 index 0000000..3aa5e7c --- /dev/null +++ b/apps/harness/src/app/api/tasks/[id]/stop/route.ts @@ -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" }); +} diff --git a/apps/harness/src/app/api/tasks/route.ts b/apps/harness/src/app/api/tasks/route.ts new file mode 100644 index 0000000..64cd949 --- /dev/null +++ b/apps/harness/src/app/api/tasks/route.ts @@ -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 }); +} diff --git a/apps/harness/src/app/layout.tsx b/apps/harness/src/app/layout.tsx new file mode 100644 index 0000000..38a0e43 --- /dev/null +++ b/apps/harness/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/harness/src/app/page.tsx b/apps/harness/src/app/page.tsx new file mode 100644 index 0000000..4a188da --- /dev/null +++ b/apps/harness/src/app/page.tsx @@ -0,0 +1,5 @@ +import HarnessDashboard from "@/components/harness-dashboard"; + +export default function Page() { + return ; +} diff --git a/apps/harness/src/components/harness-dashboard.tsx b/apps/harness/src/components/harness-dashboard.tsx new file mode 100644 index 0000000..7cf4fcd --- /dev/null +++ b/apps/harness/src/components/harness-dashboard.tsx @@ -0,0 +1,1889 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { + tokens, STATUS, Label, Mono, Divider, StatusBadge, Panel, PanelHeader, + Btn, Input, Textarea, EvalPip, IterDot, PathCrumb, BackBtn, + SearchableDropdown, DropdownOption, +} from "./harness-design-system"; + +// ─── TYPES ────────────────────────────────────────────────────────────────────── + +interface Eval { + label: string; + value: number | string; + unit: string; + pass: boolean; + target: string; +} + +interface Iteration { + n: number; + status: string; + diagnosis: string | null; +} + +interface PR { + number: number; + title: string; + status: string; +} + +interface Task { + id: string; + slug: string; + goal: string; + status: string; + iteration: number; + maxIterations: number; + startedAt: number | null; + completedAt?: number; + project: string; + evals: Record; + iterations: Iteration[]; + pr?: PR; +} + +interface KnowledgeDoc { + path: string; + title: string; + verificationStatus: string; + lastUpdated: string; + project: string; + preview: string; +} + +interface Project { + id: string; + name: string; + workspaces: { name: string; repo: string }[]; +} + +// ─── RESPONSIVE HOOK ───────────────────────────────────────────────────────── + +function useIsMobile() { + const [mobile, setMobile] = useState(false); + useEffect(() => { + const fn = () => setMobile(window.innerWidth < 768); + fn(); + window.addEventListener("resize", fn); + return () => window.removeEventListener("resize", fn); + }, []); + return mobile; +} + +// ─── MOCK DATA ──────────────────────────────────────────────────────────────── + +const MOCK_TASKS: Task[] = [ + { + id: "task-001", slug: "pubsub-pipeline-migration", + goal: "Replace CDC replication with Pub/Sub → GCS → BigQuery pipeline", + status: "running", iteration: 3, maxIterations: 6, + startedAt: Date.now() - 1000 * 60 * 23, project: "Hypixel", + evals: { + cost: { value: -38, unit: "%", label: "Cost Δ", pass: false, target: "<-40%" }, + latency: { value: 22, unit: "s", label: "P99 Latency", pass: true, target: "<30s" }, + tests: { value: 97, unit: "%", label: "Test Pass", pass: true, target: "100%" }, + }, + iterations: [ + { n: 1, status: "failed", diagnosis: "Schema mismatch on UGC event table — BQ partition column incompatible" }, + { n: 2, status: "failed", diagnosis: "Cost reduction insufficient — Pub/Sub fan-out creating duplicate messages" }, + { n: 3, status: "running", diagnosis: null }, + ], + }, + { + id: "task-002", slug: "haiku-moderation-tier2", + goal: "Implement tiered UGC image moderation with Haiku classifier for tier-2 content", + status: "completed", iteration: 4, maxIterations: 6, + startedAt: Date.now() - 1000 * 60 * 60 * 3, completedAt: Date.now() - 1000 * 60 * 40, + project: "Hypixel", + pr: { number: 1847, title: "feat: tiered UGC moderation with Haiku classifier", status: "open" }, + evals: { + accuracy: { value: 94.2, unit: "%", label: "Accuracy", pass: true, target: ">92%" }, + throughput: { value: 312, unit: "rps", label: "Throughput", pass: true, target: ">200rps" }, + tests: { value: 100, unit: "%", label: "Test Pass", pass: true, target: "100%" }, + }, + iterations: [ + { n: 1, status: "failed", diagnosis: "Classifier confidence threshold too low — 23% false positive rate" }, + { n: 2, status: "failed", diagnosis: "Rate limiting on Haiku API at burst load — throughput degraded" }, + { n: 3, status: "failed", diagnosis: "Accuracy marginal — prompt engineering needed for edge cases" }, + { n: 4, status: "passed", diagnosis: null }, + ], + }, + { + id: "task-003", slug: "neurotype-job-cancellation", + goal: "Implement cancellable background jobs with rate limiting and dynamic prioritisation", + status: "pending", iteration: 0, maxIterations: 5, + startedAt: null, project: "Neurotype", + evals: {}, iterations: [], + }, +]; + +const MOCK_KNOWLEDGE: KnowledgeDoc[] = [ + { path: "docs/architecture/bigquery-pipeline.md", title: "BigQuery Pipeline Architecture", verificationStatus: "stale", lastUpdated: "2026-03-18", project: "Hypixel", preview: "Documents the Pub/Sub → GCS → BigQuery replacement for CDC replication. Original CDC pattern caused billing spike due to per-row streaming inserts at scale." }, + { path: "docs/architecture/ugc-moderation.md", title: "UGC Moderation Tiering", verificationStatus: "verified", lastUpdated: "2026-03-20", project: "Hypixel", preview: "Three-tier classification: Haiku (fast, high-volume), Sonnet (complex cases), human review (appeals). Accuracy targets per tier defined." }, + { path: "docs/architecture/neurotype-job-system.md", title: "Background Job Processing", verificationStatus: "verified", lastUpdated: "2026-03-19", project: "Neurotype", preview: "Postgres-native job queue with cancellation tokens, rate limiting per clinical workflow type, and dynamic priority lanes." }, + { path: "docs/beliefs.md", title: "Core Beliefs", verificationStatus: "verified", lastUpdated: "2026-03-15", project: "Global", preview: "Invariants: no BQ streaming inserts, NHS audit trail on all clinical state mutations, Haiku only for non-PII classification." }, + { path: "decisions/2026-03-20-haiku-tier2-iter3.md", title: "Haiku Tier-2: Iter 3 Failure", verificationStatus: "decision", lastUpdated: "2026-03-20", project: "Hypixel", preview: "Accuracy 92.1% — marginally below target. Root cause: edge cases in animated content. Fix: few-shot examples in system prompt." }, + { path: "decisions/2026-03-19-pubsub-iter2.md", title: "Pub/Sub Migration: Iter 2 Failure", verificationStatus: "decision", lastUpdated: "2026-03-19", project: "Hypixel", preview: "Fan-out producing 2.3x message duplication on retry. Root cause: missing dedup window in Dataflow job." }, +]; + +const MOCK_PROJECTS: Project[] = [ + { + id: "proj-001", name: "Hypixel", + workspaces: [ + { name: "hypixel-api", repo: "github.com/org/hypixel-api" }, + { name: "hypixel-web", repo: "github.com/org/hypixel-web" }, + { name: "hypixel-infra", repo: "github.com/org/hypixel-infra" }, + ], + }, + { + id: "proj-002", name: "Neurotype", + workspaces: [ + { name: "neurotype-backend", repo: "github.com/org/neurotype-backend" }, + { name: "neurotype-dashboard", repo: "github.com/org/neurotype-dashboard" }, + ], + }, + { + id: "proj-003", name: "Homelab", + workspaces: [ + { name: "homelab", repo: "github.com/lazorgurl/homelab" }, + ], + }, +]; + +function elapsed(ms: number) { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s`; + return `${Math.floor(m / 60)}h ${m % 60}m`; +} + +// ─── SHARED DETAIL VIEWS ───────────────────────────────────────────────────── + +function TaskDetail({ task, onStop, onStart, mobile, onBack }: { + task: Task; onStop: (id: string) => void; onStart: (id: string) => void; mobile: boolean; onBack?: () => void; +}) { + const evalEntries = Object.values(task.evals); + + return ( +
+ {/* Header */} +
+ {mobile && onBack && } +
+
+
+ + +
+ {task.slug} + {task.goal} +
+
+
+ {task.status === "running" && onStop(task.id)} style={{ flex: mobile ? 1 : "none" }}>STOP LOOP} + {task.status === "pending" && onStart(task.id)} style={{ flex: mobile ? 1 : "none" }}>START LOOP} + {task.status === "completed" && task.pr && APPROVE PR} +
+
+ +
+ {/* Evals */} + {evalEntries.length > 0 && ( +
+ +
+ {evalEntries.map(e => )} +
+
+ )} + + {/* Iteration log */} +
+ +
+ {task.iterations.length === 0 + ? No iterations yet. + : task.iterations.map(iter => ( +
+
+ + +
+
+ + {iter.diagnosis && {iter.diagnosis}} + {iter.status === "running" && !iter.diagnosis && In progress...} +
+
+ )) + } +
+
+ + {/* PR */} + {task.pr && ( +
+ + +
+
+ + #{task.pr.number} + {task.pr.title} +
+
+ VIEW DIFF + APPROVE +
+
+
+
+ )} +
+
+ ); +} + +function DocDetail({ doc, mobile, onBack }: { doc: KnowledgeDoc; mobile: boolean; onBack?: () => void }) { + return ( +
+
+ {mobile && onBack && } +
+
+ + + +
+ {doc.title} + +
+
+ MARK STALE + EDIT +
+
+
+
+ {doc.preview} + [ Full document content renders here ] +
+
+
+ ); +} + +// ─── LOOPS TAB ──────────────────────────────────────────────────────────────── + +function LoopsTab({ tasks, setTasks, mobile }: { tasks: Task[]; setTasks: React.Dispatch>; mobile: boolean }) { + const [selectedId, setSelectedId] = useState(null); + const selectedTask = tasks.find(t => t.id === selectedId); + const showDetail = mobile ? selectedId !== null : true; + const showList = mobile ? selectedId === null : true; + + const handleStop = (id: string) => setTasks(prev => prev.map(t => t.id === id ? { ...t, status: "completed" } : t)); + const handleStart = (id: string) => setTasks(prev => prev.map(t => t.id === id ? { ...t, status: "running", startedAt: Date.now() } : t)); + + const TaskRow = ({ task }: { task: Task }) => { + const s = STATUS[task.status] || STATUS.pending; + return ( +
setSelectedId(task.id)} + style={{ background: tokens.color.bg1, border: `1px solid ${tokens.color.border0}`, borderLeft: `3px solid ${s.color}`, cursor: "pointer", transition: tokens.transition.normal, marginBottom: 1 }}> +
+
+ + {task.slug} +
+
+ + {task.status === "running" && ( + { e.stopPropagation(); handleStop(task.id); }} style={{ fontSize: tokens.size.xs, padding: "0 8px", minHeight: 28 }}>STOP + )} + {task.status === "pending" && ( + { e.stopPropagation(); handleStart(task.id); }} style={{ fontSize: tokens.size.xs, padding: "0 8px", minHeight: 28 }}>START + )} + {mobile && } +
+
+ +
+ {task.goal} +
+
+
+ {Array.from({ length: task.maxIterations }).map((_, i) => ( + + ))} + +
+ +
+ {task.pr && ( + <> + +
+ + #{task.pr.number} — {task.pr.title} +
+ + )} +
+ ); + }; + + return ( +
+ {showList && ( +
+ +
+ {tasks.map(task => )} +
+
+ )} + + {!mobile && ( + selectedTask + ? + :
+ )} + {mobile && showDetail && selectedTask && ( + setSelectedId(null)} /> + )} +
+ ); +} + +// ─── KNOWLEDGE TAB ──────────────────────────────────────────────────────────── + +function KnowledgeTab({ docs, mobile }: { docs: KnowledgeDoc[]; mobile: boolean }) { + const [selected, setSelected] = useState(null); + const [filter, setFilter] = useState(""); + const [projectFilter, setProjectFilter] = useState("ALL"); + const filtered = docs.filter(d => { + if (projectFilter !== "ALL" && d.project !== projectFilter) return false; + return [d.title, d.path, d.project].some(s => s.toLowerCase().includes(filter.toLowerCase())); + }); + const selectedDoc = docs.find(d => d.path === selected); + const showDetail = mobile ? selected !== null : true; + const showList = mobile ? selected === null : true; + + const projectNames = ["ALL", ...Array.from(new Set(docs.map(d => d.project)))]; + + return ( +
+ {showList && ( +
+ +
+ setFilter(e.target.value)} placeholder="filter..." /> +
+ {projectNames.map(name => ( + + ))} +
+
+
+ {filtered.map(doc => { + const badge = STATUS[doc.verificationStatus] || STATUS.pending; + const isSel = selected === doc.path; + return ( +
setSelected(doc.path)} + style={{ padding: `${tokens.space[3]}px ${tokens.space[4]}px`, borderBottom: `1px solid ${tokens.color.border0}`, background: isSel ? tokens.color.bg2 : "transparent", cursor: "pointer", transition: tokens.transition.fast, borderLeft: `2px solid ${isSel ? badge.color : "transparent"}`, minHeight: tokens.touch.min, display: "flex", flexDirection: "column", justifyContent: "center", gap: tokens.space[1] }}> +
+ +
+ + {mobile && } +
+
+ {doc.title} + +
+ ); + })} +
+
+ )} + + {!mobile && ( + selectedDoc + ? + :
+ )} + {mobile && showDetail && selectedDoc && ( + setSelected(null)} /> + )} +
+ ); +} + +// ─── HISTORY TAB ───────────────────────────────────────────────────────────── + +function HistoryTab({ tasks, mobile }: { tasks: Task[]; mobile: boolean }) { + return ( +
+
+ {[...tasks].reverse().map(task => { + const s = STATUS[task.status] || STATUS.pending; + return ( + +
+
+ + {task.slug} + +
+
+
+ {Array.from({ length: task.maxIterations }).map((_, i) => ( + + ))} +
+ + {task.startedAt && } +
+
+ {Object.keys(task.evals).length > 0 && ( + <> + +
+ {Object.values(task.evals).map(e => ( +
+ + {e.value}{e.unit} +
+ ))} + {task.pr && ( +
+ + #{task.pr.number} +
+ )} +
+ + )} +
+ ); + })} +
+
+ ); +} + +// ─── NEW TASK TAB ───────────────────────────────────────────────────────────── + +interface TaskForm { + slug: string; + goal: string; + projectId: string; + agentId: string; + maxIterations: string; + criteria: { label: string; target: string }[]; + constraints: string[]; + knowledgeRefs: string[]; +} + +const EMPTY: TaskForm = { slug: "", goal: "", projectId: "", agentId: "", maxIterations: "6", criteria: [{ label: "", target: "" }], constraints: [""], knowledgeRefs: [] }; + +// ─── AGENT SELECTOR ────────────────────────────────────────────────────────── + +interface AgentConfigDisplay { + id: string; + name: string; + runtime: string; + modelId: string; + provider: string; +} + +const RUNTIME_LABELS: Record = { + "claude-code": "Claude Code", + "codex": "Codex CLI", + "opencode": "OpenCode", +}; + +function AgentSelector({ value, onChange }: { value: string; onChange: (id: string) => void }) { + const [agents, setAgents] = useState([]); + + useEffect(() => { + fetch("/api/agents") + .then(r => r.json()) + .then(data => setAgents(data.configs || [])) + .catch(() => {}); + }, []); + + const options: DropdownOption[] = agents.map(a => ({ + value: a.id, + label: a.name, + detail: `${RUNTIME_LABELS[a.runtime] || a.runtime} · ${a.modelId}`, + })); + + return ( + onChange(v as string)} + placeholder={agents.length === 0 ? "No agents configured — set up in Models tab" : "Select agent..."} + /> + ); +} + +function NewTaskTab({ onSubmit, mobile, projects, knowledgeDocs }: { + onSubmit: (form: TaskForm) => void; + mobile: boolean; + projects: Project[]; + knowledgeDocs: KnowledgeDoc[]; +}) { + const [form, setForm] = useState(EMPTY); + const set = (k: keyof TaskForm, v: string) => setForm(f => ({ ...f, [k]: v })); + const setCrit = (i: number, k: "label" | "target", v: string) => setForm(f => { const c = [...f.criteria]; c[i] = { ...c[i], [k]: v }; return { ...f, criteria: c }; }); + const setItem = (lk: "constraints", i: number, v: string) => setForm(f => { const a = [...f[lk]]; a[i] = v; return { ...f, [lk]: a }; }); + + const projectOptions: DropdownOption[] = projects.map(p => ({ + value: p.id, + label: p.name, + detail: p.workspaces.map(w => w.name).join(", "), + })); + + const selectedProject = projects.find(p => p.id === form.projectId); + + const knowledgeOptions: DropdownOption[] = knowledgeDocs + .filter(d => !selectedProject || d.project === selectedProject.name || d.project === "Global") + .map(d => ({ + value: d.path, + label: d.title, + detail: d.path, + })); + + const Field = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ + {children} +
+ ); + + return ( +
+
+
+ +
+ +
+ + set("slug", e.target.value)} placeholder="my-task-slug" /> + + + set("maxIterations", e.target.value)} placeholder="6" style={mobile ? {} : { width: 100 }} /> + +
+ +
+
+ + { + setForm(f => ({ ...f, projectId: v as string, knowledgeRefs: [] })); + }} + placeholder="Select project..." + /> + +
+
+ + set("agentId", v)} /> + +
+
+ + {selectedProject && selectedProject.workspaces.length > 0 && ( +
+ +
+ {selectedProject.workspaces.map(w => ( + + {w.name} + + ))} +
+
+ )} + + +