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>
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>
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>
- 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>
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>
- 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>
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>
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>
Handled by the framework via instructionsPathKey/supportsInstructionsBundle.
Surfacing it as an editable field in the config schema was redundant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cancel poll now uses ctx.authToken exclusively. Remove forwarding of
PAPERCLIP_DEV_API_KEY into job pods and all associated tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds back STATIC_MODELS with correct provider/model IDs and consistent
labels (matching listModels output format) so the UI is not blank before
listModels resolves. Restores server-adapter test with a contract check
that enforces provider/model label consistency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The cancel poll was sending empty Authorization headers because
PAPERCLIP_API_KEY is not set on the Paperclip server pod. Use the
per-run authToken from ctx instead, which is the JWT issued by Paperclip
for this execution. PAPERCLIP_DEV_API_KEY still overrides for dev instances.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace static hardcoded fallback list with the same robust approach used
by the opencode_local adapter: runChildProcess + ensurePathInEnv, HOME fix
via os.userInfo(), 60s TTL cache, returns [] on failure instead of a stale
list. Also updates CLAUDE.md and README.md with missing fields/features.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The UI was showing 0 models because the adapter only set listModels (async)
without a static models array. The static array is what the UI reads
synchronously to populate the selector — listModels is for refresh.
- Move the fallback list out as STATIC_MODELS (expanded to cover Opus/Sonnet/Haiku 4.x, GPT-4o/5, Gemini 2.5, Grok 4, DeepSeek)
- Set models: STATIC_MODELS on the adapter module
- Keep listK8sModels for runtime refresh from `opencode models` (with STATIC_MODELS fallback on error or empty stdout)
- Add server-adapter.test.ts asserting models is non-empty
- Add models.test.ts coverage for the empty-stdout fallback path
chore: bump version to 0.1.33
Fixes FAR-94.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Reads available models dynamically via 'opencode models' command
to populate the Paperclip UI model selector. Falls back to previous
static list on any error (missing CLI, timeout, parse failure).
Fixes FAR-94: 0 models shown in adapter UI.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Locks in the existing override behavior so a future regression that
reverts to a hardcoded image is caught immediately. Closes the
investigation on FAR-90.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The cancel-poll test sets PAPERCLIP_API_KEY='test-key' but the actual
PAPERCLIP_DEV_API_KEY was leaking through from the harness environment.
Since execute.ts prefers PAPERCLIP_DEV_API_KEY over PAPERCLIP_API_KEY,
the poll was sending the real dev key instead of 'test-key'.
Fix: add beforeEach to set PAPERCLIP_DEV_API_KEY='test-key', and afterEach
to clean both env vars.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The env var was set to /opencode-db (the mount point directory), but sqlite
requires a file path. Changed to /opencode-db/opencode.db.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
So we can answer "what's coverage?" without re-installing each time.
Run with: \`npx vitest run --coverage --coverage.provider=v8 --coverage.reporter=text-summary\`
Co-Authored-By: Paperclip <noreply@paperclip.ing>
There was no test file for k8s-client.ts. Existing pvc.test.ts mocked
`getPvc` directly and never exercised the underlying isNotFound predicate,
so the v1.x ApiException `code` vs `statusCode` regression had nothing to
catch it.
Add k8s-client.test.ts that mocks @kubernetes/client-node, throws errors
shaped exactly like the real ApiException (status under `code`), and
verifies:
- getPvc returns null on code=404 (the FAR-85 case)
- getPvc still handles legacy statusCode=404 and response.statusCode=404
- getPvc re-throws non-404 errors (500, 403)
- deletePvc swallows 404, re-throws others
- createPvc forwards spec to the SDK
Confirmed the new tests fail when k8s-client.ts is reverted to the
pre-fix predicate (2 failures), and pass with the fix in place.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The v1.x ApiException exposes the HTTP status as `code`, not `statusCode`.
Both `isNotFound` (k8s-client) and `isK8s404` (execute) only checked
`statusCode`/`response.statusCode`, so 404s were never recognized:
- `getPvc` re-threw the 404 instead of returning null, which bubbled up
through `ensureAgentDbPvc` as `k8s_job_create_failed` with the raw
"persistentvolumeclaims X not found" body — the symptom in FAR-85.
- The PVC was never actually created, because the existence check threw
before reaching `createPvc`.
Add `code === 404` to both predicates and a regression test for `isK8s404`.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The previous workflow ran npm publish on every push to master and
gated it via npm view on a stale scoped package name, which made
the check always think the version was unpublished and 403'd
whenever the registry already had it.
Switch the publish job to fire only on push of a v* tag, verify
the tag matches package.json, and use the standard
NODE_AUTH_TOKEN flow via setup-node's registry-url. Tests still
run on master push and PRs.
Release flow: bump version, commit, push master, then
git tag v<version> && git push origin v<version>.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adapter releases are distributed via the Paperclip adapter plugin
system, not tarballs in git. Removes legacy 0.1.22/0.1.23/0.1.26
tarballs and a stray screenshot, and adds *.tgz to .gitignore so
future npm pack output is not committed.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Before creating a PVC, ensureAgentDbPvc checks if it exists and creates
it if not. However, the Kubernetes API may return a Success response
without actually creating the resource. This commit adds a verification
step after createPvc to confirm the PVC actually exists before returning.
Fixes FAR-84.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The exponential backoff sleep in streamPodLogs used a single setTimeout
for the full delay (3s, 6s, 12s...). When logStopSignal.stopped was set
mid-backoff (e.g. by external cancel), the loop body could not check the
signal until the timer expired — causing the cancel test to time out when
the 12s backoff overlapped with the 15s cancel window.
Sleep in 200ms chunks so a stop signal can exit the backoff immediately.
Fixes the pre-existing CI timeout in execute.test.ts.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Prior commit from remote + this branch both added the field; deduplicate,
keeping the entry at the top of the Kubernetes group.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds serviceAccountName field to the Kubernetes group in getConfigSchema()
so operators can specify a dedicated SA (e.g. paperclip-developer) for Job
pods that need k8s API access. The field was already consumed in job-manifest.ts;
this makes it visible in the UI. Bumps to 0.1.25.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surfaces the serviceAccountName field in the adapter UI under the
Kubernetes group. The job manifest builder already reads this field;
this change makes it configurable via the UI.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
External cancel polling in execute.ts used PAPERCLIP_API_KEY which is
a short-lived run JWT for the main Paperclip instance. In multi-instance
setups (dev vs main), the agent runs on the dev instance but the run JWT
is only valid on the main instance, causing 401 on every poll.
Now polls with PAPERCLIP_DEV_API_KEY if set, falling back to
PAPERCLIP_API_KEY. The dev key is inherited through job-manifest.ts
from the pod's inherited env.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Replace fixed 3s reconnect delay with exponential backoff (3s → 6s → 12s → 24s → capped at 30s) to avoid hammering the K8s API server during prolonged network blips while remaining responsive during brief disconnects.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Seven direct unit tests for ensureAgentDbPvc covering ephemeral mode,
existing PVC (no create), PVC creation with storage class/capacity,
missing storage class error, default mode, and agent ID slug derivation.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Replaces the Option A shared-PVC path implementation with a long-lived
dedicated PVC per agent, mounted at /opencode-db with OPENCODE_DB=/opencode-db.
Changes:
- k8s-client.ts: add getPvc/createPvc/deletePvc CoreV1Api helpers
- execute.ts: add ensureAgentDbPvc() that gets-or-creates a PVC named
opencode-db-<agentId> before Job creation; pass agentDbClaimName through
to buildJobManifest; return null for ephemeral mode (emptyDir used instead)
- job-manifest.ts: accept agentDbClaimName on JobBuildInput; mount dedicated
PVC or emptyDir at /opencode-db; set OPENCODE_DB=/opencode-db; revert init
container to simple form (no mkdir, no PVC mount)
- config-schema.ts: replace opencodeDbMode/opencodeDbPath with agentDbMode
(dedicated_pvc|ephemeral, default dedicated_pvc), agentDbStorageClass
(required for dedicated_pvc), agentDbStorageCapacity (default 1Gi)
- test.ts: add create/delete RBAC checks for persistentvolumeclaims
- pvc.test.ts: unit tests for ensureAgentDbPvc (7 cases incl. error paths)
- 289/289 tests pass; typecheck clean
- No agent-delete hook exists; opencode-db PVC janitor routine is a deferred
follow-up task
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- shared_pvc mode (default): sets OPENCODE_DB to /paperclip/.opencode/db/<agentId>
and prepends mkdir -p to the busybox init container when a PVC is present
- ephemeral mode: mounts an emptyDir at /opencode-db and points OPENCODE_DB there
- config-schema: adds opencodeDbMode (select, default shared_pvc) and
opencodeDbPath (optional text override for shared_pvc path)
- No agent-delete hook exists in this adapter; per-agent DB dir cleanup is
deferred to a janitor routine (follow-up work)
- 284/284 tests pass; typecheck clean
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The cancel poller was calling GET /api/heartbeat-runs/{runId} which
returned 401 because the adapter key lacks access to the internal
heartbeat-runs endpoint. Switch to GET /api/issues/{issueId}, which
the adapter key can read. Also tighten the trigger condition from
status !== "running" to status === "cancelled" so that other terminal
states (done, blocked, etc.) do not abort the K8s job.
Co-Authored-By: Paperclip <noreply@paperclip.ing>