Compare commits

..

8 Commits

Author SHA1 Message Date
Chris Farhood da1b55d233 test: add coverage for listK8sModels CLI fetch and fallback
chore: bump version to 0.1.32

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 02:07:51 +00:00
Chris Farhood 168161148c feat: fetch model list from opencode CLI instead of hardcoded static list
Reads available models dynamically via 'opencode models' command
to populate the Paperclip UI model selector. Falls back to previous
static list on any error (missing CLI, timeout, parse failure).

Fixes FAR-94: 0 models shown in adapter UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:59:35 +00:00
Chris Farhood 2daedda537 Surface volume mount and PVC bind errors in waitForPod()
Handle MountVolumeFailed/ContainerCannotMount waiting reasons in pod
container status checks, throwing clear errors instead of silent failure.

Detect PVC/volume/bind/mount keywords in PodScheduled condition messages
and surface as 'PVC bind failed' error.

Fixes FAR-93: serviceAccountName missing error surfacing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:47:31 +00:00
Chris Farhood e364e09113 test: assert config.image overrides selfPod image in job manifest
Locks in the existing override behavior so a future regression that
reverts to a hardcoded image is caught immediately. Closes the
investigation on FAR-90.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:12:05 +00:00
Chris Farhood 4c956cc039 chore: bump version to 0.1.31 2026-04-26 00:25:06 +00:00
Chris Farhood 4fcd3b4547 fix test: stub PAPERCLIP_DEV_API_KEY before each cancel-poll test
The cancel-poll test sets PAPERCLIP_API_KEY='test-key' but the actual
PAPERCLIP_DEV_API_KEY was leaking through from the harness environment.
Since execute.ts prefers PAPERCLIP_DEV_API_KEY over PAPERCLIP_API_KEY,
the poll was sending the real dev key instead of 'test-key'.

