Compare commits

..

2 Commits

Author SHA1 Message Date
Test User 77e4a13644 feat: declare adapter plugin capabilities on ServerAdapterModule
Adds supportsInstructionsBundle, instructionsPathKey, and
requiresMaterializedRuntimeSkills flags so the UI renders the
bundle editor for claude_k8s agents. Bumps adapter-utils peer
dep to the canary that includes the capability type fields.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 21:42:20 +00:00
Barcode Betty b24f849198 feat: add RTK hook support for token-optimized CLI output
When enableRtk is set in adapter config, the adapter:
- Adds an init container to download the RTK binary via configurable image
- Mounts RTK binary in the main container via shared emptyDir volume
- Runs `rtk install claude-code` with an isolated temp HOME, then moves
  the generated hooks to the workspace's .claude/settings.local.json
  (project-level) to avoid polluting the shared PVC's global settings
- Disables RTK telemetry (RTK_NO_TELEMETRY=1) for automated environments
- Supports rtkVersion for pinning and rtkImage for custom installer images

Key improvement over the reverted d074cb2: hooks are written to the
project-level settings.local.json instead of the shared ~/.claude/settings.json,
preventing RTK hooks from leaking to non-RTK agents on the same PVC.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 12:10:51 +00:00
15 changed files with 203 additions and 103 deletions
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AA2SlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAkQ3F"}
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AA6PlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA2P3F"}
+7 -47
View File
@@ -5,7 +5,6 @@ import { buildJobManifest } from "./job-manifest.js";
import { Writable } from "node:stream";
const POLL_INTERVAL_MS = 2000;
const KEEPALIVE_INTERVAL_MS = 15_000;
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
/**
* Wait for the Job's pod to reach a terminal or running state.
* Returns the pod name once logs can be streamed, or throws on failure.
@@ -100,10 +99,10 @@ async function waitForPod(namespace, jobName, timeoutMs, onLog, kubeconfigPath)
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).
* Stream pod logs and accumulate stdout for result parsing.
* Returns accumulated stdout when the stream ends.
*/
async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds) {
async function streamPodLogs(namespace, podName, onLog, kubeconfigPath) {
const logApi = getLogApi(kubeconfigPath);
const chunks = [];
const writable = new Writable({
@@ -117,47 +116,14 @@ async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinc
await logApi.log(namespace, podName, "claude", writable, {
follow: true,
pretty: false,
...(sinceSeconds ? { sinceSeconds } : {}),
});
}
catch {
// follow may fail if the container already exited or the API
// connection dropped — not fatal, caller decides whether to retry.
// follow may fail if the container already exited — not fatal,
// we'll try a one-shot read below
}
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.
*/
async function streamPodLogs(namespace, podName, onLog, kubeconfigPath, stopSignal) {
const allChunks = [];
let attempt = 0;
const streamStartedAt = Math.floor(Date.now() / 1000);
while (!stopSignal?.stopped) {
// On reconnect, ask for logs since the stream originally started to
// avoid missing output during the reconnect gap. Duplicates are
// tolerable — the UI deduplicates log chunks.
const sinceSeconds = attempt > 0
? Math.max(1, Math.floor(Date.now() / 1000) - streamStartedAt + 5)
: undefined;
if (attempt > 0) {
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt})...\n`);
}
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds);
if (result)
allChunks.push(result);
attempt++;
// If the job is done or the container exited, no need to reconnect.
if (stopSignal?.stopped)
break;
// Brief pause before reconnecting to avoid tight loops.
await new Promise((resolve) => setTimeout(resolve, LOG_STREAM_RECONNECT_DELAY_MS));
}
return allChunks.join("");
}
/**
* One-shot read of pod logs (no follow). Used as fallback when the
* follow stream missed output because the container exited quickly.
@@ -342,15 +308,9 @@ export async function execute(ctx) {
lastLogAt = Date.now();
return onLog(stream, chunk);
};
// Shared signal: when job completion resolves, tell the log
// streamer to stop reconnecting.
const logStopSignal = { stopped: false };
const [logResult, completionResult] = await Promise.allSettled([
streamPodLogs(namespace, podName, wrappedOnLog, kubeconfigPath, logStopSignal),
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath).then((r) => {
logStopSignal.stopped = true;
return r;
}),
streamPodLogs(namespace, podName, wrappedOnLog, kubeconfigPath),
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath),
]);
if (logResult.status === "fulfilled") {
stdout = logResult.value;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAA4B,MAAM,4BAA4B,CAAC;AAEhG,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAgB5C,wBAAgB,mBAAmB,IAAI,mBAAmB,CAkBzD;AAED,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAG5C,wBAAgB,mBAAmB,IAAI,mBAAmB,CAWzD;AAED,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC"}
-19
View File
@@ -3,33 +3,14 @@ import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
import { getConfigSchema } from "./config-schema.js";
import { listK8sSkills, syncK8sSkills } from "./skills.js";
import { listK8sModels } from "./models.js";
const sessionManagement = {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: {
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
},
};
export function createServerAdapter() {
return {
type,
execute,
testEnvironment,
sessionCodec,
sessionManagement,
models,
listModels: listK8sModels,
listSkills: listK8sSkills,
syncSkills: syncK8sSkills,
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
agentConfigurationDoc,
getConfigSchema,
};
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,iBAAiB,GAA6B;IAClD,qBAAqB,EAAE,IAAI;IAC3B,uBAAuB,EAAE,WAAW;IACpC,wBAAwB,EAAE;QACxB,OAAO,EAAE,IAAI;QACb,cAAc,EAAE,CAAC;QACjB,iBAAiB,EAAE,CAAC;QACpB,kBAAkB,EAAE,CAAC;KACtB;CACF,CAAC;AAEF,MAAM,UAAU,mBAAmB;IACjC,OAAO;QACL,IAAI;QACJ,OAAO;QACP,eAAe;QACf,YAAY;QACZ,iBAAiB;QACjB,MAAM;QACN,UAAU,EAAE,aAAa;QACzB,UAAU,EAAE,aAAa;QACzB,UAAU,EAAE,aAAa;QACzB,qBAAqB,EAAE,IAAI;QAC3B,0BAA0B,EAAE,IAAI;QAChC,mBAAmB,EAAE,sBAAsB;QAC3C,iCAAiC,EAAE,KAAK;QACxC,qBAAqB;QACrB,eAAe;KAChB,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAErD,MAAM,UAAU,mBAAmB;IACjC,OAAO;QACL,IAAI;QACJ,OAAO;QACP,eAAe;QACf,YAAY;QACZ,MAAM;QACN,qBAAqB,EAAE,IAAI;QAC3B,qBAAqB;QACrB,eAAe;KACO,CAAC;AAC3B,CAAC;AAED,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC"}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAoBtE,eAAO,MAAM,YAAY,EAAE,mBAiC1B,CAAC"}
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAMtE,eAAO,MAAM,YAAY,EAAE,mBA4C1B,CAAC"}
+15 -15
View File
@@ -1,46 +1,46 @@
function readNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function extractSessionFields(record) {
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
const cwd = readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
const promptBundleKey = readNonEmptyString(record.promptBundleKey) ?? readNonEmptyString(record.prompt_bundle_key);
return { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey };
}
export const sessionCodec = {
deserialize(raw) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey } = extractSessionFields(raw);
const record = raw;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId)
return null;
const cwd = readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
};
},
serialize(params) {
if (!params)
return null;
const { sessionId, cwd, workspaceId, repoUrl, repoRef, promptBundleKey } = extractSessionFields(params);
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId)
return null;
const cwd = readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
};
},
getDisplayId(params) {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAEA,SAAS,kBAAkB,CAAC,KAAc;IACxC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACpF,CAAC;AAED,SAAS,oBAAoB,CAAC,MAA+B;IAC3D,MAAM,SAAS,GAAG,kBAAkB,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAChG,MAAM,GAAG,GACP,kBAAkB,CAAC,MAAM,CAAC,GAAG,CAAC;QAC9B,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC;QAClC,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACpC,MAAM,WAAW,GAAG,kBAAkB,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;IACtG,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC1F,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC1F,MAAM,eAAe,GACnB,kBAAkB,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC;IAC7F,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,CAAC;AAC5E,CAAC;AAED,MAAM,CAAC,MAAM,YAAY,GAAwB;IAC/C,WAAW,CAAC,GAAY;QACtB,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC;QAC/E,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,GACtE,oBAAoB,CAAC,GAA8B,CAAC,CAAC;QACvD,IAAI,CAAC,SAAS;YAAE,OAAO,IAAI,CAAC;QAC5B,OAAO;YACL,SAAS;YACT,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvB,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/B,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/B,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAChD,CAAC;IACJ,CAAC;IACD,SAAS,CAAC,MAAsC;QAC9C,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QACzB,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,GACtE,oBAAoB,CAAC,MAAM,CAAC,CAAC;QAC/B,IAAI,CAAC,SAAS;YAAE,OAAO,IAAI,CAAC;QAC5B,OAAO;YACL,SAAS;YACT,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvB,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/B,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/B,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAChD,CAAC;IACJ,CAAC;IACD,YAAY,CAAC,MAAsC;QACjD,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QACzB,OAAO,kBAAkB,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IACvF,CAAC;CACF,CAAC"}
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"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"}
+2 -2
View File
@@ -13,14 +13,14 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@paperclipai/adapter-utils": "^2026.415.0-canary.7",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.4",
"typescript": "^5.7.3",
"vitest": "^4.1.4"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
"@paperclipai/adapter-utils": ">=0.3.0"
}
},
"node_modules/@babel/helper-string-parser": {
+5
View File
@@ -38,6 +38,11 @@ Kubernetes fields:
- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300
- retainJobs (boolean, optional): skip cleanup on completion for debugging
RTK fields (token optimization):
- enableRtk (boolean, optional): enable RTK to reduce token usage by filtering CLI output. Configures Claude Code PreToolUse/PostToolUse hooks automatically via project-level settings. Adds an init container to download the RTK binary.
- rtkVersion (string, optional): RTK version to install; defaults to "latest"
- rtkImage (string, optional): container image for the RTK download init container; defaults to "curlimages/curl:8.12.1"
Operational fields:
- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout
- graceSec (number, optional): additional grace before adapter gives up after Job deadline
+20
View File
@@ -108,6 +108,26 @@ export function getConfigSchema(): AdapterConfigSchema {
label: "Memory Limit",
hint: "Memory limit for Job pods (e.g. 128Mi, 512Mi, 1Gi).",
},
// RTK (token optimization)
{
type: "toggle",
key: "enableRtk",
label: "Enable RTK",
hint: "Install and enable RTK (rtk-ai/rtk) to reduce token usage by filtering CLI output through PreToolUse/PostToolUse hooks. Adds an init container to download the RTK binary.",
default: false,
},
{
type: "text",
key: "rtkVersion",
label: "RTK Version",
hint: "RTK version to install (e.g. '0.5.0'). Defaults to 'latest'.",
},
{
type: "text",
key: "rtkImage",
label: "RTK Installer Image",
hint: "Container image for the RTK download init container. Defaults to curlimages/curl:8.12.1.",
},
// Scheduling
{
type: "textarea",
-13
View File
@@ -387,19 +387,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
try {
podName = await waitForPod(namespace, jobName, scheduleTimeoutMs, onLog, kubeconfigPath);
await onLog("stdout", `[paperclip] Pod running: ${podName}\n`);
// Notify the server that execution has started. Without this call,
// the server has no processStartedAt timestamp for the run, so the
// stale-run reaper (reapOrphanedRuns) cannot distinguish a live K8s
// job from an orphaned run and may mark it as failed — causing the
// UI to show no active runs and triggering duplicate run attempts.
if (ctx.onSpawn) {
await ctx.onSpawn({
pid: -1, // no local process; sentinel for K8s Job
processGroupId: null,
startedAt: new Date().toISOString(),
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Pod scheduling failed: ${msg}\n`);
+110
View File
@@ -497,6 +497,116 @@ describe("buildJobManifest", () => {
});
});
describe("RTK integration", () => {
it("does not add RTK init container by default", () => {
const { job } = buildJobManifest({ ctx, selfPod });
const inits = job.spec?.template?.spec?.initContainers ?? [];
expect(inits).toHaveLength(1);
expect(inits[0]?.name).toBe("write-prompt");
});
it("adds install-rtk init container when enableRtk is true", () => {
ctx.config = { enableRtk: true };
const { job } = buildJobManifest({ ctx, selfPod });
const inits = job.spec?.template?.spec?.initContainers ?? [];
expect(inits).toHaveLength(2);
expect(inits[1]?.name).toBe("install-rtk");
expect(inits[1]?.image).toBe("curlimages/curl:8.12.1");
});
it("uses custom rtkImage for init container", () => {
ctx.config = { enableRtk: true, rtkImage: "my-registry/rtk-installer:v1" };
const { job } = buildJobManifest({ ctx, selfPod });
const inits = job.spec?.template?.spec?.initContainers ?? [];
const rtkInit = inits.find((c) => c.name === "install-rtk");
expect(rtkInit?.image).toBe("my-registry/rtk-installer:v1");
});
it("adds rtk-bin emptyDir volume when enableRtk is true", () => {
ctx.config = { enableRtk: true };
const { job } = buildJobManifest({ ctx, selfPod });
const rtkVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "rtk-bin");
expect(rtkVol?.emptyDir).toEqual({});
});
it("mounts rtk-bin in main container when enableRtk is true", () => {
ctx.config = { enableRtk: true };
const { job } = buildJobManifest({ ctx, selfPod });
const rtkMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find(
(vm) => vm.name === "rtk-bin",
);
expect(rtkMount?.mountPath).toBe("/tmp/rtk-bin");
});
it("prepends rtk setup to main command with project-level settings isolation", () => {
ctx.config = { enableRtk: true };
const { job } = buildJobManifest({ ctx, selfPod });
const command = job.spec?.template?.spec?.containers[0]?.command;
expect(command?.[2]).toContain('export PATH="/tmp/rtk-bin:$PATH"');
expect(command?.[2]).toContain("rtk install claude-code");
expect(command?.[2]).toContain("settings.local.json");
expect(command?.[2]).toContain("export HOME=/paperclip");
expect(command?.[2]).toContain("cat /tmp/prompt/prompt.txt | claude");
});
it("does not prepend rtk setup when enableRtk is false", () => {
ctx.config = { enableRtk: false };
const { job } = buildJobManifest({ ctx, selfPod });
const command = job.spec?.template?.spec?.containers[0]?.command;
expect(command?.[2]).not.toContain("rtk");
expect(command?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt/);
});
it("does not add rtk-bin volume when enableRtk is false", () => {
ctx.config = { enableRtk: false };
const { job } = buildJobManifest({ ctx, selfPod });
expect(job.spec?.template?.spec?.volumes?.find((v) => v.name === "rtk-bin")).toBeUndefined();
});
it("sets RTK_NO_TELEMETRY env var when enableRtk is true", () => {
ctx.config = { enableRtk: true };
const { job } = buildJobManifest({ ctx, selfPod });
const rtkTelemetry = job.spec?.template?.spec?.containers[0]?.env?.find(
(e) => e.name === "RTK_NO_TELEMETRY",
);
expect(rtkTelemetry?.value).toBe("1");
});
it("does not set RTK_NO_TELEMETRY when enableRtk is false", () => {
const { job } = buildJobManifest({ ctx, selfPod });
const rtkTelemetry = job.spec?.template?.spec?.containers[0]?.env?.find(
(e) => e.name === "RTK_NO_TELEMETRY",
);
expect(rtkTelemetry).toBeUndefined();
});
it("uses custom rtkVersion in install command", () => {
ctx.config = { enableRtk: true, rtkVersion: "0.5.0" };
const { job } = buildJobManifest({ ctx, selfPod });
const inits = job.spec?.template?.spec?.initContainers ?? [];
const rtkInit = inits.find((c) => c.name === "install-rtk");
expect(rtkInit?.command?.[2]).toContain("RTK_VERSION=0.5.0");
});
it("mounts rtk-bin in install-rtk init container", () => {
ctx.config = { enableRtk: true };
const { job } = buildJobManifest({ ctx, selfPod });
const inits = job.spec?.template?.spec?.initContainers ?? [];
const rtkInit = inits.find((c) => c.name === "install-rtk");
expect(rtkInit?.volumeMounts).toContainEqual({ name: "rtk-bin", mountPath: "/tmp/rtk-bin" });
});
it("writes hooks to workspace .claude/settings.local.json not global settings", () => {
ctx.context = { paperclipWorkspace: { cwd: "/paperclip/workspaces/agent-abc" } };
ctx.config = { enableRtk: true };
const { job } = buildJobManifest({ ctx, selfPod });
const command = job.spec?.template?.spec?.containers[0]?.command;
expect(command?.[2]).toContain("/paperclip/workspaces/agent-abc/.claude");
expect(command?.[2]).toContain("settings.local.json");
expect(command?.[2]).not.toMatch(/HOME="\/paperclip".*rtk install/);
});
});
describe("return value", () => {
it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics", () => {
const result = buildJobManifest({ ctx, selfPod });
+38 -1
View File
@@ -148,6 +148,10 @@ function buildEnvVars(
// HOME must be /paperclip to match PVC mount and enable session resume
merged.HOME = "/paperclip";
if (asBoolean(config.enableRtk, false)) {
merged.RTK_NO_TELEMETRY = "1";
}
// Convert to V1EnvVar array
const envVars: k8s.V1EnvVar[] = Object.entries(merged).map(([name, value]) => ({
name,
@@ -171,6 +175,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
// K8s Job pods are always unattended — no one to approve permission prompts
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const extraArgs = asStringArray(config.extraArgs);
const enableRtk = asBoolean(config.enableRtk, false);
const rtkVersion = asString(config.rtkVersion, "latest");
const rtkImage = asString(config.rtkImage, "curlimages/curl:8.12.1");
const timeoutSec = asNumber(config.timeoutSec, 0);
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
const resources = parseObject(config.resources);
@@ -282,6 +289,11 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
},
];
if (enableRtk) {
volumes.push({ name: "rtk-bin", emptyDir: {} });
volumeMounts.push({ name: "rtk-bin", mountPath: "/tmp/rtk-bin" });
}
// Mount shared PVC for /paperclip (session state, workspaces, data)
if (selfPod.pvcClaimName) {
volumes.push({
@@ -326,7 +338,10 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
// Build the claude command string for the main container
const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
const mainCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
const claudeCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
const mainCommand = enableRtk
? `export PATH="/tmp/rtk-bin:$PATH" && _RTK_HOME=$(mktemp -d) && HOME="$_RTK_HOME" rtk install claude-code 2>/dev/null && mkdir -p '${workingDir}/.claude' && mv "$_RTK_HOME/.claude/settings.json" '${workingDir}/.claude/settings.local.json' 2>/dev/null; rm -rf "$_RTK_HOME"; export HOME=/paperclip && ${claudeCommand}`
: claudeCommand;
const job: k8s.V1Job = {
apiVersion: "batch/v1",
@@ -368,6 +383,28 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
limits: { cpu: "100m", memory: "64Mi" },
},
},
...(enableRtk
? [
{
name: "install-rtk",
image: rtkImage,
imagePullPolicy: "IfNotPresent" as const,
command: [
"sh",
"-c",
rtkVersion === "latest"
? "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | RTK_INSTALL_DIR=/tmp/rtk-bin sh"
: `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | RTK_INSTALL_DIR=/tmp/rtk-bin RTK_VERSION=${rtkVersion} sh`,
],
volumeMounts: [{ name: "rtk-bin", mountPath: "/tmp/rtk-bin" }],
securityContext,
resources: {
requests: { cpu: "10m", memory: "32Mi" },
limits: { cpu: "200m", memory: "128Mi" },
},
},
]
: []),
],
containers: [
{