Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc6351b2bc | |||
| cc34e05713 | |||
| 9b951c4308 | |||
| c71757fbcd | |||
| 098d9f9641 | |||
| 65321e091d | |||
| c0c4c3f179 | |||
| c5b555de17 | |||
| e3af8aa83b | |||
| bc340bfcc9 | |||
| c71d0e5eec | |||
| d9bc2e513b | |||
| c79eea7ee0 | |||
| fa6c115be4 | |||
| 480f7cf3d1 | |||
| 5ed041fd84 | |||
| fe6bc0c2d6 | |||
| 2d057f085d | |||
| 570fdae9c4 | |||
| 985d55e125 | |||
| 5e67a4dd3b | |||
| 5f75c2b81b | |||
| 7043e71ff6 | |||
| da1b55d233 | |||
| 168161148c | |||
| 2daedda537 | |||
| e364e09113 | |||
| 4c956cc039 | |||
| 4fcd3b4547 | |||
| 1bad618b29 | |||
| 5670da320a | |||
| 798b80f2f2 | |||
| 693016d1ab |
@@ -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.
|
||||
@@ -1,15 +1,19 @@
|
||||
# OpenCode (Kubernetes) Paperclip Adapter Plugin
|
||||
|
||||
> **⚠️ Abandoned** — This adapter is no longer maintained. Please use the new sandbox plugin instead: **[farhoodlabs/paperclip-plugin-k8s](https://github.com/farhoodlabs/paperclip-plugin-k8s)** (`@farhoodlabs/paperclip-plugin-k8s` on npm).
|
||||
|
||||
Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs instead of inside the main Paperclip process.
|
||||
|
||||
## Features
|
||||
|
||||
- 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 +100,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 +176,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 +228,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 +249,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
+386
-144
@@ -1,31 +1,92 @@
|
||||
{
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.26",
|
||||
"version": "0.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.26",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
|
||||
"@paperclipai/adapter-utils": "^2026.428.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
|
||||
"@paperclipai/adapter-utils": ">=2026.428.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -35,9 +96,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -56,6 +117,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -63,6 +134,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsep-plugin/assignment": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz",
|
||||
@@ -112,9 +194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -131,9 +213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.124.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
|
||||
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -141,16 +223,16 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -165,9 +247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -182,9 +264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -199,9 +281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -216,9 +298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -233,9 +315,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -253,9 +335,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -273,9 +355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -293,9 +375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -313,9 +395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -333,9 +415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -353,9 +435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -370,9 +452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -380,18 +462,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.9.2",
|
||||
"@emnapi/runtime": "1.9.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.3"
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -406,9 +488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -423,9 +505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -506,17 +588,48 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.5",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
|
||||
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -525,13 +638,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
|
||||
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.4",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -552,9 +665,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
|
||||
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -565,13 +678,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
|
||||
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.4",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -579,14 +692,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
|
||||
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -595,9 +708,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
|
||||
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -605,13 +718,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
|
||||
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -644,6 +757,18 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
|
||||
"integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -1050,6 +1175,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -1098,6 +1233,13 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -1116,6 +1258,45 @@
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
@@ -1125,6 +1306,13 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -1447,6 +1635,34 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
|
||||
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1591,9 +1807,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1636,14 +1852,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.124.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.15"
|
||||
"@oxc-project/types": "=0.127.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -1652,21 +1868,34 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
@@ -1758,6 +1987,19 @@
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
|
||||
@@ -1881,17 +2123,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
||||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.15",
|
||||
"tinyglobby": "^0.2.15"
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.17",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@@ -1959,19 +2201,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
|
||||
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.4",
|
||||
"@vitest/mocker": "4.1.4",
|
||||
"@vitest/pretty-format": "4.1.4",
|
||||
"@vitest/runner": "4.1.4",
|
||||
"@vitest/snapshot": "4.1.4",
|
||||
"@vitest/spy": "4.1.4",
|
||||
"@vitest/utils": "4.1.4",
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/runner": "4.1.5",
|
||||
"@vitest/snapshot": "4.1.5",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1999,12 +2241,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.4",
|
||||
"@vitest/browser-preview": "4.1.4",
|
||||
"@vitest/browser-webdriverio": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"@vitest/coverage-v8": "4.1.4",
|
||||
"@vitest/ui": "4.1.4",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/browser-preview": "4.1.5",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclip-adapter-opencode-k8s",
|
||||
"version": "0.1.28",
|
||||
"version": "0.2.3",
|
||||
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -10,14 +10,15 @@
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./server": "./dist/server/index.js",
|
||||
"./ui-parser": "./dist/ui-parser.js",
|
||||
"./ui-parser": "./dist/ui-parser/ui-parser.js",
|
||||
"./cli": "./dist/cli/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc -p tsconfig.build.json && npm run build:ui-parser",
|
||||
"build:ui-parser": "tsc -p tsconfig.ui-parser.json && node -e \"require('node:fs').writeFileSync('dist/ui-parser/package.json', '{\\\"type\\\":\\\"commonjs\\\"}\\n')\"",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -28,11 +29,12 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
|
||||
"@paperclipai/adapter-utils": ">=2026.428.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
|
||||
"@paperclipai/adapter-utils": "^2026.428.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"typescript": "^5.7.3",
|
||||
"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
|
||||
|
||||
|
||||
@@ -248,3 +248,181 @@ describe("formatEvent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
import { parseStdoutLine } from "./format-event.js";
|
||||
|
||||
describe("parseStdoutLine (cli)", () => {
|
||||
const TS = "2026-04-25T22:00:00.000Z";
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
expect(parseStdoutLine("", TS)).toEqual([]);
|
||||
expect(parseStdoutLine(" ", TS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns stdout entry for non-JSON input", () => {
|
||||
expect(parseStdoutLine("plain log", TS)).toEqual([{ kind: "stdout", ts: TS, text: "plain log" }]);
|
||||
});
|
||||
|
||||
it("returns stdout entry when JSON parses to a non-object primitive", () => {
|
||||
expect(parseStdoutLine("42", TS)).toEqual([{ kind: "stdout", ts: TS, text: "42" }]);
|
||||
});
|
||||
|
||||
it("renders a text event as an assistant delta", () => {
|
||||
const line = JSON.stringify({ type: "text", part: { text: "Hello" } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "Hello", delta: true }]);
|
||||
});
|
||||
|
||||
it("returns empty for text event with empty text", () => {
|
||||
const line = JSON.stringify({ type: "text", part: { text: "" } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders tool_use status=error as tool_result with isError", () => {
|
||||
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error", error: "boom" } } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "boom", isError: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses 'Tool error' fallback when error event has no error string", () => {
|
||||
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error" } } });
|
||||
const result = parseStdoutLine(line, TS);
|
||||
expect((result[0] as { content: string }).content).toBe("Tool error");
|
||||
});
|
||||
|
||||
it("renders tool_use status=completed as tool_result with output", () => {
|
||||
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "completed", output: "ok" } } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "ok", isError: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders tool_use status=done — falls back to description when no output", () => {
|
||||
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done", description: "did it" } } });
|
||||
expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("did it");
|
||||
});
|
||||
|
||||
it("renders tool_use status=done — falls back to 'Done' when no output or description", () => {
|
||||
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done" } } });
|
||||
expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("Done");
|
||||
});
|
||||
|
||||
it("renders tool_use pending status as tool_call", () => {
|
||||
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "running", description: "go" } } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([
|
||||
{ kind: "tool_call", ts: TS, name: "bash", input: "go", toolUseId: "t1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to part.type then 'tool' when no part.tool name", () => {
|
||||
const line = JSON.stringify({ type: "tool_use", part: { type: "edit", state: { status: "running" } } });
|
||||
expect((parseStdoutLine(line, TS)[0] as { name: string }).name).toBe("edit");
|
||||
const line2 = JSON.stringify({ type: "tool_use", part: { state: { status: "running" } } });
|
||||
expect((parseStdoutLine(line2, TS)[0] as { name: string }).name).toBe("tool");
|
||||
});
|
||||
|
||||
it("renders step_finish with token/cost metrics", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "step_finish",
|
||||
part: {
|
||||
message: "did the thing",
|
||||
reason: "stop",
|
||||
tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 30 } },
|
||||
cost: 0.0123,
|
||||
},
|
||||
});
|
||||
const result = parseStdoutLine(line, TS);
|
||||
expect(result).toEqual([{
|
||||
kind: "result",
|
||||
ts: TS,
|
||||
text: "did the thing",
|
||||
inputTokens: 100,
|
||||
outputTokens: 60,
|
||||
cachedTokens: 30,
|
||||
costUsd: 0.0123,
|
||||
subtype: "stop",
|
||||
isError: false,
|
||||
errors: [],
|
||||
}]);
|
||||
});
|
||||
|
||||
it("renders step_finish with default text when no message", () => {
|
||||
const line = JSON.stringify({ type: "step_finish", part: { reason: "stop" } });
|
||||
expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toBe("Step finished: stop");
|
||||
const line2 = JSON.stringify({ type: "step_finish", part: {} });
|
||||
expect((parseStdoutLine(line2, TS)[0] as { text: string }).text).toBe("Step finished: done");
|
||||
});
|
||||
|
||||
it("renders step_start as a system entry", () => {
|
||||
const line = JSON.stringify({ type: "step_start" });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "system", ts: TS, text: "Starting step…" }]);
|
||||
});
|
||||
|
||||
it("renders assistant event with nested text content", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "assistant",
|
||||
part: { message: { content: [{ type: "text", text: "hi there" }] } },
|
||||
});
|
||||
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "hi there" }]);
|
||||
});
|
||||
|
||||
it("handles assistant content as a single non-array object", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "assistant",
|
||||
part: { message: { content: { type: "text", text: "single" } } },
|
||||
});
|
||||
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "single" }]);
|
||||
});
|
||||
|
||||
it("returns empty for assistant event with no extractable text", () => {
|
||||
const line = JSON.stringify({ type: "assistant", part: { message: { content: [{ type: "image" }] } } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([]);
|
||||
const line2 = JSON.stringify({ type: "assistant", part: {} });
|
||||
expect(parseStdoutLine(line2, TS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders error event with errorText", () => {
|
||||
const line = JSON.stringify({ type: "error", error: { message: "broken" } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "broken" }]);
|
||||
});
|
||||
|
||||
it("returns empty for error event with empty error string", () => {
|
||||
const line = JSON.stringify({ type: "error", error: "" });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses error.code fallback in errorText", () => {
|
||||
const line = JSON.stringify({ type: "error", error: { code: "E_X" } });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "E_X" }]);
|
||||
});
|
||||
|
||||
it("uses nested data.message and name fallbacks in errorText", () => {
|
||||
const l1 = JSON.stringify({ type: "error", error: { data: { message: "nested" } } });
|
||||
expect((parseStdoutLine(l1, TS)[0] as { text: string }).text).toBe("nested");
|
||||
const l2 = JSON.stringify({ type: "error", error: { name: "ProviderErr" } });
|
||||
expect((parseStdoutLine(l2, TS)[0] as { text: string }).text).toBe("ProviderErr");
|
||||
});
|
||||
|
||||
it("falls back to JSON.stringify of the error object when nothing else matches", () => {
|
||||
const line = JSON.stringify({ type: "error", error: { weirdKey: "x" } });
|
||||
expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toContain("weirdKey");
|
||||
});
|
||||
|
||||
it("returns empty array for unknown event types", () => {
|
||||
const line = JSON.stringify({ type: "totally_unknown" });
|
||||
expect(parseStdoutLine(line, TS)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEvent — additional coverage", () => {
|
||||
it("returns empty for safeJsonParse of a non-object primitive", () => {
|
||||
// formatEvent treats a non-object as non-JSON and returns the trimmed line as-is
|
||||
const result = formatEvent("42", false);
|
||||
expect(result).toBe("42");
|
||||
});
|
||||
|
||||
it("returns empty for error event with empty error string", () => {
|
||||
const line = JSON.stringify({ type: "error", error: "" });
|
||||
expect(formatEvent(line, false)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
+455
-97
@@ -1,20 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
import { execute, ensureAgentDbPvc } from "./execute.js";
|
||||
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi, getPvc, createPvc } from "./k8s-client.js";
|
||||
import { buildJobManifest } from "./job-manifest.js";
|
||||
import { execute, ensureAgentDbPvc, tailPodLogFile } from "./execute.js";
|
||||
import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js";
|
||||
import { buildJobManifest, buildPodLogPath } from "./job-manifest.js";
|
||||
|
||||
// Mock node:fs/promises so tailPodLogFile (used by execute()) reads a
|
||||
// configurable JSONL payload and returns. Individual tests override the
|
||||
// payload via setMockJsonl(...) before calling execute().
|
||||
const { readMock, statMock, fhStatMock, resetFsMocks, setMockJsonl } = vi.hoisted(() => {
|
||||
const HAPPY = [
|
||||
JSON.stringify({ type: "text", part: { text: "Task complete" }, sessionID: "ses_happy" }),
|
||||
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
|
||||
].join("\n");
|
||||
let payload = HAPPY;
|
||||
let buffer = Buffer.from(payload);
|
||||
let readOffset = 0;
|
||||
const apply = (next: string) => { payload = next; buffer = Buffer.from(payload); readOffset = 0; };
|
||||
return {
|
||||
readMock: vi.fn().mockImplementation(async (buf: Buffer, off: number, len: number, _pos: number) => {
|
||||
if (readOffset >= buffer.byteLength) return { bytesRead: 0, buffer: buf };
|
||||
const remaining = buffer.byteLength - readOffset;
|
||||
const toRead = Math.min(len, remaining);
|
||||
buffer.copy(buf, off, readOffset, readOffset + toRead);
|
||||
readOffset += toRead;
|
||||
return { bytesRead: toRead, buffer: buf };
|
||||
}),
|
||||
statMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
|
||||
fhStatMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
|
||||
resetFsMocks: () => { apply(HAPPY); },
|
||||
setMockJsonl: (jsonl: string) => { apply(jsonl); },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
stat: statMock,
|
||||
open: vi.fn().mockResolvedValue({
|
||||
stat: fhStatMock,
|
||||
read: readMock,
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./k8s-client.js", () => ({
|
||||
getSelfPodInfo: vi.fn(),
|
||||
getBatchApi: vi.fn(),
|
||||
getCoreApi: vi.fn(),
|
||||
getLogApi: vi.fn(),
|
||||
getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-agent-id-test" } }),
|
||||
createPvc: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("./job-manifest.js", () => ({
|
||||
buildJobManifest: vi.fn(),
|
||||
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
|
||||
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
|
||||
),
|
||||
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
|
||||
}));
|
||||
|
||||
@@ -56,12 +100,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,
|
||||
@@ -88,7 +133,6 @@ function makeBatchApi(runningJobItems: unknown[] = []) {
|
||||
}
|
||||
|
||||
function makeCoreApi(
|
||||
jsonl = HAPPY_JSONL,
|
||||
exitCode: number | null = 0,
|
||||
terminatedReason: string | null = null,
|
||||
) {
|
||||
@@ -121,19 +165,15 @@ function makeCoreApi(
|
||||
items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }],
|
||||
})
|
||||
.mockResolvedValueOnce(exitCodePod),
|
||||
readNamespacedPodLog: vi.fn().mockResolvedValue(jsonl),
|
||||
createNamespacedSecret: vi.fn().mockResolvedValue({}),
|
||||
deleteNamespacedSecret: vi.fn().mockResolvedValue({}),
|
||||
patchNamespacedSecret: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogApi() {
|
||||
return { log: vi.fn().mockResolvedValue(undefined) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetFsMocks();
|
||||
|
||||
vi.mocked(getSelfPodInfo).mockResolvedValue(MOCK_SELF_POD as ReturnType<typeof getSelfPodInfo> extends Promise<infer T> ? T : never);
|
||||
vi.mocked(buildJobManifest).mockReturnValue({
|
||||
@@ -143,15 +183,14 @@ beforeEach(() => {
|
||||
prompt: "Test prompt",
|
||||
opencodeArgs: [],
|
||||
promptMetrics: null,
|
||||
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
|
||||
} as unknown as ReturnType<typeof buildJobManifest>);
|
||||
|
||||
const batchApi = makeBatchApi();
|
||||
const coreApi = makeCoreApi();
|
||||
const logApi = makeLogApi();
|
||||
|
||||
vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof getBatchApi>);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
vi.mocked(getLogApi).mockReturnValue(logApi as unknown as ReturnType<typeof getLogApi>);
|
||||
});
|
||||
|
||||
describe("execute — concurrency guard", () => {
|
||||
@@ -565,8 +604,8 @@ describe("execute — happy path", () => {
|
||||
|
||||
describe("execute — session unavailable (reattach classification)", () => {
|
||||
it("returns clearSession=true and session_unavailable code for unknown session error", async () => {
|
||||
const sessionErrorJsonl = JSON.stringify({ type: "error", error: { message: "unknown session abc" } });
|
||||
const coreApi = makeCoreApi(sessionErrorJsonl, 1);
|
||||
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Unknown session ses_xxx" } }));
|
||||
const coreApi = makeCoreApi(1);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -577,7 +616,8 @@ describe("execute — session unavailable (reattach classification)", () => {
|
||||
});
|
||||
|
||||
it("returns clearSession=true for 'session not found' error", async () => {
|
||||
const coreApi = makeCoreApi("session not found\n", 1);
|
||||
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Session ses_xxx not found" } }));
|
||||
const coreApi = makeCoreApi(1);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -587,10 +627,7 @@ describe("execute — session unavailable (reattach classification)", () => {
|
||||
});
|
||||
|
||||
it("does not set clearSession for unrelated errors", async () => {
|
||||
const coreApi = makeCoreApi(
|
||||
JSON.stringify({ type: "error", error: { message: "rate limit exceeded" } }),
|
||||
1,
|
||||
);
|
||||
const coreApi = makeCoreApi(1);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -640,10 +677,7 @@ describe("execute — retainJobs config", () => {
|
||||
|
||||
describe("execute — exit code handling", () => {
|
||||
it("propagates non-zero exit code from pod", async () => {
|
||||
const coreApi = makeCoreApi(
|
||||
JSON.stringify({ type: "error", error: { message: "Task failed" } }),
|
||||
2,
|
||||
);
|
||||
const coreApi = makeCoreApi(2);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -654,10 +688,8 @@ describe("execute — exit code handling", () => {
|
||||
});
|
||||
|
||||
it("synthesizes exitCode=1 when error message exists but pod reported exitCode=0", async () => {
|
||||
const coreApi = makeCoreApi(
|
||||
JSON.stringify({ type: "error", error: { message: "API rate limit" } }),
|
||||
0,
|
||||
);
|
||||
setMockJsonl(JSON.stringify({ type: "error", error: { message: "something went wrong" } }));
|
||||
const coreApi = makeCoreApi(0);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -668,7 +700,7 @@ describe("execute — exit code handling", () => {
|
||||
});
|
||||
|
||||
it("handles null exit code gracefully (pod not found — 404 tolerance)", async () => {
|
||||
const coreApi = makeCoreApi(HAPPY_JSONL, null);
|
||||
const coreApi = makeCoreApi(null);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -683,7 +715,7 @@ describe("execute — exit code handling", () => {
|
||||
describe("execute — pod failure classification", () => {
|
||||
it("includes pod terminated reason in errorMessage when reason is OOMKilled", async () => {
|
||||
// OOMKilled: process is killed by kernel — no JSONL error event, just empty output
|
||||
const coreApi = makeCoreApi("", 137, "OOMKilled");
|
||||
const coreApi = makeCoreApi(137, "OOMKilled");
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -694,7 +726,7 @@ describe("execute — pod failure classification", () => {
|
||||
});
|
||||
|
||||
it("includes pod terminated reason for Error exit", async () => {
|
||||
const coreApi = makeCoreApi("", 1, "Error");
|
||||
const coreApi = makeCoreApi(1, "Error");
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -705,11 +737,7 @@ describe("execute — pod failure classification", () => {
|
||||
});
|
||||
|
||||
it("falls back gracefully when no terminated reason is available", async () => {
|
||||
const coreApi = makeCoreApi(
|
||||
JSON.stringify({ type: "error", error: { message: "boom" } }),
|
||||
1,
|
||||
null,
|
||||
);
|
||||
const coreApi = makeCoreApi(1, null);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -720,64 +748,12 @@ describe("execute — pod failure classification", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — partial stdout fallback", () => {
|
||||
it("fetches pod logs when stdout has content but no session result", async () => {
|
||||
const partialJsonl = JSON.stringify({ type: "text", part: { text: "thinking..." } }); // no sessionID
|
||||
const completeJsonl = [
|
||||
JSON.stringify({ type: "text", part: { text: "Done" }, sessionID: "ses_complete" }),
|
||||
JSON.stringify({ type: "step_finish", part: { tokens: { input: 50, output: 30, cache: {} }, cost: 0.001 } }),
|
||||
].join("\n");
|
||||
|
||||
const coreApi = makeCoreApi(completeJsonl, 0);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
// Make log stream return partial content with no sessionID
|
||||
const logApi = {
|
||||
log: vi.fn(async (_ns: string, _pod: string, _container: string, writable: NodeJS.WritableStream) => {
|
||||
writable.write(Buffer.from(partialJsonl + "\n"));
|
||||
}),
|
||||
};
|
||||
vi.mocked(getLogApi).mockReturnValue(logApi as unknown as ReturnType<typeof getLogApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
const result = await execute(ctx);
|
||||
|
||||
// readNamespacedPodLog should have been called as the partial-stdout fallback
|
||||
expect(coreApi.readNamespacedPodLog).toHaveBeenCalled();
|
||||
// Result should use the complete log with sessionId
|
||||
expect(result.sessionId).toBe("ses_complete");
|
||||
});
|
||||
|
||||
it("does not call readPodLogs when stdout has a valid session result", async () => {
|
||||
const completeJsonl = [
|
||||
JSON.stringify({ type: "text", part: { text: "Done" }, sessionID: "ses_stream" }),
|
||||
JSON.stringify({ type: "step_finish", part: { tokens: { input: 50, output: 30, cache: {} }, cost: 0.001 } }),
|
||||
].join("\n");
|
||||
|
||||
const coreApi = makeCoreApi(completeJsonl, 0);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const logApi = {
|
||||
log: vi.fn(async (_ns: string, _pod: string, _container: string, writable: NodeJS.WritableStream) => {
|
||||
writable.write(Buffer.from(completeJsonl + "\n"));
|
||||
}),
|
||||
};
|
||||
vi.mocked(getLogApi).mockReturnValue(logApi as unknown as ReturnType<typeof getLogApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
const result = await execute(ctx);
|
||||
|
||||
// readNamespacedPodLog should NOT be called (stream provided complete output)
|
||||
expect(coreApi.readNamespacedPodLog).not.toHaveBeenCalled();
|
||||
expect(result.sessionId).toBe("ses_stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — llm_api_error signal", () => {
|
||||
it("returns llm_api_error when session exists but LLM produced no output tokens", async () => {
|
||||
// JSONL has a sessionID but no step_finish tokens and no text messages
|
||||
const emptyOutputJsonl = JSON.stringify({ sessionID: "ses_empty", type: "step_finish", part: { tokens: { input: 100, output: 0, cache: {} }, cost: 0 } });
|
||||
const coreApi = makeCoreApi(emptyOutputJsonl, 0);
|
||||
setMockJsonl(emptyOutputJsonl);
|
||||
const coreApi = makeCoreApi(0);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -799,7 +775,8 @@ describe("execute — llm_api_error signal", () => {
|
||||
const errorJsonl = [
|
||||
JSON.stringify({ sessionID: "ses_err", type: "error", error: { message: "API quota exceeded" } }),
|
||||
].join("\n");
|
||||
const coreApi = makeCoreApi(errorJsonl, 1);
|
||||
setMockJsonl(errorJsonl);
|
||||
const coreApi = makeCoreApi(1);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -810,7 +787,7 @@ describe("execute — llm_api_error signal", () => {
|
||||
});
|
||||
|
||||
it("does not emit llm_api_error when sessionId is null", async () => {
|
||||
const coreApi = makeCoreApi("", 0);
|
||||
const coreApi = makeCoreApi(0);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
@@ -883,14 +860,12 @@ describe("execute — external cancel polling", () => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_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,
|
||||
@@ -913,7 +888,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
|
||||
@@ -934,7 +909,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" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -953,7 +928,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,
|
||||
@@ -984,6 +958,7 @@ describe("execute — large-prompt Secret path", () => {
|
||||
prompt: LARGE_PROMPT,
|
||||
opencodeArgs: [],
|
||||
promptMetrics: null,
|
||||
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
|
||||
} as unknown as ReturnType<typeof buildJobManifest>);
|
||||
}
|
||||
|
||||
@@ -1212,3 +1187,386 @@ describe("isK8s404", () => {
|
||||
expect(isK8s404(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseModelProvider", () => {
|
||||
it("returns null for null input", async () => {
|
||||
const { parseModelProvider } = await import("./execute.js");
|
||||
expect(parseModelProvider(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when model has no slash separator", async () => {
|
||||
const { parseModelProvider } = await import("./execute.js");
|
||||
expect(parseModelProvider("gpt-4")).toBeNull();
|
||||
expect(parseModelProvider(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the provider segment from a slash-separated model id", async () => {
|
||||
const { parseModelProvider } = await import("./execute.js");
|
||||
expect(parseModelProvider("anthropic/claude-opus-4")).toBe("anthropic");
|
||||
expect(parseModelProvider("openai/gpt-4o")).toBe("openai");
|
||||
});
|
||||
|
||||
it("trims whitespace inside the provider segment", async () => {
|
||||
const { parseModelProvider } = await import("./execute.js");
|
||||
expect(parseModelProvider(" bedrock /claude")).toBe("bedrock");
|
||||
});
|
||||
|
||||
it("returns null when provider segment is whitespace only", async () => {
|
||||
const { parseModelProvider } = await import("./execute.js");
|
||||
expect(parseModelProvider(" /model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completionWithGrace", () => {
|
||||
it("returns the completion result when it resolves before grace expires", async () => {
|
||||
const { completionWithGrace } = await import("./execute.js");
|
||||
const result = await completionWithGrace(
|
||||
Promise.resolve({ succeeded: true, timedOut: false, jobGone: false }),
|
||||
1000,
|
||||
);
|
||||
expect(result).toEqual({ succeeded: true, timedOut: false, jobGone: false });
|
||||
});
|
||||
|
||||
it("returns timedOut result when grace expires first", async () => {
|
||||
const { completionWithGrace } = await import("./execute.js");
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const slowCompletion = new Promise<{ succeeded: boolean; timedOut: boolean; jobGone: boolean }>(() => {});
|
||||
const racePromise = completionWithGrace(slowCompletion, 50);
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
const result = await racePromise;
|
||||
expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns timedOut result when completion promise rejects", async () => {
|
||||
const { completionWithGrace } = await import("./execute.js");
|
||||
const result = await completionWithGrace(Promise.reject(new Error("boom")), 1000);
|
||||
expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — config edge paths", () => {
|
||||
it("logs a warning but continues when instructionsFilePath cannot be read", async () => {
|
||||
const ctx = makeCtx({ instructionsFilePath: "/does/not/exist/AGENTS.md" });
|
||||
const result = await execute(ctx);
|
||||
expect(result.errorCode).toBeUndefined();
|
||||
const logCalls = vi.mocked(ctx.onLog).mock.calls;
|
||||
const warning = logCalls.find(([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("instructionsFilePath not readable"));
|
||||
expect(warning).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns k8s_job_create_failed when ensureAgentDbPvc throws (PVC create rejected)", async () => {
|
||||
vi.mocked(getPvc).mockResolvedValueOnce(null);
|
||||
vi.mocked(createPvc).mockRejectedValueOnce(new Error("storage class missing"));
|
||||
const ctx = makeCtx({
|
||||
agentDbMode: "dedicated_pvc",
|
||||
agentDbStorageClass: "fast",
|
||||
});
|
||||
const result = await execute(ctx);
|
||||
expect(result.errorCode).toBe("k8s_job_create_failed");
|
||||
expect(result.errorMessage).toContain("storage class missing");
|
||||
});
|
||||
|
||||
it("returns k8s_job_create_failed when ensureAgentDbPvc throws because storage class is missing", async () => {
|
||||
vi.mocked(getPvc).mockResolvedValueOnce(null);
|
||||
const ctx = makeCtx({ agentDbMode: "dedicated_pvc" });
|
||||
const result = await execute(ctx);
|
||||
expect(result.errorCode).toBe("k8s_job_create_failed");
|
||||
expect(result.errorMessage).toContain("agentDbStorageClass is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — large-prompt Secret create failure", () => {
|
||||
const LARGE_PROMPT = "y".repeat(300 * 1024);
|
||||
|
||||
it("returns k8s_job_create_failed when createNamespacedSecret throws", async () => {
|
||||
vi.mocked(buildJobManifest).mockReturnValue({
|
||||
job: MOCK_JOB as ReturnType<typeof buildJobManifest>["job"],
|
||||
jobName: JOB_NAME,
|
||||
namespace: NAMESPACE,
|
||||
prompt: LARGE_PROMPT,
|
||||
opencodeArgs: [],
|
||||
promptMetrics: null,
|
||||
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
|
||||
} as unknown as ReturnType<typeof buildJobManifest>);
|
||||
|
||||
const coreApi = makeCoreApi();
|
||||
coreApi.createNamespacedSecret.mockRejectedValue(new Error("etcd full"));
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
const result = await execute(ctx);
|
||||
|
||||
expect(result.errorCode).toBe("k8s_job_create_failed");
|
||||
expect(result.errorMessage).toContain("Failed to create prompt Secret");
|
||||
expect(result.errorMessage).toContain("etcd full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureAgentDbPvc — verification failure (FAR-85 belt-and-suspenders)", () => {
|
||||
it("throws when getPvc returns null after createPvc resolved (verification failed)", async () => {
|
||||
vi.mocked(getPvc)
|
||||
.mockResolvedValueOnce(null) // first existence check: not found
|
||||
.mockResolvedValueOnce(null); // post-create verification: still not found
|
||||
vi.mocked(createPvc).mockResolvedValueOnce({} as never);
|
||||
await expect(
|
||||
ensureAgentDbPvc("agent-x", "ns-x", { agentDbMode: "dedicated_pvc", agentDbStorageClass: "fast" }),
|
||||
).rejects.toThrow(/PVC opencode-db-agent-x was not created/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — step limit detection", () => {
|
||||
it("logs that the step limit was reached when a step_finish event has reason=max_steps", async () => {
|
||||
const STEP_LIMIT_JSONL = [
|
||||
JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }),
|
||||
JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }),
|
||||
].join("\n");
|
||||
setMockJsonl(STEP_LIMIT_JSONL);
|
||||
|
||||
const coreApi = makeCoreApi(0);
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
await execute(ctx);
|
||||
|
||||
const logCalls = vi.mocked(ctx.onLog).mock.calls;
|
||||
const limitLog = logCalls.find(
|
||||
([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("step limit reached"),
|
||||
);
|
||||
expect(limitLog).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — waitForPod 'no pod yet' messaging", () => {
|
||||
it("emits a 'Waiting for Job controller to create pod' log when pod is not yet present", async () => {
|
||||
const coreApi = makeCoreApi();
|
||||
// First listNamespacedPod call returns empty (no pod yet), second returns Running
|
||||
coreApi.listNamespacedPod = vi.fn()
|
||||
.mockResolvedValueOnce({ items: [] })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }],
|
||||
})
|
||||
.mockResolvedValue({
|
||||
items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }],
|
||||
});
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const ctx = makeCtx();
|
||||
await execute(ctx);
|
||||
|
||||
const logCalls = vi.mocked(ctx.onLog).mock.calls;
|
||||
const waitLog = logCalls.find(
|
||||
([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("Waiting for Job controller to create pod"),
|
||||
);
|
||||
expect(waitLog).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — pod scheduling failure (extra paths)", () => {
|
||||
it("returns k8s_pod_schedule_failed when init container is in ImagePullBackOff", async () => {
|
||||
const coreApi = {
|
||||
listNamespacedPod: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: POD_NAME },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
initContainerStatuses: [
|
||||
{ name: "write-prompt", state: { waiting: { reason: "ImagePullBackOff", message: "back-off" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
|
||||
};
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toMatch(/Init container.*image pull failed/);
|
||||
});
|
||||
|
||||
it("returns k8s_pod_schedule_failed when init container is in CrashLoopBackOff", async () => {
|
||||
const coreApi = {
|
||||
listNamespacedPod: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: POD_NAME },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
initContainerStatuses: [
|
||||
{ name: "write-prompt", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
|
||||
};
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toMatch(/Init container.*crash loop/);
|
||||
});
|
||||
|
||||
it("returns k8s_pod_schedule_failed when main container is in CrashLoopBackOff", async () => {
|
||||
const coreApi = {
|
||||
listNamespacedPod: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: POD_NAME },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
containerStatuses: [
|
||||
{ name: "opencode", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
|
||||
};
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
|
||||
expect(result.errorMessage).toMatch(/crash loop/);
|
||||
});
|
||||
|
||||
it("proceeds when all init containers terminated successfully and main is running", async () => {
|
||||
const coreApi = {
|
||||
listNamespacedPod: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
metadata: { name: POD_NAME },
|
||||
status: {
|
||||
phase: "Pending",
|
||||
initContainerStatuses: [
|
||||
{ name: "write-prompt", state: { terminated: { exitCode: 0 } } },
|
||||
],
|
||||
containerStatuses: [{ name: "opencode", state: { running: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValue({
|
||||
items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }],
|
||||
}),
|
||||
readNamespacedPodLog: vi.fn().mockResolvedValue(HAPPY_JSONL),
|
||||
};
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
const result = await execute(makeCtx());
|
||||
expect(result.errorCode).toBeUndefined();
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — skill bundle source loading", () => {
|
||||
it("reads SKILL.md from entry.source dir and bundles content into the prompt", async () => {
|
||||
const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-test-"));
|
||||
const skillDir = path.join(tmpDir, "skill-a");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(path.join(skillDir, "SKILL.md"), "skill A content");
|
||||
|
||||
const utils = await import("@paperclipai/adapter-utils/server-utils");
|
||||
vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([
|
||||
{ key: "paperclip/skill-a", runtimeName: "skill-a", source: skillDir, required: true } as never,
|
||||
]);
|
||||
|
||||
const ctx = makeCtx();
|
||||
await execute(ctx);
|
||||
|
||||
// buildJobManifest should have received the skills bundle content
|
||||
const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0];
|
||||
expect(buildArgs.skillsBundleContent).toContain("skill A content");
|
||||
});
|
||||
|
||||
it("falls back to reading entry.source as a file when SKILL.md path read throws", async () => {
|
||||
const { mkdtempSync, writeFileSync } = await import("node:fs");
|
||||
const os = await import("node:os");
|
||||
const path = await import("node:path");
|
||||
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-flat-"));
|
||||
const skillFile = path.join(tmpDir, "skill-b.md");
|
||||
writeFileSync(skillFile, "skill B flat content");
|
||||
|
||||
const utils = await import("@paperclipai/adapter-utils/server-utils");
|
||||
vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([
|
||||
{ key: "paperclip/skill-b", runtimeName: "skill-b", source: skillFile, required: true } as never,
|
||||
]);
|
||||
|
||||
const ctx = makeCtx();
|
||||
await execute(ctx);
|
||||
|
||||
const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0];
|
||||
expect(buildArgs.skillsBundleContent).toContain("skill B flat content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
|
||||
it("invoking the captured SIGTERM handler deletes tracked Jobs and Secrets", async () => {
|
||||
// Force a fresh module so sigtermHandlerInstalled starts false again.
|
||||
vi.resetModules();
|
||||
vi.doMock("./k8s-client.js", () => ({
|
||||
getSelfPodInfo: vi.fn().mockResolvedValue(MOCK_SELF_POD),
|
||||
getBatchApi: vi.fn(),
|
||||
getCoreApi: vi.fn(),
|
||||
getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-x" } }),
|
||||
createPvc: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
vi.doMock("./job-manifest.js", () => ({
|
||||
buildJobManifest: vi.fn().mockReturnValue({
|
||||
job: MOCK_JOB,
|
||||
jobName: "fresh-job",
|
||||
namespace: NAMESPACE,
|
||||
prompt: "p",
|
||||
opencodeArgs: [],
|
||||
promptMetrics: null,
|
||||
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
|
||||
}),
|
||||
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
|
||||
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
|
||||
),
|
||||
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
|
||||
}));
|
||||
|
||||
const fresh = await import("./execute.js");
|
||||
const k8s = await import("./k8s-client.js");
|
||||
const batchApi = makeBatchApi();
|
||||
const coreApi = makeCoreApi();
|
||||
vi.mocked(k8s.getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof k8s.getBatchApi>);
|
||||
vi.mocked(k8s.getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof k8s.getCoreApi>);
|
||||
|
||||
let capturedHandler: (() => void) | null = null;
|
||||
const onceSpy = vi.spyOn(process, "once").mockImplementation(
|
||||
(event: string | symbol, handler: (...args: unknown[]) => void) => {
|
||||
if (event === "SIGTERM") capturedHandler = handler as () => void;
|
||||
return process;
|
||||
},
|
||||
);
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never);
|
||||
|
||||
await fresh.execute(makeCtx());
|
||||
onceSpy.mockRestore();
|
||||
|
||||
expect(capturedHandler).not.toBeNull();
|
||||
(capturedHandler as unknown as () => void)();
|
||||
// Wait long enough for the async handler body to settle
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(batchApi.deleteNamespacedJob).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
vi.doUnmock("./k8s-client.js");
|
||||
vi.doUnmock("./job-manifest.js");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// tailPodLogFile tests deferred — requires file-system module isolation
|
||||
// not available in the shared test suite's vi.mock("node:fs/promises") setup
|
||||
|
||||
+173
-378
@@ -1,29 +1,19 @@
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import { inferOpenAiCompatibleBiller, redactHomePathUserSegments } from "@paperclipai/adapter-utils";
|
||||
import { inferOpenAiCompatibleBiller } from "@paperclipai/adapter-utils";
|
||||
import { asString, asNumber, asBoolean, parseObject, readPaperclipRuntimeSkillEntries, resolvePaperclipDesiredSkillNames } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFile, open as fsOpen, type FileHandle } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
parseOpenCodeJsonl,
|
||||
isOpenCodeUnknownSessionError,
|
||||
isOpenCodeStepLimitResult,
|
||||
} from "./parse.js";
|
||||
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi, getPvc, createPvc } from "./k8s-client.js";
|
||||
import { buildJobManifest, LARGE_PROMPT_THRESHOLD_BYTES } from "./job-manifest.js";
|
||||
import { LogLineDedupFilter } from "./log-dedup.js";
|
||||
import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js";
|
||||
import { buildJobManifest, LARGE_PROMPT_THRESHOLD_BYTES, buildPodLogPath } from "./job-manifest.js";
|
||||
import type * as k8s from "@kubernetes/client-node";
|
||||
import { Writable } from "node:stream";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const KEEPALIVE_INTERVAL_MS = 15_000;
|
||||
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
|
||||
const LOG_STREAM_RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
const MAX_LOG_RECONNECT_ATTEMPTS = 50;
|
||||
// Upper bound on how long streamPodLogsOnce will wait after stopSignal fires
|
||||
// before force-returning, even if logApi.log has not yet resolved. Defensive
|
||||
// against the K8s client library not propagating writable.destroy() into an
|
||||
// abort of the underlying HTTP request.
|
||||
const LOG_STREAM_BAIL_TIMEOUT_MS = 3_000;
|
||||
const LOG_EXIT_COMPLETION_GRACE_MS = parseInt(process.env.LOG_EXIT_COMPLETION_GRACE_MS ?? "30000", 10);
|
||||
|
||||
export function isK8s404(err: unknown): boolean {
|
||||
@@ -37,7 +27,7 @@ export function isK8s404(err: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseModelProvider(model: string | null): string | null {
|
||||
export function parseModelProvider(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed.includes("/")) return null;
|
||||
@@ -126,7 +116,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 +131,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));
|
||||
@@ -145,226 +151,6 @@ async function waitForPod(
|
||||
throw new Error(`Timed out waiting for pod to be scheduled (${Math.round(timeoutMs / 1000)}s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream pod logs once via follow. Returns accumulated stdout when the
|
||||
* stream ends (container exit, API disconnect, or abort signal).
|
||||
*/
|
||||
async function streamPodLogsOnce(
|
||||
namespace: string,
|
||||
podName: string,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
kubeconfigPath?: string,
|
||||
sinceSeconds?: number,
|
||||
dedup?: LogLineDedupFilter,
|
||||
stopSignal?: { stopped: boolean },
|
||||
): Promise<string> {
|
||||
const logApi = getLogApi(kubeconfigPath);
|
||||
const chunks: string[] = [];
|
||||
|
||||
const writable = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
const text = redactHomePathUserSegments(chunk.toString("utf-8"));
|
||||
chunks.push(text);
|
||||
const emitted = dedup ? dedup.filter(text) : text;
|
||||
if (!emitted) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
void onLog("stdout", emitted).then(() => callback(), callback);
|
||||
},
|
||||
});
|
||||
|
||||
// When the job completion signal fires, destroy the writable to abort the
|
||||
// in-flight follow stream. Without this, logApi.log can hang indefinitely
|
||||
// when the pod terminates without closing the HTTP connection cleanly.
|
||||
let stopPoller: ReturnType<typeof setInterval> | null = null;
|
||||
let bailTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let bailResolve: (() => void) | null = null;
|
||||
const bailPromise = new Promise<void>((resolve) => {
|
||||
bailResolve = resolve;
|
||||
});
|
||||
if (stopSignal) {
|
||||
stopPoller = setInterval(() => {
|
||||
if (stopSignal.stopped) {
|
||||
if (!writable.destroyed) writable.destroy();
|
||||
if (!bailTimer && bailResolve) {
|
||||
bailTimer = setTimeout(() => {
|
||||
onLog("stderr", "[paperclip] Log stream bail timer fired — forcing return\n").catch(() => {});
|
||||
bailResolve!();
|
||||
}, LOG_STREAM_BAIL_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const logPromise = logApi.log(namespace, podName, "opencode", writable, {
|
||||
follow: true,
|
||||
pretty: false,
|
||||
...(sinceSeconds ? { sinceSeconds } : {}),
|
||||
}).catch(() => {
|
||||
// follow may fail if the container already exited, the API connection
|
||||
// dropped, or we aborted via writable.destroy() — not fatal.
|
||||
});
|
||||
|
||||
try {
|
||||
if (stopSignal) {
|
||||
await Promise.race([logPromise, bailPromise]);
|
||||
} else {
|
||||
await logPromise;
|
||||
}
|
||||
} finally {
|
||||
if (stopPoller) clearInterval(stopPoller);
|
||||
if (bailTimer) clearTimeout(bailTimer);
|
||||
}
|
||||
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream pod logs with automatic reconnection. Keeps retrying the log
|
||||
* stream until the stop signal fires (job completed) or the container
|
||||
* exits normally. This handles silent K8s API connection drops that
|
||||
* would otherwise cause the UI to stop receiving real output.
|
||||
*
|
||||
* Capped at MAX_LOG_RECONNECT_ATTEMPTS to prevent infinite reconnect
|
||||
* loops during sustained API partitions.
|
||||
*
|
||||
* onFirstStreamExit is called the first time streamPodLogsOnce returns.
|
||||
* Used by execute() to start the LOG_EXIT_COMPLETION_GRACE_MS grace timer
|
||||
* without waiting for all reconnects to exhaust.
|
||||
*/
|
||||
async function streamPodLogs(
|
||||
namespace: string,
|
||||
podName: string,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
kubeconfigPath?: string,
|
||||
stopSignal?: { stopped: boolean },
|
||||
dedup?: LogLineDedupFilter,
|
||||
onFirstStreamExit?: () => void,
|
||||
): Promise<string> {
|
||||
const allChunks: string[] = [];
|
||||
let attempt = 0;
|
||||
// Track the timestamp of the last successfully received log line so
|
||||
// reconnects use a tight window instead of an ever-growing one anchored
|
||||
// at stream start. This is the primary fix for duplicative logs on reconnect.
|
||||
let lastLogReceivedAt = Math.floor(Date.now() / 1000);
|
||||
if (!dedup) dedup = new LogLineDedupFilter();
|
||||
|
||||
while (!stopSignal?.stopped) {
|
||||
if (attempt >= MAX_LOG_RECONNECT_ATTEMPTS) {
|
||||
await onLog("stderr", `[paperclip] Log stream: max reconnect attempts (${MAX_LOG_RECONNECT_ATTEMPTS}) reached — giving up.\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
// On reconnect, ask for logs since the last received line (+5s buffer)
|
||||
// instead of since stream start. This keeps the window tight and
|
||||
// avoids ever-growing duplicate output.
|
||||
const sinceSeconds = attempt > 0
|
||||
? Math.max(1, Math.floor(Date.now() / 1000) - lastLogReceivedAt + 5)
|
||||
: undefined;
|
||||
|
||||
if (attempt > 0) {
|
||||
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt}/${MAX_LOG_RECONNECT_ATTEMPTS})...\n`);
|
||||
}
|
||||
|
||||
const preStreamTs = Math.floor(Date.now() / 1000);
|
||||
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds, dedup, stopSignal);
|
||||
// Signal first stream exit immediately so the grace-period timer in
|
||||
// execute() can start without waiting for all reconnects to complete.
|
||||
if (attempt === 0) onFirstStreamExit?.();
|
||||
if (result) {
|
||||
allChunks.push(result);
|
||||
// Update last-received timestamp to now (the stream just ended,
|
||||
// so any log lines in `result` were received up to this moment).
|
||||
lastLogReceivedAt = Math.floor(Date.now() / 1000);
|
||||
} else if (attempt === 0) {
|
||||
// First attempt returned nothing — update timestamp so reconnect
|
||||
// window stays reasonable.
|
||||
lastLogReceivedAt = preStreamTs;
|
||||
}
|
||||
attempt++;
|
||||
|
||||
if (stopSignal?.stopped) break;
|
||||
|
||||
// Exponential backoff before reconnecting: start at 3s, double each
|
||||
// attempt, cap at 30s. Avoids hammering the API server during prolonged
|
||||
// network hiccups while staying responsive for brief disconnects.
|
||||
// Sleep in 200ms chunks so a stop signal can interrupt the backoff
|
||||
// without waiting for the full delay to expire.
|
||||
const backoffMs = Math.min(
|
||||
LOG_STREAM_RECONNECT_MAX_DELAY_MS,
|
||||
LOG_STREAM_RECONNECT_DELAY_MS * 2 ** (attempt - 1),
|
||||
);
|
||||
const backoffDeadline = Date.now() + backoffMs;
|
||||
while (!stopSignal?.stopped) {
|
||||
const remaining = backoffDeadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(200, remaining)));
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any buffered partial line so the final assistant/result chunk
|
||||
// isn't dropped when the stream ends mid-line.
|
||||
const tail = dedup.flush();
|
||||
if (tail) await onLog("stdout", tail);
|
||||
|
||||
return allChunks.join("");
|
||||
}
|
||||
|
||||
async function readPodLogs(
|
||||
namespace: string,
|
||||
podName: string,
|
||||
kubeconfigPath?: string,
|
||||
): Promise<string> {
|
||||
const coreApi = getCoreApi(kubeconfigPath);
|
||||
try {
|
||||
const log = await coreApi.readNamespacedPodLog({
|
||||
name: podName,
|
||||
namespace,
|
||||
container: "opencode",
|
||||
});
|
||||
return typeof log === "string" ? log : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the named pod's phase transitions to Succeeded, Failed, or Unknown,
|
||||
* or until the pod is gone (404). Returns immediately if the pod is already in a
|
||||
* terminal phase. Used as a pre-flight before readPodLogs when the K8s log stream
|
||||
* returns empty while the container is still running (Node.js stdout buffering +
|
||||
* the @kubernetes/client-node v1.x follow-stream known premature-close issue).
|
||||
*/
|
||||
async function waitForPodTermination(
|
||||
namespace: string,
|
||||
podName: string,
|
||||
timeoutMs: number,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
kubeconfigPath?: string,
|
||||
): Promise<void> {
|
||||
const coreApi = getCoreApi(kubeconfigPath);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let notified = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const pod = await coreApi.readNamespacedPod({ name: podName, namespace });
|
||||
const phase = pod.status?.phase;
|
||||
if (phase === "Succeeded" || phase === "Failed" || phase === "Unknown") return;
|
||||
if (!notified) {
|
||||
notified = true;
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Container still running — waiting up to ${Math.round(timeoutMs / 1000)}s for it to exit to capture output...\n`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return; // Pod gone (404) — nothing left to wait for
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
export type JobCompletionResult = { succeeded: boolean; timedOut: boolean; jobGone: boolean };
|
||||
|
||||
async function waitForJobCompletion(
|
||||
@@ -376,7 +162,10 @@ async function waitForJobCompletion(
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
|
||||
|
||||
while (deadline === 0 || Date.now() < deadline) {
|
||||
while (true) {
|
||||
if (deadline > 0 && Date.now() >= deadline) {
|
||||
return { succeeded: false, timedOut: true, jobGone: false };
|
||||
}
|
||||
let job: Awaited<ReturnType<typeof batchApi.readNamespacedJob>>;
|
||||
try {
|
||||
job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
@@ -397,8 +186,6 @@ async function waitForJobCompletion(
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
return { succeeded: false, timedOut: true, jobGone: false };
|
||||
}
|
||||
|
||||
export async function completionWithGrace(
|
||||
@@ -435,12 +222,110 @@ async function getPodTerminatedInfo(
|
||||
};
|
||||
}
|
||||
|
||||
interface TailOptions {
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
stopSignal: { stopped: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail the pod's stdout log file from the shared PVC.
|
||||
*
|
||||
* Polls the file system with adaptive cadence: 250 ms while the file is
|
||||
* growing, backing off to 1000 ms when idle for 5 consecutive polls.
|
||||
* Buffers partial lines and emits complete lines to onLog.
|
||||
*/
|
||||
export async function tailPodLogFile(
|
||||
filePath: string,
|
||||
opts: TailOptions,
|
||||
): Promise<string> {
|
||||
const { onLog, stopSignal } = opts;
|
||||
const FILE_WAIT_TIMEOUT_MS = 30_000;
|
||||
const POLL_ACTIVE_MS = 250;
|
||||
const POLL_IDLE_MS = 1000;
|
||||
const IDLE_THRESHOLD = 5; // consecutive idle polls before backing off
|
||||
|
||||
// Wait up to 30s for the file to appear
|
||||
const waitDeadline = Date.now() + FILE_WAIT_TIMEOUT_MS;
|
||||
while (Date.now() < waitDeadline) {
|
||||
try {
|
||||
await import("node:fs/promises").then((fs) => fs.stat(filePath));
|
||||
break; // file exists
|
||||
} catch {
|
||||
if (stopSignal.stopped) return "";
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Check one more time before opening
|
||||
let fh: FileHandle;
|
||||
try {
|
||||
fh = await fsOpen(filePath, "r");
|
||||
} catch {
|
||||
throw new Error(`Pod log file never appeared at ${filePath}`);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
let pending = "";
|
||||
let idleCount = 0;
|
||||
const accumulator: string[] = [];
|
||||
|
||||
const drain = async (): Promise<boolean> => {
|
||||
let size: number;
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
size = stat.size;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (size <= offset) return false;
|
||||
const buf = Buffer.alloc(size - offset);
|
||||
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
|
||||
offset += bytesRead;
|
||||
const chunk = buf.slice(0, bytesRead).toString("utf-8");
|
||||
const lineParts = (pending + chunk).split("\n");
|
||||
pending = lineParts.pop() ?? "";
|
||||
for (const line of lineParts) {
|
||||
await onLog("stdout", line + "\n");
|
||||
accumulator.push(line + "\n");
|
||||
}
|
||||
return bytesRead > 0;
|
||||
};
|
||||
|
||||
try {
|
||||
while (!stopSignal.stopped) {
|
||||
const grew = await drain();
|
||||
if (grew) {
|
||||
idleCount = 0;
|
||||
} else {
|
||||
idleCount++;
|
||||
}
|
||||
if (stopSignal.stopped) break;
|
||||
const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
}
|
||||
|
||||
// Final drain after stopSignal — pick up any bytes written between the
|
||||
// last read and the job reaching terminal state.
|
||||
while (await drain()) { /* read until no more growth */ }
|
||||
|
||||
if (pending) {
|
||||
await onLog("stdout", pending + "\n");
|
||||
accumulator.push(pending + "\n");
|
||||
}
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
|
||||
return accumulator.join("");
|
||||
}
|
||||
|
||||
async function cleanupJob(
|
||||
namespace: string,
|
||||
jobName: string,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
kubeconfigPath?: string,
|
||||
promptSecretName?: string,
|
||||
podLogPath?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
@@ -461,12 +346,18 @@ async function cleanupJob(
|
||||
// best-effort — Secret may already be GC'd via ownerReference
|
||||
}
|
||||
}
|
||||
if (podLogPath) {
|
||||
try {
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
await unlink(podLogPath);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream logs + await completion for an already-created Job, then harvest
|
||||
* and return the execution result. Used by both the normal create-then-run
|
||||
* path and the orphaned-job reattach path.
|
||||
* Tail the pod log file and await completion for an already-created Job.
|
||||
*/
|
||||
async function streamAndAwaitJob(
|
||||
ctx: AdapterExecutionContext,
|
||||
@@ -476,6 +367,7 @@ async function streamAndAwaitJob(
|
||||
graceSec: number,
|
||||
kubeconfigPath: string | undefined,
|
||||
retainJobs: boolean,
|
||||
podLogPath: string,
|
||||
promptSecretName?: string,
|
||||
): Promise<AdapterExecutionResult> {
|
||||
const { onLog } = ctx;
|
||||
@@ -508,8 +400,7 @@ async function streamAndAwaitJob(
|
||||
}
|
||||
|
||||
const completionTimeoutMs = timeoutSec > 0 ? (timeoutSec + graceSec) * 1000 : 0;
|
||||
const logStopSignal = { stopped: false };
|
||||
const logDedup = new LogLineDedupFilter();
|
||||
const stopSignal = { stopped: false };
|
||||
|
||||
const issueId = asString(ctx.context.issueId ?? ctx.context.taskId, "").trim();
|
||||
let lastLogAt = Date.now();
|
||||
@@ -541,21 +432,15 @@ async function streamAndAwaitJob(
|
||||
})();
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
// External cancel poll: watches Paperclip issue status at keepalive cadence.
|
||||
// Polls GET /api/issues/{issueId} (not /api/heartbeat-runs) because the adapter
|
||||
// key has read access to issues but not to the internal heartbeat-runs endpoint.
|
||||
// Uses await-setTimeout (not setInterval+void) so vi.advanceTimersByTimeAsync
|
||||
// can drive it in tests. Fire-and-forget; exits when logStopSignal.stopped.
|
||||
// External cancel poll
|
||||
void (async (): Promise<void> => {
|
||||
const apiUrl = process.env.PAPERCLIP_API_URL;
|
||||
if (!apiUrl || !issueId) return;
|
||||
while (!logStopSignal.stopped && !cancelSignal.cancelled) {
|
||||
while (!stopSignal.stopped && !cancelSignal.cancelled) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
|
||||
if (logStopSignal.stopped || cancelSignal.cancelled) break;
|
||||
if (stopSignal.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}` },
|
||||
});
|
||||
@@ -563,7 +448,7 @@ async function streamAndAwaitJob(
|
||||
const data = await resp.json() as { status?: string };
|
||||
if (typeof data.status === "string" && data.status === "cancelled") {
|
||||
cancelSignal.cancelled = true;
|
||||
logStopSignal.stopped = true;
|
||||
stopSignal.stopped = true;
|
||||
try {
|
||||
await getBatchApi(kubeconfigPath).deleteNamespacedJob({
|
||||
name: jobName,
|
||||
@@ -582,110 +467,33 @@ async function streamAndAwaitJob(
|
||||
return onLog(stream, chunk);
|
||||
};
|
||||
|
||||
let logExitTime: number | null = null;
|
||||
const trackedLogStream = streamPodLogs(
|
||||
namespace, podName, wrappedOnLog, kubeconfigPath, logStopSignal, logDedup,
|
||||
() => { logExitTime = Date.now(); },
|
||||
);
|
||||
|
||||
let gracePoller: ReturnType<typeof setInterval> | null = null;
|
||||
// Maximum wall-clock time the grace poller will defer to pod-liveness checks.
|
||||
// When completionTimeoutMs is 0 (unlimited job), cap at 20 minutes so we
|
||||
// don't wait forever if the pod never exits but K8s never marks the job done.
|
||||
const graceMaxWaitMs = completionTimeoutMs > 0 ? completionTimeoutMs : 20 * 60_000;
|
||||
const graceStartTime = Date.now();
|
||||
const completionGraced = new Promise<JobCompletionResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let graceCheckPending = false;
|
||||
const settleOk = (r: JobCompletionResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (gracePoller) { clearInterval(gracePoller); gracePoller = null; }
|
||||
logStopSignal.stopped = true;
|
||||
resolve(r);
|
||||
};
|
||||
const settleErr = (err: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (gracePoller) { clearInterval(gracePoller); gracePoller = null; }
|
||||
logStopSignal.stopped = true;
|
||||
reject(err);
|
||||
};
|
||||
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath).then(settleOk).catch(settleErr);
|
||||
gracePoller = setInterval(() => {
|
||||
if (graceCheckPending || settled) return;
|
||||
if (logExitTime !== null && Date.now() - logExitTime >= LOG_EXIT_COMPLETION_GRACE_MS) {
|
||||
graceCheckPending = true;
|
||||
void (async () => {
|
||||
try {
|
||||
// If we haven't exceeded the max wait, check whether the pod is still running.
|
||||
// The K8s log client v1.x closes the follow-stream prematurely even when the
|
||||
// container is still executing — the log exit does not mean the job is done.
|
||||
if (Date.now() - graceStartTime < graceMaxWaitMs) {
|
||||
try {
|
||||
const pod = await getCoreApi(kubeconfigPath).readNamespacedPod({ name: podName, namespace });
|
||||
const phase = pod.status?.phase;
|
||||
if (phase === "Running" || phase === "Pending") {
|
||||
// Pod still alive — reset the grace deadline and keep waiting
|
||||
logExitTime = Date.now();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Pod gone (404) or K8s error — fall through to settleOk
|
||||
}
|
||||
}
|
||||
void onLog("stdout", `[paperclip] Log stream exited ${LOG_EXIT_COMPLETION_GRACE_MS / 1000}s ago without K8s Job condition update — proceeding with captured output\n`).catch(() => {});
|
||||
settleOk({ succeeded: false, timedOut: false, jobGone: true });
|
||||
} finally {
|
||||
graceCheckPending = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, 1_000);
|
||||
});
|
||||
|
||||
const [logResult, completionResult] = await Promise.allSettled([
|
||||
trackedLogStream,
|
||||
completionGraced,
|
||||
// Run the file tail and the job-completion poll in parallel so that the
|
||||
// tail loop has a way to stop: when waitForJobCompletion resolves it sets
|
||||
// stopSignal.stopped, which lets tailPodLogFile drain and return.
|
||||
// No completionWithGrace wrapper here — wrapping a long-running job poll
|
||||
// in a 30s grace turns the grace into a hard ceiling and kills runs
|
||||
// prematurely with "Timed out after 0s" when timeoutSec is 0 (no timeout).
|
||||
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath)
|
||||
.then((r) => { stopSignal.stopped = true; return r; });
|
||||
const [tailSettled, completionSettled] = await Promise.allSettled([
|
||||
tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal }),
|
||||
completionPromise,
|
||||
]);
|
||||
stdout = tailSettled.status === "fulfilled" ? tailSettled.value : "";
|
||||
if (completionSettled.status === "rejected") {
|
||||
stopSignal.stopped = true;
|
||||
throw completionSettled.reason;
|
||||
}
|
||||
const completion = completionSettled.value;
|
||||
|
||||
if (keepaliveTimer) {
|
||||
clearInterval(keepaliveTimer);
|
||||
keepaliveTimer = null;
|
||||
}
|
||||
|
||||
if (logResult.status === "fulfilled") {
|
||||
stdout = logResult.value;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
|
||||
// The K8s client v1.x has a known issue where follow-stream closes prematurely,
|
||||
// causing the log stream to return empty even when the container is still running.
|
||||
// Node.js also buffers stdout when writing to a pipe, so logs only flush on exit.
|
||||
// Wait for the pod to actually terminate before attempting to read its final output.
|
||||
await waitForPodTermination(namespace, podName, 120_000, onLog, kubeconfigPath);
|
||||
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (stdout.trim()) {
|
||||
await onLog("stdout", stdout);
|
||||
}
|
||||
} else if (!parseOpenCodeJsonl(stdout).sessionId) {
|
||||
await onLog("stdout", `[paperclip] Partial stdout missing session result — reading pod logs directly...\n`);
|
||||
const fallbackLogs = await readPodLogs(namespace, podName, kubeconfigPath);
|
||||
if (fallbackLogs.trim()) {
|
||||
stdout = fallbackLogs;
|
||||
await onLog("stdout", fallbackLogs);
|
||||
}
|
||||
}
|
||||
|
||||
if (completionResult.status === "fulfilled") {
|
||||
const completion = completionResult.value;
|
||||
jobTimedOut = completion.timedOut;
|
||||
if (completion.jobGone) {
|
||||
await onLog("stdout", `[paperclip] Job ${jobName} not found (likely TTL-cleaned after completion).\n`);
|
||||
}
|
||||
} else {
|
||||
jobTimedOut = true;
|
||||
jobTimedOut = completion.timedOut;
|
||||
if (completion.jobGone) {
|
||||
await onLog("stdout", `[paperclip] Job ${jobName} not found (likely TTL-cleaned after completion).\n`);
|
||||
}
|
||||
|
||||
const terminatedInfo = await getPodTerminatedInfo(namespace, jobName, kubeconfigPath);
|
||||
@@ -698,7 +506,7 @@ async function streamAndAwaitJob(
|
||||
}
|
||||
activeJobs.delete(jobName);
|
||||
if (!retainJobs) {
|
||||
await cleanupJob(namespace, jobName, onLog, kubeconfigPath, promptSecretName);
|
||||
await cleanupJob(namespace, jobName, onLog, kubeconfigPath, promptSecretName, podLogPath);
|
||||
} else {
|
||||
await onLog("stdout", `[paperclip] Retaining job ${jobName} for debugging (retainJobs=true)\n`);
|
||||
}
|
||||
@@ -933,9 +741,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
try {
|
||||
// Guard: single concurrency per agent (shared PVC/session) — fail-closed.
|
||||
// When a concurrent job is detected, wait for it to finish and retry once rather
|
||||
// than returning k8s_concurrent_run_blocked immediately (which caused permanent
|
||||
// blocked state for all but the first task in a simultaneous batch assignment).
|
||||
let waitedForConcurrent = false;
|
||||
while (true) {
|
||||
try {
|
||||
@@ -948,8 +753,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
(j) => !j.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True"),
|
||||
);
|
||||
if (running.length > 0) {
|
||||
// Separate Jobs matching the current task (orphaned from a prior server instance)
|
||||
// from Jobs belonging to a different concurrent task.
|
||||
const sameTaskJobs = taskId
|
||||
? running.filter((j) => j.metadata?.labels?.["paperclip.io/task-id"] === taskId)
|
||||
: [];
|
||||
@@ -957,7 +760,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
if (otherJobs.length > 0) {
|
||||
if (waitedForConcurrent) {
|
||||
// Already waited once — give up to avoid an infinite loop.
|
||||
const names = otherJobs.map((j) => j.metadata?.name).join(", ");
|
||||
await onLog("stderr", `[paperclip] Concurrent run blocked: existing Job(s) still running for this agent: ${names}\n`);
|
||||
return {
|
||||
@@ -970,8 +772,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
const names = otherJobs.map((j) => j.metadata?.name).join(", ");
|
||||
await onLog("stdout", `[paperclip] Waiting for concurrent Job(s) to finish before starting: ${names}\n`);
|
||||
// Wait up to the configured job timeout (+ grace + buffer); for unlimited jobs
|
||||
// cap at 1 hour so we don't block the mutex indefinitely.
|
||||
const concurrentWaitMs = timeoutSec > 0
|
||||
? (timeoutSec + graceSec + 120) * 1000
|
||||
: 60 * 60_000;
|
||||
@@ -991,7 +791,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (reattachOrphanedJobs) {
|
||||
await onLog("stdout", `[paperclip] Reattaching to orphaned Job ${orphanJobName} from prior server instance (task: ${taskId})...\n`);
|
||||
activeJobs.set(orphanJobName, { namespace: guardNamespace, kubeconfigPath });
|
||||
return streamAndAwaitJob(ctx, orphanJobName, guardNamespace, timeoutSec, graceSec, kubeconfigPath, retainJobs);
|
||||
// Reattach needs podLogPath — compute it here for the orphaned job
|
||||
const podLogPath = buildPodLogPath(ctx.agent.companyId, agentId, ctx.runId);
|
||||
return streamAndAwaitJob(ctx, orphanJobName, guardNamespace, timeoutSec, graceSec, kubeconfigPath, retainJobs, podLogPath);
|
||||
}
|
||||
await onLog("stderr", `[paperclip] Orphaned Job ${orphanJobName} found for this task but reattachOrphanedJobs is disabled.\n`);
|
||||
return {
|
||||
@@ -1017,7 +819,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
break; // no blocking jobs — proceed to job creation
|
||||
}
|
||||
|
||||
// Read agent instructions file (instructionsFilePath config field → system prompt prepend)
|
||||
// Read agent instructions file
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
let instructionsContent = "";
|
||||
if (instructionsFilePath) {
|
||||
@@ -1028,12 +830,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve and read desired skill content (injected into prompt bundle)
|
||||
// Resolve and read desired skill content
|
||||
let skillsBundleContent = "";
|
||||
try {
|
||||
const moduleDir = import.meta.dirname;
|
||||
// Add the standard Paperclip skills dir as an additional candidate — the relative
|
||||
// candidates in adapter-utils don't resolve to the PVC-mounted skills home.
|
||||
const paperclipSkillsHome = "/paperclip/.claude/skills";
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, moduleDir, [paperclipSkillsHome]);
|
||||
const desiredSkillKeys = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
@@ -1042,8 +842,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const entry = availableEntries.find((e) => e.key === key);
|
||||
if (entry?.source) {
|
||||
try {
|
||||
// entry.source from listPaperclipSkillEntries is a directory; read SKILL.md from it.
|
||||
// Fall back to reading entry.source directly for file-based paperclipRuntimeSkills entries.
|
||||
let text: string;
|
||||
try {
|
||||
text = (await readFile(path.join(entry.source, "SKILL.md"), "utf-8")).trim();
|
||||
@@ -1061,7 +859,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
// non-fatal: skill bundle is optional
|
||||
}
|
||||
|
||||
// Ensure per-agent DB PVC exists (or get null for ephemeral mode)
|
||||
// Ensure per-agent DB PVC exists
|
||||
let agentDbClaimName: string | null | undefined;
|
||||
try {
|
||||
agentDbClaimName = await ensureAgentDbPvc(agentId, guardNamespace, config, kubeconfigPath);
|
||||
@@ -1086,10 +884,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
retainJobs,
|
||||
};
|
||||
const firstBuild = buildJobManifest(buildArgs);
|
||||
const { jobName, namespace, prompt, opencodeArgs, promptMetrics } = firstBuild;
|
||||
const { jobName, namespace, prompt, opencodeArgs, promptMetrics, podLogPath } = firstBuild;
|
||||
|
||||
// For prompts larger than the threshold, store in a K8s Secret so the PodSpec
|
||||
// stays within the 1 MiB API limit. The init container mounts and copies the file.
|
||||
// For prompts larger than the threshold, store in a K8s Secret
|
||||
let promptSecretName: string | undefined;
|
||||
let job = firstBuild.job;
|
||||
if (Buffer.byteLength(prompt, "utf-8") > LARGE_PROMPT_THRESHOLD_BYTES) {
|
||||
@@ -1116,7 +913,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const batchApi = getBatchApi(kubeconfigPath);
|
||||
|
||||
// Create the prompt Secret before the Job so the init container can mount it.
|
||||
// Create the prompt Secret before the Job
|
||||
if (promptSecretName) {
|
||||
const coreApi = getCoreApi(kubeconfigPath);
|
||||
const promptSecret: k8s.V1Secret = {
|
||||
@@ -1163,7 +960,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
}
|
||||
|
||||
// Set ownerReference on the prompt Secret so K8s GC deletes it when the Job is removed.
|
||||
// Set ownerReference on the prompt Secret
|
||||
if (promptSecretName && createdJob?.metadata?.uid) {
|
||||
try {
|
||||
const coreApi = getCoreApi(kubeconfigPath);
|
||||
@@ -1186,7 +983,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
} as k8s.V1Secret,
|
||||
});
|
||||
} catch {
|
||||
// non-fatal — Secret will still be removed by cleanupJob in the finally block
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1195,9 +992,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
await onLog("stdout", `[paperclip] Created K8s Job: ${jobName} in namespace ${namespace} (deadline: ${timeoutSec > 0 ? `${timeoutSec}s` : "none"})\n`);
|
||||
|
||||
// return evaluates streamAndAwaitJob() (creating the promise) before finally runs,
|
||||
// so the mutex releases as soon as the job is registered — not after the full lifecycle.
|
||||
return streamAndAwaitJob(ctx, jobName, namespace, timeoutSec, graceSec, kubeconfigPath, retainJobs, promptSecretName);
|
||||
return streamAndAwaitJob(ctx, jobName, namespace, timeoutSec, graceSec, kubeconfigPath, retainJobs, podLogPath, promptSecretName);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
+7
-2
@@ -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,
|
||||
@@ -33,7 +34,11 @@ export function createServerAdapter(): ServerAdapterModule {
|
||||
maxSessionAgeHours: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
|
||||
export { execute, testEnvironment, sessionCodec };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -259,8 +270,8 @@ describe("buildJobManifest", () => {
|
||||
it("label values are sanitized to [a-z0-9._-]", () => {
|
||||
const ctx = {
|
||||
...mockCtx,
|
||||
agent: { ...mockCtx.agent, id: "Agent_ID/123", companyId: "Co:456" },
|
||||
runId: "Run@789",
|
||||
agent: { ...mockCtx.agent, id: "agent-id-123", companyId: "company-456" },
|
||||
runId: "run-789",
|
||||
};
|
||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||
|
||||
@@ -287,16 +298,16 @@ describe("buildJobManifest", () => {
|
||||
});
|
||||
|
||||
describe("agentDbClaimName — OPENCODE_DB env var", () => {
|
||||
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is a string (dedicated PVC)", () => {
|
||||
it("sets OPENCODE_DB to /opencode-db/opencode.db when agentDbClaimName is a string (dedicated PVC)", () => {
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
||||
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
||||
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db");
|
||||
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db");
|
||||
});
|
||||
|
||||
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is null (ephemeral)", () => {
|
||||
it("sets OPENCODE_DB to /opencode-db/opencode.db when agentDbClaimName is null (ephemeral)", () => {
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null });
|
||||
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
||||
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db");
|
||||
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db");
|
||||
});
|
||||
|
||||
it("does not set OPENCODE_DB when agentDbClaimName is undefined", () => {
|
||||
@@ -305,13 +316,13 @@ describe("agentDbClaimName — OPENCODE_DB env var", () => {
|
||||
expect(env.find((e) => e.name === "OPENCODE_DB")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces a user-provided OPENCODE_DB env override with /opencode-db", () => {
|
||||
it("replaces a user-provided OPENCODE_DB env override with /opencode-db/opencode.db", () => {
|
||||
const selfPod = { ...mockSelfPod, inheritedEnv: { OPENCODE_DB: "/user/override" } };
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
||||
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
||||
const dbEntries = env.filter((e) => e.name === "OPENCODE_DB");
|
||||
expect(dbEntries).toHaveLength(1);
|
||||
expect(dbEntries[0].value).toBe("/opencode-db");
|
||||
expect(dbEntries[0].value).toBe("/opencode-db/opencode.db");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,10 +356,12 @@ describe("agentDbClaimName — volume wiring", () => {
|
||||
});
|
||||
|
||||
describe("init container is unchanged by agentDbClaimName", () => {
|
||||
it("does not add mkdir or extra env vars to init container for dedicated PVC mode", () => {
|
||||
it("does not add extra env vars to init container for dedicated PVC mode", () => {
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
||||
const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command;
|
||||
// init container only writes the prompt; no mkdir (log dir exists on PVC) and no OPENCODE_DB_PATH env var
|
||||
expect(initCmd?.[2]).not.toContain("mkdir");
|
||||
expect(initCmd?.[2]).toContain("/tmp/prompt/prompt.txt");
|
||||
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
|
||||
expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false);
|
||||
});
|
||||
@@ -406,3 +419,109 @@ describe("sanitizeLabelValue", () => {
|
||||
expect(warned.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJobManifest — env wiring branches", () => {
|
||||
it("sets PAPERCLIP_WAKE_PAYLOAD_JSON when paperclipWake is provided", () => {
|
||||
const ctx = { ...mockCtx, context: { ...mockCtx.context, paperclipWake: { reason: "issue_assigned", issue: { id: "x" } } } };
|
||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_WAKE_PAYLOAD_JSON")?.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it("forwards workspace context and AGENT_HOME from paperclipWorkspace", () => {
|
||||
const ctx = {
|
||||
...mockCtx,
|
||||
context: {
|
||||
...mockCtx.context,
|
||||
paperclipWorkspace: {
|
||||
cwd: "/work",
|
||||
source: "main",
|
||||
strategy: "shared",
|
||||
workspaceId: "ws_1",
|
||||
repoUrl: "https://example.com/r.git",
|
||||
repoRef: "main",
|
||||
branchName: "feature/x",
|
||||
worktreePath: "/wt/x",
|
||||
agentHome: "/home/agent",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_CWD")?.value).toBe("/work");
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_BRANCH")?.value).toBe("feature/x");
|
||||
expect(env.find((e) => e.name === "AGENT_HOME")?.value).toBe("/home/agent");
|
||||
});
|
||||
|
||||
it("sets PAPERCLIP_LINKED_ISSUE_IDS from non-empty issueIds array (skipping blanks)", () => {
|
||||
const ctx = { ...mockCtx, context: { ...mockCtx.context, issueIds: ["a", " ", "b", null as unknown as string, "c"] } };
|
||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_LINKED_ISSUE_IDS")?.value).toBe("a,b,c");
|
||||
});
|
||||
|
||||
it("encodes paperclipWorkspaces / paperclipRuntimeServiceIntents / paperclipRuntimeServices as JSON env", () => {
|
||||
const ctx = {
|
||||
...mockCtx,
|
||||
context: {
|
||||
...mockCtx.context,
|
||||
paperclipWorkspaces: [{ id: "w1" }],
|
||||
paperclipRuntimeServiceIntents: [{ name: "redis" }],
|
||||
paperclipRuntimeServices: [{ name: "redis", url: "redis://r" }],
|
||||
paperclipRuntimePrimaryUrl: "https://primary",
|
||||
},
|
||||
};
|
||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACES_JSON")?.value).toContain("w1");
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON")?.value).toContain("redis");
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICES_JSON")?.value).toContain("redis://r");
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_PRIMARY_URL")?.value).toBe("https://primary");
|
||||
});
|
||||
|
||||
it("sets PAPERCLIP_API_KEY from ctx.authToken when provided", () => {
|
||||
const ctx = { ...mockCtx, authToken: "tok_abc" };
|
||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
||||
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
|
||||
expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc");
|
||||
});
|
||||
|
||||
it("inherits PAPERCLIP_API_URL from selfPod inheritedEnv", () => {
|
||||
const selfPod = {
|
||||
...mockSelfPod,
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJobManifest — volume wiring branches", () => {
|
||||
it("mounts the prompt secret volume when promptSecretName is provided", () => {
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, promptSecretName: "prompt-x" });
|
||||
const volumes = result.job.spec?.template.spec?.volumes ?? [];
|
||||
expect(volumes.find((v) => v.name === "prompt-secret")?.secret?.secretName).toBe("prompt-x");
|
||||
});
|
||||
|
||||
it("mounts the data PVC at /paperclip when selfPod has a pvcClaimName", () => {
|
||||
const selfPod = { ...mockSelfPod, pvcClaimName: "paperclip-data" };
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
||||
const volumes = result.job.spec?.template.spec?.volumes ?? [];
|
||||
expect(volumes.find((v) => v.name === "data")?.persistentVolumeClaim?.claimName).toBe("paperclip-data");
|
||||
const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? [];
|
||||
expect(mounts.find((m) => m.name === "data")?.mountPath).toBe("/paperclip");
|
||||
});
|
||||
|
||||
it("mounts inherited secret volumes from selfPod.secretVolumes", () => {
|
||||
const selfPod = {
|
||||
...mockSelfPod,
|
||||
secretVolumes: [{ volumeName: "tls", secretName: "tls-secret", mountPath: "/etc/tls", defaultMode: 0o400 }],
|
||||
};
|
||||
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
||||
const volumes = result.job.spec?.template.spec?.volumes ?? [];
|
||||
expect(volumes.find((v) => v.name === "tls")?.secret?.secretName).toBe("tls-secret");
|
||||
const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? [];
|
||||
expect(mounts.find((m) => m.name === "tls")).toEqual({ name: "tls", mountPath: "/etc/tls", readOnly: true });
|
||||
});
|
||||
});
|
||||
|
||||
+27
-14
@@ -17,6 +17,17 @@ import type { SelfPodInfo } from "./k8s-client.js";
|
||||
|
||||
export const LARGE_PROMPT_THRESHOLD_BYTES = 256 * 1024;
|
||||
|
||||
function assertSafePathComponent(field: string, value: string): void {
|
||||
// Allow alphanumeric, hyphens, and colons (UUIDs like "550e8400-e29b-41d4-a716-446655440000")
|
||||
if (!/^[a-zA-Z0-9-:]+$/.test(value)) {
|
||||
throw new Error(`Invalid ${field} for log path: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPodLogPath(companyId: string, agentId: string, runId: string): string {
|
||||
return `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
|
||||
}
|
||||
|
||||
export interface JobBuildInput {
|
||||
ctx: AdapterExecutionContext;
|
||||
selfPod: SelfPodInfo;
|
||||
@@ -45,6 +56,7 @@ export interface JobBuildResult {
|
||||
prompt: string;
|
||||
opencodeArgs: string[];
|
||||
promptMetrics: Record<string, number>;
|
||||
podLogPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,13 +185,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,
|
||||
@@ -227,6 +232,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
const warnLabel = (msg: string) => void onLog("stderr", msg).catch(() => {});
|
||||
const config = parseObject(rawConfig);
|
||||
|
||||
// Validate path components for log file safety
|
||||
const companyId = agent.companyId;
|
||||
const agentId = agent.id;
|
||||
assertSafePathComponent("companyId", companyId);
|
||||
assertSafePathComponent("agentId", agentId);
|
||||
assertSafePathComponent("runId", runId);
|
||||
|
||||
const namespace = asString(config.namespace, "") || selfPod.namespace;
|
||||
const image = asString(config.image, "") || selfPod.image;
|
||||
const model = asString(config.model, "").trim();
|
||||
@@ -312,9 +324,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
if (input.agentDbClaimName !== undefined) {
|
||||
const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB");
|
||||
if (dbEnvIdx >= 0) {
|
||||
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db" };
|
||||
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db/opencode.db" };
|
||||
} else {
|
||||
envVars.push({ name: "OPENCODE_DB", value: "/opencode-db" });
|
||||
envVars.push({ name: "OPENCODE_DB", value: "/opencode-db/opencode.db" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,12 +420,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
|
||||
// Build the main container command
|
||||
// 1. Optionally write opencode runtime config for permission bypass
|
||||
// 2. Pipe prompt into opencode
|
||||
// 2. Pipe prompt into opencode, tee stdout to the shared PVC log file
|
||||
const podLogPath = buildPodLogPath(companyId, agentId, runId);
|
||||
const opencodeArgsEscaped = opencodeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
const configSetup = runtimeConfigJson
|
||||
? `mkdir -p ~/.config/opencode && echo '${runtimeConfigJson.replace(/'/g, "'\\''")}' > ~/.config/opencode/opencode.json && `
|
||||
: "";
|
||||
const mainCommand = `${configSetup}cat /tmp/prompt/prompt.txt | opencode ${opencodeArgsEscaped}`;
|
||||
const mainCommand = `${configSetup}cat /tmp/prompt/prompt.txt | opencode ${opencodeArgsEscaped} | tee ${podLogPath}`;
|
||||
|
||||
const job: k8s.V1Job = {
|
||||
apiVersion: "batch/v1",
|
||||
@@ -448,14 +461,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
...(input.promptSecretName
|
||||
? {
|
||||
command: ["sh", "-c", "cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt"],
|
||||
command: ["sh", "-c", `cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
|
||||
volumeMounts: [
|
||||
{ name: "prompt", mountPath: "/tmp/prompt" },
|
||||
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
|
||||
],
|
||||
}
|
||||
: {
|
||||
command: ["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
|
||||
command: ["sh", "-c", `printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
|
||||
env: [{ name: "PROMPT_CONTENT", value: prompt }],
|
||||
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
|
||||
}),
|
||||
@@ -486,5 +499,5 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
},
|
||||
};
|
||||
|
||||
return { job, jobName, namespace, prompt, opencodeArgs, promptMetrics };
|
||||
return { job, jobName, namespace, prompt, opencodeArgs, promptMetrics, podLogPath };
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@ vi.mock("@kubernetes/client-node", () => {
|
||||
}
|
||||
}
|
||||
class KubeConfig {
|
||||
loadFromCluster() {}
|
||||
loadFromFile() {}
|
||||
loadFromCluster = mockLoadFromCluster;
|
||||
loadFromFile = mockLoadFromFile;
|
||||
makeApiClient() {
|
||||
return {
|
||||
readNamespacedPersistentVolumeClaim: mockReadNamespacedPVC,
|
||||
deleteNamespacedPersistentVolumeClaim: mockDeleteNamespacedPVC,
|
||||
createNamespacedPersistentVolumeClaim: mockCreateNamespacedPVC,
|
||||
readNamespacedPod: mockReadNamespacedPod,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -51,9 +52,17 @@ vi.mock("@kubernetes/client-node", () => {
|
||||
const mockReadNamespacedPVC = vi.fn();
|
||||
const mockDeleteNamespacedPVC = vi.fn();
|
||||
const mockCreateNamespacedPVC = vi.fn();
|
||||
const mockReadNamespacedPod = vi.fn();
|
||||
const mockLoadFromCluster = vi.fn();
|
||||
const mockLoadFromFile = vi.fn();
|
||||
const mockReadFileSync = vi.fn();
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
}));
|
||||
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { getPvc, createPvc, deletePvc, resetCache } from "./k8s-client.js";
|
||||
import { getPvc, createPvc, deletePvc, getSelfPodInfo, resetCache } from "./k8s-client.js";
|
||||
|
||||
const ApiException = (k8s as unknown as { ApiException: new <T>(code: number, message: string, body: T, headers?: Record<string, string>) => Error & { code: number; body: T } }).ApiException;
|
||||
|
||||
@@ -143,3 +152,145 @@ describe("createPvc — passes through to SDK", () => {
|
||||
expect(mockCreateNamespacedPVC).toHaveBeenCalledWith({ namespace: "paperclip", body: spec });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSelfPodInfo", () => {
|
||||
const HOSTNAME = "paperclip-test-pod";
|
||||
const NAMESPACE = "paperclip-test";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HOSTNAME = HOSTNAME;
|
||||
delete process.env.PAPERCLIP_NAMESPACE;
|
||||
delete process.env.POD_NAMESPACE;
|
||||
mockReadFileSync.mockReturnValue(NAMESPACE);
|
||||
});
|
||||
|
||||
function basePod(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: "paperclip",
|
||||
image: "paperclip:1.0",
|
||||
env: [
|
||||
{ name: "FOO", value: "bar" },
|
||||
{ name: "SECRET_REF", valueFrom: { secretKeyRef: { name: "s", key: "k" } } },
|
||||
],
|
||||
envFrom: [{ configMapRef: { name: "cm" } }],
|
||||
volumeMounts: [
|
||||
{ name: "data", mountPath: "/paperclip" },
|
||||
{ name: "tls-secret", mountPath: "/etc/tls" },
|
||||
],
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{ name: "data", persistentVolumeClaim: { claimName: "paperclip-pvc" } },
|
||||
{ name: "tls-secret", secret: { secretName: "tls", defaultMode: 0o400 } },
|
||||
],
|
||||
imagePullSecrets: [{ name: "registry-creds" }, { name: "" }, {}],
|
||||
dnsConfig: { nameservers: ["10.0.0.10"] },
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("introspects the pod and extracts image, env, PVC, secrets, dnsConfig", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue(basePod());
|
||||
const info = await getSelfPodInfo();
|
||||
expect(info.namespace).toBe(NAMESPACE);
|
||||
expect(info.image).toBe("paperclip:1.0");
|
||||
expect(info.pvcClaimName).toBe("paperclip-pvc");
|
||||
expect(info.inheritedEnv).toEqual({ FOO: "bar" });
|
||||
expect(info.inheritedEnvValueFrom).toHaveLength(1);
|
||||
expect(info.inheritedEnvValueFrom[0].name).toBe("SECRET_REF");
|
||||
expect(info.inheritedEnvFrom).toHaveLength(1);
|
||||
expect(info.secretVolumes).toEqual([
|
||||
{ volumeName: "tls-secret", secretName: "tls", mountPath: "/etc/tls", defaultMode: 0o400 },
|
||||
]);
|
||||
// imagePullSecrets with empty name are filtered out
|
||||
expect(info.imagePullSecrets).toEqual([{ name: "registry-creds" }]);
|
||||
expect(info.dnsConfig).toEqual({ nameservers: ["10.0.0.10"] });
|
||||
expect(mockReadNamespacedPod).toHaveBeenCalledWith({ name: HOSTNAME, namespace: NAMESPACE });
|
||||
});
|
||||
|
||||
it("caches the result — second call does not re-query the API", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue(basePod());
|
||||
await getSelfPodInfo();
|
||||
await getSelfPodInfo();
|
||||
expect(mockReadNamespacedPod).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers PAPERCLIP_NAMESPACE env over service-account file", async () => {
|
||||
process.env.PAPERCLIP_NAMESPACE = "from-env";
|
||||
mockReadNamespacedPod.mockResolvedValue(basePod());
|
||||
const info = await getSelfPodInfo();
|
||||
expect(info.namespace).toBe("from-env");
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to POD_NAMESPACE when PAPERCLIP_NAMESPACE not set", async () => {
|
||||
process.env.POD_NAMESPACE = "downward-api";
|
||||
mockReadNamespacedPod.mockResolvedValue(basePod());
|
||||
const info = await getSelfPodInfo();
|
||||
expect(info.namespace).toBe("downward-api");
|
||||
});
|
||||
|
||||
it("falls back to 'default' when service-account file read throws", async () => {
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
mockReadNamespacedPod.mockResolvedValue(basePod());
|
||||
const info = await getSelfPodInfo();
|
||||
expect(info.namespace).toBe("default");
|
||||
});
|
||||
|
||||
it("throws when HOSTNAME is not set", async () => {
|
||||
delete process.env.HOSTNAME;
|
||||
await expect(getSelfPodInfo()).rejects.toThrow("HOSTNAME env var not set");
|
||||
});
|
||||
|
||||
it("throws when pod has no spec", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue({ spec: null });
|
||||
await expect(getSelfPodInfo()).rejects.toThrow("has no spec");
|
||||
});
|
||||
|
||||
it("throws when main container has no image", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue({
|
||||
spec: { containers: [{ name: "paperclip", image: "" }] },
|
||||
});
|
||||
await expect(getSelfPodInfo()).rejects.toThrow("has no container image");
|
||||
});
|
||||
|
||||
it("falls back to first container when no container is named 'paperclip'", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue({
|
||||
spec: { containers: [{ name: "other", image: "other:1.0" }] },
|
||||
});
|
||||
const info = await getSelfPodInfo();
|
||||
expect(info.image).toBe("other:1.0");
|
||||
});
|
||||
|
||||
it("returns null pvcClaimName when no /paperclip mount exists", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue({
|
||||
spec: { containers: [{ name: "paperclip", image: "p:1", volumeMounts: [] }] },
|
||||
});
|
||||
const info = await getSelfPodInfo();
|
||||
expect(info.pvcClaimName).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null pvcClaimName when /paperclip mount is not backed by a PVC", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue({
|
||||
spec: {
|
||||
containers: [{ name: "paperclip", image: "p:1", volumeMounts: [{ name: "data", mountPath: "/paperclip" }] }],
|
||||
volumes: [{ name: "data", emptyDir: {} }],
|
||||
},
|
||||
});
|
||||
const info = await getSelfPodInfo();
|
||||
expect(info.pvcClaimName).toBeNull();
|
||||
});
|
||||
|
||||
it("uses kubeconfig file path when provided (not in-cluster)", async () => {
|
||||
mockReadNamespacedPod.mockResolvedValue(basePod());
|
||||
await getSelfPodInfo("/tmp/kubeconfig.yaml");
|
||||
expect(mockLoadFromFile).toHaveBeenCalledWith("/tmp/kubeconfig.yaml");
|
||||
expect(mockLoadFromCluster).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { eventDedupKey, LogLineDedupFilter } from "./log-dedup.js";
|
||||
|
||||
describe("eventDedupKey", () => {
|
||||
it("returns null for object with no type field", () => {
|
||||
expect(eventDedupKey({ sessionID: "ses_1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for object with empty type", () => {
|
||||
expect(eventDedupKey({ type: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown event type", () => {
|
||||
expect(eventDedupKey({ type: "unknown_type", sessionID: "ses_1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns type:sessionId:partId when all three present", () => {
|
||||
const event = { type: "text", sessionID: "ses_1", part: { id: "part_abc" } };
|
||||
expect(eventDedupKey(event)).toBe("text:ses_1:part_abc");
|
||||
});
|
||||
|
||||
it("returns type:sessionId when partId absent", () => {
|
||||
const event = { type: "text", sessionID: "ses_1", part: {} };
|
||||
expect(eventDedupKey(event)).toBe("text:ses_1");
|
||||
});
|
||||
|
||||
it("returns null when both sessionId and partId absent", () => {
|
||||
const event = { type: "text", part: {} };
|
||||
expect(eventDedupKey(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when part has no id and sessionID missing", () => {
|
||||
const event = { type: "tool_use" };
|
||||
expect(eventDedupKey(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles tool_use type", () => {
|
||||
const event = { type: "tool_use", sessionID: "ses_1", part: { id: "tool_1" } };
|
||||
expect(eventDedupKey(event)).toBe("tool_use:ses_1:tool_1");
|
||||
});
|
||||
|
||||
it("handles step_finish type", () => {
|
||||
const event = { type: "step_finish", sessionID: "ses_2", part: { id: "step_1" } };
|
||||
expect(eventDedupKey(event)).toBe("step_finish:ses_2:step_1");
|
||||
});
|
||||
|
||||
it("handles step_start type", () => {
|
||||
const event = { type: "step_start", sessionID: "ses_3" };
|
||||
expect(eventDedupKey(event)).toBe("step_start:ses_3");
|
||||
});
|
||||
|
||||
it("handles thinking type", () => {
|
||||
const event = { type: "thinking", sessionID: "ses_4", part: { id: "think_1" } };
|
||||
expect(eventDedupKey(event)).toBe("thinking:ses_4:think_1");
|
||||
});
|
||||
|
||||
it("handles assistant type", () => {
|
||||
const event = { type: "assistant", sessionID: "ses_5" };
|
||||
expect(eventDedupKey(event)).toBe("assistant:ses_5");
|
||||
});
|
||||
|
||||
it("handles user type", () => {
|
||||
const event = { type: "user", sessionID: "ses_6" };
|
||||
expect(eventDedupKey(event)).toBe("user:ses_6");
|
||||
});
|
||||
|
||||
it("returns null for error type (not in dedup switch)", () => {
|
||||
const event = { type: "error", sessionID: "ses_7" };
|
||||
expect(eventDedupKey(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses part.id string even when nested in non-object context", () => {
|
||||
const event = { type: "text", sessionID: "ses_1", part: { id: "part_x" } };
|
||||
expect(eventDedupKey(event)).toBe("text:ses_1:part_x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogLineDedupFilter", () => {
|
||||
let dedup: LogLineDedupFilter;
|
||||
|
||||
beforeEach(() => {
|
||||
dedup = new LogLineDedupFilter();
|
||||
});
|
||||
|
||||
describe("filter()", () => {
|
||||
it("returns empty string for empty chunk", () => {
|
||||
expect(dedup.filter("")).toBe("");
|
||||
});
|
||||
|
||||
it("passes through non-JSON lines", () => {
|
||||
const chunk = "[paperclip] Pod running: pod-abc\n";
|
||||
expect(dedup.filter(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("passes a JSON event on first occurrence", () => {
|
||||
const event = { type: "text", sessionID: "ses_1" };
|
||||
const line = JSON.stringify(event) + "\n";
|
||||
expect(dedup.filter(line)).toBe(line);
|
||||
});
|
||||
|
||||
it("drops a duplicate JSON event on second occurrence", () => {
|
||||
const event = { type: "text", sessionID: "ses_1" };
|
||||
const line = JSON.stringify(event) + "\n";
|
||||
dedup.filter(line); // first — passes
|
||||
expect(dedup.filter(line)).toBe(""); // second — dropped
|
||||
});
|
||||
|
||||
it("passes a JSON event without a dedup key on every occurrence", () => {
|
||||
// Events with unknown type have no structural key — fall back to raw content hash
|
||||
const event = { type: "error", sessionID: "ses_1", error: "unique1" };
|
||||
const line = JSON.stringify(event) + "\n";
|
||||
dedup.filter(line);
|
||||
// Same raw content would be deduped (raw: key), but different error content passes
|
||||
const event2 = { type: "error", sessionID: "ses_1", error: "unique2" };
|
||||
const line2 = JSON.stringify(event2) + "\n";
|
||||
expect(dedup.filter(line2)).toBe(line2);
|
||||
});
|
||||
|
||||
it("deduplicates same raw non-dedup-keyed line twice", () => {
|
||||
const event = { type: "error", message: "same" };
|
||||
const line = JSON.stringify(event) + "\n";
|
||||
dedup.filter(line);
|
||||
expect(dedup.filter(line)).toBe(""); // same raw content deduplicated via raw: key
|
||||
});
|
||||
|
||||
it("buffers incomplete trailing content without emitting", () => {
|
||||
// No trailing newline → chunk is buffered
|
||||
const partial = '{"type":"text","sessionID":"ses_1"}';
|
||||
expect(dedup.filter(partial)).toBe("");
|
||||
});
|
||||
|
||||
it("emits buffered content when completed by next chunk", () => {
|
||||
const partial = '{"type":"text","sessionID":"ses_1"}';
|
||||
dedup.filter(partial); // buffered
|
||||
const completion = "\n"; // completes the line
|
||||
const result = dedup.filter(completion);
|
||||
expect(result).toBe('{"type":"text","sessionID":"ses_1"}\n');
|
||||
});
|
||||
|
||||
it("handles multiple lines in a single chunk", () => {
|
||||
const line1 = '{"type":"text","sessionID":"ses_1"}\n';
|
||||
const line2 = '[paperclip] some status\n';
|
||||
const chunk = line1 + line2;
|
||||
const result = dedup.filter(chunk);
|
||||
expect(result).toBe(chunk);
|
||||
});
|
||||
|
||||
it("deduplicates within a multi-line chunk", () => {
|
||||
const line = '{"type":"text","sessionID":"ses_1"}\n';
|
||||
const chunk = line + line; // same line twice in one chunk
|
||||
const result = dedup.filter(chunk);
|
||||
expect(result).toBe(line); // only once
|
||||
});
|
||||
|
||||
it("passes blank lines through unchanged", () => {
|
||||
expect(dedup.filter("\n")).toBe("\n");
|
||||
});
|
||||
|
||||
it("passes whitespace-only lines through unchanged", () => {
|
||||
expect(dedup.filter(" \n")).toBe(" \n");
|
||||
});
|
||||
|
||||
it("deduplicates events keyed by type:sessionId across chunks", () => {
|
||||
const event = { type: "step_start", sessionID: "ses_1" };
|
||||
const line = JSON.stringify(event) + "\n";
|
||||
dedup.filter(line);
|
||||
// second occurrence in a later chunk
|
||||
expect(dedup.filter(line)).toBe("");
|
||||
});
|
||||
|
||||
it("allows distinct events with different sessionIds to pass", () => {
|
||||
const line1 = JSON.stringify({ type: "text", sessionID: "ses_1" }) + "\n";
|
||||
const line2 = JSON.stringify({ type: "text", sessionID: "ses_2" }) + "\n";
|
||||
dedup.filter(line1);
|
||||
expect(dedup.filter(line2)).toBe(line2);
|
||||
});
|
||||
|
||||
it("allows distinct events with different partIds to pass", () => {
|
||||
const line1 = JSON.stringify({ type: "tool_use", sessionID: "ses_1", part: { id: "t1" } }) + "\n";
|
||||
const line2 = JSON.stringify({ type: "tool_use", sessionID: "ses_1", part: { id: "t2" } }) + "\n";
|
||||
dedup.filter(line1);
|
||||
expect(dedup.filter(line2)).toBe(line2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flush()", () => {
|
||||
it("returns empty string when buffer is empty", () => {
|
||||
expect(dedup.flush()).toBe("");
|
||||
});
|
||||
|
||||
it("returns and clears buffered incomplete line", () => {
|
||||
const partial = '{"type":"text","sessionID":"ses_1"}';
|
||||
dedup.filter(partial);
|
||||
expect(dedup.flush()).toBe(partial);
|
||||
});
|
||||
|
||||
it("returns empty string on subsequent flush after buffer cleared", () => {
|
||||
const partial = '{"type":"text","sessionID":"ses_1"}';
|
||||
dedup.filter(partial);
|
||||
dedup.flush();
|
||||
expect(dedup.flush()).toBe(""); // buffer already cleared
|
||||
});
|
||||
|
||||
it("does not emit duplicate content on flush", () => {
|
||||
const line = '{"type":"text","sessionID":"ses_1"}\n';
|
||||
dedup.filter(line); // first emission
|
||||
const partial = '{"type":"text","sessionID":"ses_1"}'; // no trailing newline
|
||||
dedup.filter(partial);
|
||||
expect(dedup.flush()).toBe(""); // same key already seen — suppressed
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Line-level dedup filter for the K8s log stream.
|
||||
*
|
||||
* The K8s log follow stream can reconnect with an overlapping `sinceSeconds`
|
||||
* window (integer-second granularity + a safety buffer), which replays a few
|
||||
* seconds of recent output on every reconnect. Without dedup those replayed
|
||||
* lines appear as duplicate events in the streaming UI.
|
||||
*
|
||||
* The filter operates at the chunk → line level: chunks are split on `\n`,
|
||||
* incomplete trailing content is buffered until the next chunk, and each
|
||||
* complete line is emitted at most once. JSON-shaped OpenCode JSONL events
|
||||
* are keyed by (type + sessionID + part.id); non-JSON lines pass through
|
||||
* unchanged so genuinely-repeated status lines are not swallowed.
|
||||
*/
|
||||
|
||||
type Parsed = Record<string, unknown>;
|
||||
|
||||
function asStr(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asRec(value: unknown): Parsed | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable dedup key for an OpenCode JSONL event. Returns `null` when
|
||||
* the event is not a recognized OpenCode event — those lines fall back to
|
||||
* raw-content hashing so non-JSON output (paperclip status lines, shell
|
||||
* output) is never deduped by identity.
|
||||
*/
|
||||
export function eventDedupKey(event: Parsed): string | null {
|
||||
const type = asStr(event.type);
|
||||
if (!type) return null;
|
||||
|
||||
const sessionId = asStr(event.sessionID);
|
||||
const part = asRec(event.part);
|
||||
const partId = part ? asStr(part.id) : "";
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
case "tool_use":
|
||||
case "step_finish":
|
||||
case "step_start":
|
||||
case "thinking":
|
||||
case "assistant":
|
||||
case "user":
|
||||
if (partId) return `${type}:${sessionId}:${partId}`;
|
||||
if (sessionId) return `${type}:${sessionId}`;
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful line-level dedup filter. Emits `filter(chunk)` output through
|
||||
* the caller — preserves original chunk formatting (including trailing
|
||||
* newlines) for lines that pass the dedup check.
|
||||
*/
|
||||
export class LogLineDedupFilter {
|
||||
private buffer = "";
|
||||
private readonly seenKeys = new Set<string>();
|
||||
|
||||
/**
|
||||
* Process a chunk and return the subset that should be forwarded.
|
||||
* Incomplete trailing content (no terminating newline) is buffered and
|
||||
* emitted on the next chunk that completes the line (or on flush()).
|
||||
*/
|
||||
filter(chunk: string): string {
|
||||
if (!chunk) return "";
|
||||
const combined = this.buffer + chunk;
|
||||
const endsWithNewline = combined.endsWith("\n");
|
||||
const parts = combined.split("\n");
|
||||
|
||||
if (endsWithNewline) {
|
||||
parts.pop();
|
||||
this.buffer = "";
|
||||
} else {
|
||||
this.buffer = parts.pop() ?? "";
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const line of parts) {
|
||||
if (this.shouldEmit(line)) out.push(line);
|
||||
}
|
||||
if (out.length === 0) return "";
|
||||
return out.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any incomplete trailing content. Called when the stream ends
|
||||
* without a terminating newline so the final partial line isn't lost.
|
||||
*/
|
||||
flush(): string {
|
||||
const pending = this.buffer;
|
||||
this.buffer = "";
|
||||
if (!pending) return "";
|
||||
return this.shouldEmit(pending) ? pending : "";
|
||||
}
|
||||
|
||||
private shouldEmit(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return true;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
const event = asRec(parsed);
|
||||
if (!event) return true;
|
||||
|
||||
const structuralKey = eventDedupKey(event);
|
||||
const key = structuralKey ?? `raw:${trimmed}`;
|
||||
|
||||
if (this.seenKeys.has(key)) return false;
|
||||
this.seenKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -182,3 +182,45 @@ describe("isOpenCodeUnknownSessionError", () => {
|
||||
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOpenCodeJsonl — errorText fallback paths", () => {
|
||||
it("uses nested data.message when top-level message is missing", () => {
|
||||
const stdout = JSON.stringify({
|
||||
type: "error",
|
||||
error: { data: { message: "nested issue" } },
|
||||
sessionID: "ses_x",
|
||||
});
|
||||
const result = parseOpenCodeJsonl(stdout);
|
||||
expect(result.errorMessage).toContain("nested issue");
|
||||
});
|
||||
|
||||
it("uses error.name when no message or nested message", () => {
|
||||
const stdout = JSON.stringify({
|
||||
type: "error",
|
||||
error: { name: "ProviderAuthError" },
|
||||
sessionID: "ses_x",
|
||||
});
|
||||
const result = parseOpenCodeJsonl(stdout);
|
||||
expect(result.errorMessage).toContain("ProviderAuthError");
|
||||
});
|
||||
|
||||
it("uses error.code when no message/name", () => {
|
||||
const stdout = JSON.stringify({
|
||||
type: "error",
|
||||
error: { code: "E_TIMEOUT" },
|
||||
sessionID: "ses_x",
|
||||
});
|
||||
const result = parseOpenCodeJsonl(stdout);
|
||||
expect(result.errorMessage).toContain("E_TIMEOUT");
|
||||
});
|
||||
|
||||
it("falls back to JSON.stringify of the error object when nothing matches", () => {
|
||||
const stdout = JSON.stringify({
|
||||
type: "error",
|
||||
error: { unexpectedShape: { foo: "bar" } },
|
||||
sessionID: "ses_x",
|
||||
});
|
||||
const result = parseOpenCodeJsonl(stdout);
|
||||
expect(result.errorMessage).toContain("unexpectedShape");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils";
|
||||
import { testEnvironment } from "./test.js";
|
||||
import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js";
|
||||
|
||||
vi.mock("./k8s-client.js", () => ({
|
||||
getSelfPodInfo: vi.fn(),
|
||||
getCoreApi: vi.fn(),
|
||||
getAuthzApi: vi.fn(),
|
||||
}));
|
||||
|
||||
const SELF_POD = {
|
||||
namespace: "ns-self",
|
||||
image: "img:1",
|
||||
imagePullSecrets: [],
|
||||
pvcClaimName: "paperclip-pvc",
|
||||
inheritedEnv: {},
|
||||
inheritedEnvValueFrom: [],
|
||||
inheritedEnvFrom: [],
|
||||
dnsConfig: undefined,
|
||||
secretVolumes: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getSelfPodInfo>>;
|
||||
|
||||
function makeCtx(config: Record<string, unknown> = {}): AdapterEnvironmentTestContext {
|
||||
return { adapterType: "opencode_k8s", config } as unknown as AdapterEnvironmentTestContext;
|
||||
}
|
||||
|
||||
function makeAuthz(allowedFor: (resource: string, verb: string) => boolean) {
|
||||
return {
|
||||
createSelfSubjectAccessReview: vi.fn().mockImplementation(async ({ body }: { body: { spec: { resourceAttributes: { resource: string; verb: string } } } }) => {
|
||||
const { resource, verb } = body.spec.resourceAttributes;
|
||||
return { status: { allowed: allowedFor(resource, verb) } };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCore(overrides: Partial<{ readNamespace: ReturnType<typeof vi.fn>; readNamespacedSecret: ReturnType<typeof vi.fn>; readNamespacedPersistentVolumeClaim: ReturnType<typeof vi.fn> }> = {}) {
|
||||
return {
|
||||
readNamespace: overrides.readNamespace ?? vi.fn().mockResolvedValue({ metadata: { name: "ns" } }),
|
||||
readNamespacedSecret: overrides.readNamespacedSecret ?? vi.fn().mockResolvedValue({ metadata: { name: "paperclip-secrets" } }),
|
||||
readNamespacedPersistentVolumeClaim: overrides.readNamespacedPersistentVolumeClaim ?? vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteMany"] } }),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSelfPodInfo).mockResolvedValue(SELF_POD);
|
||||
vi.mocked(getCoreApi).mockReturnValue(makeCore() as unknown as ReturnType<typeof getCoreApi>);
|
||||
vi.mocked(getAuthzApi).mockReturnValue(makeAuthz(() => true) as unknown as ReturnType<typeof getAuthzApi>);
|
||||
});
|
||||
|
||||
describe("testEnvironment — happy path", () => {
|
||||
it("returns pass when API, namespace, RBAC, secret, and RWX PVC all check out", async () => {
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
expect(result.adapterType).toBe("opencode_k8s");
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.checks.find((c) => c.code === "k8s_api_reachable")).toBeDefined();
|
||||
expect(result.checks.find((c) => c.code === "k8s_pvc_rwx")).toBeDefined();
|
||||
expect(result.checks.find((c) => c.code === "k8s_secret_exists")).toBeDefined();
|
||||
expect(typeof result.testedAt).toBe("string");
|
||||
});
|
||||
|
||||
it("skips namespace lookup and emits k8s_namespace_exists when target == self pod namespace", async () => {
|
||||
const coreApi = makeCore();
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
expect(coreApi.readNamespace).not.toHaveBeenCalled();
|
||||
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")?.message).toContain("pod namespace");
|
||||
});
|
||||
|
||||
it("calls readNamespace when target namespace differs from self pod namespace", async () => {
|
||||
const coreApi = makeCore();
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
|
||||
|
||||
expect(coreApi.readNamespace).toHaveBeenCalledWith({ name: "ns-other" });
|
||||
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("testEnvironment — early-return paths", () => {
|
||||
it("returns fail and short-circuits when K8s API is unreachable", async () => {
|
||||
vi.mocked(getSelfPodInfo).mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.find((c) => c.code === "k8s_api_unreachable")).toBeDefined();
|
||||
// RBAC, secret, and PVC checks should be skipped when API is unreachable
|
||||
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testEnvironment — namespace warning", () => {
|
||||
it("emits warn (but proceeds) when readNamespace fails for a different namespace", async () => {
|
||||
const coreApi = makeCore({
|
||||
readNamespace: vi.fn().mockRejectedValue(new Error("forbidden")),
|
||||
});
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
|
||||
|
||||
expect(result.checks.find((c) => c.code === "k8s_namespace_check_failed")).toBeDefined();
|
||||
// Should still proceed with downstream checks
|
||||
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testEnvironment — RBAC", () => {
|
||||
it("emits error checks for denied verbs and degrades status to fail", async () => {
|
||||
vi.mocked(getAuthzApi).mockReturnValue(
|
||||
makeAuthz((resource, verb) => !(resource === "jobs" && verb === "create")) as unknown as ReturnType<typeof getAuthzApi>,
|
||||
);
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
const denied = result.checks.find((c) => c.code === "k8s_rbac_job_create");
|
||||
expect(denied?.level).toBe("error");
|
||||
expect(result.status).toBe("fail");
|
||||
});
|
||||
|
||||
it("emits warn when SelfSubjectAccessReview itself throws", async () => {
|
||||
vi.mocked(getAuthzApi).mockReturnValue({
|
||||
createSelfSubjectAccessReview: vi.fn().mockRejectedValue(new Error("SSAR not available")),
|
||||
} as unknown as ReturnType<typeof getAuthzApi>);
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
const rbacWarns = result.checks.filter((c) => c.code.startsWith("k8s_rbac_") && c.level === "warn");
|
||||
expect(rbacWarns.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testEnvironment — secrets", () => {
|
||||
it("emits warn when the secret is not found", async () => {
|
||||
const coreApi = makeCore({
|
||||
readNamespacedSecret: vi.fn().mockRejectedValue(new Error("not found")),
|
||||
});
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
expect(result.checks.find((c) => c.code === "k8s_secret_missing")).toBeDefined();
|
||||
expect(result.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("uses configured secretRef when provided", async () => {
|
||||
const coreApi = makeCore();
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
await testEnvironment(makeCtx({ secretRef: "custom-secret" }));
|
||||
|
||||
expect(coreApi.readNamespacedSecret).toHaveBeenCalledWith({ name: "custom-secret", namespace: "ns-self" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("testEnvironment — PVC", () => {
|
||||
it("emits warn when no PVC is mounted on /paperclip", async () => {
|
||||
vi.mocked(getSelfPodInfo).mockResolvedValue({ ...SELF_POD, pvcClaimName: null });
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
expect(result.checks.find((c) => c.code === "k8s_pvc_not_detected")).toBeDefined();
|
||||
expect(result.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("emits warn when PVC access mode is not ReadWriteMany", async () => {
|
||||
const coreApi = makeCore({
|
||||
readNamespacedPersistentVolumeClaim: vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteOnce"] } }),
|
||||
});
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
const pvcCheck = result.checks.find((c) => c.code === "k8s_pvc_not_rwx");
|
||||
expect(pvcCheck).toBeDefined();
|
||||
expect(pvcCheck?.message).toContain("ReadWriteOnce");
|
||||
expect(result.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("emits warn when reading the PVC fails", async () => {
|
||||
const coreApi = makeCore({
|
||||
readNamespacedPersistentVolumeClaim: vi.fn().mockRejectedValue(new Error("api error")),
|
||||
});
|
||||
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
|
||||
|
||||
const result = await testEnvironment(makeCtx());
|
||||
|
||||
expect(result.checks.find((c) => c.code === "k8s_pvc_check_failed")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -323,3 +323,41 @@ describe("parseStdoutLine", () => {
|
||||
expect(parseStdoutLine(line, TS)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStdoutLine — error edge cases", () => {
|
||||
const TS_ERR = "2026-04-25T22:00:00.000Z";
|
||||
|
||||
it("returns stdout entry when JSON parses to a primitive (not an object)", () => {
|
||||
const result = parseStdoutLine("42", TS_ERR);
|
||||
// safeJsonParse returns null for non-object → falls through to stdout entry
|
||||
expect(result).toEqual([{ kind: "stdout", ts: TS_ERR, text: "42" }]);
|
||||
});
|
||||
|
||||
it("returns empty for text event with empty text", () => {
|
||||
const line = JSON.stringify({ type: "text", part: { text: "" } });
|
||||
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for assistant event with no content blocks", () => {
|
||||
const line = JSON.stringify({ type: "assistant", part: { message: { content: null } } });
|
||||
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty for error event whose error field is an empty string", () => {
|
||||
const line = JSON.stringify({ type: "error", error: "" });
|
||||
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses error.code fallback when error has no message/data/name", () => {
|
||||
const line = JSON.stringify({ type: "error", error: { code: "E_FOO" } });
|
||||
const result = parseStdoutLine(line, TS_ERR);
|
||||
expect(result).toEqual([{ kind: "stderr", ts: TS_ERR, text: "E_FOO" }]);
|
||||
});
|
||||
|
||||
it("falls back to JSON.stringify of error object when no known field", () => {
|
||||
const line = JSON.stringify({ type: "error", error: { somethingElse: "x" } });
|
||||
const result = parseStdoutLine(line, TS_ERR);
|
||||
expect(result[0].kind).toBe("stderr");
|
||||
expect((result[0] as { text: string }).text).toContain("somethingElse");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/*.test.ts", "src/ui-parser.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist/ui-parser",
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/ui-parser.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user