Compare commits

..

2 Commits

Author SHA1 Message Date
Chris Farhood 127eab89e7 fix: correct fs mock with vi.hoisted for proper per-test reset
The vi.mock("node:fs/promises") factory previously used a closure variable
that accumulated across tests despite vi.clearAllMocks(). Switched to
vi.hoisted() with an explicit resetFsMocks() called in beforeEach() so
the read offset counter is properly reset between tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:38:57 -04:00
Chris Farhood 78d655eeb6 feat: replace K8s log streaming with PVC filesystem tailing
- 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>
2026-04-27 21:56:58 -04:00
11 changed files with 78 additions and 328 deletions
-2
View File
@@ -1,7 +1,5 @@
# OpenCode (Kubernetes) Paperclip Adapter Plugin # OpenCode (Kubernetes) Paperclip Adapter Plugin
> **⚠️ Abandoned** — This adapter is no longer maintained. Please use the new sandbox plugin instead: **[farhoodlabs/paperclip-plugin-k8s](https://github.com/farhoodlabs/paperclip-plugin-k8s)** (`@farhoodlabs/paperclip-plugin-k8s` on npm).
Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs instead of inside the main Paperclip process. Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs instead of inside the main Paperclip process.
## Features ## Features
+7 -7
View File
@@ -1,26 +1,26 @@
{ {
"name": "paperclip-adapter-opencode-k8s", "name": "paperclip-adapter-opencode-k8s",
"version": "0.2.3", "version": "0.1.38",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paperclip-adapter-opencode-k8s", "name": "paperclip-adapter-opencode-k8s",
"version": "0.2.3", "version": "0.1.38",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^1.0.0", "@kubernetes/client-node": "^1.0.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@paperclipai/adapter-utils": "^2026.428.0", "@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5", "@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^4.1.4" "vitest": "^4.1.4"
}, },
"peerDependencies": { "peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.428.0" "@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
@@ -223,9 +223,9 @@
} }
}, },
"node_modules/@paperclipai/adapter-utils": { "node_modules/@paperclipai/adapter-utils": {
"version": "2026.428.0", "version": "2026.415.0-canary.7",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz", "resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.415.0-canary.7.tgz",
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==", "integrity": "sha512-VNzIZmu1lrK6QM8Ad9WkOihZItfkj21NHKQf+artDcbwFT2hHbDAD9hdW2W9NMVxYdFvvnws3w76FI/BUbCMbQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+5 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperclip-adapter-opencode-k8s", "name": "paperclip-adapter-opencode-k8s",
"version": "0.2.3", "version": "0.1.38",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs", "description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -10,15 +10,14 @@
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
"./server": "./dist/server/index.js", "./server": "./dist/server/index.js",
"./ui-parser": "./dist/ui-parser/ui-parser.js", "./ui-parser": "./dist/ui-parser.js",
"./cli": "./dist/cli/index.js" "./cli": "./dist/cli/index.js"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json && npm run build:ui-parser", "build": "tsc",
"build:ui-parser": "tsc -p tsconfig.ui-parser.json && node -e \"require('node:fs').writeFileSync('dist/ui-parser/package.json', '{\\\"type\\\":\\\"commonjs\\\"}\\n')\"",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -29,10 +28,10 @@
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.428.0" "@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
}, },
"devDependencies": { "devDependencies": {
"@paperclipai/adapter-utils": "^2026.428.0", "@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5", "@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3", "typescript": "^5.7.3",
+1
View File
@@ -64,3 +64,4 @@ Notes:
`; `;
export { createServerAdapter } from "./server/index.js"; export { createServerAdapter } from "./server/index.js";
export { parseStdoutLine } from "./ui-parser.js";
+27 -47
View File
@@ -4,47 +4,33 @@ import { execute, ensureAgentDbPvc, tailPodLogFile } from "./execute.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js"; import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js";
import { buildJobManifest, buildPodLogPath } from "./job-manifest.js"; import { buildJobManifest, buildPodLogPath } from "./job-manifest.js";
// Mock node:fs/promises so tailPodLogFile (used by execute()) reads a // Mock node:fs/promises to prevent tailPodLogFile (used by execute()) from
// configurable JSONL payload and returns. Individual tests override the // hanging on unmocked fs.stat calls in test environment.
// payload via setMockJsonl(...) before calling execute(). // vi.hoisted creates shared module-level state; beforeEach resets it so every
const { readMock, statMock, fhStatMock, resetFsMocks, setMockJsonl } = vi.hoisted(() => { // test gets a clean first-read-success.
const HAPPY = [ const { readMock, resetFsMocks } = vi.hoisted(() => {
JSON.stringify({ type: "text", part: { text: "Task complete" }, sessionID: "ses_happy" }),
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
].join("\n");
let payload = HAPPY;
let buffer = Buffer.from(payload);
let readOffset = 0; let readOffset = 0;
const apply = (next: string) => { payload = next; buffer = Buffer.from(payload); readOffset = 0; };
return { return {
readMock: vi.fn().mockImplementation(async (buf: Buffer, off: number, len: number, _pos: number) => { readMock: vi.fn().mockImplementation(async () => {
if (readOffset >= buffer.byteLength) return { bytesRead: 0, buffer: buf }; if (readOffset === 0) {
const remaining = buffer.byteLength - readOffset; readOffset = 17;
const toRead = Math.min(len, remaining); return { bytesRead: 17, buffer: Buffer.from('{"type":"text"}\n') };
buffer.copy(buf, off, readOffset, readOffset + toRead); }
readOffset += toRead; return { bytesRead: 0, buffer: Buffer.alloc(0) };
return { bytesRead: toRead, buffer: buf };
}), }),
statMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })), resetFsMocks: () => { readOffset = 0; },
fhStatMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
resetFsMocks: () => { apply(HAPPY); },
setMockJsonl: (jsonl: string) => { apply(jsonl); },
}; };
}); });
vi.mock("node:fs/promises", async (importOriginal) => { vi.mock("node:fs/promises", () => ({
const actual = await importOriginal<typeof import("node:fs/promises")>(); stat: vi.fn().mockResolvedValue({ size: 17 }),
return { open: vi.fn().mockResolvedValue({
...actual, stat: vi.fn().mockResolvedValue({ size: 17 }),
stat: statMock, read: readMock,
open: vi.fn().mockResolvedValue({ close: vi.fn().mockResolvedValue(undefined),
stat: fhStatMock, }),
read: readMock, unlink: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined), }));
}),
unlink: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("./k8s-client.js", () => ({ vi.mock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn(), getSelfPodInfo: vi.fn(),
@@ -57,7 +43,7 @@ vi.mock("./k8s-client.js", () => ({
vi.mock("./job-manifest.js", () => ({ vi.mock("./job-manifest.js", () => ({
buildJobManifest: vi.fn(), buildJobManifest: vi.fn(),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) => buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson` `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
), ),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024, LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
})); }));
@@ -183,7 +169,7 @@ beforeEach(() => {
prompt: "Test prompt", prompt: "Test prompt",
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>); } as unknown as ReturnType<typeof buildJobManifest>);
const batchApi = makeBatchApi(); const batchApi = makeBatchApi();
@@ -604,7 +590,6 @@ describe("execute — happy path", () => {
describe("execute — session unavailable (reattach classification)", () => { describe("execute — session unavailable (reattach classification)", () => {
it("returns clearSession=true and session_unavailable code for unknown session error", async () => { it("returns clearSession=true and session_unavailable code for unknown session error", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Unknown session ses_xxx" } }));
const coreApi = makeCoreApi(1); const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -616,7 +601,6 @@ describe("execute — session unavailable (reattach classification)", () => {
}); });
it("returns clearSession=true for 'session not found' error", async () => { it("returns clearSession=true for 'session not found' error", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Session ses_xxx not found" } }));
const coreApi = makeCoreApi(1); const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -688,7 +672,6 @@ describe("execute — exit code handling", () => {
}); });
it("synthesizes exitCode=1 when error message exists but pod reported exitCode=0", async () => { it("synthesizes exitCode=1 when error message exists but pod reported exitCode=0", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "something went wrong" } }));
const coreApi = makeCoreApi(0); const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -752,7 +735,6 @@ describe("execute — llm_api_error signal", () => {
it("returns llm_api_error when session exists but LLM produced no output tokens", async () => { it("returns llm_api_error when session exists but LLM produced no output tokens", async () => {
// JSONL has a sessionID but no step_finish tokens and no text messages // JSONL has a sessionID but no step_finish tokens and no text messages
const emptyOutputJsonl = JSON.stringify({ sessionID: "ses_empty", type: "step_finish", part: { tokens: { input: 100, output: 0, cache: {} }, cost: 0 } }); const emptyOutputJsonl = JSON.stringify({ sessionID: "ses_empty", type: "step_finish", part: { tokens: { input: 100, output: 0, cache: {} }, cost: 0 } });
setMockJsonl(emptyOutputJsonl);
const coreApi = makeCoreApi(0); const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -775,7 +757,6 @@ describe("execute — llm_api_error signal", () => {
const errorJsonl = [ const errorJsonl = [
JSON.stringify({ sessionID: "ses_err", type: "error", error: { message: "API quota exceeded" } }), JSON.stringify({ sessionID: "ses_err", type: "error", error: { message: "API quota exceeded" } }),
].join("\n"); ].join("\n");
setMockJsonl(errorJsonl);
const coreApi = makeCoreApi(1); const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -958,7 +939,7 @@ describe("execute — large-prompt Secret path", () => {
prompt: LARGE_PROMPT, prompt: LARGE_PROMPT,
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>); } as unknown as ReturnType<typeof buildJobManifest>);
} }
@@ -1290,7 +1271,7 @@ describe("execute — large-prompt Secret create failure", () => {
prompt: LARGE_PROMPT, prompt: LARGE_PROMPT,
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>); } as unknown as ReturnType<typeof buildJobManifest>);
const coreApi = makeCoreApi(); const coreApi = makeCoreApi();
@@ -1324,7 +1305,6 @@ describe("execute — step limit detection", () => {
JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }), JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }),
JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }), JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }),
].join("\n"); ].join("\n");
setMockJsonl(STEP_LIMIT_JSONL);
const coreApi = makeCoreApi(0); const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>); vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -1527,10 +1507,10 @@ describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
prompt: "p", prompt: "p",
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`, podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
}), }),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) => buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson` `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
), ),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024, LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
})); }));
+33 -49
View File
@@ -269,45 +269,40 @@ export async function tailPodLogFile(
let idleCount = 0; let idleCount = 0;
const accumulator: string[] = []; const accumulator: string[] = [];
const drain = async (): Promise<boolean> => {
let size: number;
try {
const stat = await fh.stat();
size = stat.size;
} catch {
return false;
}
if (size <= offset) return false;
const buf = Buffer.alloc(size - offset);
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
offset += bytesRead;
const chunk = buf.slice(0, bytesRead).toString("utf-8");
const lineParts = (pending + chunk).split("\n");
pending = lineParts.pop() ?? "";
for (const line of lineParts) {
await onLog("stdout", line + "\n");
accumulator.push(line + "\n");
}
return bytesRead > 0;
};
try { try {
while (!stopSignal.stopped) { while (!stopSignal.stopped) {
const grew = await drain(); const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
if (grew) { await new Promise((r) => setTimeout(r, pollMs));
if (stopSignal.stopped) break;
let size: number;
try {
const stat = await fh.stat();
size = stat.size;
} catch {
break;
}
if (size > offset) {
const buf = Buffer.alloc(size - offset);
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
offset += bytesRead;
idleCount = 0; idleCount = 0;
const chunk = buf.slice(0, bytesRead).toString("utf-8");
const lineParts = (pending + chunk).split("\n");
pending = lineParts.pop() ?? "";
for (const line of lineParts) {
await onLog("stdout", line + "\n");
accumulator.push(line + "\n");
}
} else { } else {
idleCount++; idleCount++;
} }
if (stopSignal.stopped) break;
const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
await new Promise((r) => setTimeout(r, pollMs));
} }
// Final drain after stopSignal — pick up any bytes written between the // Final drain on stop
// last read and the job reaching terminal state.
while (await drain()) { /* read until no more growth */ }
if (pending) { if (pending) {
await onLog("stdout", pending + "\n"); await onLog("stdout", pending + "\n");
accumulator.push(pending + "\n"); accumulator.push(pending + "\n");
@@ -467,24 +462,13 @@ async function streamAndAwaitJob(
return onLog(stream, chunk); return onLog(stream, chunk);
}; };
// Run the file tail and the job-completion poll in parallel so that the const tailResult = await tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal });
// tail loop has a way to stop: when waitForJobCompletion resolves it sets stdout = tailResult;
// stopSignal.stopped, which lets tailPodLogFile drain and return.
// No completionWithGrace wrapper here — wrapping a long-running job poll // Wait for job completion (may already be done by the time we read the file)
// in a 30s grace turns the grace into a hard ceiling and kills runs const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath);
// prematurely with "Timed out after 0s" when timeoutSec is 0 (no timeout). const completionGraced = completionWithGrace(completionPromise, LOG_EXIT_COMPLETION_GRACE_MS);
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath) const completion = await completionGraced;
.then((r) => { stopSignal.stopped = true; return r; });
const [tailSettled, completionSettled] = await Promise.allSettled([
tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal }),
completionPromise,
]);
stdout = tailSettled.status === "fulfilled" ? tailSettled.value : "";
if (completionSettled.status === "rejected") {
stopSignal.stopped = true;
throw completionSettled.reason;
}
const completion = completionSettled.value;
if (keepaliveTimer) { if (keepaliveTimer) {
clearInterval(keepaliveTimer); clearInterval(keepaliveTimer);
+2 -3
View File
@@ -359,9 +359,8 @@ describe("init container is unchanged by agentDbClaimName", () => {
it("does not add extra env vars to init container for dedicated PVC mode", () => { 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 result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command; const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command;
// init container only writes the prompt; no mkdir (log dir exists on PVC) and no OPENCODE_DB_PATH env var // mkdir is added for log directory but OPENCODE_DB_PATH env var is NOT added
expect(initCmd?.[2]).not.toContain("mkdir"); expect(initCmd?.[2]).toContain("mkdir");
expect(initCmd?.[2]).toContain("/tmp/prompt/prompt.txt");
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? []; const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false); expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false);
}); });
+3 -3
View File
@@ -25,7 +25,7 @@ function assertSafePathComponent(field: string, value: string): void {
} }
export function buildPodLogPath(companyId: string, agentId: string, runId: string): string { export function buildPodLogPath(companyId: string, agentId: string, runId: string): string {
return `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`; return `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
} }
export interface JobBuildInput { export interface JobBuildInput {
@@ -461,14 +461,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
imagePullPolicy: "IfNotPresent", imagePullPolicy: "IfNotPresent",
...(input.promptSecretName ...(input.promptSecretName
? { ? {
command: ["sh", "-c", `cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`], command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
volumeMounts: [ volumeMounts: [
{ name: "prompt", mountPath: "/tmp/prompt" }, { name: "prompt", mountPath: "/tmp/prompt" },
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true }, { name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
], ],
} }
: { : {
command: ["sh", "-c", `printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`], command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
env: [{ name: "PROMPT_CONTENT", value: prompt }], env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }], volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
}), }),
-195
View File
@@ -1,195 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils";
import { testEnvironment } from "./test.js";
import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js";
vi.mock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn(),
getCoreApi: vi.fn(),
getAuthzApi: vi.fn(),
}));
const SELF_POD = {
namespace: "ns-self",
image: "img:1",
imagePullSecrets: [],
pvcClaimName: "paperclip-pvc",
inheritedEnv: {},
inheritedEnvValueFrom: [],
inheritedEnvFrom: [],
dnsConfig: undefined,
secretVolumes: [],
} as unknown as Awaited<ReturnType<typeof getSelfPodInfo>>;
function makeCtx(config: Record<string, unknown> = {}): AdapterEnvironmentTestContext {
return { adapterType: "opencode_k8s", config } as unknown as AdapterEnvironmentTestContext;
}
function makeAuthz(allowedFor: (resource: string, verb: string) => boolean) {
return {
createSelfSubjectAccessReview: vi.fn().mockImplementation(async ({ body }: { body: { spec: { resourceAttributes: { resource: string; verb: string } } } }) => {
const { resource, verb } = body.spec.resourceAttributes;
return { status: { allowed: allowedFor(resource, verb) } };
}),
};
}
function makeCore(overrides: Partial<{ readNamespace: ReturnType<typeof vi.fn>; readNamespacedSecret: ReturnType<typeof vi.fn>; readNamespacedPersistentVolumeClaim: ReturnType<typeof vi.fn> }> = {}) {
return {
readNamespace: overrides.readNamespace ?? vi.fn().mockResolvedValue({ metadata: { name: "ns" } }),
readNamespacedSecret: overrides.readNamespacedSecret ?? vi.fn().mockResolvedValue({ metadata: { name: "paperclip-secrets" } }),
readNamespacedPersistentVolumeClaim: overrides.readNamespacedPersistentVolumeClaim ?? vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteMany"] } }),
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSelfPodInfo).mockResolvedValue(SELF_POD);
vi.mocked(getCoreApi).mockReturnValue(makeCore() as unknown as ReturnType<typeof getCoreApi>);
vi.mocked(getAuthzApi).mockReturnValue(makeAuthz(() => true) as unknown as ReturnType<typeof getAuthzApi>);
});
describe("testEnvironment — happy path", () => {
it("returns pass when API, namespace, RBAC, secret, and RWX PVC all check out", async () => {
const result = await testEnvironment(makeCtx());
expect(result.adapterType).toBe("opencode_k8s");
expect(result.status).toBe("pass");
expect(result.checks.find((c) => c.code === "k8s_api_reachable")).toBeDefined();
expect(result.checks.find((c) => c.code === "k8s_pvc_rwx")).toBeDefined();
expect(result.checks.find((c) => c.code === "k8s_secret_exists")).toBeDefined();
expect(typeof result.testedAt).toBe("string");
});
it("skips namespace lookup and emits k8s_namespace_exists when target == self pod namespace", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(coreApi.readNamespace).not.toHaveBeenCalled();
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")?.message).toContain("pod namespace");
});
it("calls readNamespace when target namespace differs from self pod namespace", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
expect(coreApi.readNamespace).toHaveBeenCalledWith({ name: "ns-other" });
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")).toBeDefined();
});
});
describe("testEnvironment — early-return paths", () => {
it("returns fail and short-circuits when K8s API is unreachable", async () => {
vi.mocked(getSelfPodInfo).mockRejectedValueOnce(new Error("ECONNREFUSED"));
const result = await testEnvironment(makeCtx());
expect(result.status).toBe("fail");
expect(result.checks.find((c) => c.code === "k8s_api_unreachable")).toBeDefined();
// RBAC, secret, and PVC checks should be skipped when API is unreachable
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(false);
});
});
describe("testEnvironment — namespace warning", () => {
it("emits warn (but proceeds) when readNamespace fails for a different namespace", async () => {
const coreApi = makeCore({
readNamespace: vi.fn().mockRejectedValue(new Error("forbidden")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
expect(result.checks.find((c) => c.code === "k8s_namespace_check_failed")).toBeDefined();
// Should still proceed with downstream checks
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(true);
});
});
describe("testEnvironment — RBAC", () => {
it("emits error checks for denied verbs and degrades status to fail", async () => {
vi.mocked(getAuthzApi).mockReturnValue(
makeAuthz((resource, verb) => !(resource === "jobs" && verb === "create")) as unknown as ReturnType<typeof getAuthzApi>,
);
const result = await testEnvironment(makeCtx());
const denied = result.checks.find((c) => c.code === "k8s_rbac_job_create");
expect(denied?.level).toBe("error");
expect(result.status).toBe("fail");
});
it("emits warn when SelfSubjectAccessReview itself throws", async () => {
vi.mocked(getAuthzApi).mockReturnValue({
createSelfSubjectAccessReview: vi.fn().mockRejectedValue(new Error("SSAR not available")),
} as unknown as ReturnType<typeof getAuthzApi>);
const result = await testEnvironment(makeCtx());
const rbacWarns = result.checks.filter((c) => c.code.startsWith("k8s_rbac_") && c.level === "warn");
expect(rbacWarns.length).toBeGreaterThan(0);
});
});
describe("testEnvironment — secrets", () => {
it("emits warn when the secret is not found", async () => {
const coreApi = makeCore({
readNamespacedSecret: vi.fn().mockRejectedValue(new Error("not found")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_secret_missing")).toBeDefined();
expect(result.status).toBe("warn");
});
it("uses configured secretRef when provided", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
await testEnvironment(makeCtx({ secretRef: "custom-secret" }));
expect(coreApi.readNamespacedSecret).toHaveBeenCalledWith({ name: "custom-secret", namespace: "ns-self" });
});
});
describe("testEnvironment — PVC", () => {
it("emits warn when no PVC is mounted on /paperclip", async () => {
vi.mocked(getSelfPodInfo).mockResolvedValue({ ...SELF_POD, pvcClaimName: null });
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_pvc_not_detected")).toBeDefined();
expect(result.status).toBe("warn");
});
it("emits warn when PVC access mode is not ReadWriteMany", async () => {
const coreApi = makeCore({
readNamespacedPersistentVolumeClaim: vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteOnce"] } }),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
const pvcCheck = result.checks.find((c) => c.code === "k8s_pvc_not_rwx");
expect(pvcCheck).toBeDefined();
expect(pvcCheck?.message).toContain("ReadWriteOnce");
expect(result.status).toBe("warn");
});
it("emits warn when reading the PVC fails", async () => {
const coreApi = makeCore({
readNamespacedPersistentVolumeClaim: vi.fn().mockRejectedValue(new Error("api error")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_pvc_check_failed")).toBeDefined();
});
});
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.ts", "src/ui-parser.ts"]
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist/ui-parser",
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/ui-parser.ts"]
}