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
@@ -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
Reference in New Issue
Block a user