From 908880a25b8b1801698707c80de91e8f9b581001 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 25 Apr 2026 12:29:38 +0000 Subject: [PATCH] feat: implement OPENCODE_DB per-agent path on shared PVC (FAR-62) - shared_pvc mode (default): sets OPENCODE_DB to /paperclip/.opencode/db/ and prepends mkdir -p to the busybox init container when a PVC is present - ephemeral mode: mounts an emptyDir at /opencode-db and points OPENCODE_DB there - config-schema: adds opencodeDbMode (select, default shared_pvc) and opencodeDbPath (optional text override for shared_pvc path) - No agent-delete hook exists in this adapter; per-agent DB dir cleanup is deferred to a janitor routine (follow-up work) - 284/284 tests pass; typecheck clean Co-Authored-By: Paperclip --- src/server/config-schema.test.ts | 18 ++++++ src/server/config-schema.ts | 19 ++++++ src/server/job-manifest.test.ts | 105 +++++++++++++++++++++++++++++++ src/server/job-manifest.ts | 56 +++++++++++++---- 4 files changed, 185 insertions(+), 13 deletions(-) diff --git a/src/server/config-schema.test.ts b/src/server/config-schema.test.ts index 7c2edbc..b0015eb 100644 --- a/src/server/config-schema.test.ts +++ b/src/server/config-schema.test.ts @@ -85,6 +85,24 @@ describe("getConfigSchema", () => { } }); + it("has opencodeDbMode as select with shared_pvc default", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "opencodeDbMode"); + expect(field).toBeDefined(); + expect(field!.type).toBe("select"); + expect(field!.default).toBe("shared_pvc"); + expect(field!.options).toContainEqual(expect.objectContaining({ value: "shared_pvc" })); + expect(field!.options).toContainEqual(expect.objectContaining({ value: "ephemeral" })); + }); + + it("has opencodeDbPath as optional text field with no default", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "opencodeDbPath"); + expect(field).toBeDefined(); + expect(field!.type).toBe("text"); + expect(field!.default).toBeUndefined(); + }); + it("has nodeSelector and tolerations as textarea", () => { const schema = getConfigSchema(); const nodeField = schema.fields.find((f: ConfigFieldSchema) => f.key === "nodeSelector"); diff --git a/src/server/config-schema.ts b/src/server/config-schema.ts index daf0b03..5d8af76 100644 --- a/src/server/config-schema.ts +++ b/src/server/config-schema.ts @@ -26,6 +26,25 @@ export function getConfigSchema(): AdapterConfigSchema { hint: "Inject runtime config with permission.external_directory=allow", group: "Core", }, + { + key: "opencodeDbMode", + label: "OpenCode DB Mode", + type: "select", + options: [ + { label: "Shared PVC (per-agent path)", value: "shared_pvc" }, + { label: "Ephemeral (emptyDir)", value: "ephemeral" }, + ], + default: "shared_pvc", + hint: "shared_pvc stores the DB on the PVC at /paperclip/.opencode/db/; ephemeral uses a Job-local emptyDir (lost after each run)", + group: "Core", + }, + { + key: "opencodeDbPath", + label: "OpenCode DB Path Override", + type: "text", + hint: "Optional: override the OPENCODE_DB path (only used in shared_pvc mode); defaults to /paperclip/.opencode/db/", + group: "Core", + }, // Kubernetes fields { diff --git a/src/server/job-manifest.test.ts b/src/server/job-manifest.test.ts index cdc2460..9a13fbd 100644 --- a/src/server/job-manifest.test.ts +++ b/src/server/job-manifest.test.ts @@ -286,6 +286,111 @@ describe("buildJobManifest", () => { }); }); +const mockSelfPodWithPvc: JobBuildInput["selfPod"] = { + ...mockSelfPod, + pvcClaimName: "data-pvc", +}; + +describe("OPENCODE_DB env var", () => { + it("sets OPENCODE_DB to default per-agent path in shared_pvc mode", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); + const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; + const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); + expect(dbEnv?.value).toBe(`/paperclip/.opencode/db/${mockCtx.agent.id}`); + }); + + it("uses opencodeDbPath override when set in shared_pvc mode", () => { + const ctx = { ...mockCtx, config: { opencodeDbPath: "/custom/db/path" } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; + const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); + expect(dbEnv?.value).toBe("/custom/db/path"); + }); + + it("sets OPENCODE_DB to /opencode-db in ephemeral mode", () => { + const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; + const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); + expect(dbEnv?.value).toBe("/opencode-db"); + }); + + it("ignores opencodeDbPath in ephemeral mode", () => { + const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral", opencodeDbPath: "/ignored" } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; + const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); + expect(dbEnv?.value).toBe("/opencode-db"); + }); + + it("defaults to shared_pvc when opencodeDbMode is unset", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); + const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; + const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); + expect(dbEnv?.value).toContain("/paperclip/.opencode/db/"); + }); +}); + +describe("ephemeral DB volume", () => { + it("adds opencode-db emptyDir volume in ephemeral mode", () => { + const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const volumes = result.job.spec?.template?.spec?.volumes ?? []; + const dbVolume = volumes.find((v) => v.name === "opencode-db"); + expect(dbVolume?.emptyDir).toBeDefined(); + }); + + it("mounts opencode-db at /opencode-db in the main container in ephemeral mode", () => { + const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + const mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? []; + const dbMount = mounts.find((m) => m.name === "opencode-db"); + expect(dbMount?.mountPath).toBe("/opencode-db"); + }); + + it("does not add opencode-db volume in shared_pvc mode", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); + const volumes = result.job.spec?.template?.spec?.volumes ?? []; + expect(volumes.find((v) => v.name === "opencode-db")).toBeUndefined(); + }); +}); + +describe("init container mkdir for shared_pvc", () => { + it("adds mkdir -p to init container command when PVC is present and mode is shared_pvc", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); + const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command; + expect(initCmd?.[2]).toContain("mkdir -p"); + }); + + it("mounts PVC in init container for shared_pvc mode with PVC", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); + const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? []; + expect(initMounts.some((m) => m.name === "data")).toBe(true); + }); + + it("passes OPENCODE_DB_PATH env var to init container in shared_pvc mode with PVC", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); + const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? []; + const dbPathEnv = initEnv.find((e) => e.name === "OPENCODE_DB_PATH"); + expect(dbPathEnv?.value).toBe(`/paperclip/.opencode/db/${mockCtx.agent.id}`); + }); + + it("does not add mkdir when PVC is not present in shared_pvc mode", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); // no PVC + const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command; + expect(initCmd?.[2]).not.toContain("mkdir"); + }); + + it("does not add mkdir or PVC mount in ephemeral mode even when PVC is present", () => { + const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; + const result = buildJobManifest({ ctx, selfPod: mockSelfPodWithPvc }); + const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command; + expect(initCmd?.[2]).not.toContain("mkdir"); + const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? []; + expect(initMounts.some((m) => m.name === "data")).toBe(false); + }); +}); + describe("sanitizeLabelValue", () => { it("passes through clean values unchanged", () => { expect(sanitizeLabelValue("abc-123")).toBe("abc-123"); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index 8407596..104cec8 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -233,6 +233,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { const configuredCwd = asString(config.cwd, ""); const workingDir = workspaceCwd || configuredCwd || "/paperclip"; + // OpenCode DB configuration + const opencodeDbMode = (asString(config.opencodeDbMode, "shared_pvc").trim() || "shared_pvc") as "shared_pvc" | "ephemeral"; + const opencodeDbPathOverride = asString(config.opencodeDbPath, "").trim(); + const resolvedDbPath = opencodeDbMode === "ephemeral" + ? "/opencode-db" + : (opencodeDbPathOverride || `/paperclip/.opencode/db/${agent.id}`); + // Job naming: slug + 6-char hash for collision resistance; strip trailing hyphens const agentSlug = sanitizeForK8sName(agent.id); const runSlug = sanitizeForK8sName(runId); @@ -296,6 +303,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { // Build env vars const envVars = buildEnvVars(ctx, selfPod, config); + // OPENCODE_DB: always set after user overrides to enforce per-agent isolation or ephemeral mode + const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB"); + if (dbEnvIdx >= 0) { + envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: resolvedDbPath }; + } else { + envVars.push({ name: "OPENCODE_DB", value: resolvedDbPath }); + } + // Runtime config for permissions const runtimeConfigJson = buildRuntimeConfigJson(config); @@ -345,6 +360,12 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { volumeMounts.push({ name: "data", mountPath: "/paperclip" }); } + // Ephemeral DB volume (only for ephemeral mode) + if (opencodeDbMode === "ephemeral") { + volumes.push({ name: "opencode-db", emptyDir: {} }); + volumeMounts.push({ name: "opencode-db", mountPath: "/opencode-db" }); + } + // Mount secret volumes inherited from the Deployment pod for (const sv of selfPod.secretVolumes) { volumes.push({ @@ -374,6 +395,25 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { fsGroupChangePolicy: "OnRootMismatch", }; + // Build init container pieces + // shared_pvc mode: prepend mkdir -p to guarantee the per-agent DB dir exists before opencode starts + const useSharedPvcMkdir = opencodeDbMode === "shared_pvc" && Boolean(selfPod.pvcClaimName); + const dbSetupPrefix = useSharedPvcMkdir ? `mkdir -p "$OPENCODE_DB_PATH" && ` : ""; + const initVolumeMounts: k8s.V1VolumeMount[] = [{ name: "prompt", mountPath: "/tmp/prompt" }]; + const initEnvVars: k8s.V1EnvVar[] = []; + if (input.promptSecretName) { + initVolumeMounts.push({ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true }); + } else { + initEnvVars.push({ name: "PROMPT_CONTENT", value: prompt }); + } + if (useSharedPvcMkdir) { + initVolumeMounts.push({ name: "data", mountPath: "/paperclip" }); + initEnvVars.push({ name: "OPENCODE_DB_PATH", value: resolvedDbPath }); + } + const initContainerCommand = input.promptSecretName + ? `${dbSetupPrefix}cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt` + : `${dbSetupPrefix}printf '%s' "$PROMPT_CONTENT" > /tmp/prompt/prompt.txt`; + // Build the main container command // 1. Optionally write opencode runtime config for permission bypass // 2. Pipe prompt into opencode @@ -414,19 +454,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { name: "write-prompt", image: "busybox:1.36", imagePullPolicy: "IfNotPresent", - ...(input.promptSecretName - ? { - command: ["sh", "-c", "cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt"], - volumeMounts: [ - { name: "prompt", mountPath: "/tmp/prompt" }, - { name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true }, - ], - } - : { - command: ["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"], - env: [{ name: "PROMPT_CONTENT", value: prompt }], - volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }], - }), + command: ["sh", "-c", initContainerCommand], + ...(initEnvVars.length > 0 ? { env: initEnvVars } : {}), + volumeMounts: initVolumeMounts, securityContext, resources: { requests: { cpu: "10m", memory: "16Mi" },