Compare commits

..

15 Commits

Author SHA1 Message Date
Chris Farhood fc6351b2bc docs: mark README as abandoned, point to new sandbox plugin
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 08:48:29 -04:00
Chris Farhood cc34e05713 0.2.3 2026-04-30 16:58:40 -04:00
Chris Farhood 9b951c4308 fix(execute): stop wrapping waitForJobCompletion in completionWithGrace
The 30s completionWithGrace was originally a "wait a bit for the job to
settle after tail returns" — sequential. When 0.2.0 moved tail and
completion into Promise.allSettled to give tail a stop signal, the grace
wrapper was kept around the parallel completion poll. That turned the 30s
grace into a hard ceiling on the entire run: completionGraced resolves
with {timedOut: true} after 30s regardless of how the actual job is doing,
which feeds back into jobTimedOut and surfaces to the user as
"Timed out after 0s" when timeoutSec is 0 (no configured timeout).

Drop the wrapper. Use the bare completionPromise. The tail loop already
has a clean stop path via stopSignal.stopped which is set when the real
job completion resolves; no separate grace timer is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:58:39 -04:00
Chris Farhood c71757fbcd 0.2.2 2026-04-30 16:00:46 -04:00
Chris Farhood 098d9f9641 fix(ui-parser): emit as proper CJS subpackage so ESM named imports work
Paperclip's plugin-loader does ESM named imports of parseStdoutLine when
loading opencode_k8s, e.g.:

    import { parseStdoutLine } from "paperclip-adapter-opencode-k8s/ui-parser"

The previous esbuild bundle wrote CJS via __toCommonJS getters, which
cjs-module-lexer can't statically detect — Node fails the link with:

    SyntaxError: The requested module './ui-parser.js' does not provide
    an export named 'parseStdoutLine'

Also, with the package.json `"type": "module"` field, dist/ui-parser.js
was being interpreted as ESM by the loader, compounding the failure.

Fix: emit ui-parser as a proper CJS sub-package.

- Move output to dist/ui-parser/ui-parser.js
- Generate dist/ui-parser/package.json with `{"type":"commonjs"}` so Node
  treats the file as CJS regardless of the parent type:module
- Use `tsc -p tsconfig.ui-parser.json` (module: commonjs) instead of
  esbuild — the output is plain `exports.parseStdoutLine = parseStdoutLine`
  which cjs-module-lexer detects natively
- Update the exports map: `"./ui-parser": "./dist/ui-parser/ui-parser.js"`
- Drop the esbuild devDependency

Verified locally:
- `import { parseStdoutLine } from "...ui-parser"` works (Node 25)
- Read-file-as-text + `new Function(...)` worker pattern still works
- 382/382 tests pass; typecheck clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:00:42 -04:00
Chris Farhood 65321e091d 0.2.1 2026-04-30 15:25:28 -04:00
Chris Farhood c0c4c3f179 fix(build): exclude *.test.ts from emit so test files aren't shipped
Paperclip's plugin loader links the package as ESM, and dist/ui-parser.test.js
contained `import { parseStdoutLine } from "./ui-parser.js"` — but ui-parser.js
is bundled as CJS (see 480f7cf) so Node's ESM linker can't resolve the named
export. Result: adapter install fails with

    SyntaxError: The requested module './ui-parser.js' does not provide an
    export named 'parseStdoutLine'

Same root cause as c79eea7, just on the test file instead of src/index.ts.

Fix: introduce tsconfig.build.json that extends the base tsconfig and adds
"exclude": ["**/*.test.ts"]. The build script now runs tsc against that
config, so test files don't end up in dist/. tsconfig.json (used by --noEmit
typecheck and vitest) still includes them, so test type-safety is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:25:25 -04:00
Chris Farhood c5b555de17 0.2.0 2026-04-30 14:58:03 -04:00
Chris Farhood e3af8aa83b fix(server): make tailPodLogFile exit on job completion + port c8429cf
- Run tailPodLogFile and waitForJobCompletion in parallel via Promise.allSettled;
  completion sets stopSignal.stopped so the tail loop drains and exits. Without
  this, tailPodLogFile loops forever — the only natural exit was fh.stat()
  throwing on file removal, which never happened during normal job completion.
- Restructure tail loop to read-then-sleep, with a final drain after stopSignal
  is set to capture bytes written between the last poll and terminal state.
- Port the c8429cf fix from paperclip-adapter-claude-k8s:
  * buildPodLogPath now writes to /paperclip/instances/default/data/run-logs/...
    to match the server PVC layout (the /data/ segment was missing).
  * Drop the mkdir -p ... && from both init container command variants — the
    PVC isn't mounted in the init container, so the mkdir was failing with
    exit code 1 and the && short-circuit prevented the prompt copy.
- Test infrastructure:
  * Hoisted fs/promises mock now uses importOriginal so readFile (used for
    skill bundle loading) hits the real implementation.
  * setMockJsonl() lets individual tests inject specific JSONL into the tail's
    read buffer (previously dead constants in the test file).
  * fh.read mock now writes into the caller's buffer instead of returning a
    separate one.
