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 # 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.1.38", "version": "0.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paperclip-adapter-opencode-k8s", "name": "paperclip-adapter-opencode-k8s",
"version": "0.1.38", "version": "0.2.3",
"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.415.0-canary.7", "@paperclipai/adapter-utils": "^2026.428.0",
"@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.415.0-canary.7" "@paperclipai/adapter-utils": ">=2026.428.0"
} }
}, },
"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.415.0-canary.7", "version": "2026.428.0",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.415.0-canary.7.tgz", "resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
"integrity": "sha512-VNzIZmu1lrK6QM8Ad9WkOihZItfkj21NHKQf+artDcbwFT2hHbDAD9hdW2W9NMVxYdFvvnws3w76FI/BUbCMbQ==", "integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+6 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "paperclip-adapter-opencode-k8s", "name": "paperclip-adapter-opencode-k8s",
"version": "0.1.38", "version": "0.2.3",
"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,14 +10,15 @@
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
"./server": "./dist/server/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" "./cli": "./dist/cli/index.js"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"scripts": { "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", "clean": "rm -rf dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -28,10 +29,10 @@
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7" "@paperclipai/adapter-utils": ">=2026.428.0"
}, },
"devDependencies": { "devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7", "@paperclipai/adapter-utils": "^2026.428.0",
"@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,4 +64,3 @@ Notes:
`; `;
export { createServerAdapter } from "./server/index.js"; 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 { 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 to prevent tailPodLogFile (used by execute()) from // Mock node:fs/promises so tailPodLogFile (used by execute()) reads a
// hanging on unmocked fs.stat calls in test environment. // configurable JSONL payload and returns. Individual tests override the
// vi.hoisted creates shared module-level state; beforeEach resets it so every // payload via setMockJsonl(...) before calling execute().
// test gets a clean first-read-success. const { readMock, statMock, fhStatMock, resetFsMocks, setMockJsonl } = vi.hoisted(() => {
const { readMock, resetFsMocks } = 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; let readOffset = 0;
const apply = (next: string) => { payload = next; buffer = Buffer.from(payload); readOffset = 0; };
return { return {
readMock: vi.fn().mockImplementation(async () => { readMock: vi.fn().mockImplementation(async (buf: Buffer, off: number, len: number, _pos: number) => {
if (readOffset === 0) { if (readOffset >= buffer.byteLength) return { bytesRead: 0, buffer: buf };
readOffset = 17; const remaining = buffer.byteLength - readOffset;
return { bytesRead: 17, buffer: Buffer.from('{"type":"text"}\n') }; const toRead = Math.min(len, remaining);
} buffer.copy(buf, off, readOffset, readOffset + toRead);
return { bytesRead: 0, buffer: Buffer.alloc(0) }; 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", () => ({ vi.mock("node:fs/promises", async (importOriginal) => {
stat: vi.fn().mockResolvedValue({ size: 17 }), const actual = await importOriginal<typeof import("node:fs/promises")>();
open: vi.fn().mockResolvedValue({ return {
stat: vi.fn().mockResolvedValue({ size: 17 }), ...actual,
read: readMock, stat: statMock,
close: vi.fn().mockResolvedValue(undefined), open: vi.fn().mockResolvedValue({
}), stat: fhStatMock,
unlink: vi.fn().mockResolvedValue(undefined), read: readMock,
})); 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(),
@@ -43,7 +57,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/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson` `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
), ),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024, LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
})); }));
@@ -169,7 +183,7 @@ beforeEach(() => {
prompt: "Test prompt", prompt: "Test prompt",
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, 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>); } as unknown as ReturnType<typeof buildJobManifest>);
const batchApi = makeBatchApi(); const batchApi = makeBatchApi();
@@ -590,6 +604,7 @@ 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>);
@@ -601,6 +616,7 @@ 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>);
@@ -672,6 +688,7 @@ 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>);
@@ -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 () => { 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>);
@@ -757,6 +775,7 @@ 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>);
@@ -939,7 +958,7 @@ describe("execute — large-prompt Secret path", () => {
prompt: LARGE_PROMPT, prompt: LARGE_PROMPT,
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, 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>); } as unknown as ReturnType<typeof buildJobManifest>);
} }
@@ -1271,7 +1290,7 @@ describe("execute — large-prompt Secret create failure", () => {
prompt: LARGE_PROMPT, prompt: LARGE_PROMPT,
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, 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>); } as unknown as ReturnType<typeof buildJobManifest>);
const coreApi = makeCoreApi(); 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: "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>);
@@ -1507,10 +1527,10 @@ describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
prompt: "p", prompt: "p",
opencodeArgs: [], opencodeArgs: [],
promptMetrics: null, 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) => 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, LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
})); }));
+49 -33
View File
@@ -269,40 +269,45 @@ 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 pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS; const grew = await drain();
await new Promise((r) => setTimeout(r, pollMs)); if (grew) {
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 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) { if (pending) {
await onLog("stdout", pending + "\n"); await onLog("stdout", pending + "\n");
accumulator.push(pending + "\n"); accumulator.push(pending + "\n");
@@ -462,13 +467,24 @@ async function streamAndAwaitJob(
return onLog(stream, chunk); return onLog(stream, chunk);
}; };
const tailResult = await tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal }); // Run the file tail and the job-completion poll in parallel so that the
stdout = tailResult; // tail loop has a way to stop: when waitForJobCompletion resolves it sets
// stopSignal.stopped, which lets tailPodLogFile drain and return.
// Wait for job completion (may already be done by the time we read the file) // No completionWithGrace wrapper here — wrapping a long-running job poll
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath); // in a 30s grace turns the grace into a hard ceiling and kills runs
const completionGraced = completionWithGrace(completionPromise, LOG_EXIT_COMPLETION_GRACE_MS); // prematurely with "Timed out after 0s" when timeoutSec is 0 (no timeout).
const completion = await completionGraced; 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) { if (keepaliveTimer) {
clearInterval(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", () => { 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;
// mkdir is added for log directory but OPENCODE_DB_PATH env var is NOT added // init container only writes the prompt; no mkdir (log dir exists on PVC) and no OPENCODE_DB_PATH env var
expect(initCmd?.[2]).toContain("mkdir"); expect(initCmd?.[2]).not.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/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`; return `/paperclip/instances/default/data/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", `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: [ 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", `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 }], env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/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"]
}