Fix: add beforeEach to set PAPERCLIP_DEV_API_KEY='test-key', and afterEach
to clean both env vars.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 23:40:41 +00:00
Chris Farhood 1bad618b29 chore: bump version to 0.1.30
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 23:25:17 +00:00
Chris Farhood 5670da320a Fix OPENCODE_DB to be a file path inside /opencode-db mount
The env var was set to /opencode-db (the mount point directory), but sqlite
requires a file path. Changed to /opencode-db/opencode.db.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 23:23:59 +00:00
8 changed files with 151 additions and 23 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.28",
"version": "0.1.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.28",
"version": "0.1.30",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.29",
"version": "0.1.32",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
+5
View File
@@ -879,11 +879,16 @@ describe("execute — log dedup (waitForPod status dedup)", () => {
describe("execute — external cancel polling", () => {
const KEEPALIVE_MS = 15_000;
beforeEach(() => {
process.env.PAPERCLIP_DEV_API_KEY = "test-key";
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_DEV_API_KEY;
});
it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => {
+17 -1
View File
@@ -126,7 +126,11 @@ async function waitForPod(
(c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable",
);
if (unschedulable) {
throw new Error(`Pod unschedulable: ${unschedulable.message ?? "insufficient resources"}`);
const msg = unschedulable.message ?? "insufficient resources";
if (/pvc|volume|bind|mount/i.test(msg)) {
throw new Error(`PVC bind failed: ${msg}`);
}
throw new Error(`Pod unschedulable: ${msg}`);
}
for (const cs of containerStatuses) {
@@ -137,6 +141,18 @@ async function waitForPod(
if (waiting?.reason === "CrashLoopBackOff") {
throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`);
}
if (waiting?.reason === "MountVolumeFailed" || waiting?.reason === "ContainerCannotMount") {
throw new Error(`Volume mount failed for "${cs.name}": ${waiting.message ?? waiting.reason}`);
}
}
for (const cs of containerStatuses) {
const terminated = cs.state?.terminated;
if (terminated?.exitCode !== undefined && terminated.exitCode !== 0) {
if (terminated.reason === "ContainerCannotMount" || terminated.reason === "MountVolumeFailed") {
throw new Error(`Volume mount failed for "${cs.name}": ${terminated.message ?? terminated.reason}`);
}
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
+17 -6
View File
@@ -44,6 +44,17 @@ describe("buildJobManifest", () => {
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 });
@@ -287,16 +298,16 @@ describe("buildJobManifest", () => {
});
describe("agentDbClaimName — OPENCODE_DB env var", () => {
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is a string (dedicated PVC)", () => {
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");
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db");
});
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is null (ephemeral)", () => {
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");
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db");
});
it("does not set OPENCODE_DB when agentDbClaimName is undefined", () => {
@@ -305,13 +316,13 @@ describe("agentDbClaimName — OPENCODE_DB env var", () => {
expect(env.find((e) => e.name === "OPENCODE_DB")).toBeUndefined();
});
it("replaces a user-provided OPENCODE_DB env override with /opencode-db", () => {
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");
expect(dbEntries[0].value).toBe("/opencode-db/opencode.db");
});
});
+2 -2
View File
@@ -312,9 +312,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
if (input.agentDbClaimName !== undefined) {
const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB");
if (dbEnvIdx >= 0) {
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db" };
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db/opencode.db" };
} else {
envVars.push({ name: "OPENCODE_DB", value: "/opencode-db" });
envVars.push({ name: "OPENCODE_DB", value: "/opencode-db/opencode.db" });
}
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execMock = vi.fn();
vi.mock("child_process", () => ({
exec: (cmd: string, opts: unknown, cb: (err: Error | null, result: { stdout: string; stderr: string }) => void) => {
execMock(cmd, opts, cb);
},
}));
const { listK8sModels } = await import("./models.js");
function mockExecResult(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(null, { stdout, stderr: "" });
});
}
function mockExecError(err: Error) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(err, { stdout: "", stderr: "" });
});
}
describe("listK8sModels", () => {
beforeEach(() => {
execMock.mockReset();
});
it("parses opencode models output into AdapterModel entries", async () => {
mockExecResult(
[
"anthropic/claude-opus-4-7",
"openai/gpt-4o",
"google/gemini-2.5-pro",
].join("\n"),
);
const models = await listK8sModels();
expect(models).toHaveLength(3);
expect(models[0]).toEqual({ id: "anthropic/claude-opus-4-7", label: "claude opus 4 7" });
expect(models[1]).toEqual({ id: "openai/gpt-4o", label: "gpt 4o" });
expect(models[2]).toEqual({ id: "google/gemini-2.5-pro", label: "gemini 2.5 pro" });
});
it("ignores blank lines and trims whitespace", async () => {
mockExecResult("\nanthropic/claude-opus-4-7\n\n openai/gpt-4o \n\n");
const models = await listK8sModels();
expect(models.map((m) => m.id)).toEqual([
"anthropic/claude-opus-4-7",
"openai/gpt-4o",
]);
});
it("invokes opencode models with a timeout", async () => {
mockExecResult("anthropic/claude-opus-4-7");
await listK8sModels();
expect(execMock).toHaveBeenCalledTimes(1);
const [cmd, opts] = execMock.mock.calls[0];
expect(cmd).toBe("opencode models");
expect(opts).toMatchObject({ timeout: 30_000 });
});
it("falls back to the static list when the CLI fails", async () => {
mockExecError(new Error("ENOENT: opencode not found"));
const models = await listK8sModels();
expect(models.length).toBeGreaterThan(0);
expect(models.map((m) => m.id)).toContain("anthropic/claude-opus-4-7");
expect(models.map((m) => m.id)).toContain("openai/gpt-4o");
});
});
+29 -11
View File
@@ -1,15 +1,33 @@
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { exec } from "child_process";
import { promisify } from "util";
const MODELS: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5" },
{ id: "openai/gpt-4o", label: "GPT-4o" },
{ id: "openai/gpt-4o-mini", label: "GPT-4o mini" },
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
];
const execAsync = promisify(exec);
export async function listK8sModels(): Promise<AdapterModel[]> {
return MODELS;
}
try {
const result = await execAsync("opencode models", { timeout: 30_000 });
const output = result.stdout;
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
const models: AdapterModel[] = [];
for (const line of lines) {
if (!line) continue;
const parts = line.split("/");
const id = line;
const label = parts[parts.length - 1].replace(/-/g, " ").replace(/_/g, " ");
models.push({ id, label });
}
return models;
} catch {
const fallback: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5" },
{ id: "openai/gpt-4o", label: "GPT-4o" },
{ id: "openai/gpt-4o-mini", label: "GPT-4o mini" },
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
];
return fallback;
}
}