- Add src/server/test.test.ts covering testEnvironment (was 0% → 98.5% stmts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:57:40 -04:00
Chris Farhood bc340bfcc9 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-30 13:55:12 -04:00
Chris Farhood c71d0e5eec 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-30 13:55:12 -04:00
Chris Farhood d9bc2e513b 0.1.40 2026-04-30 13:50:39 -04:00
Chris Farhood c79eea7ee0 fix(index): drop ESM re-export of parseStdoutLine from CJS ui-parser
dist/ui-parser.js is bundled as CJS (480f7cf, so the sandboxed UI worker
can load it via new Function), but src/index.ts re-exported a named
binding from it as ESM:

    export { parseStdoutLine } from "./ui-parser.js";

Since the package is "type": "module", Node's ESM loader resolves the
import as ESM and can't find named exports on a CJS module bundle —
linking fails at adapter-load time:

    SyntaxError: The requested module './ui-parser.js' does not provide
    an export named 'parseStdoutLine'

The adapter then gets dropped on every Paperclip pod restart with only
claude_k8s surviving. Nothing in the runtime imports parseStdoutLine
from the package root — the plugin-loader serves ui-parser.js to the UI
worker by reading it as a string (server/src/adapters/plugin-loader.ts),
and tests import the TS source directly. Removing the re-export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:50:37 -04:00
Chris Farhood fa6c115be4 0.1.39 2026-04-30 09:03:07 -04:00
Chris Farhood 480f7cf3d1 fix(ui-parser): bundle as CJS so the sandboxed worker can load it
The Paperclip UI loads each adapter's ui-parser.js inside a sandboxed
Web Worker via `new Function(...)` to render the run transcript. The
worker can only evaluate CJS — ESM `export` syntax silently fails to
register `parseStdoutLine`, and the run window falls back to dumping
raw JSONL.

tsc was emitting ESM `export function parseStdoutLine`, so every
published version since the parser was added has shipped a parser the
UI can't load. Add the same esbuild step the claude-k8s adapter uses
(0.2.4) to overwrite dist/ui-parser.js with a CJS bundle that assigns
to module.exports.

Also bump @paperclipai/adapter-utils from a stale 2026.415.0-canary.7
pin to ^2026.428.0 (current stable). All 406 tests pass against the
new types; no API drift in the imported surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:03:03 -04:00
11 changed files with 328 additions and 78 deletions
+2
View File
@@ -1,5 +1,7 @@
# 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.
## Features
+7 -7
View File
@@ -1,26 +1,26 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.38",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.38",
"version": "0.2.3",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@paperclipai/adapter-utils": "^2026.428.0",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3",
"vitest": "^4.1.4"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
"@paperclipai/adapter-utils": ">=2026.428.0"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -223,9 +223,9 @@
}
},
"node_modules/@paperclipai/adapter-utils": {
"version": "2026.415.0-canary.7",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.415.0-canary.7.tgz",
"integrity": "sha512-VNzIZmu1lrK6QM8Ad9WkOihZItfkj21NHKQf+artDcbwFT2hHbDAD9hdW2W9NMVxYdFvvnws3w76FI/BUbCMbQ==",
"version": "2026.428.0",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
"dev": true,
"license": "MIT"
},
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.38",
"version": "0.2.3",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
@@ -10,14 +10,15 @@
"exports": {
".": "./dist/index.js",
"./server": "./dist/server/index.js",
"./ui-parser": "./dist/ui-parser.js",
"./ui-parser": "./dist/ui-parser/ui-parser.js",
"./cli": "./dist/cli/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"build": "tsc -p tsconfig.build.json && npm run build:ui-parser",
"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",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -28,10 +29,10 @@
"picocolors": "^1.1.1"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
"@paperclipai/adapter-utils": ">=2026.428.0"
},
"devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@paperclipai/adapter-utils": "^2026.428.0",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3",
-1
View File
@@ -64,4 +64,3 @@ Notes:
`;
export { createServerAdapter } from "./server/index.js";
export { parseStdoutLine } from "./ui-parser.js";
+47 -27
View File
@@ -4,33 +4,47 @@ import { execute, ensureAgentDbPvc, tailPodLogFile } from "./execute.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js";
import { buildJobManifest, buildPodLogPath } from "./job-manifest.js";
// Mock node:fs/promises to prevent tailPodLogFile (used by execute()) from
// hanging on unmocked fs.stat calls in test environment.
// vi.hoisted creates shared module-level state; beforeEach resets it so every
// test gets a clean first-read-success.
const { readMock, resetFsMocks } = vi.hoisted(() => {
// Mock node:fs/promises so tailPodLogFile (used by execute()) reads a
// configurable JSONL payload and returns. Individual tests override the
// payload via setMockJsonl(...) before calling execute().
const { readMock, statMock, fhStatMock, resetFsMocks, setMockJsonl } = vi.hoisted(() => {
const HAPPY = [
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;
const apply = (next: string) => { payload = next; buffer = Buffer.from(payload); readOffset = 0; };
return {
readMock: vi.fn().mockImplementation(async () => {
if (readOffset === 0) {
readOffset = 17;
return { bytesRead: 17, buffer: Buffer.from('{"type":"text"}\n') };
}
return { bytesRead: 0, buffer: Buffer.alloc(0) };
readMock: vi.fn().mockImplementation(async (buf: Buffer, off: number, len: number, _pos: number) => {
if (readOffset >= buffer.byteLength) return { bytesRead: 0, buffer: buf };
const remaining = buffer.byteLength - readOffset;
const toRead = Math.min(len, remaining);
buffer.copy(buf, off, readOffset, readOffset + toRead);
readOffset += toRead;
return { bytesRead: toRead, buffer: buf };
}),
resetFsMocks: () => { readOffset = 0; },
statMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
fhStatMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
resetFsMocks: () => { apply(HAPPY); },
setMockJsonl: (jsonl: string) => { apply(jsonl); },
};
});
vi.mock("node:fs/promises", () => ({
stat: vi.fn().mockResolvedValue({ size: 17 }),
open: vi.fn().mockResolvedValue({
stat: vi.fn().mockResolvedValue({ size: 17 }),
read: readMock,
close: vi.fn().mockResolvedValue(undefined),
}),
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
stat: statMock,
open: vi.fn().mockResolvedValue({
stat: fhStatMock,
read: readMock,
close: vi.fn().mockResolvedValue(undefined),
}),
unlink: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn(),
@@ -43,7 +57,7 @@ vi.mock("./k8s-client.js", () => ({
vi.mock("./job-manifest.js", () => ({
buildJobManifest: vi.fn(),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
@@ -169,7 +183,7 @@ beforeEach(() => {
prompt: "Test prompt",
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
const batchApi = makeBatchApi();
@@ -590,6 +604,7 @@ describe("execute — happy path", () => {
describe("execute — session unavailable (reattach classification)", () => {
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);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -601,6 +616,7 @@ describe("execute — session unavailable (reattach classification)", () => {
});
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);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -672,6 +688,7 @@ describe("execute — exit code handling", () => {
});
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);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -735,6 +752,7 @@ describe("execute — llm_api_error signal", () => {
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
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);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -757,6 +775,7 @@ describe("execute — llm_api_error signal", () => {
const errorJsonl = [
JSON.stringify({ sessionID: "ses_err", type: "error", error: { message: "API quota exceeded" } }),
].join("\n");
setMockJsonl(errorJsonl);
const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -939,7 +958,7 @@ describe("execute — large-prompt Secret path", () => {
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
}
@@ -1271,7 +1290,7 @@ describe("execute — large-prompt Secret create failure", () => {
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
const coreApi = makeCoreApi();
@@ -1305,6 +1324,7 @@ describe("execute — step limit detection", () => {
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 } }),
].join("\n");
setMockJsonl(STEP_LIMIT_JSONL);
const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -1507,10 +1527,10 @@ describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
prompt: "p",
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
}),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
+49 -33
View File
@@ -269,40 +269,45 @@ export async function tailPodLogFile(
let idleCount = 0;
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 {
while (!stopSignal.stopped) {
const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
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;
const grew = await drain();
if (grew) {
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 {
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 on stop
// Final drain after stopSignal — pick up any bytes written between the
// last read and the job reaching terminal state.
while (await drain()) { /* read until no more growth */ }
if (pending) {
await onLog("stdout", pending + "\n");
accumulator.push(pending + "\n");
@@ -462,13 +467,24 @@ async function streamAndAwaitJob(
return onLog(stream, chunk);
};
const tailResult = await tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal });
stdout = tailResult;
// Wait for job completion (may already be done by the time we read the file)
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath);
const completionGraced = completionWithGrace(completionPromise, LOG_EXIT_COMPLETION_GRACE_MS);
const completion = await completionGraced;
// Run the file tail and the job-completion poll in parallel so that the
// tail loop has a way to stop: when waitForJobCompletion resolves it sets
// stopSignal.stopped, which lets tailPodLogFile drain and return.
// No completionWithGrace wrapper here — wrapping a long-running job poll
// in a 30s grace turns the grace into a hard ceiling and kills runs
// prematurely with "Timed out after 0s" when timeoutSec is 0 (no timeout).
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath)
.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) {
clearInterval(keepaliveTimer);
+3 -2
View File
@@ -359,8 +359,9 @@ 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");
// init container only writes the prompt; no mkdir (log dir exists on PVC) and no OPENCODE_DB_PATH env var
expect(initCmd?.[2]).not.toContain("mkdir");
expect(initCmd?.[2]).toContain("/tmp/prompt/prompt.txt");
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
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 {
return `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
return `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
}
export interface JobBuildInput {
@@ -461,14 +461,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
imagePullPolicy: "IfNotPresent",
...(input.promptSecretName
? {
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
command: ["sh", "-c", `cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
volumeMounts: [
{ name: "prompt", mountPath: "/tmp/prompt" },
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
],
}
: {
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
command: ["sh", "-c", `printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
}),
+195
View File
@@ -0,0 +1,195 @@
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
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.ts", "src/ui-parser.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist/ui-parser",
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/ui-parser.ts"]
}