Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9bc2e513b | |||
| c79eea7ee0 | |||
| fa6c115be4 | |||
| 480f7cf3d1 | |||
| 5ed041fd84 | |||
| fe6bc0c2d6 | |||
| 2d057f085d | |||
| 570fdae9c4 | |||
| 985d55e125 | |||
| 5e67a4dd3b | |||
| 5f75c2b81b | |||
| 7043e71ff6 | |||
| da1b55d233 | |||
| 168161148c | |||
| 2daedda537 | |||
| e364e09113 |
@@ -13,6 +13,16 @@ npm run test:watch # Run vitest in watch mode
|
||||
|
||||
Run a single test file: `npx vitest run src/server/parse.test.ts`
|
||||
|
||||
## Publishing
|
||||
|
||||
Bump `version` in `package.json`, commit, push to `master`, then push a matching tag — the CI publish job only runs on `v*` tags:
|
||||
|
||||
```bash
|
||||
git tag v0.1.x && git push origin v0.1.x
|
||||
```
|
||||
|
||||
The workflow verifies the tag matches `package.json` version before publishing to npm.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Job pods. It exposes three entry points:
|
||||
@@ -20,23 +30,24 @@ This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kuberne
|
||||
- `.` → `src/index.ts` — main ServerAdapterModule factory
|
||||
- `./server` → `src/server/index.ts` — server adapter internals
|
||||
- `./ui-parser` → `src/ui-parser.ts` — real-time stdout line parser for the Paperclip UI
|
||||
- `./cli` → `src/cli/index.ts` — CLI adapter module with console output formatter
|
||||
|
||||
### Execution Flow (`src/server/execute.ts`)
|
||||
|
||||
1. **Concurrency guard** — checks for existing running Jobs for the same agent (shared PVC/session enforcement)
|
||||
2. **Self-pod introspection** (`getSelfPodInfo`) — queries own pod to inherit image, imagePullSecrets, DNS config, PVC mount, and all env vars from the Deployment
|
||||
3. **Instructions + skill bundle resolution** — reads `instructionsFilePath` from config and desired skill markdown files from the PVC; content is prepended to the prompt at build time
|
||||
4. **Job manifest build** (`buildJobManifest`) — constructs a K8s Job with:
|
||||
- Init container (busybox) that writes the prompt to an emptyDir volume
|
||||
- Main opencode container that pipes the prompt via stdin
|
||||
4. **Agent DB PVC management** — if `agentDbMode: dedicated_pvc`, ensures a per-agent RWX PVC named `opencode-db-{agentId}` exists (creates it if missing), then mounts it at `/opencode-db` with `OPENCODE_DB=/opencode-db`; defaults to ephemeral emptyDir
|
||||
5. **Job manifest build** (`buildJobManifest`) — constructs a K8s Job with:
|
||||
- Prompt delivery: small prompts (< 256 KiB) via env var; large prompts via K8s Secret + busybox init container that copies to emptyDir, then piped via stdin
|
||||
- Prompt assembled as: `[instructionsContent] + [skillsBundleContent] + bootstrapPrompt + wakePrompt + sessionHandoff + heartbeatPrompt`
|
||||
- Inherited env vars layered: Deployment env → PAPERCLIP_* vars → user overrides
|
||||
- Inherited env vars layered: Deployment env → PAPERCLIP_* vars → user overrides; always sets `HOME=/paperclip` and `OPENCODE_DISABLE_PROJECT_CONFIG=true`
|
||||
- Resource requests/limits, security contexts, tolerations, nodeSelector applied from config
|
||||
5. **Job creation** — creates the Job in the target namespace
|
||||
6. **Pod scheduling wait** — polls for the pod to be scheduled, checking init container states and image pull issues
|
||||
7. **Log streaming + completion wait** — streams pod logs to the Paperclip UI while waiting for Job completion (with configurable timeout)
|
||||
8. **JSONL parsing** (`parseOpenCodeJsonl`) — extracts session ID, usage tokens, cost, summary, and errors from OpenCode JSONL output
|
||||
9. **Result synthesis** — returns exit code, usage metrics, session params for resume, and billing type inference
|
||||
6. **Job creation** — creates the Job in the target namespace
|
||||
7. **Pod scheduling wait** — polls for the pod to be scheduled, checking init container states and image pull issues
|
||||
8. **Log streaming + completion wait** — streams pod logs with automatic reconnect on K8s API drops; `LogLineDedupFilter` (`log-dedup.ts`) deduplicates replayed lines on reconnect using structural keys (`type:sessionID:partId` for JSONL events, `raw:{content}` for plain lines)
|
||||
9. **JSONL parsing** (`parseOpenCodeJsonl`) — extracts session ID, usage tokens, cost, summary, and errors from OpenCode JSONL output
|
||||
10. **Result synthesis** — returns exit code, usage metrics, session params for resume, and billing type inference
|
||||
|
||||
### Skill Materialization (`src/server/skills.ts` + `src/server/execute.ts`)
|
||||
|
||||
@@ -84,5 +95,7 @@ src/
|
||||
session.ts — sessionCodec (serialize/deserialize session params)
|
||||
config-schema.ts — getConfigSchema() (adapter UI config fields)
|
||||
test.ts — testEnvironment() (K8s environment health checks)
|
||||
models.ts — static model list + dynamic fetch from `opencode models` CLI (with fallback)
|
||||
log-dedup.ts — LogLineDedupFilter for reconnect replay deduplication
|
||||
*.test.ts — vitest unit tests
|
||||
```
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
You are implementing two coordinated changes to a Paperclip adapter plugin.
|
||||
The repo is at /Users/Repositories/paperclip-adapter-opencode-k8s on branch
|
||||
master. Work on a new branch off master — do NOT commit directly to master.
|
||||
|
||||
Before you start, read these files fully:
|
||||
- src/server/execute.ts (~1218 lines; this is the main file you'll edit)
|
||||
- src/server/job-manifest.ts
|
||||
- src/server/log-dedup.ts (you will delete this)
|
||||
- src/server/parse.ts
|
||||
- src/server/index.ts
|
||||
- src/index.ts
|
||||
|
||||
Run `npm install` and then `npm test`. Confirm green. Note the test count
|
||||
for later comparison. Do NOT run `npm run build` — CI handles that.
|
||||
|
||||
=============================================================================
|
||||
WHY WE ARE DOING THIS
|
||||
=============================================================================
|
||||
|
||||
Two tightly coupled bugs:
|
||||
|
||||
(A) The adapter doesn't declare `hasOutOfProcessLiveness: true` on its
|
||||
ServerAdapterModule. The revitalize reaper therefore treats it as an
|
||||
in-process adapter, expects a local child PID, finds none, and marks
|
||||
every run `process_lost` after 5 minutes of staleness.
|
||||
|
||||
(B) The adapter reads pod logs via the Kubernetes log API (follow mode).
|
||||
At production scale the stream drops every few seconds, exhausting
|
||||
the 50-reconnect cap within 2.5 minutes. Long runs lose live UI
|
||||
output, and combined with (A) they fail entirely.
|
||||
|
||||
Fix both in one PR:
|
||||
|
||||
1. Declare `hasOutOfProcessLiveness: true` in createServerAdapter().
|
||||
2. Have the pod tee opencode's stdout to a file on the shared PVC, and
|
||||
have the adapter tail that file from the Paperclip server process.
|
||||
|
||||
We are NOT going to:
|
||||
- wrap the opencode binary
|
||||
- use hooks
|
||||
- add a sidecar
|
||||
- change revitalize
|
||||
- keep the k8s log API as a fallback
|
||||
|
||||
We ARE going to:
|
||||
- replace k8s log streaming with filesystem tailing entirely
|
||||
- delete all reconnect logic and the log-dedup filter
|
||||
- keep `kubectl logs -f` working (tee preserves stdout)
|
||||
- add the liveness flag so the reaper uses staleness-based liveness
|
||||
|
||||
=============================================================================
|
||||
SCOPE OF CHANGES
|
||||
=============================================================================
|
||||
|
||||
--- hasOutOfProcessLiveness flag (src/server/index.ts) ---
|
||||
|
||||
The file today returns a plain object from createServerAdapter(). Add
|
||||
`hasOutOfProcessLiveness: true` to the returned object, matching the
|
||||
pattern from paperclip-adapter-claude-k8s. The adapter-utils type predates
|
||||
this field, so the return needs a cast.
|
||||
|
||||
Before (approximately):
|
||||
export function createServerAdapter(): ServerAdapterModule {
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
// ... other fields ...
|
||||
};
|
||||
}
|
||||
|
||||
After:
|
||||
export function createServerAdapter(): ServerAdapterModule {
|
||||
return {
|
||||
type,
|
||||
execute,
|
||||
// ... other fields ...
|
||||
// Tells the reaper to skip local PID checks and use the staleness-based
|
||||
// liveness window instead (adapter spawns K8s Jobs in separate pods).
|
||||
// Cast required: adapter-utils ServerAdapterModule type predates this field.
|
||||
hasOutOfProcessLiveness: true,
|
||||
} as ServerAdapterModule;
|
||||
}
|
||||
|
||||
--- Job manifest (src/server/job-manifest.ts) ---
|
||||
|
||||
1. MODIFY the main container command to tee stdout. Current code at
|
||||
approximately line 409:
|
||||
const mainCommand = `${configSetup}cat /tmp/prompt/prompt.txt | opencode ${opencodeArgsEscaped}`;
|
||||
Change to:
|
||||
const podLogPath =
|
||||
`/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
|
||||
const mainCommand = `${configSetup}cat /tmp/prompt/prompt.txt | opencode ${opencodeArgsEscaped} | tee ${podLogPath}`;
|
||||
|
||||
`companyId`, `agentId`, `runId` are already in scope in buildJobManifest
|
||||
via the destructuring at line 219 (`agent`, `runId`) — use `agent.id`
|
||||
and `agent.companyId`. If you prefer cleaner code, add a local:
|
||||
const companyId = agent.companyId;
|
||||
const agentId = agent.id;
|
||||
|
||||
2. MODIFY the init container command to create the parent directory before
|
||||
the main container starts. The existing init container today writes the
|
||||
prompt file with `printf`. Amend its command to also `mkdir -p` the log
|
||||
directory. The init container is at approximately line 444 (prompt
|
||||
secret path) and line 451 (direct printf path) — there are TWO init
|
||||
container variants. Amend BOTH to prepend the mkdir:
|
||||
|
||||
Variant 1 (large-prompt path, approx line 444):
|
||||
Before: `cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`
|
||||
After: `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`
|
||||
|
||||
Variant 2 (direct path, approx line 451):
|
||||
Before: `printf '%s' "$PROMPT_CONTENT" > /tmp/prompt/prompt.txt`
|
||||
After: `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && printf '%s' "$PROMPT_CONTENT" > /tmp/prompt/prompt.txt`
|
||||
|
||||
Use template substitution for companyId/agentId/runId — these are all
|
||||
in scope in the builder.
|
||||
|
||||
3. EXPORT the log path builder so execute.ts can compute the same path:
|
||||
export function buildPodLogPath(companyId: string, agentId: string, runId: string): string {
|
||||
return `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
|
||||
}
|
||||
Return this path from buildJobManifest alongside other fields in
|
||||
JobBuildResult (add `podLogPath: string` to the interface at approx
|
||||
line 46). Update the final `return { job, jobName, namespace, prompt,
|
||||
opencodeArgs, promptMetrics }` (approx line 482) to include podLogPath.
|
||||
|
||||
4. ID SANITIZATION: before using companyId/agentId/runId in the path,
|
||||
validate they match `^[a-zA-Z0-9-]+$`. Add a helper at the top of
|
||||
job-manifest.ts:
|
||||
function assertSafePathComponent(field: string, value: string): void {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(value)) {
|
||||
throw new Error(`Invalid ${field} for log path: ${value}`);
|
||||
}
|
||||
}
|
||||
Call it for companyId, agentId, and runId before computing podLogPath
|
||||
and before interpolating into the init container commands.
|
||||
|
||||
--- Adapter (src/server/execute.ts) ---
|
||||
|
||||
1. DELETE the `LogLineDedupFilter` import (approx line 13).
|
||||
2. DELETE constants (approx lines 19-26):
|
||||
LOG_STREAM_RECONNECT_DELAY_MS
|
||||
LOG_STREAM_RECONNECT_MAX_DELAY_MS
|
||||
MAX_LOG_RECONNECT_ATTEMPTS
|
||||
LOG_STREAM_BAIL_TIMEOUT_MS
|
||||
3. DELETE functions:
|
||||
streamPodLogsOnce (approx line 168)
|
||||
streamPodLogs (approx line 252)
|
||||
readPodLogs (approx line 330)
|
||||
waitForPodTermination (approx line 355) — only used by the fallback
|
||||
4. DELETE the bail timer machinery inside any function being removed
|
||||
(bailTimer, bailResolve, bailPromise, stopPoller).
|
||||
5. DELETE the fallback path in `execute` around lines 675-693:
|
||||
if (!stdout.trim()) {
|
||||
// ... waitForPodTermination + readPodLogs fallback
|
||||
} else if (!parseOpenCodeJsonl(stdout).sessionId) {
|
||||
// ... partial-stdout fallback
|
||||
}
|
||||
|
||||
6. ADD a new function `tailPodLogFile` in execute.ts. Inline is fine; do
|
||||
not create a new module. Signature:
|
||||
|
||||
interface TailOptions {
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
stopSignal: { stopped: boolean };
|
||||
}
|
||||
|
||||
async function tailPodLogFile(
|
||||
filePath: string,
|
||||
opts: TailOptions,
|
||||
): Promise<string> { ... }
|
||||
|
||||
Behavior:
|
||||
- Wait up to 30 seconds for the file to exist. Poll with
|
||||
fs.promises.stat every 250ms. If the file doesn't appear in 30s,
|
||||
throw an Error: `Pod log file never appeared at ${filePath}`.
|
||||
- Once it exists, open with fs.promises.open(filePath, 'r').
|
||||
- Track a byte offset starting at 0.
|
||||
- Poll loop: 250ms active cadence, backs off to 1000ms if the file
|
||||
hasn't grown for 5 consecutive polls (reset to 250ms on any
|
||||
growth). For each poll:
|
||||
a. stat the file, compare size to offset
|
||||
b. if size > offset, read bytes from [offset, size) into a Buffer
|
||||
c. update offset = size
|
||||
d. concatenate any pending partial line with the new buffer,
|
||||
split on '\n'
|
||||
e. last element is the new pending partial line (if no trailing
|
||||
newline) or empty
|
||||
f. for every complete line, call onLog("stdout", line + "\n")
|
||||
and append to an in-memory accumulator (string)
|
||||
- Exit when opts.stopSignal.stopped === true. Before returning, do
|
||||
ONE final read-to-EOF to drain tail bytes. Close the handle.
|
||||
Return the accumulator.
|
||||
|
||||
Use fs.promises.open / FileHandle.read / FileHandle.close. Do NOT use
|
||||
fs.watch or chokidar.
|
||||
|
||||
7. REPLACE the existing log-streaming section of `execute`. Find where
|
||||
streamPodLogs is invoked inside a `Promise.allSettled` with
|
||||
waitForJobCompletion (approx line 660). Replace that call with
|
||||
tailPodLogFile. Pattern:
|
||||
|
||||
const { /* ..., */ podLogPath } = built;
|
||||
// ... create secret, create job, wait for pod ...
|
||||
const stopSignal = { stopped: false };
|
||||
const [tailResult, completionResult] = await Promise.allSettled([
|
||||
tailPodLogFile(podLogPath, { onLog, stopSignal }),
|
||||
waitForJobCompletion(namespace, jobName, ...).then(r => { stopSignal.stopped = true; return r; }),
|
||||
]);
|
||||
const stdout = tailResult.status === "fulfilled" ? tailResult.value : "";
|
||||
|
||||
Keep waitForJobCompletion unchanged. Keep the existing `keepaliveTimer`
|
||||
and `cancelSignal` / cancel-polling machinery unchanged — those are
|
||||
independent of log streaming.
|
||||
|
||||
8. ADD log file cleanup. Find `cleanupJob` (the function that deletes the
|
||||
K8s Job). After successful deletion, best-effort delete the log file:
|
||||
try { await fs.promises.unlink(podLogPath); } catch { /* non-fatal */ }
|
||||
Skip the unlink if `retainJobs === true`.
|
||||
cleanupJob will need podLogPath passed in; thread it from the caller.
|
||||
|
||||
--- Delete entire files ---
|
||||
|
||||
- src/server/log-dedup.ts
|
||||
- src/server/log-dedup.test.ts
|
||||
|
||||
--- Tests ---
|
||||
|
||||
- Delete any execute.test.ts tests covering streamPodLogsOnce,
|
||||
streamPodLogs, readPodLogs, waitForPodTermination, the bail timer, or
|
||||
LogLineDedupFilter. Search for those identifiers; remove matching
|
||||
describe/it blocks. Non-log-streaming tests in the same file stay.
|
||||
- Add test cases for tailPodLogFile to execute.test.ts. Cover:
|
||||
1. File appears within 30s; content is tailed line-by-line
|
||||
2. File never appears; function throws with expected message
|
||||
3. Partial trailing line buffered and emitted on next poll
|
||||
4. Stop signal exits the loop; final drain reads remaining bytes
|
||||
5. Adaptive backoff: idle polls slow; active polls speed up
|
||||
Use vitest fake timers (vi.useFakeTimers) and a tmpdir via
|
||||
`fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-tailer-'))`.
|
||||
|
||||
=============================================================================
|
||||
TESTING
|
||||
=============================================================================
|
||||
|
||||
After all changes:
|
||||
1. `npm run typecheck` — must pass (the `as ServerAdapterModule` cast
|
||||
may be needed; mirror claude-k8s's pattern)
|
||||
2. `npm test` — must pass. Test count will drop vs baseline because you
|
||||
deleted tests. Record the new passing count.
|
||||
|
||||
Do NOT run the adapter end-to-end. Do NOT require a k8s cluster.
|
||||
|
||||
=============================================================================
|
||||
BRANCH, COMMIT, PUSH, PR
|
||||
=============================================================================
|
||||
|
||||
1. Create a new branch off master:
|
||||
git checkout master && git pull && git checkout -b feat/filesystem-log-tail-and-liveness-flag
|
||||
|
||||
2. Make all changes above. Commit as ONE commit:
|
||||
|
||||
feat: declare hasOutOfProcessLiveness and tail pod log from filesystem
|
||||
|
||||
Two coordinated fixes for long-running agent failures:
|
||||
|
||||
(1) Declare hasOutOfProcessLiveness: true on the ServerAdapterModule.
|
||||
Without it the reaper treated this adapter as in-process, expected
|
||||
a local child PID, and marked every run process_lost after 5min
|
||||
staleness. Flag tells the reaper to use the staleness-based
|
||||
liveness window for out-of-process adapters.
|
||||
|
||||
(2) Replace k8s log API streaming with filesystem tailing. The k8s
|
||||
follow stream drops every ~3 seconds at production scale,
|
||||
exhausting the 50-attempt reconnect cap within 2.5 minutes. Pod
|
||||
now tees opencode's stdout to
|
||||
/paperclip/instances/default/run-logs/<companyId>/<agentId>/<runId>.pod.ndjson
|
||||
on the shared PVC; adapter tails the file directly. kubectl logs -f
|
||||
still works (tee preserves stdout).
|
||||
|
||||
Deletes:
|
||||
- LogLineDedupFilter and all reconnect logic
|
||||
- streamPodLogsOnce, streamPodLogs, readPodLogs, waitForPodTermination
|
||||
- Both fallback paths (empty-stream and missing-sessionId)
|
||||
|
||||
Adds:
|
||||
- tailPodLogFile: adaptive 250ms/1s poll loop with partial-line
|
||||
buffering and tail-drain on stopSignal
|
||||
- Log file cleanup tied to retainJobs
|
||||
- Path-component sanitization (companyId/agentId/runId must match
|
||||
[a-zA-Z0-9-]+)
|
||||
|
||||
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
|
||||
|
||||
3. Push:
|
||||
git push -u origin feat/filesystem-log-tail-and-liveness-flag
|
||||
|
||||
4. Open a PR against master with `gh pr create`:
|
||||
Title: `feat: declare hasOutOfProcessLiveness and tail pod log from filesystem`
|
||||
Body (use a heredoc):
|
||||
|
||||
## Summary
|
||||
- Declares `hasOutOfProcessLiveness: true` so the reaper uses
|
||||
staleness-based liveness instead of expecting a local PID
|
||||
- Pod tees opencode stdout to PVC; adapter tails the file directly
|
||||
- Eliminates k8s log API dependency for streaming
|
||||
- Deletes LogLineDedupFilter, reconnect logic, both fallback paths
|
||||
|
||||
## Why
|
||||
At production scale (144 concurrent runs), two bugs combined:
|
||||
(a) no liveness flag → reaper marked runs process_lost at 5min
|
||||
(b) k8s log follow stream drops every ~3s, exhausting the 50-reconnect
|
||||
cap. Runs over ~2.5min lost live output; over 5min failed outright.
|
||||
|
||||
Both must be fixed together — the flag alone doesn't help if the log
|
||||
stream still drops, and the log tail alone doesn't help if the reaper
|
||||
kills the run for missing PID.
|
||||
|
||||
## Path
|
||||
`/paperclip/instances/default/run-logs/<companyId>/<agentId>/<runId>.pod.ndjson`
|
||||
— the `.pod.ndjson` suffix distinguishes the pod-written file from
|
||||
revitalize's server-side `<runId>.ndjson` log store.
|
||||
|
||||
## Breaking
|
||||
Old Job manifests (pre-tee) are incompatible — the tailer's 30s
|
||||
"file missing" window will surface an error on in-flight runs at
|
||||
deploy time. Operator retry required. Consistent with the companion
|
||||
change in paperclip-adapter-claude-k8s.
|
||||
|
||||
## Test plan
|
||||
- [ ] npm test passes
|
||||
- [ ] Manual: deploy to cluster, run a >5min agent, confirm live UI
|
||||
output and no reaper fire
|
||||
- [ ] Manual: verify kubectl logs -f still works on the Job pod
|
||||
- [ ] Manual: confirm log file is cleaned up when Job cleanup runs
|
||||
(retainJobs=false) and preserved when retainJobs=true
|
||||
|
||||
=============================================================================
|
||||
WRAPPING UP
|
||||
=============================================================================
|
||||
|
||||
Report back with:
|
||||
1. Branch name and commit hash
|
||||
2. PR URL
|
||||
3. Final test count (numbers will drop vs baseline because you deleted
|
||||
tests — record baseline and final)
|
||||
4. Line count of execute.ts before and after (should drop significantly)
|
||||
5. Any deviation from these instructions, with reason
|
||||
|
||||
If ANY of the following happens, STOP and report instead of improvising:
|
||||
- A file path doesn't match what's described (e.g. the mainCommand
|
||||
pattern has changed)
|
||||
- A function you're supposed to delete has other callers you didn't
|
||||
expect (streamPodLogsOnce in particular may have test-only imports
|
||||
that need untangling)
|
||||
- A test you're supposed to keep depends on something you deleted
|
||||
- Typecheck fails and the fix is non-obvious
|
||||
- The `as ServerAdapterModule` cast doesn't satisfy TypeScript
|
||||
|
||||
Do NOT push to master. Do NOT tag a version. Do NOT bump package.json
|
||||
version — leave it as-is.
|
||||
@@ -6,10 +6,12 @@ Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs i
|
||||
|
||||
- Spawns agent runs as K8s Jobs with full pod isolation
|
||||
- Inherits container image, secrets, DNS, and PVC from the Paperclip Deployment automatically
|
||||
- Real-time log streaming from Job pods back to the Paperclip UI
|
||||
- Real-time log streaming from Job pods back to the Paperclip UI with automatic reconnect and replay deduplication
|
||||
- Session resume via shared RWX PVC
|
||||
- Per-agent concurrency guard
|
||||
- Configurable resources, namespace, kubeconfig
|
||||
- Skills bundle injection — skill markdown content prepended to each run prompt at execution time
|
||||
- Optional per-agent database PVC (`agentDbMode: dedicated_pvc`) for persistent agent state across runs
|
||||
- Configurable resources, namespace, kubeconfig, node selectors, and tolerations
|
||||
- Runtime config injection for permission bypass
|
||||
|
||||
## Prerequisites
|
||||
@@ -96,15 +98,15 @@ rules:
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get"]
|
||||
|
||||
# Verify the RWX PVC exists and has the correct access mode
|
||||
# Verify the RWX PVC; create/delete per-agent DB PVCs (agentDbMode: dedicated_pvc)
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["get"]
|
||||
verbs: ["get", "create", "delete"]
|
||||
|
||||
# Verify optional secrets exist (e.g. paperclip-secrets)
|
||||
# Verify optional secrets; create/delete prompt-delivery Secrets for large prompts (> 256 KiB)
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get"]
|
||||
verbs: ["create", "delete", "get"]
|
||||
|
||||
# RBAC self-test during adapter validation
|
||||
- apiGroups: ["authorization.k8s.io"]
|
||||
@@ -172,17 +174,40 @@ curl -X POST http://localhost:3100/api/adapters \
|
||||
|
||||
Agent-level configuration fields:
|
||||
|
||||
**Core**
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `model` | **yes** | — | OpenCode model in `provider/model` format |
|
||||
| `variant` | no | — | Reasoning profile variant |
|
||||
| `instructionsFilePath` | no | — | Absolute path to a markdown file prepended to every run prompt (e.g. `/paperclip/.claude/projects/COMPANY/agents/AGENT/AGENTS.md`) |
|
||||
| `dangerouslySkipPermissions` | no | `true` | Inject runtime config granting `permission.external_directory=allow` |
|
||||
| `agentDbMode` | no | `ephemeral` | `ephemeral` (emptyDir, lost on exit) or `dedicated_pvc` (per-agent RWX PVC at `/opencode-db`) |
|
||||
| `agentDbStorageClass` | no | Cluster default | StorageClass for dedicated agent DB PVC |
|
||||
| `agentDbStorageCapacity` | no | `10Gi` | Storage size for dedicated agent DB PVC |
|
||||
|
||||
**Kubernetes**
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `namespace` | no | Deployment namespace | K8s namespace for agent Jobs |
|
||||
| `image` | no | Deployment image | Override container image for Jobs |
|
||||
| `imagePullPolicy` | no | — | Image pull policy for Job pods |
|
||||
| `kubeconfig` | no | In-cluster | Path to kubeconfig file |
|
||||
| `serviceAccountName` | no | Default SA | Service account for Job pods |
|
||||
| `resources` | no | See below | CPU/memory requests and limits |
|
||||
| `nodeSelector` | no | — | Node selector key=value pairs (one per line) |
|
||||
| `tolerations` | no | — | Pod tolerations in YAML format |
|
||||
| `ttlSecondsAfterFinished` | no | `300` | Seconds before completed Jobs are auto-deleted |
|
||||
| `retainJobs` | no | `false` | Keep completed Jobs for debugging (disables TTL) |
|
||||
| `reattachOrphanedJobs` | no | `false` | Resume streaming if a matching Job is already running after adapter restart |
|
||||
|
||||
**Operational**
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `timeoutSec` | no | `0` (none) | Run timeout in seconds |
|
||||
| `retainJobs` | no | `false` | Keep completed Jobs for debugging |
|
||||
| `graceSec` | no | `30` | Grace period after timeout before forceful termination |
|
||||
| `env` | no | — | Additional environment variables for Jobs |
|
||||
|
||||
### Default Resource Requests and Limits
|
||||
@@ -201,11 +226,13 @@ resources:
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Self-introspection** — on first run, the adapter reads the Paperclip Deployment pod's own spec to discover the PVC claim name, mounted secrets, image pull secrets, DNS config, and environment variables.
|
||||
2. **Job creation** — each agent run creates a Kubernetes Job in the target namespace. The Job pod mounts the same RWX PVC at `/paperclip`, inherits all secrets and env vars, and runs the agent command.
|
||||
3. **Log streaming** — the adapter streams stdout/stderr from the Job pod back to the Paperclip UI in real time.
|
||||
4. **Concurrency guard** — only one Job per agent is allowed at a time (enforced via label selectors).
|
||||
5. **Cleanup** — completed Jobs are automatically deleted after 300 seconds (`ttlSecondsAfterFinished`), or retained if `retainJobs` is enabled.
|
||||
1. **Self-introspection** — on first run, the adapter reads the Paperclip Deployment pod's own spec to discover the PVC claim name, mounted secrets, image pull secrets, DNS config, and environment variables. This is cached for all subsequent runs in the same process.
|
||||
2. **Concurrency guard** — only one Job per agent is allowed at a time, enforced via K8s label selectors before Job creation.
|
||||
3. **Prompt assembly** — instructions file, skills markdown bundle, bootstrap prompt, session handoff, and heartbeat are concatenated in order. Prompts under 256 KiB are delivered via environment variable; larger prompts are written to a K8s Secret and copied into the pod by a busybox init container.
|
||||
4. **Agent DB PVC** — if `agentDbMode: dedicated_pvc`, a per-agent RWX PVC named `opencode-db-{agentId}` is created if it does not exist, then mounted at `/opencode-db` with `OPENCODE_DB=/opencode-db`.
|
||||
5. **Job creation** — a Kubernetes Job is created in the target namespace. The Job pod mounts the shared RWX PVC at `/paperclip`, inherits all secrets and env vars, and runs the OpenCode agent.
|
||||
6. **Log streaming** — the adapter streams stdout/stderr from the Job pod back to the Paperclip UI in real time, with automatic reconnect on K8s API drops and replay deduplication to avoid duplicate output.
|
||||
7. **Cleanup** — completed Jobs are automatically deleted after `ttlSecondsAfterFinished` seconds (default 300), or retained if `retainJobs` is enabled.
|
||||
|
||||
### Security Context
|
||||
|
||||
@@ -220,7 +247,7 @@ All Job pods run with a locked-down security context:
|
||||
## Dependencies
|
||||
|
||||
- `@kubernetes/client-node` ^1.0.0
|
||||
- `@paperclipai/adapter-utils` >=2026.411.0-canary.8 (peer dependency)
|
||||
- `@paperclipai/adapter-utils` >=2026.415.0-canary.7 (peer dependency)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Generated
+474
-7
@@ -1,26 +1,27 @@
|
||||
{
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.39",
|
||||
"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",
|
||||
"esbuild": "^0.24.0",
|
||||
"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": {
|
||||
@@ -117,6 +118,431 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -223,9 +649,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"
|
||||
},
|
||||
@@ -1033,6 +1459,47 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.40",
|
||||
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -17,7 +17,8 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && npm run build:ui-parser",
|
||||
"build:ui-parser": "esbuild src/ui-parser.ts --bundle --format=cjs --target=es2020 --outfile=dist/ui-parser.js --log-level=warning",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -28,12 +29,13 @@
|
||||
"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",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
|
||||
Generated
+161
-12
@@ -21,15 +21,39 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.12.2
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(vitest@4.1.5)
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.5(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2))
|
||||
version: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2))
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -39,9 +63,16 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@jsep-plugin/assignment@1.3.0':
|
||||
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
@@ -104,42 +135,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
|
||||
@@ -194,6 +219,15 @@ packages:
|
||||
'@types/stream-buffers@3.0.8':
|
||||
resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==}
|
||||
|
||||
'@vitest/coverage-v8@4.1.5':
|
||||
resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.5
|
||||
vitest: 4.1.5
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.5':
|
||||
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
|
||||
|
||||
@@ -234,6 +268,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
@@ -390,6 +427,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -406,6 +447,9 @@ packages:
|
||||
resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
ip-address@10.1.0:
|
||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -415,9 +459,24 @@ packages:
|
||||
peerDependencies:
|
||||
ws: '*'
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
jose@6.2.2:
|
||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
@@ -466,28 +525,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -508,6 +563,13 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.2:
|
||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -574,6 +636,11 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
@@ -606,6 +673,10 @@ packages:
|
||||
streamx@2.25.0:
|
||||
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
tar-fs@3.1.2:
|
||||
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
|
||||
|
||||
@@ -759,6 +830,21 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -775,8 +861,15 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
|
||||
dependencies:
|
||||
jsep: 1.4.0
|
||||
@@ -905,6 +998,20 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.12.2
|
||||
|
||||
'@vitest/coverage-v8@4.1.5(vitest@4.1.5)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.5
|
||||
ast-v8-to-istanbul: 1.0.0
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.2
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2))
|
||||
|
||||
'@vitest/expect@4.1.5':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -952,6 +1059,12 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-v8-to-istanbul@1.0.0:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 10.0.0
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
b4a@1.8.0: {}
|
||||
@@ -1087,6 +1200,8 @@ snapshots:
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
@@ -1099,14 +1214,31 @@ snapshots:
|
||||
|
||||
hpagent@1.2.0: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
ip-address@10.1.0: {}
|
||||
|
||||
isomorphic-ws@5.0.0(ws@8.20.0):
|
||||
dependencies:
|
||||
ws: 8.20.0
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
jose@6.2.2: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -1172,6 +1304,16 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.2:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
@@ -1241,6 +1383,8 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
@@ -1275,6 +1419,10 @@ snapshots:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
tar-fs@3.1.2:
|
||||
dependencies:
|
||||
pump: 3.0.4
|
||||
@@ -1342,7 +1490,7 @@ snapshots:
|
||||
'@types/node': 24.12.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@4.1.5(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)):
|
||||
vitest@4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.5
|
||||
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2))
|
||||
@@ -1366,6 +1514,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.2
|
||||
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
||||
@@ -64,4 +64,3 @@ Notes:
|
||||
`;
|
||||
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
export { parseStdoutLine } from "./ui-parser.js";
|
||||
|
||||
@@ -11,13 +11,6 @@ export function getConfigSchema(): AdapterConfigSchema {
|
||||
hint: "Provider-specific reasoning/profile variant passed as --variant",
|
||||
group: "Core",
|
||||
},
|
||||
{
|
||||
key: "instructionsFilePath",
|
||||
label: "Instructions File Path",
|
||||
type: "text",
|
||||
hint: "Absolute path to a markdown file (e.g. AGENTS.md) prepended as system instructions before the task prompt",
|
||||
group: "Core",
|
||||
},
|
||||
{
|
||||
key: "dangerouslySkipPermissions",
|
||||
label: "Skip Permission Checks",
|
||||
|
||||
@@ -56,12 +56,13 @@ const HAPPY_JSONL = [
|
||||
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
|
||||
].join("\n");
|
||||
|
||||
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}): AdapterExecutionContext {
|
||||
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}, authToken = "test-auth-token"): AdapterExecutionContext {
|
||||
return {
|
||||
runId: "run-test-123",
|
||||
agent: { id: "agent-id-test", name: "Test Agent", companyId: "co-1", adapterType: null, adapterConfig: null },
|
||||
runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null },
|
||||
config: configOverrides,
|
||||
authToken,
|
||||
context: {
|
||||
taskId: null,
|
||||
issueId: null,
|
||||
@@ -879,23 +880,16 @@ describe("execute — log dedup (waitForPod status dedup)", () => {
|
||||
describe("execute — external cancel polling", () => {
|
||||
const KEEPALIVE_MS = 15_000;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.PAPERCLIP_DEV_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_DEV_API_KEY;
|
||||
});
|
||||
|
||||
it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
process.env.PAPERCLIP_API_URL = "http://test-api";
|
||||
process.env.PAPERCLIP_API_KEY = "test-key";
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -918,7 +912,7 @@ describe("execute — external cancel polling", () => {
|
||||
});
|
||||
vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof getBatchApi>);
|
||||
|
||||
const ctx = makeCtx({}, { issueId: "issue-test-456" });
|
||||
const ctx = makeCtx({}, { issueId: "issue-test-456" }, "run-jwt-token");
|
||||
const executePromise = execute(ctx);
|
||||
|
||||
// Advance in 1-second steps. vi.advanceTimersByTimeAsync fires fake timers
|
||||
@@ -939,7 +933,7 @@ describe("execute — external cancel polling", () => {
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://test-api/api/issues/issue-test-456",
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-key" }) }),
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer run-jwt-token" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -958,7 +952,6 @@ describe("execute — external cancel polling", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
process.env.PAPERCLIP_API_URL = "http://test-api";
|
||||
process.env.PAPERCLIP_API_KEY = "test-key";
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
+18
-4
@@ -126,7 +126,11 @@ async function waitForPod(
|
||||
(c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable",
|
||||
);
|
||||
if (unschedulable) {
|
||||
throw new Error(`Pod unschedulable: ${unschedulable.message ?? "insufficient resources"}`);
|
||||
const msg = unschedulable.message ?? "insufficient resources";
|
||||
if (/pvc|volume|bind|mount/i.test(msg)) {
|
||||
throw new Error(`PVC bind failed: ${msg}`);
|
||||
}
|
||||
throw new Error(`Pod unschedulable: ${msg}`);
|
||||
}
|
||||
|
||||
for (const cs of containerStatuses) {
|
||||
@@ -137,6 +141,18 @@ async function waitForPod(
|
||||
if (waiting?.reason === "CrashLoopBackOff") {
|
||||
throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`);
|
||||
}
|
||||
if (waiting?.reason === "MountVolumeFailed" || waiting?.reason === "ContainerCannotMount") {
|
||||
throw new Error(`Volume mount failed for "${cs.name}": ${waiting.message ?? waiting.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cs of containerStatuses) {
|
||||
const terminated = cs.state?.terminated;
|
||||
if (terminated?.exitCode !== undefined && terminated.exitCode !== 0) {
|
||||
if (terminated.reason === "ContainerCannotMount" || terminated.reason === "MountVolumeFailed") {
|
||||
throw new Error(`Volume mount failed for "${cs.name}": ${terminated.message ?? terminated.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
@@ -553,9 +569,7 @@ async function streamAndAwaitJob(
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
|
||||
if (logStopSignal.stopped || cancelSignal.cancelled) break;
|
||||
try {
|
||||
// Prefer PAPERCLIP_DEV_API_KEY if set (allows dev instance key to be
|
||||
// distinct from the main-instance run JWT in PAPERCLIP_API_KEY).
|
||||
const apiKey = process.env.PAPERCLIP_DEV_API_KEY ?? process.env.PAPERCLIP_API_KEY ?? "";
|
||||
const apiKey = ctx.authToken ?? "";
|
||||
const resp = await fetch(`${apiUrl}/api/issues/${issueId}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||
import { type, agentConfigurationDoc } from "../index.js";
|
||||
import { listK8sModels } from "./models.js";
|
||||
import { listK8sModels, STATIC_MODELS } from "./models.js";
|
||||
import { execute } from "./execute.js";
|
||||
import { testEnvironment } from "./test.js";
|
||||
import { sessionCodec } from "./session.js";
|
||||
@@ -14,6 +14,7 @@ export function createServerAdapter(): ServerAdapterModule {
|
||||
execute,
|
||||
testEnvironment,
|
||||
sessionCodec,
|
||||
models: STATIC_MODELS,
|
||||
listModels: listK8sModels,
|
||||
listSkills: listOpenCodeSkills,
|
||||
syncSkills: syncOpenCodeSkills,
|
||||
|
||||
@@ -44,6 +44,17 @@ describe("buildJobManifest", () => {
|
||||
expect(container?.image).toBe("paperclip/paperclip:latest");
|
||||
});
|
||||
|
||||
it("uses config.image when provided, overriding selfPod image", () => {
|
||||
const ctxWithImage = {
|
||||
...mockCtx,
|
||||
config: { image: "my-custom-image:v1.2.3" },
|
||||
};
|
||||
const result = buildJobManifest({ ctx: ctxWithImage, selfPod: mockSelfPod });
|
||||
|
||||
const container = result.job.spec?.template?.spec?.containers?.[0];
|
||||
expect(container?.image).toBe("my-custom-image:v1.2.3");
|
||||
});
|
||||
|
||||
it("sets fsGroupChangePolicy to OnRootMismatch", () => {
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||
|
||||
@@ -473,15 +484,14 @@ describe("buildJobManifest — env wiring branches", () => {
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc");
|
||||
});
|
||||
|
||||
it("inherits PAPERCLIP_API_URL and PAPERCLIP_DEV_API_KEY from selfPod inheritedEnv", () => {
|
||||
it("inherits PAPERCLIP_API_URL from selfPod inheritedEnv", () => {
|
||||
const selfPod = {
|
||||
...mockSelfPod,
|
||||
inheritedEnv: { PAPERCLIP_API_URL: "http://api", PAPERCLIP_DEV_API_KEY: "dev_key" },
|
||||
inheritedEnv: { PAPERCLIP_API_URL: "http://api" },
|
||||
};
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
||||
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api");
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_DEV_API_KEY")?.value).toBe("dev_key");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -173,13 +173,6 @@ function buildEnvVars(
|
||||
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
|
||||
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
|
||||
}
|
||||
// Inherit PAPERCLIP_DEV_API_KEY if set (dev-instance key, distinct from the
|
||||
// main-instance run JWT in PAPERCLIP_API_KEY). Used by the external cancel
|
||||
// polling in execute.ts to authenticate against the dev Paperclip instance.
|
||||
if (selfPod.inheritedEnv.PAPERCLIP_DEV_API_KEY) {
|
||||
paperclipEnv.PAPERCLIP_DEV_API_KEY = selfPod.inheritedEnv.PAPERCLIP_DEV_API_KEY;
|
||||
}
|
||||
|
||||
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
|
||||
const merged: Record<string, string> = {
|
||||
...selfPod.inheritedEnv,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runChildProcessMock = vi.fn();
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@paperclipai/adapter-utils/server-utils")>();
|
||||
return { ...actual, runChildProcess: runChildProcessMock };
|
||||
});
|
||||
|
||||
const { listK8sModels, discoverK8sModels, resetK8sModelsCacheForTests } = await import("./models.js");
|
||||
|
||||
type MockResult = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean };
|
||||
|
||||
function mockSuccess(stdout: string): void {
|
||||
runChildProcessMock.mockResolvedValue({ exitCode: 0, stdout, stderr: "", timedOut: false } satisfies MockResult);
|
||||
}
|
||||
|
||||
function mockFailure(stderr = "ENOENT: opencode not found"): void {
|
||||
runChildProcessMock.mockResolvedValue({ exitCode: 1, stdout: "", stderr, timedOut: false } satisfies MockResult);
|
||||
}
|
||||
|
||||
function mockTimeout(): void {
|
||||
runChildProcessMock.mockResolvedValue({ exitCode: null, stdout: "", stderr: "", timedOut: true } satisfies MockResult);
|
||||
}
|
||||
|
||||
describe("listK8sModels", () => {
|
||||
afterEach(() => {
|
||||
runChildProcessMock.mockReset();
|
||||
resetK8sModelsCacheForTests();
|
||||
});
|
||||
|
||||
it("parses provider/model lines into AdapterModel entries", async () => {
|
||||
mockSuccess(["anthropic/claude-opus-4-7", "openai/gpt-4o", "google/gemini-2.5-pro"].join("\n"));
|
||||
|
||||
const models = await listK8sModels();
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
expect(models.find((m) => m.id === "anthropic/claude-opus-4-7")).toEqual({
|
||||
id: "anthropic/claude-opus-4-7",
|
||||
label: "anthropic/claude-opus-4-7",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores blank lines and trims whitespace", async () => {
|
||||
mockSuccess("\nanthropic/claude-opus-4-7\n\n openai/gpt-4o \n\n");
|
||||
|
||||
const models = await listK8sModels();
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual(
|
||||
["anthropic/claude-opus-4-7", "openai/gpt-4o"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips lines without a provider/model slash", async () => {
|
||||
mockSuccess("anthropic/claude-opus-4-7\nnot-a-model-line\nopenai/gpt-4o");
|
||||
|
||||
const models = await listK8sModels();
|
||||
|
||||
expect(models.map((m) => m.id)).not.toContain("not-a-model-line");
|
||||
expect(models).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("deduplicates repeated model IDs", async () => {
|
||||
mockSuccess("anthropic/claude-opus-4-7\nanthropic/claude-opus-4-7\nopenai/gpt-4o");
|
||||
|
||||
const models = await listK8sModels();
|
||||
|
||||
expect(models.filter((m) => m.id === "anthropic/claude-opus-4-7")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns models sorted alphabetically by ID", async () => {
|
||||
mockSuccess("openai/gpt-4o\nanthropic/claude-opus-4-7");
|
||||
|
||||
const models = await listK8sModels();
|
||||
|
||||
expect(models[0].id).toBe("anthropic/claude-opus-4-7");
|
||||
expect(models[1].id).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("returns empty array when the CLI fails", async () => {
|
||||
mockFailure("ENOENT: opencode not found");
|
||||
|
||||
expect(await listK8sModels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when the CLI times out", async () => {
|
||||
mockTimeout();
|
||||
|
||||
expect(await listK8sModels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when the CLI returns empty stdout", async () => {
|
||||
mockSuccess("");
|
||||
|
||||
expect(await listK8sModels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("caches results and only calls the CLI once within the TTL", async () => {
|
||||
mockSuccess("anthropic/claude-opus-4-7");
|
||||
|
||||
await listK8sModels();
|
||||
await listK8sModels();
|
||||
|
||||
expect(runChildProcessMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-fetches after the cache is reset", async () => {
|
||||
mockSuccess("anthropic/claude-opus-4-7");
|
||||
|
||||
await listK8sModels();
|
||||
resetK8sModelsCacheForTests();
|
||||
await listK8sModels();
|
||||
|
||||
expect(runChildProcessMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverK8sModels", () => {
|
||||
afterEach(() => {
|
||||
runChildProcessMock.mockReset();
|
||||
resetK8sModelsCacheForTests();
|
||||
});
|
||||
|
||||
it("passes OPENCODE_DISABLE_PROJECT_CONFIG=true to the subprocess", async () => {
|
||||
mockSuccess("anthropic/claude-opus-4-7");
|
||||
|
||||
await discoverK8sModels();
|
||||
|
||||
const [, , , opts] = runChildProcessMock.mock.calls[0] as [unknown, unknown, unknown, { env: Record<string, string> }];
|
||||
expect(opts.env).toMatchObject({ OPENCODE_DISABLE_PROJECT_CONFIG: "true" });
|
||||
});
|
||||
|
||||
it("invokes opencode with the models subcommand", async () => {
|
||||
mockSuccess("anthropic/claude-opus-4-7");
|
||||
|
||||
await discoverK8sModels();
|
||||
|
||||
const [, command, args] = runChildProcessMock.mock.calls[0] as [unknown, string, string[]];
|
||||
expect(command).toBe("opencode");
|
||||
expect(args).toEqual(["models"]);
|
||||
});
|
||||
|
||||
it("throws when the CLI exits non-zero", async () => {
|
||||
mockFailure("provider not configured");
|
||||
|
||||
await expect(discoverK8sModels()).rejects.toThrow("opencode models` failed");
|
||||
});
|
||||
|
||||
it("throws when the CLI times out", async () => {
|
||||
mockTimeout();
|
||||
|
||||
await expect(discoverK8sModels()).rejects.toThrow("timed out");
|
||||
});
|
||||
});
|
||||
+173
-10
@@ -1,15 +1,178 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
import { asString, ensurePathInEnv, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const MODELS: AdapterModel[] = [
|
||||
{ id: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ id: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5" },
|
||||
{ id: "openai/gpt-4o", label: "GPT-4o" },
|
||||
{ id: "openai/gpt-4o-mini", label: "GPT-4o mini" },
|
||||
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
export const STATIC_MODELS: AdapterModel[] = [
|
||||
{ id: "anthropic/claude-opus-4-7", label: "anthropic/claude-opus-4-7" },
|
||||
{ id: "anthropic/claude-sonnet-4-6", label: "anthropic/claude-sonnet-4-6" },
|
||||
{ id: "anthropic/claude-haiku-4-5", label: "anthropic/claude-haiku-4-5" },
|
||||
{ id: "openai/gpt-4o", label: "openai/gpt-4o" },
|
||||
{ id: "google/gemini-2.5-pro", label: "google/gemini-2.5-pro" },
|
||||
{ id: "google/gemini-2.5-flash", label: "google/gemini-2.5-flash" },
|
||||
];
|
||||
|
||||
export async function listK8sModels(): Promise<AdapterModel[]> {
|
||||
return MODELS;
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
|
||||
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
|
||||
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
|
||||
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]);
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function sortModels(models: AdapterModel[]): AdapterModel[] {
|
||||
return [...models].sort((a, b) =>
|
||||
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function parseModelsOutput(stdout: string): AdapterModel[] {
|
||||
const parsed: AdapterModel[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
|
||||
if (!firstToken.includes("/")) continue;
|
||||
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
|
||||
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
|
||||
if (!provider || !model) continue;
|
||||
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
|
||||
}
|
||||
return dedupeModels(parsed);
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
const envInput =
|
||||
typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
? (input as Record<string, unknown>)
|
||||
: {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envInput)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function isVolatileEnvKey(key: string): boolean {
|
||||
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
|
||||
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
|
||||
const envKey = Object.entries(env)
|
||||
.filter(([key]) => !isVolatileEnvKey(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${hashValue(value)}`)
|
||||
.join("\n");
|
||||
return `${command}\n${cwd}\n${envKey}`;
|
||||
}
|
||||
|
||||
function pruneExpiredDiscoveryCache(now: number) {
|
||||
for (const [key, value] of discoveryCache.entries()) {
|
||||
if (value.expiresAt <= now) discoveryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverK8sModels(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = asString(input.command, "opencode");
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
|
||||
let resolvedHome: string | undefined;
|
||||
try {
|
||||
resolvedHome = os.userInfo().homedir || undefined;
|
||||
} catch {
|
||||
// os.userInfo() throws when the UID has no /etc/passwd entry (e.g. distroless images)
|
||||
}
|
||||
|
||||
const runtimeEnv = normalizeEnv(
|
||||
ensurePathInEnv({
|
||||
...process.env,
|
||||
...env,
|
||||
...(resolvedHome ? { HOME: resolvedHome } : {}),
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: "true",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runChildProcess(
|
||||
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
["models"],
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000,
|
||||
graceSec: 3,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.timedOut) {
|
||||
throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`);
|
||||
}
|
||||
if ((result.exitCode ?? 1) !== 0) {
|
||||
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
|
||||
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
|
||||
}
|
||||
|
||||
return sortModels(parseModelsOutput(result.stdout));
|
||||
}
|
||||
|
||||
export async function discoverK8sModelsCached(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = asString(input.command, "opencode");
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const key = discoveryCacheKey(command, cwd, env);
|
||||
const now = Date.now();
|
||||
pruneExpiredDiscoveryCache(now);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > now) return cached.models;
|
||||
|
||||
const models = await discoverK8sModels({ command, cwd, env });
|
||||
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function listK8sModels(): Promise<AdapterModel[]> {
|
||||
try {
|
||||
return await discoverK8sModelsCached();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resetK8sModelsCacheForTests() {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createServerAdapter } from "./index.js";
|
||||
|
||||
describe("createServerAdapter", () => {
|
||||
it("declares the opencode_k8s type", () => {
|
||||
const adapter = createServerAdapter();
|
||||
expect(adapter.type).toBe("opencode_k8s");
|
||||
});
|
||||
|
||||
it("exposes a non-empty static models list so the UI renders before listModels resolves", () => {
|
||||
const adapter = createServerAdapter();
|
||||
expect(Array.isArray(adapter.models)).toBe(true);
|
||||
expect(adapter.models!.length).toBeGreaterThan(0);
|
||||
for (const m of adapter.models!) {
|
||||
expect(m.id).toMatch(/^[^/]+\/.+/);
|
||||
expect(m.label).toBe(m.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes listModels for dynamic model discovery", () => {
|
||||
const adapter = createServerAdapter();
|
||||
expect(typeof adapter.listModels).toBe("function");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user