feat: dedicated PVC per agent for OPENCODE_DB (FAR-63, Option B)

Replaces the Option A shared-PVC path implementation with a long-lived
dedicated PVC per agent, mounted at /opencode-db with OPENCODE_DB=/opencode-db.

Changes:
- k8s-client.ts: add getPvc/createPvc/deletePvc CoreV1Api helpers
- execute.ts: add ensureAgentDbPvc() that gets-or-creates a PVC named
  opencode-db-<agentId> before Job creation; pass agentDbClaimName through
  to buildJobManifest; return null for ephemeral mode (emptyDir used instead)
- job-manifest.ts: accept agentDbClaimName on JobBuildInput; mount dedicated
  PVC or emptyDir at /opencode-db; set OPENCODE_DB=/opencode-db; revert init
  container to simple form (no mkdir, no PVC mount)
- config-schema.ts: replace opencodeDbMode/opencodeDbPath with agentDbMode
  (dedicated_pvc|ephemeral, default dedicated_pvc), agentDbStorageClass
  (required for dedicated_pvc), agentDbStorageCapacity (default 1Gi)
- test.ts: add create/delete RBAC checks for persistentvolumeclaims
- pvc.test.ts: unit tests for ensureAgentDbPvc (7 cases incl. error paths)
- 289/289 tests pass; typecheck clean
- No agent-delete hook exists; opencode-db PVC janitor routine is a deferred
  follow-up task

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 12:38:54 +00:00
parent 908880a25b
commit 46ce5cc599
9 changed files with 330 additions and 132 deletions
+70 -1
View File
@@ -8,7 +8,7 @@ import {
isOpenCodeUnknownSessionError,
isOpenCodeStepLimitResult,
} from "./parse.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi, getPvc, createPvc } from "./k8s-client.js";
import { buildJobManifest, LARGE_PROMPT_THRESHOLD_BYTES } from "./job-manifest.js";
import { LogLineDedupFilter } from "./log-dedup.js";
import type * as k8s from "@kubernetes/client-node";
@@ -831,6 +831,58 @@ function ensureSigtermHandler(): void {
});
}
/**
* Ensure the per-agent dedicated PVC exists (dedicated_pvc mode) or return null (ephemeral).
* Returns the PVC claim name on success, null when agentDbMode is "ephemeral".
* Throws when agentDbStorageClass is missing in dedicated_pvc mode.
*/
export async function ensureAgentDbPvc(
agentId: string,
namespace: string,
config: Record<string, unknown>,
kubeconfigPath?: string,
): Promise<string | null> {
const agentDbMode = (asString(config.agentDbMode, "dedicated_pvc").trim() || "dedicated_pvc") as "dedicated_pvc" | "ephemeral";
if (agentDbMode === "ephemeral") return null;
// Build a K8s-safe PVC name from the agent ID (UUIDs are already alphanumeric+hyphens)
const agentSlug = agentId.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "").slice(0, 208);
const pvcName = `opencode-db-${agentSlug}`;
const existing = await getPvc(namespace, pvcName, kubeconfigPath);
if (existing) return pvcName;
const storageClass = asString(config.agentDbStorageClass, "").trim();
if (!storageClass) {
throw new Error(
"agentDbStorageClass is required when agentDbMode is \"dedicated_pvc\" but is not configured. " +
"Set agentDbStorageClass in the adapter config or switch agentDbMode to \"ephemeral\".",
);
}
const capacity = asString(config.agentDbStorageCapacity, "1Gi").trim() || "1Gi";
await createPvc(namespace, {
apiVersion: "v1",
kind: "PersistentVolumeClaim",
metadata: {
name: pvcName,
namespace,
labels: {
"app.kubernetes.io/managed-by": "paperclip",
"paperclip.io/agent-id": agentId,
"paperclip.io/pvc-type": "agent-db",
},
},
spec: {
accessModes: ["ReadWriteOnce"],
storageClassName: storageClass,
resources: { requests: { storage: capacity } },
},
}, kubeconfigPath);
return pvcName;
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { config: rawConfig, onLog, onMeta } = ctx;
const config = parseObject(rawConfig);
@@ -985,11 +1037,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// non-fatal: skill bundle is optional
}
// Ensure per-agent DB PVC exists (or get null for ephemeral mode)
let agentDbClaimName: string | null | undefined;
try {
agentDbClaimName = await ensureAgentDbPvc(agentId, guardNamespace, config, kubeconfigPath);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Failed to ensure agent DB PVC: ${msg}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: msg,
errorCode: "k8s_job_create_failed",
};
}
const buildArgs = {
ctx,
selfPod,
instructionsContent: instructionsContent || undefined,
skillsBundleContent: skillsBundleContent || undefined,
agentDbClaimName,
};
const firstBuild = buildJobManifest(buildArgs);
const { jobName, namespace, prompt, opencodeArgs, promptMetrics } = firstBuild;