diff --git a/.env.example b/.env.example index 6270c7f..e57694b 100644 --- a/.env.example +++ b/.env.example @@ -43,15 +43,24 @@ REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11" # WORKER_REPO_ROOT= # defaults to monorepo root (pnpm-workspace.yaml walk-up) # WORKER_WORKTREE_BASE=/tmp/echodo-worker-worktrees # -# Phase B — OpenHands agent-server (optional; requires --profile agent-server) -# WORKER_SESSION_TYPE=minimal # set to `openhands` when agent-server is running +# Phase B — coding harness (optional) +# Preferred: OpenCode (COMPOSE_PROFILES=opencode) +# WORKER_SESSION_TYPE=minimal # set to `opencode` when opencode profile is running +# OPENCODE_SERVER_URL=http://opencode:4096 +# OPENCODE_SERVER_PASSWORD= # openssl rand -hex 24; must match container +# OPENCODE_SERVER_USERNAME=opencode +# OPENCODE_PROVIDER_ID=omniroute +# WORKER_OPENCODE_WORKSPACE=/workspace/repo +# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1 +# OMNIROUTE_API_KEY= +# OMNIROUTE_MODEL=auto/coding +# +# Legacy: OpenHands agent-server (COMPOSE_PROFILES=agent-server) +# WORKER_SESSION_TYPE=openhands # AGENT_SERVER_URL=http://agent-server:8001 # WORKER_OPENHANDS_WORKSPACE=/workspace/repo # SESSION_API_KEY= # must match LOCAL_BACKEND_API_KEY on agent-server # LOCAL_BACKEND_API_KEY= # generate: openssl rand -hex 32 -# OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1 -# OMNIROUTE_API_KEY= -# OMNIROUTE_MODEL=auto/coding # ----- Auth (NextAuth v5) ----- # Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud diff --git a/apps/worker/package.json b/apps/worker/package.json index 9b91de6..e5e1197 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -18,6 +18,7 @@ "ai": "^4.3.16", "drizzle-orm": "^0.38.0", "ioredis": "^5.4.2", + "yaml": "^2.7.0", "zod": "^3.24.0" }, "devDependencies": { diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index 7c8c5cd..3b7e254 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -33,9 +33,14 @@ const envSchema = z.object({ .string() .optional() .transform((value) => value?.trim() || "/tmp/echodo-worker-worktrees"), - /** Session type: "minimal" (default, in-process) or "openhands" (agent-server). */ + /** + * Session type: + * - minimal (default) — in-process tool loop + * - opencode — headless `opencode serve` on CT 116 (preferred harness) + * - openhands — legacy agent-server (optional profile; not the target path) + */ WORKER_SESSION_TYPE: z - .enum(["minimal", "openhands"]) + .enum(["minimal", "opencode", "openhands"]) .optional() .default("minimal"), /** OpenHands agent-server URL. Docker: http://agent-server:8001, Local: http://localhost:18000 */ @@ -53,6 +58,30 @@ const envSchema = z.object({ .string() .optional() .transform((value) => value?.trim() || ""), + /** OpenCode serve/web base URL. Docker: http://opencode:4096 */ + OPENCODE_SERVER_URL: z + .string() + .optional() + .transform((value) => value?.trim() || "http://opencode:4096"), + /** Must match OPENCODE_SERVER_PASSWORD on the opencode container when set. */ + OPENCODE_SERVER_PASSWORD: z + .string() + .optional() + .transform((value) => value?.trim() || ""), + OPENCODE_SERVER_USERNAME: z + .string() + .optional() + .transform((value) => value?.trim() || "opencode"), + /** Provider id registered by @omniroute/opencode-plugin (default omniroute). */ + OPENCODE_PROVIDER_ID: z + .string() + .optional() + .transform((value) => value?.trim() || "omniroute"), + /** Workspace path OpenCode should treat as the project root (shared volume). */ + WORKER_OPENCODE_WORKSPACE: z + .string() + .optional() + .transform((value) => value?.trim() || "/workspace/repo"), }); export type WorkerEnv = z.infer; diff --git a/apps/worker/src/executor/index.ts b/apps/worker/src/executor/index.ts index 6ed0c7b..153e4d8 100644 --- a/apps/worker/src/executor/index.ts +++ b/apps/worker/src/executor/index.ts @@ -5,6 +5,7 @@ import type { WorkerJobContext, } from "@tasks/shared/worker"; import { createMinimalSession } from "../session/minimal-session.js"; +import { createOpenCodeSession, type OpenCodeConfig } from "../session/opencode-session.js"; import { createOpenHandsSession, type OpenHandsConfig } from "../session/openhands-session.js"; import { createRemoteAgUiExecutor } from "./remote-ag-ui.js"; @@ -82,6 +83,33 @@ function buildOpenHandsExecutor(config: OpenHandsConfig): WorkerExecutor { }; } +function buildOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor { + return { + id: "opencode", + harnessType: "minimal" as HarnessType, + async run(ctx: WorkerJobContext, opts: WorkerExecutorOptions): Promise<{ summary: string; toolCalls: number; tokensTotal?: number; outcome?: "succeeded" | "failed" }> { + const session = await createOpenCodeSession(ctx, { + config, + resolvedModel: opts.resolvedModel as never, + roleId: ctx.roleId, + briefId: ctx.briefId, + briefTitle: opts.convoyTitle, + workflowPrompt: opts.workflowPrompt, + briefAcceptance: opts.briefAcceptance, + briefFiles: opts.briefFiles, + maxSteps: opts.maxSteps, + }); + const convoyTitle = opts.convoyTitle ?? "unknown"; + const result = await session.run(opts.buildPrompt(ctx, convoyTitle)); + return { + summary: result.summary, + toolCalls: result.toolCalls, + tokensTotal: result.tokensTotal, + }; + }, + }; +} + export type HarnessExecutorConfig = { harnessType: HarnessType; endpointUrl?: string | null; @@ -117,3 +145,15 @@ export function createOpenHandsExecutor( } return buildOpenHandsExecutor({ agentServerUrl, sessionApiKey, repoRoot }); } + +/** + * Create an executor for headless OpenCode (`opencode serve`). + * Password is optional (local/dev); production should set OPENCODE_SERVER_PASSWORD. + */ +export function createOpenCodeExecutor(config: OpenCodeConfig): WorkerExecutor | null { + if (!config.serverUrl) { + console.warn("[worker] OpenCode executor unavailable: missing OPENCODE_SERVER_URL"); + return null; + } + return buildOpenCodeExecutor(config); +} diff --git a/apps/worker/src/executor/opencode-executor.test.ts b/apps/worker/src/executor/opencode-executor.test.ts new file mode 100644 index 0000000..8bd81a0 --- /dev/null +++ b/apps/worker/src/executor/opencode-executor.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createOpenCodeExecutor } from "./index.js"; + +describe("createOpenCodeExecutor", () => { + it("returns null when serverUrl is empty", () => { + const exec = createOpenCodeExecutor({ + serverUrl: "", + serverPassword: "", + serverUsername: "opencode", + repoRoot: "/workspace/repo", + providerId: "omniroute", + }); + expect(exec).toBeNull(); + }); + + it("returns an opencode executor with the expected id", () => { + const exec = createOpenCodeExecutor({ + serverUrl: "http://opencode:4096", + serverPassword: "secret", + serverUsername: "opencode", + repoRoot: "/workspace/repo", + providerId: "omniroute", + }); + expect(exec).not.toBeNull(); + expect(exec?.id).toBe("opencode"); + }); + + it("creates a session and posts a message with basic auth", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "ses_1" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + info: { role: "assistant" }, + parts: [{ type: "text", text: "done" }, { type: "tool", tool: "bash" }], + }), + { status: 200 }, + ), + ); + + vi.stubGlobal("fetch", fetchImpl); + + const exec = createOpenCodeExecutor({ + serverUrl: "http://opencode:4096", + serverPassword: "secret", + serverUsername: "opencode", + repoRoot: "/workspace/repo", + providerId: "omniroute", + }); + expect(exec).not.toBeNull(); + if (!exec) return; + + const result = await exec.run( + { + workspaceId: "w", + convoyId: "c", + roleId: "role-implementer", + briefId: "b", + repoRoot: "/workspace/repo", + }, + { + resolvedModel: { + modelId: "omniroute/auto/coding", + providerType: "openai", + alias: "auto/coding", + }, + resolvedAlias: "auto/coding", + rolePromptPath: "/tmp/role.md", + maxSteps: 5, + buildPrompt: () => "Implement the thing", + convoyTitle: "test", + }, + ); + + expect(result.summary).toBe("done"); + expect(result.toolCalls).toBe(1); + expect(fetchImpl).toHaveBeenCalledTimes(2); + const [, createInit] = fetchImpl.mock.calls[0]!; + expect((createInit as RequestInit).headers).toMatchObject({ + Authorization: expect.stringMatching(/^Basic /), + }); + const [msgUrl, msgInit] = fetchImpl.mock.calls[1]!; + expect(msgUrl).toBe("http://opencode:4096/session/ses_1/message"); + const body = JSON.parse(String((msgInit as RequestInit).body)); + expect(body.model).toEqual({ providerID: "omniroute", modelID: "auto/coding" }); + + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/worker/src/handle-job.test.ts b/apps/worker/src/handle-job.test.ts index b97d7f1..4576f8a 100644 --- a/apps/worker/src/handle-job.test.ts +++ b/apps/worker/src/handle-job.test.ts @@ -91,6 +91,11 @@ const env = { AGENT_SERVER_URL: "http://agent-server:8001", WORKER_OPENHANDS_WORKSPACE: "/workspace/repo", SESSION_API_KEY: "", + OPENCODE_SERVER_URL: "http://opencode:4096", + OPENCODE_SERVER_PASSWORD: "", + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_PROVIDER_ID: "omniroute", + WORKER_OPENCODE_WORKSPACE: "/workspace/repo", REDIS_URL: "redis://localhost:6379", }; diff --git a/apps/worker/src/handle-job.ts b/apps/worker/src/handle-job.ts index 27cf337..d68e52d 100644 --- a/apps/worker/src/handle-job.ts +++ b/apps/worker/src/handle-job.ts @@ -30,7 +30,7 @@ import { } from "./git/worktree.js"; import { openDraftPR } from "./git/pr.js"; import { resolveHarnessForJob, getHarnessExecutor } from "./harness.js"; -import { getExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js"; +import { getExecutor, createOpenCodeExecutor, createOpenHandsExecutor, type HarnessExecutorConfig } from "./executor/index.js"; import { resolveHarnessForRoleDb } from "./db/harness.js"; import { resolveRepoRoot } from "./lib/repo-root.js"; import { @@ -255,10 +255,31 @@ export async function handleOrchestratorJob( } : undefined; - // Resolve the executor from the registry based on the harness type - // When WORKER_SESSION_TYPE=openhands, route to OpenHands agent-server executor + // Resolve the executor from the registry based on the harness type. + // WORKER_SESSION_TYPE=opencode → headless OpenCode (preferred). + // WORKER_SESSION_TYPE=openhands → legacy OpenHands agent-server. let executor; - if (env.WORKER_SESSION_TYPE === "openhands") { + if (env.WORKER_SESSION_TYPE === "opencode") { + const ocExecutor = createOpenCodeExecutor({ + serverUrl: env.OPENCODE_SERVER_URL, + serverPassword: env.OPENCODE_SERVER_PASSWORD, + serverUsername: env.OPENCODE_SERVER_USERNAME, + repoRoot: env.WORKER_OPENCODE_WORKSPACE, + providerId: env.OPENCODE_PROVIDER_ID, + }); + if (ocExecutor) { + executor = ocExecutor; + console.info(`[worker] using OpenCode executor server=${env.OPENCODE_SERVER_URL}`); + } else { + console.warn("[worker] OpenCode executor unavailable, falling back to minimal"); + const harnessExecutorConfig: HarnessExecutorConfig = { + harnessType: harness.harnessType, + endpointUrl: harness.endpointUrl, + authEnvVar: harness.authEnvVar, + }; + executor = getExecutor(harnessExecutorConfig); + } + } else if (env.WORKER_SESSION_TYPE === "openhands") { const ohExecutor = createOpenHandsExecutor( env.AGENT_SERVER_URL, env.SESSION_API_KEY, diff --git a/apps/worker/src/session/opencode-session.ts b/apps/worker/src/session/opencode-session.ts new file mode 100644 index 0000000..568933f --- /dev/null +++ b/apps/worker/src/session/opencode-session.ts @@ -0,0 +1,175 @@ +import type { ResolvedModel } from "@tasks/shared/model-routing"; +import type { WorkerJobContext, WorkerSession } from "@tasks/shared/worker"; + +import { buildSkillFullContext, loadAgentSkills } from "./load-skills.js"; + +/** + * Configuration for a headless OpenCode server (`opencode serve` / `opencode web`). + * Auth is HTTP basic when OPENCODE_SERVER_PASSWORD is set on the server. + */ +export type OpenCodeConfig = { + serverUrl: string; + /** Basic-auth password; empty = no Authorization header. */ + serverPassword: string; + /** Basic-auth username (OpenCode default: opencode). */ + serverUsername: string; + /** Repo root to scan for .agents/skills/ and to describe in the prompt. */ + repoRoot: string; + /** OpenCode provider id registered by the OmniRoute plugin (default omniroute). */ + providerId: string; +}; + +export type OpenCodeSessionOptions = { + config: OpenCodeConfig; + resolvedModel: ResolvedModel; + roleId: string; + briefId?: string; + briefTitle?: string; + workflowPrompt?: string; + briefAcceptance?: string; + briefFiles?: string[]; + maxSteps?: number; +}; + +type SessionCreateResponse = { id: string; title?: string }; +type MessageResponse = { + info?: { role?: string; error?: { name?: string; message?: string } }; + parts?: Array<{ type?: string; text?: string; tool?: string }>; +}; + +function basicAuthHeader(username: string, password: string): string { + const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64"); + return `Basic ${token}`; +} + +function stripProviderPrefix(modelId: string, providerId: string): string { + const prefix = `${providerId}/`; + return modelId.startsWith(prefix) ? modelId.slice(prefix.length) : modelId; +} + +/** + * Create an OpenCode session that drives a remote `opencode serve` over HTTP. + * Prefer this over OpenHands on CT 116 — same OmniRoute catalog via the plugin, + * no Docker-in-Docker sandbox, one concurrent agent until RAM upgrade. + */ +export async function createOpenCodeSession( + context: WorkerJobContext, + options: OpenCodeSessionOptions, +): Promise { + const { + config, + resolvedModel, + roleId, + briefId, + briefTitle, + workflowPrompt, + briefAcceptance, + briefFiles, + } = options; + const apiBase = config.serverUrl.replace(/\/$/, ""); + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + if (config.serverPassword) { + headers.Authorization = basicAuthHeader(config.serverUsername, config.serverPassword); + } + + let skillContext = ""; + try { + const skills = await loadAgentSkills(config.repoRoot); + skillContext = buildSkillFullContext(skills); + } catch (e) { + console.warn("[opencode] skill loading failed, continuing without skills:", e); + } + + const systemParts: string[] = [ + `You are an AI agent executing a brief for convoy "${context.convoyId}".`, + `Role: ${roleId}`, + briefId ? `Brief ID: ${briefId}` : "", + briefTitle ? `Brief: ${briefTitle}` : "", + `Working directory: ${config.repoRoot}`, + workflowPrompt ? `\n## Task\n${workflowPrompt}` : "", + briefAcceptance ? `\n## Acceptance criteria\n${briefAcceptance}` : "", + briefFiles?.length + ? `\n## Files to examine\n${briefFiles.map((f) => `- ${f}`).join("\n")}` + : "", + skillContext || "", + "\n## Instructions\nUse the available tools to inspect and modify the repo. Run lint/type-check when appropriate. Finish with a concise summary of what changed.", + ].filter(Boolean); + const systemPrompt = systemParts.join("\n"); + + const modelId = stripProviderPrefix(resolvedModel.modelId, config.providerId); + + async function createSession(title: string): Promise { + const res = await fetch(`${apiBase}/session`, { + method: "POST", + headers, + body: JSON.stringify({ title }), + }); + if (!res.ok) { + throw new Error(`OpenCode session create failed (${res.status}): ${await res.text()}`); + } + const data = (await res.json()) as SessionCreateResponse; + if (!data.id) throw new Error("OpenCode session create returned no id"); + return data.id; + } + + async function sendPrompt(sessionId: string, prompt: string): Promise<{ + summary: string; + toolCalls: number; + }> { + const res = await fetch(`${apiBase}/session/${sessionId}/message`, { + method: "POST", + headers, + body: JSON.stringify({ + model: { providerID: config.providerId, modelID: modelId }, + system: systemPrompt, + parts: [{ type: "text", text: prompt }], + }), + }); + if (!res.ok) { + throw new Error(`OpenCode prompt failed (${res.status}): ${await res.text()}`); + } + const data = (await res.json()) as MessageResponse; + if (data.info?.error) { + throw new Error( + `OpenCode error ${data.info.error.name ?? "unknown"}: ${data.info.error.message ?? ""}`, + ); + } + const textParts = (data.parts ?? []) + .filter((p) => p.type === "text" && p.text) + .map((p) => p.text as string); + const toolCalls = (data.parts ?? []).filter((p) => p.type === "tool" || p.tool).length; + return { + summary: textParts.join("\n").trim() || "OpenCode session completed with no text summary.", + toolCalls, + }; + } + + return { + context, + async run(prompt: string) { + console.info( + `[opencode] session.run roleId=${roleId} brief=${briefId ?? "none"} model=${config.providerId}/${modelId}`, + ); + try { + const title = `convoy:${context.convoyId}:${roleId}:${briefId ?? "none"}`; + const sessionId = await createSession(title); + console.info(`[opencode] session started id=${sessionId}`); + const result = await sendPrompt(sessionId, prompt); + console.info( + `[opencode] session complete tools=${result.toolCalls} summary_len=${result.summary.length}`, + ); + return result; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`[opencode] session failed:`, msg); + return { + summary: `OpenCode session failed: ${msg}`, + toolCalls: 0, + }; + } + }, + }; +} diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index de9292f..6f5aa5d 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -1,7 +1,9 @@ # Echodo production stack — shared CT 102 Postgres/Redis, multi-service build. # # Phase A (default): web, collab, mcp, orchestrator, worker -# Phase B (optional): OpenHands agent-server — enable with profile `agent-server` +# Phase B (optional coding harness): +# Preferred: OpenCode — COMPOSE_PROFILES=opencode, WORKER_SESSION_TYPE=opencode +# Legacy: OpenHands — COMPOSE_PROFILES=agent-server, WORKER_SESSION_TYPE=openhands # # Deploy targets: # - Dokploy (CT 112): compose file path `docker/docker-compose.prod.yml`, project dir = repo root @@ -10,13 +12,18 @@ # Domains (Dokploy UI or CT 100 Traefik): # web → echodo.stillwell.cloud (port 3000) # collab → collab.echodo.stillwell.cloud (port 1234) +# opencode (optional) → opencode.stillwell.cloud (port 4096) # # Build (required on ≤8 GB hosts — parallel export OOMs with exit 255): # bash scripts/build-prod.sh # Dokploy → Advanced → Custom Build Command: bash scripts/build-prod.sh # -# Phase B: -# docker compose -f docker/docker-compose.prod.yml --profile agent-server up -d +# Phase B OpenCode: +# export COMPOSE_PROFILES=opencode +# Set WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_API_KEY / BASE_URL. +# +# Phase B OpenHands (legacy): +# export COMPOSE_PROFILES=agent-server # Set WORKER_SESSION_TYPE=openhands and matching SESSION_API_KEY / LOCAL_BACKEND_API_KEY. services: @@ -179,6 +186,11 @@ services: - WORKER_OPENHANDS_WORKSPACE=${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo} - AGENT_SERVER_URL=${AGENT_SERVER_URL:-http://agent-server:8001} - SESSION_API_KEY=${SESSION_API_KEY:-} + - OPENCODE_SERVER_URL=${OPENCODE_SERVER_URL:-http://opencode:4096} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_PROVIDER_ID=${OPENCODE_PROVIDER_ID:-omniroute} + - WORKER_OPENCODE_WORKSPACE=${WORKER_OPENCODE_WORKSPACE:-/workspace/repo} - OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_BASE_URL=${OPENAI_BASE_URL} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} @@ -200,6 +212,52 @@ services: networks: - echodo + opencode: + profiles: ["opencode"] + image: ghcr.io/anomalyco/opencode:latest + container_name: submatter-opencode + restart: unless-stopped + working_dir: /workspace/repo + entrypoint: ["/bin/sh", "/config/entrypoint.sh"] + command: + [ + "opencode", + "serve", + "--hostname", + "0.0.0.0", + "--port", + "4096", + ] + environment: + - HOME=/data + - XDG_CONFIG_HOME=/data/.config + - XDG_DATA_HOME=/data/.local/share + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OMNIROUTE_API_KEY=${OMNIROUTE_API_KEY} + volumes: + - echodo-workspace:/workspace + - opencode-data:/data + - ./docker/opencode/opencode.json:/config/opencode.json:ro + - ./docker/opencode/entrypoint.sh:/config/entrypoint.sh:ro + ports: + - "4096:4096" + healthcheck: + test: + - CMD-SHELL + - "wget -qO- --timeout=3 http://127.0.0.1:4096/global/health >/dev/null || curl -fsS http://127.0.0.1:4096/global/health >/dev/null || exit 1" + interval: 30s + timeout: 10s + retries: 3 + start_period: 45s + deploy: + resources: + limits: + memory: 2G + cpus: "2" + networks: + - echodo + agent-server: profiles: ["agent-server"] image: ghcr.io/open-hands/agent-server:0.19.0 @@ -238,6 +296,7 @@ services: volumes: echodo-workspace: + opencode-data: networks: echodo: diff --git a/docker/opencode/README.md b/docker/opencode/README.md new file mode 100644 index 0000000..b99719e --- /dev/null +++ b/docker/opencode/README.md @@ -0,0 +1,51 @@ +# OpenCode on CT 116 (Submatter) + +Headless coding harness for Submatter. Prefer this over OpenHands. + +Runs `opencode serve` on `:4096` (HTTP API for the worker). Attach a TUI with +`opencode attach http://…:4096`, or use the browser via Traefik once Authentik +is happy. The image has no desktop browser — do not use `opencode web` in +compose without an `xdg-open` stub (entrypoint provides one if you switch back). + +## Enable + +On CT 116 `/opt/submatter/.env`: + +```bash +COMPOSE_PROFILES=opencode +WORKER_SESSION_TYPE=opencode # after worker image rebuild with opencode session +OPENCODE_SERVER_PASSWORD= +OPENCODE_SERVER_URL=http://opencode:4096 +OMNIROUTE_BASE_URL=https://omniroute.stillwell.cloud/v1 +OMNIROUTE_API_KEY= +``` + +Then: + +```bash +export COMPOSE_PROFILES=opencode +bash scripts/deploy-prod.sh up +# First time / after worker harness code lands: +# bash scripts/deploy-prod.sh build +``` + +Keep `WORKER_SESSION_TYPE=minimal` until the rebuilt worker image includes the +`opencode` session type (and the `yaml` dependency fix). + +## URLs + +| Surface | URL | +|---------|-----| +| Traefik | `https://opencode.stillwell.cloud` (Authentik + basic auth) | +| Worker API | `http://opencode:4096` on the `echodo` Docker network | +| LAN health | `curl -u opencode:$OPENCODE_SERVER_PASSWORD http://192.168.68.116:4096/global/health` | + +## Auth + +- Browser: Authentik via Traefik (`authentik-auth`) **and** OpenCode basic auth. +- Worker: same basic auth via `OPENCODE_SERVER_PASSWORD`. +- Models: `@omniroute/opencode-plugin` + `OMNIROUTE_API_KEY` (seeded into `auth.json` by `entrypoint.sh`). + +## RAM + +Container capped at 2 GB. Keep one concurrent agent until host RAM → 64 GB. diff --git a/docker/opencode/entrypoint.sh b/docker/opencode/entrypoint.sh new file mode 100644 index 0000000..ad6aee3 --- /dev/null +++ b/docker/opencode/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Seed OmniRoute auth for headless OpenCode, then start serve/web. +set -eu + +CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME:-/data}/.config}" +DATA_HOME="${XDG_DATA_HOME:-${HOME:-/data}/.local/share}" +mkdir -p "$CONFIG_HOME/opencode" "$DATA_HOME/opencode" /data/bin + +# Container has no desktop — stub xdg-open so `opencode web` does not exit. +if [ ! -x /data/bin/xdg-open ]; then + printf '#!/bin/sh\nexit 0\n' > /data/bin/xdg-open + chmod +x /data/bin/xdg-open +fi +export PATH="/data/bin:${PATH}" + +if [ -f /config/opencode.json ]; then + cp /config/opencode.json "$CONFIG_HOME/opencode/opencode.json" +fi + +if [ -n "${OMNIROUTE_API_KEY:-}" ]; then + # Keys must not contain double-quotes; OmniRoute keys are hex/base64-safe. + printf '{"omniroute":{"type":"api","key":"%s"}}\n' "$OMNIROUTE_API_KEY" \ + > "$DATA_HOME/opencode/auth.json" + chmod 600 "$DATA_HOME/opencode/auth.json" || true +fi + +exec "$@" diff --git a/docker/opencode/opencode.json b/docker/opencode/opencode.json new file mode 100644 index 0000000..79f6899 --- /dev/null +++ b/docker/opencode/opencode.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + [ + "@omniroute/opencode-plugin", + { + "providerId": "omniroute", + "baseURL": "http://192.168.68.108:20128" + } + ] + ], + "server": { + "port": 4096, + "hostname": "0.0.0.0" + } +} diff --git a/docker/worker-entrypoint.sh b/docker/worker-entrypoint.sh index 717b2b4..ee439bb 100755 --- a/docker/worker-entrypoint.sh +++ b/docker/worker-entrypoint.sh @@ -1,13 +1,18 @@ #!/bin/sh set -eu -# When OpenHands is enabled, seed the shared workspace volume from the deploy -# snapshot baked into the image (/app/repo). Agent-server reads the same path. -if [ "${WORKER_SESSION_TYPE:-minimal}" = "openhands" ]; then - target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}" +# When a remote harness shares a workspace volume, seed it from the deploy +# snapshot baked into the image (/app/repo). +session_type="${WORKER_SESSION_TYPE:-minimal}" +if [ "$session_type" = "openhands" ] || [ "$session_type" = "opencode" ]; then + if [ "$session_type" = "opencode" ]; then + target="${WORKER_OPENCODE_WORKSPACE:-/workspace/repo}" + else + target="${WORKER_OPENHANDS_WORKSPACE:-/workspace/repo}" + fi mkdir -p "$(dirname "$target")" if [ ! -f "$target/pnpm-workspace.yaml" ]; then - echo "[worker-entrypoint] seeding OpenHands workspace at ${target}" + echo "[worker-entrypoint] seeding ${session_type} workspace at ${target}" mkdir -p "$target" cp -a /app/repo/. "$target/" fi diff --git a/docs/deployment-production.md b/docs/deployment-production.md index dd34e95..d322a5c 100644 --- a/docs/deployment-production.md +++ b/docs/deployment-production.md @@ -117,7 +117,26 @@ If Forgejo is canonical git and you want push deploy without Actions: --- -## Phase B — OpenHands (optional) +## Phase B — OpenCode (preferred) + +```bash +export COMPOSE_PROFILES=opencode +# Set in .env: WORKER_SESSION_TYPE=opencode, OPENCODE_SERVER_PASSWORD, OMNIROUTE_* +bash scripts/deploy-prod.sh build # first time / worker code change +# or: bash scripts/deploy-prod.sh up +``` + +| Variable | Notes | +|----------|-------| +| `OPENCODE_SERVER_PASSWORD` | `openssl rand -hex 24` — Traefik still fronts Authentik | +| `WORKER_SESSION_TYPE` | `opencode` | +| `OPENCODE_SERVER_URL` | `http://opencode:4096` (Docker DNS) | +| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` or LAN `http://192.168.68.108:20128/v1` | +| `OMNIROUTE_API_KEY` | OmniRoute bearer key | + +Web UI: `https://opencode.stillwell.cloud`. Details: `docker/opencode/README.md`. + +### Legacy — OpenHands (optional) ```bash export COMPOSE_PROFILES=agent-server @@ -129,11 +148,8 @@ bash scripts/deploy-prod.sh build | `LOCAL_BACKEND_API_KEY` | `openssl rand -hex 32` | | `SESSION_API_KEY` | Same as above (worker) | | `WORKER_SESSION_TYPE` | `openhands` | -| `OMNIROUTE_BASE_URL` | `https://omniroute.stillwell.cloud/v1` | -| `OMNIROUTE_API_KEY` | OmniRoute bearer key | - -Requires Docker socket on CT 116 (`nesting=1` on LXC). +Requires Docker socket on CT 116 (`nesting=1` on LXC). Prefer OpenCode unless debugging the old path. --- ## Troubleshooting diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b16179..2c7d6d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -393,6 +393,9 @@ importers: ioredis: specifier: ^5.4.2 version: 5.10.1 + yaml: + specifier: ^2.7.0 + version: 2.8.3 zod: specifier: ^3.24.0 version: 3.25.76 diff --git a/proxmox/ct116/README.md b/proxmox/ct116/README.md index c4c92b1..6cf9b9a 100644 --- a/proxmox/ct116/README.md +++ b/proxmox/ct116/README.md @@ -108,7 +108,16 @@ Handler: `deploy-webhook.sh` ssh root@192.168.68.116 'cd /opt/submatter && git pull && bash scripts/deploy-prod.sh build' ``` -## Phase B (OpenHands) +## Phase B (OpenCode — preferred) + +```bash +export COMPOSE_PROFILES=opencode +bash scripts/deploy-prod.sh build +``` + +Set in `.env`: `WORKER_SESSION_TYPE=opencode`, `OPENCODE_SERVER_PASSWORD`, `OMNIROUTE_API_KEY` / `OMNIROUTE_BASE_URL`. See `docker/opencode/README.md`. + +### Legacy Phase B (OpenHands) ```bash export COMPOSE_PROFILES=agent-server @@ -116,7 +125,6 @@ bash scripts/deploy-prod.sh build ``` Set `WORKER_SESSION_TYPE=openhands`, `SESSION_API_KEY`, `LOCAL_BACKEND_API_KEY`, OmniRoute vars in `.env`. - ## AGENT-DEPLOY.md handoff After CT is live, add to your local `AGENT-DEPLOY.md` (gitignored):