78d655eeb6
- Replaced streamPodLogs / streamPodLogsOnce / readPodLogs / waitForPodTermination with tailPodLogFile() that polls a shared PVC file path with adaptive cadence (250ms active, 1000ms idle after 5 consecutive empty polls) - Added buildPodLogPath() export and podLogPath to JobBuildResult - Added assertSafePathComponent with [a-zA-Z0-9-:] allowance for UUIDs - Updated Job manifest to tee stdout to /paperclip/instances/default/run-logs/<companyId>/<agentId>/<runId>.pod.ndjson - Added hasOutOfProcessLiveness: true to createServerAdapter (cast required) - Deleted log-dedup.ts and log-dedup.test.ts entirely - Removed all LogLineDedupFilter, Writable, and LOG_STREAM_* constants - Removed completionResult.status workaround (completionWithGrace returns directly) - Test infrastructure: mocked node:fs/promises to prevent unmocked fs.stat hangs Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
527 lines
22 KiB
TypeScript
527 lines
22 KiB
TypeScript
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 });
|
|
});
|
|
});
|