import { describe, it, expect } from "vitest"; import { buildJobManifest, sanitizeLabelValue, type JobBuildInput } from "./job-manifest.js"; const mockSelfPod: JobBuildInput["selfPod"] = { namespace: "paperclip", image: "paperclip/paperclip:latest", imagePullSecrets: [], inheritedEnv: {}, inheritedEnvValueFrom: [], inheritedEnvFrom: [], pvcClaimName: null, dnsConfig: undefined, secretVolumes: [], }; const mockCtx: JobBuildInput["ctx"] = { runId: "run123456", agent: { id: "agent-abc", name: "Test Agent", companyId: "co123", adapterType: null, adapterConfig: null }, runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null }, config: {}, context: { taskId: null, issueId: null, paperclipWorkspace: null, issueIds: null, paperclipWorkspaces: null, paperclipRuntimeServiceIntents: null, paperclipRuntimeServices: null, }, onLog: async () => {}, }; describe("buildJobManifest", () => { it("creates job with agent-opencode- prefix in name", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(result.jobName).toMatch(/^agent-opencode-/); }); it("uses default image from selfPod", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const container = result.job.spec?.template?.spec?.containers?.[0]; expect(container?.image).toBe("paperclip/paperclip:latest"); }); it("uses config.image when provided, overriding selfPod image", () => { const ctxWithImage = { ...mockCtx, config: { image: "my-custom-image:v1.2.3" }, }; const result = buildJobManifest({ ctx: ctxWithImage, selfPod: mockSelfPod }); const container = result.job.spec?.template?.spec?.containers?.[0]; expect(container?.image).toBe("my-custom-image:v1.2.3"); }); it("sets fsGroupChangePolicy to OnRootMismatch", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const securityContext = result.job.spec?.template?.spec?.securityContext; expect(securityContext?.fsGroupChangePolicy).toBe("OnRootMismatch"); }); it("sets runAsNonRoot and runAsUser 1000", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const securityContext = result.job.spec?.template?.spec?.securityContext; expect(securityContext?.runAsNonRoot).toBe(true); expect(securityContext?.runAsUser).toBe(1000); expect(securityContext?.runAsGroup).toBe(1000); expect(securityContext?.fsGroup).toBe(1000); }); it("maps labels to job metadata", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(result.job.metadata?.labels?.["app.kubernetes.io/managed-by"]).toBe("paperclip"); expect(result.job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("opencode_k8s"); expect(result.job.metadata?.labels?.["paperclip.io/agent-id"]).toBe("agent-abc"); expect(result.job.metadata?.labels?.["paperclip.io/run-id"]).toBe("run123456"); }); it("sets paperclip.io/task-id label when context.taskId is present", () => { const ctx = { ...mockCtx, context: { ...mockCtx.context, taskId: "7e0829c0-cbf7-4652-9554-4d777ce84bff" }, }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBe("7e0829c0-cbf7-4652-9554-4d777ce84bff"); }); it("falls back to context.issueId for paperclip.io/task-id when taskId is null", () => { const ctx = { ...mockCtx, context: { ...mockCtx.context, taskId: null, issueId: "issue-uuid-xyz" }, }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBe("issue-uuid-xyz"); }); it("omits paperclip.io/task-id label when context.taskId and issueId are both null", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined(); }); it("sets paperclip.io/session-id label when runtime.sessionParams.sessionId is present", () => { const ctx = { ...mockCtx, runtime: { ...mockCtx.runtime, sessionParams: { sessionId: "ses_abc123" } }, }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.job.metadata?.labels?.["paperclip.io/session-id"]).toBe("ses_abc123"); }); it("omits paperclip.io/session-id label when no session exists", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(result.job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined(); }); it("creates init container for prompt", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const initContainers = result.job.spec?.template?.spec?.initContainers; expect(initContainers?.length).toBe(1); expect(initContainers?.[0].name).toBe("write-prompt"); expect(initContainers?.[0].image).toBe("busybox:1.36"); }); it("sets HOME to /paperclip", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; const homeEnv = env.find((e) => e.name === "HOME"); expect(homeEnv?.value).toBe("/paperclip"); }); it("sets OPENCODE_DISABLE_PROJECT_CONFIG=true", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; const opencodeEnv = env.find((e) => e.name === "OPENCODE_DISABLE_PROJECT_CONFIG"); expect(opencodeEnv?.value).toBe("true"); }); it("applies default ttlSecondsAfterFinished of 300", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(result.job.spec?.ttlSecondsAfterFinished).toBe(300); }); it("sets backoffLimit to 0", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(result.job.spec?.backoffLimit).toBe(0); }); it("uses job template restartPolicy Never", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(result.job.spec?.template?.spec?.restartPolicy).toBe("Never"); }); it("applies nodeSelector from key=value textarea string", () => { const ctx = { ...mockCtx, config: { nodeSelector: "kubernetes.io/arch=amd64\nkubernetes.io/os=linux" } }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "kubernetes.io/arch": "amd64", "kubernetes.io/os": "linux", }); }); it("applies nodeSelector from JSON object string", () => { const ctx = { ...mockCtx, config: { nodeSelector: '{"node-type":"gpu"}' } }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "node-type": "gpu" }); }); it("applies nodeSelector from plain object config", () => { const ctx = { ...mockCtx, config: { nodeSelector: { "zone": "us-east-1" } } }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ zone: "us-east-1" }); }); it("ignores blank lines and comments in nodeSelector textarea", () => { const ctx = { ...mockCtx, config: { nodeSelector: "# comment\n\nkubernetes.io/arch=amd64\n" }, }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "kubernetes.io/arch": "amd64" }); }); it("forwards inheritedEnvValueFrom entries onto the opencode container env", () => { const selfPod = { ...mockSelfPod, inheritedEnvValueFrom: [ { name: "MY_SECRET", valueFrom: { secretKeyRef: { name: "my-secret", key: "token" } } }, ], }; const result = buildJobManifest({ ctx: mockCtx, selfPod }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; const secretEnv = env.find((e) => e.name === "MY_SECRET"); expect(secretEnv?.valueFrom?.secretKeyRef?.name).toBe("my-secret"); expect(secretEnv?.valueFrom?.secretKeyRef?.key).toBe("token"); }); it("does not duplicate an inheritedEnvValueFrom entry if the name is already set as a literal", () => { const selfPod = { ...mockSelfPod, inheritedEnv: { HOME: "/custom" }, inheritedEnvValueFrom: [ { name: "HOME", valueFrom: { secretKeyRef: { name: "s", key: "k" } } }, ], }; const result = buildJobManifest({ ctx: mockCtx, selfPod }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; const homeEntries = env.filter((e) => e.name === "HOME"); // HOME is overridden by merged (HOME=/paperclip hardcoded last), so valueFrom must not appear expect(homeEntries.every((e) => e.value !== undefined)).toBe(true); }); it("forwards inheritedEnvFrom onto the opencode container envFrom", () => { const selfPod = { ...mockSelfPod, inheritedEnvFrom: [{ secretRef: { name: "my-config-secret" } }], }; const result = buildJobManifest({ ctx: mockCtx, selfPod }); const container = result.job.spec?.template?.spec?.containers?.[0]; expect(container?.envFrom).toEqual([{ secretRef: { name: "my-config-secret" } }]); }); it("omits envFrom when inheritedEnvFrom is empty", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const container = result.job.spec?.template?.spec?.containers?.[0]; expect(container?.envFrom).toBeUndefined(); }); it("job name includes 6-char hash suffix", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); // format: agent-opencode-{agentSlug}-{runSlug}-{6hexchars} expect(result.jobName).toMatch(/^agent-opencode-[a-z0-9-]+-[a-f0-9]{6}$/); }); it("job name has no trailing hyphens even when slug sanitization creates them", () => { const ctx = { ...mockCtx, agent: { ...mockCtx.agent, id: "agent---" }, runId: "run---", }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); expect(result.jobName).not.toMatch(/-$/); }); it("label values are sanitized to [a-z0-9._-]", () => { const ctx = { ...mockCtx, agent: { ...mockCtx.agent, id: "agent-id-123", companyId: "company-456" }, runId: "run-789", }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); const labels = result.job.metadata?.labels ?? {}; expect(labels["paperclip.io/agent-id"]).toMatch(/^[a-z0-9._-]*$/); expect(labels["paperclip.io/run-id"]).toMatch(/^[a-z0-9._-]*$/); expect(labels["paperclip.io/company-id"]).toMatch(/^[a-z0-9._-]*$/); }); it("same agent+run always produces the same job name (deterministic hash)", () => { const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const r2 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); expect(r1.jobName).toBe(r2.jobName); }); it("different runIds produce different job names", () => { const ctx2 = { ...mockCtx, runId: "run-different-999" }; const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const r2 = buildJobManifest({ ctx: ctx2, selfPod: mockSelfPod }); expect(r1.jobName).not.toBe(r2.jobName); }); }); describe("agentDbClaimName — OPENCODE_DB env var", () => { it("sets OPENCODE_DB to /opencode-db/opencode.db when agentDbClaimName is a string (dedicated PVC)", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db"); }); it("sets OPENCODE_DB to /opencode-db/opencode.db when agentDbClaimName is null (ephemeral)", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db"); }); it("does not set OPENCODE_DB when agentDbClaimName is undefined", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; expect(env.find((e) => e.name === "OPENCODE_DB")).toBeUndefined(); }); it("replaces a user-provided OPENCODE_DB env override with /opencode-db/opencode.db", () => { const selfPod = { ...mockSelfPod, inheritedEnv: { OPENCODE_DB: "/user/override" } }; const result = buildJobManifest({ ctx: mockCtx, selfPod, agentDbClaimName: "opencode-db-agent-abc" }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; const dbEntries = env.filter((e) => e.name === "OPENCODE_DB"); expect(dbEntries).toHaveLength(1); expect(dbEntries[0].value).toBe("/opencode-db/opencode.db"); }); }); describe("agentDbClaimName — volume wiring", () => { it("mounts dedicated PVC at /opencode-db when agentDbClaimName is a string", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" }); const volumes = result.job.spec?.template?.spec?.volumes ?? []; const dbVol = volumes.find((v) => v.name === "opencode-db"); expect(dbVol?.persistentVolumeClaim?.claimName).toBe("opencode-db-agent-abc"); const mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? []; expect(mounts.find((m) => m.name === "opencode-db")?.mountPath).toBe("/opencode-db"); }); it("mounts emptyDir at /opencode-db when agentDbClaimName is null (ephemeral)", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null }); const volumes = result.job.spec?.template?.spec?.volumes ?? []; const dbVol = volumes.find((v) => v.name === "opencode-db"); expect(dbVol?.emptyDir).toBeDefined(); expect(dbVol?.persistentVolumeClaim).toBeUndefined(); const mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? []; expect(mounts.find((m) => m.name === "opencode-db")?.mountPath).toBe("/opencode-db"); }); it("does not add opencode-db volume when agentDbClaimName is undefined", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const volumes = result.job.spec?.template?.spec?.volumes ?? []; expect(volumes.find((v) => v.name === "opencode-db")).toBeUndefined(); }); }); describe("init container is unchanged by agentDbClaimName", () => { it("does not add extra env vars to init container for dedicated PVC mode", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" }); const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command; // mkdir is added for log directory but OPENCODE_DB_PATH env var is NOT added expect(initCmd?.[2]).toContain("mkdir"); const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? []; expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false); }); it("does not add PVC mount to init container for dedicated PVC mode", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" }); const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? []; expect(initMounts.some((m) => m.name === "opencode-db")).toBe(false); }); }); describe("sanitizeLabelValue", () => { it("passes through clean values unchanged", () => { expect(sanitizeLabelValue("abc-123")).toBe("abc-123"); expect(sanitizeLabelValue("foo.bar_baz")).toBe("foo.bar_baz"); }); it("lowercases uppercase letters", () => { expect(sanitizeLabelValue("MyAgent")).toBe("myagent"); }); it("strips chars outside [a-z0-9._-]", () => { expect(sanitizeLabelValue("agent/id:test@v1")).toBe("agentidtestv1"); }); it("truncates to 63 chars", () => { const long = "a".repeat(80); expect(sanitizeLabelValue(long)).toHaveLength(63); }); it("calls warn when chars are dropped", () => { const warned: string[] = []; sanitizeLabelValue("agent/id", (msg) => warned.push(msg)); expect(warned.length).toBe(1); expect(warned[0]).toContain("agent/id"); }); it("does not call warn when nothing is dropped", () => { const warned: string[] = []; sanitizeLabelValue("clean-value.123", (msg) => warned.push(msg)); expect(warned.length).toBe(0); }); it("does not call warn when the only change is truncation to exactly 63", () => { const warned: string[] = []; // 63 chars all lowercase clean — no warn expected sanitizeLabelValue("a".repeat(63), (msg) => warned.push(msg)); expect(warned.length).toBe(0); }); it("calls warn when value contains invalid chars even if truncated result looks clean", () => { const warned: string[] = []; // 'a/b' repeated — '/' is stripped, so sanitized differs from lower.slice(0,63) sanitizeLabelValue("a/b".repeat(25), (msg) => warned.push(msg)); expect(warned.length).toBe(1); }); }); describe("buildJobManifest — env wiring branches", () => { it("sets PAPERCLIP_WAKE_PAYLOAD_JSON when paperclipWake is provided", () => { const ctx = { ...mockCtx, context: { ...mockCtx.context, paperclipWake: { reason: "issue_assigned", issue: { id: "x" } } } }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; expect(env.find((e) => e.name === "PAPERCLIP_WAKE_PAYLOAD_JSON")?.value).toBeTruthy(); }); it("forwards workspace context and AGENT_HOME from paperclipWorkspace", () => { const ctx = { ...mockCtx, context: { ...mockCtx.context, paperclipWorkspace: { cwd: "/work", source: "main", strategy: "shared", workspaceId: "ws_1", repoUrl: "https://example.com/r.git", repoRef: "main", branchName: "feature/x", worktreePath: "/wt/x", agentHome: "/home/agent", }, }, }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_CWD")?.value).toBe("/work"); expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_BRANCH")?.value).toBe("feature/x"); expect(env.find((e) => e.name === "AGENT_HOME")?.value).toBe("/home/agent"); }); it("sets PAPERCLIP_LINKED_ISSUE_IDS from non-empty issueIds array (skipping blanks)", () => { const ctx = { ...mockCtx, context: { ...mockCtx.context, issueIds: ["a", " ", "b", null as unknown as string, "c"] } }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; expect(env.find((e) => e.name === "PAPERCLIP_LINKED_ISSUE_IDS")?.value).toBe("a,b,c"); }); it("encodes paperclipWorkspaces / paperclipRuntimeServiceIntents / paperclipRuntimeServices as JSON env", () => { const ctx = { ...mockCtx, context: { ...mockCtx.context, paperclipWorkspaces: [{ id: "w1" }], paperclipRuntimeServiceIntents: [{ name: "redis" }], paperclipRuntimeServices: [{ name: "redis", url: "redis://r" }], paperclipRuntimePrimaryUrl: "https://primary", }, }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACES_JSON")?.value).toContain("w1"); expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON")?.value).toContain("redis"); expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICES_JSON")?.value).toContain("redis://r"); expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_PRIMARY_URL")?.value).toBe("https://primary"); }); it("sets PAPERCLIP_API_KEY from ctx.authToken when provided", () => { const ctx = { ...mockCtx, authToken: "tok_abc" }; const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc"); }); it("inherits PAPERCLIP_API_URL from selfPod inheritedEnv", () => { const selfPod = { ...mockSelfPod, inheritedEnv: { PAPERCLIP_API_URL: "http://api" }, }; const result = buildJobManifest({ ctx: mockCtx, selfPod }); const env = result.job.spec?.template.spec?.containers[0]?.env ?? []; expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api"); }); }); describe("buildJobManifest — volume wiring branches", () => { it("mounts the prompt secret volume when promptSecretName is provided", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, promptSecretName: "prompt-x" }); const volumes = result.job.spec?.template.spec?.volumes ?? []; expect(volumes.find((v) => v.name === "prompt-secret")?.secret?.secretName).toBe("prompt-x"); }); it("mounts the data PVC at /paperclip when selfPod has a pvcClaimName", () => { const selfPod = { ...mockSelfPod, pvcClaimName: "paperclip-data" }; const result = buildJobManifest({ ctx: mockCtx, selfPod }); const volumes = result.job.spec?.template.spec?.volumes ?? []; expect(volumes.find((v) => v.name === "data")?.persistentVolumeClaim?.claimName).toBe("paperclip-data"); const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? []; expect(mounts.find((m) => m.name === "data")?.mountPath).toBe("/paperclip"); }); it("mounts inherited secret volumes from selfPod.secretVolumes", () => { const selfPod = { ...mockSelfPod, secretVolumes: [{ volumeName: "tls", secretName: "tls-secret", mountPath: "/etc/tls", defaultMode: 0o400 }], }; const result = buildJobManifest({ ctx: mockCtx, selfPod }); const volumes = result.job.spec?.template.spec?.volumes ?? []; expect(volumes.find((v) => v.name === "tls")?.secret?.secretName).toBe("tls-secret"); const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? []; expect(mounts.find((m) => m.name === "tls")).toEqual({ name: "tls", mountPath: "/etc/tls", readOnly: true }); }); });