Add RTK integration for token-optimized command output
When enableRtk is set in adapter config, the adapter: - Adds an init container (curlimages/curl) to download the RTK binary - Mounts RTK binary in the main container via shared emptyDir volume - Runs `rtk install claude-code` before invoking Claude to set up hooks - Disables RTK telemetry (RTK_NO_TELEMETRY=1) for automated environments - Supports optional rtkVersion config for pinning specific versions RTK filters CLI command output before it reaches the LLM context, reducing token consumption by ~80%. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Vendored
+1
-1
@@ -4,7 +4,7 @@ export declare const models: {
|
||||
id: string;
|
||||
label: string;
|
||||
}[];
|
||||
export declare const agentConfigurationDoc = "# claude_k8s agent configuration\n\nAdapter: claude_k8s\n\nRuns Claude Code inside an isolated Kubernetes Job pod instead of the main\nPaperclip process. The Job inherits the container image, imagePullSecrets,\nDNS config, and PVC from the running Paperclip Deployment automatically.\n\nCore fields:\n- model (string, optional): Claude model id\n- effort (string, optional): reasoning effort passed via --effort (low|medium|high)\n- maxTurnsPerRun (number, optional): max turns for one run\n- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude\n- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file\n- extraArgs (string[], optional): additional CLI args appended to the claude command\n- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment\n\nKubernetes fields:\n- namespace (string, optional): namespace for Jobs; defaults to the Deployment namespace\n- image (string, optional): override container image; defaults to the running Deployment image\n- imagePullPolicy (string, optional): image pull policy; default \"IfNotPresent\"\n- kubeconfig (string, optional): absolute path to a kubeconfig file on disk; defaults to in-cluster service account auth\n- resources (object, optional): { requests: { cpu, memory }, limits: { cpu, memory } }\n- nodeSelector (object, optional): node selector for Job pods\n- tolerations (array, optional): tolerations for Job pods\n- labels (object, optional): extra labels added to Job metadata\n- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300\n- retainJobs (boolean, optional): skip cleanup on completion for debugging\n\nOperational fields:\n- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout\n- graceSec (number, optional): additional grace before adapter gives up after Job deadline\n\nInherited from Deployment (no config needed):\n- ANTHROPIC_API_KEY, OPENAI_API_KEY, and other provider API keys\n- PAPERCLIP_API_URL\n- Container image, imagePullSecrets, DNS config, PVC mount, security context\n\nNotes:\n- Session resume works via the shared /paperclip PVC (HOME=/paperclip)\n- Skills are bundled in the container image\n- Prompts are delivered via a busybox init container writing to an emptyDir volume\n";
|
||||
export declare const agentConfigurationDoc = "# claude_k8s agent configuration\n\nAdapter: claude_k8s\n\nRuns Claude Code inside an isolated Kubernetes Job pod instead of the main\nPaperclip process. The Job inherits the container image, imagePullSecrets,\nDNS config, and PVC from the running Paperclip Deployment automatically.\n\nCore fields:\n- model (string, optional): Claude model id\n- effort (string, optional): reasoning effort passed via --effort (low|medium|high)\n- maxTurnsPerRun (number, optional): max turns for one run\n- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude\n- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file\n- extraArgs (string[], optional): additional CLI args appended to the claude command\n- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment\n\nKubernetes fields:\n- namespace (string, optional): namespace for Jobs; defaults to the Deployment namespace\n- image (string, optional): override container image; defaults to the running Deployment image\n- imagePullPolicy (string, optional): image pull policy; default \"IfNotPresent\"\n- kubeconfig (string, optional): absolute path to a kubeconfig file on disk; defaults to in-cluster service account auth\n- resources (object, optional): { requests: { cpu, memory }, limits: { cpu, memory } }\n- nodeSelector (object, optional): node selector for Job pods\n- tolerations (array, optional): tolerations for Job pods\n- labels (object, optional): extra labels added to Job metadata\n- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300\n- retainJobs (boolean, optional): skip cleanup on completion for debugging\n\nRTK fields (token optimization):\n- enableRtk (boolean, optional): install and enable RTK to reduce token usage by filtering command output (~80% reduction). Adds an init container to download the RTK binary from GitHub.\n- rtkVersion (string, optional): RTK version to install; defaults to \"latest\"\n\nOperational fields:\n- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout\n- graceSec (number, optional): additional grace before adapter gives up after Job deadline\n\nInherited from Deployment (no config needed):\n- ANTHROPIC_API_KEY, OPENAI_API_KEY, and other provider API keys\n- PAPERCLIP_API_URL\n- Container image, imagePullSecrets, DNS config, PVC mount, security context\n\nNotes:\n- Session resume works via the shared /paperclip PVC (HOME=/paperclip)\n- Skills are bundled in the container image\n- Prompts are delivered via a busybox init container writing to an emptyDir volume\n";
|
||||
export { createServerAdapter } from "./server/index.js";
|
||||
export { printClaudeStreamEvent } from "./cli/index.js";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,IAAI,eAAe,CAAC;AACjC,eAAO,MAAM,KAAK,wBAAwB,CAAC;AAE3C,eAAO,MAAM,MAAM;;;GAMlB,CAAC;AAEF,eAAO,MAAM,qBAAqB,k1EA0CjC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,IAAI,eAAe,CAAC;AACjC,eAAO,MAAM,KAAK,wBAAwB,CAAC;AAE3C,eAAO,MAAM,MAAM;;;GAMlB,CAAC;AAEF,eAAO,MAAM,qBAAqB,moFA8CjC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
Vendored
+4
@@ -36,6 +36,10 @@ 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): install and enable RTK to reduce token usage by filtering command output (~80% reduction). Adds an init container to download the RTK binary from GitHub.
|
||||
- rtkVersion (string, optional): RTK version to install; defaults to "latest"
|
||||
|
||||
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
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,IAAI,GAAG,YAAY,CAAC;AACjC,MAAM,CAAC,MAAM,KAAK,GAAG,qBAAqB,CAAC;AAE3C,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,EAAE,EAAE,EAAE,iBAAiB,EAAE,KAAK,EAAE,iBAAiB,EAAE;IACnD,EAAE,EAAE,EAAE,mBAAmB,EAAE,KAAK,EAAE,mBAAmB,EAAE;IACvD,EAAE,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,kBAAkB,EAAE;IACrD,EAAE,EAAE,EAAE,4BAA4B,EAAE,KAAK,EAAE,mBAAmB,EAAE;IAChE,EAAE,EAAE,EAAE,2BAA2B,EAAE,KAAK,EAAE,kBAAkB,EAAE;CAC/D,CAAC;AAEF,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CpC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,IAAI,GAAG,YAAY,CAAC;AACjC,MAAM,CAAC,MAAM,KAAK,GAAG,qBAAqB,CAAC;AAE3C,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,EAAE,EAAE,EAAE,iBAAiB,EAAE,KAAK,EAAE,iBAAiB,EAAE;IACnD,EAAE,EAAE,EAAE,mBAAmB,EAAE,KAAK,EAAE,mBAAmB,EAAE;IACvD,EAAE,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,kBAAkB,EAAE;IACrD,EAAE,EAAE,EAAE,4BAA4B,EAAE,KAAK,EAAE,mBAAmB,EAAE;IAChE,EAAE,EAAE,EAAE,2BAA2B,EAAE,KAAK,EAAE,kBAAkB,EAAE;CAC/D,CAAC;AAEF,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8CpC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC"}
|
||||
Vendored
+1
-1
@@ -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;AA6PlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA2P3F"}
|
||||
{"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"}
|
||||
Vendored
+47
-7
@@ -5,6 +5,7 @@ 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.
|
||||
@@ -99,10 +100,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 and accumulate stdout for result parsing.
|
||||
* Returns accumulated stdout when the stream ends.
|
||||
* Stream pod logs once via follow. Returns accumulated stdout when the
|
||||
* stream ends (container exit, API disconnect, or abort signal).
|
||||
*/
|
||||
async function streamPodLogs(namespace, podName, onLog, kubeconfigPath) {
|
||||
async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds) {
|
||||
const logApi = getLogApi(kubeconfigPath);
|
||||
const chunks = [];
|
||||
const writable = new Writable({
|
||||
@@ -116,14 +117,47 @@ async function streamPodLogs(namespace, podName, onLog, kubeconfigPath) {
|
||||
await logApi.log(namespace, podName, "claude", writable, {
|
||||
follow: true,
|
||||
pretty: false,
|
||||
...(sinceSeconds ? { sinceSeconds } : {}),
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// follow may fail if the container already exited — not fatal,
|
||||
// we'll try a one-shot read below
|
||||
// follow may fail if the container already exited or the API
|
||||
// connection dropped — not fatal, caller decides whether to retry.
|
||||
}
|
||||
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.
|
||||
@@ -308,9 +342,15 @@ 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),
|
||||
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath),
|
||||
streamPodLogs(namespace, podName, wrappedOnLog, kubeconfigPath, logStopSignal),
|
||||
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath).then((r) => {
|
||||
logStopSignal.stopped = true;
|
||||
return r;
|
||||
}),
|
||||
]);
|
||||
if (logResult.status === "fulfilled") {
|
||||
stdout = logResult.value;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AA2C1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAqGD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAwOrE"}
|
||||
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AA2C1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAyGD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAwQrE"}
|
||||
Vendored
+35
-1
@@ -107,6 +107,9 @@ function buildEnvVars(ctx, selfPod, config) {
|
||||
}
|
||||
// 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 = Object.entries(merged).map(([name, value]) => ({
|
||||
name,
|
||||
@@ -127,6 +130,8 @@ export function buildJobManifest(input) {
|
||||
// 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 timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
|
||||
const resources = parseObject(config.resources);
|
||||
@@ -233,6 +238,10 @@ export function buildJobManifest(input) {
|
||||
mountPath: "/tmp/prompt",
|
||||
},
|
||||
];
|
||||
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({
|
||||
@@ -273,7 +282,10 @@ export function buildJobManifest(input) {
|
||||
};
|
||||
// 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 install claude-code && ${claudeCommand}`
|
||||
: claudeCommand;
|
||||
const job = {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
@@ -314,6 +326,28 @@ export function buildJobManifest(input) {
|
||||
limits: { cpu: "100m", memory: "64Mi" },
|
||||
},
|
||||
},
|
||||
...(enableRtk
|
||||
? [
|
||||
{
|
||||
name: "install-rtk",
|
||||
image: "curlimages/curl:8.12.1",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
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: [
|
||||
{
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -38,6 +38,10 @@ 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): install and enable RTK to reduce token usage by filtering command output (~80% reduction). Adds an init container to download the RTK binary from GitHub.
|
||||
- rtkVersion (string, optional): RTK version to install; defaults to "latest"
|
||||
|
||||
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
|
||||
|
||||
@@ -108,6 +108,20 @@ 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 command output. Adds an init container to download the RTK binary.",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
key: "rtkVersion",
|
||||
label: "RTK Version",
|
||||
hint: "RTK version to install. Defaults to 'latest'.",
|
||||
},
|
||||
// Scheduling
|
||||
{
|
||||
type: "textarea",
|
||||
|
||||
@@ -497,6 +497,96 @@ 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("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 when enableRtk is true", () => {
|
||||
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("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" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("return value", () => {
|
||||
it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics", () => {
|
||||
const result = buildJobManifest({ ctx, selfPod });
|
||||
|
||||
@@ -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,8 @@ 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 timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
|
||||
const resources = parseObject(config.resources);
|
||||
@@ -282,6 +288,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 +337,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 install claude-code && ${claudeCommand}`
|
||||
: claudeCommand;
|
||||
|
||||
const job: k8s.V1Job = {
|
||||
apiVersion: "batch/v1",
|
||||
@@ -368,6 +382,28 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
||||
limits: { cpu: "100m", memory: "64Mi" },
|
||||
},
|
||||
},
|
||||
...(enableRtk
|
||||
? [
|
||||
{
|
||||
name: "install-rtk",
|
||||
image: "curlimages/curl:8.12.1",
|
||||
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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user